Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
import { TZDate } from "@date-fns/tz";
|
|
|
|
/**
|
|
* Every wall-clock time in this app means Europe/Berlin, whatever time zone the
|
|
* editor's browser happens to be in.
|
|
*
|
|
* The Angular app used the browser's local time, which is only correct while
|
|
* the editor is in Germany: an editor abroad would enter "19:00" and save an
|
|
* event hours off. The API has no notion of time zones at all - it stores what
|
|
* it is sent and writes the iCal feed with the *server's* local `getHours()`,
|
|
* labelled `TZID=Europe/Berlin` (icalgenerator.service.ts) - so the frontend is
|
|
* the one place that can get this right, by always sending the UTC instant of a
|
|
* Berlin wall time.
|
|
*
|
|
* Dates travel through the form as "YYYY-MM-DD" and times as "HH:mm", exactly
|
|
* what <input type="date|time"> produce, so there is no Date object to mutate
|
|
* in place (the other Angular bug).
|
|
*
|
|
* DST, as @date-fns/tz resolves it and the tests pin:
|
|
* - a time that does not exist (02:30 on the last Sunday of March) moves
|
|
* forward to 03:30 CEST;
|
|
* - a time that exists twice (02:30 on the last Sunday of October) is the
|
|
* second one, CET.
|
|
*/
|
|
|
|
export const TIME_ZONE = "Europe/Berlin";
|
|
const LOCALE = "de-DE";
|
|
|
|
export interface WallTime {
|
|
/** "YYYY-MM-DD" */
|
|
date: string;
|
|
/** "HH:mm" */
|
|
time: string;
|
|
}
|
|
|
|
const pad = (n: number): string => n.toString().padStart(2, "0");
|
|
|
|
const splitDate = (date: string): [number, number, number] => {
|
|
const [y, m, d] = date.split("-").map(Number);
|
|
return [y, m, d];
|
|
};
|
|
|
|
/** Berlin wall time → instant. */
|
|
export const fromWallTime = (date: string, time: string): Date => {
|
|
const [y, m, d] = splitDate(date);
|
|
const [hh, mm] = time.split(":").map(Number);
|
|
return new Date(new TZDate(y, m - 1, d, hh, mm, TIME_ZONE).getTime());
|
|
};
|
|
|
|
/** Instant → Berlin wall time. */
|
|
export const toWallTime = (instant: Date): WallTime => {
|
|
const t = new TZDate(instant.getTime(), TIME_ZONE);
|
|
return {
|
|
date: `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`,
|
|
time: `${pad(t.getHours())}:${pad(t.getMinutes())}`,
|
|
};
|
|
};
|
|
|
|
/** The Berlin calendar day an instant falls on, as "YYYY-MM-DD" (sorts as a string). */
|
|
export const berlinDay = (instant: Date): string => toWallTime(instant).date;
|
|
|
|
/** Calendar arithmetic on "YYYY-MM-DD", independent of any time zone. */
|
|
export const addDays = (date: string, days: number): string => {
|
|
const [y, m, d] = splitDate(date);
|
|
const u = new Date(Date.UTC(y, m - 1, d + days));
|
|
return `${u.getUTCFullYear()}-${pad(u.getUTCMonth() + 1)}-${pad(u.getUTCDate())}`;
|
|
};
|
|
|
|
/** Whole days from `a` to `b` ("YYYY-MM-DD"); negative when b is earlier. */
|
|
export const daysBetween = (a: string, b: string): number => {
|
|
const [ay, am, ad] = splitDate(a);
|
|
const [by, bm, bd] = splitDate(b);
|
|
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
|
|
};
|
|
|
|
/**
|
|
* A whole-day event's stored range: 00:00 on the first day to 23:59 on the
|
|
* last, both Berlin.
|
|
*
|
|
* The end is the *inclusive* last day because that is what the iCal generator
|
|
* assumes - it adds one day to a whole-day end (`formatDate(…, true, true)`).
|
|
* 23:59 rather than 00:00 keeps the event "upcoming" for the whole of its last
|
|
* day in the list filter.
|
|
*/
|
|
export const wholeDayRange = (startDate: string, endDate: string): { start: Date; end: Date } => ({
|
|
start: fromWallTime(startDate, "00:00"),
|
|
end: fromWallTime(endDate, "23:59"),
|
|
});
|
|
|
|
/** The next full hour after `now`, as Berlin wall time. */
|
|
export const nextFullHour = (now: Date): WallTime => {
|
|
const { date, time } = toWallTime(now);
|
|
const hour = Number(time.slice(0, 2)) + 1;
|
|
return hour === 24 ? { date: addDays(date, 1), time: "00:00" } : { date, time: `${pad(hour)}:00` };
|
|
};
|
|
|
|
/**
|
|
* The first occurrence of a repeating event that starts today (Berlin) or
|
|
* later, keeping its wall-clock time. Non-repeating events, and frequencies
|
|
* the API may hold but this app does not know, return `start` unchanged.
|
|
*
|
|
* Only YEARLY is ever written by this app (birthdays), but the API accepts any
|
|
* `FREQ` string, so older data may hold others.
|
|
*
|
|
* A yearly event on 29 February falls on 28 February in other years - the
|
|
* choice a birthday list wants, though RFC 5545 would skip those years.
|
|
*/
|
|
export const nextOccurrence = (start: Date, repeatFrequency: string, now: Date): Date => {
|
|
const today = berlinDay(now);
|
|
const { date, time } = toWallTime(start);
|
|
if (date >= today) {
|
|
return start;
|
|
}
|
|
const [, m, d] = splitDate(date);
|
|
|
|
switch (repeatFrequency) {
|
|
case "YEARLY": {
|
|
const year = Number(today.slice(0, 4));
|
|
const candidate = (yy: number) => {
|
|
const lastDay = new Date(Date.UTC(yy, m, 0)).getUTCDate();
|
|
return `${yy}-${pad(m)}-${pad(Math.min(d, lastDay))}`;
|
|
};
|
|
const thisYear = candidate(year);
|
|
return fromWallTime(thisYear >= today ? thisYear : candidate(year + 1), time);
|
|
}
|
|
case "MONTHLY": {
|
|
const [ty, tm] = splitDate(today);
|
|
const candidate = (yy: number, mm: number) => {
|
|
const lastDay = new Date(Date.UTC(yy, mm, 0)).getUTCDate();
|
|
return `${yy}-${pad(mm)}-${pad(Math.min(d, lastDay))}`;
|
|
};
|
|
const thisMonth = candidate(ty, tm);
|
|
if (thisMonth >= today) {
|
|
return fromWallTime(thisMonth, time);
|
|
}
|
|
return fromWallTime(tm === 12 ? candidate(ty + 1, 1) : candidate(ty, tm + 1), time);
|
|
}
|
|
case "WEEKLY":
|
|
case "DAILY": {
|
|
const step = repeatFrequency === "WEEKLY" ? 7 : 1;
|
|
const steps = Math.ceil(daysBetween(date, today) / step);
|
|
return fromWallTime(addDays(date, steps * step), time);
|
|
}
|
|
default:
|
|
return start;
|
|
}
|
|
};
|
|
|
|
const dayFormat = new Intl.DateTimeFormat(LOCALE, {
|
|
timeZone: TIME_ZONE,
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
});
|
|
const timeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, hour: "2-digit", minute: "2-digit" });
|
|
const dateTimeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, dateStyle: "medium", timeStyle: "short" });
|
|
const monthFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, month: "long", year: "numeric" });
|
|
|
|
/** "Sa., 3. Okt. 2026" */
|
|
export const formatDay = (instant: Date): string => dayFormat.format(instant);
|
|
/** "19:00" */
|
|
export const formatTime = (instant: Date): string => timeFormat.format(instant);
|
|
/** "03.10.2026, 19:00" */
|
|
export const formatDateTime = (instant: Date): string => dateTimeFormat.format(instant);
|
|
/** "Oktober 2026" */
|
|
export const formatMonth = (instant: Date): string => monthFormat.format(instant);
|
|
/** "2026-10" - a sortable month key. */
|
|
export const monthKey = (instant: Date): string => berlinDay(instant).slice(0, 7);
|