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>
93 lines
3.3 KiB
TypeScript
93 lines
3.3 KiB
TypeScript
import { berlinDay, formatTime, TIME_ZONE } from "@/lib/dates";
|
||
import { effectiveStart, isRecurring, type Sort } from "@/lib/filter";
|
||
import type { CalendarEvent } from "@/lib/types";
|
||
|
||
/**
|
||
* How an event reads in the list. Pure, so the German wording is testable.
|
||
* All of it is about the *effective* occurrence: a birthday shows this year's
|
||
* date, not the year of birth.
|
||
*/
|
||
|
||
const LOCALE = "de-DE";
|
||
const part = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, ...options });
|
||
const weekdayFormat = part({ weekday: "short" });
|
||
const dayFormat = part({ day: "numeric" });
|
||
const monthFormat = part({ month: "short" });
|
||
const dayMonthFormat = part({ day: "numeric", month: "short" });
|
||
|
||
export interface DateBlock {
|
||
/** "Sa" */
|
||
weekday: string;
|
||
/** "3" */
|
||
day: string;
|
||
/** "Okt" */
|
||
month: string;
|
||
}
|
||
|
||
export const dateBlock = (instant: Date): DateBlock => ({
|
||
weekday: weekdayFormat.format(instant),
|
||
day: dayFormat.format(instant),
|
||
month: monthFormat.format(instant),
|
||
});
|
||
|
||
/** The occurrence the list shows: start moved to the next repeat, end moved by the same amount. */
|
||
export const occurrence = (event: CalendarEvent, now: Date): { start: Date; end: Date } => {
|
||
const start = effectiveStart(event, now);
|
||
const shift = start.getTime() - event.start.getTime();
|
||
return { start, end: new Date(event.end.getTime() + shift) };
|
||
};
|
||
|
||
/**
|
||
* "19:00–21:00 Uhr", "Ganztägig", "Ganztägig · 17.–19. Okt.",
|
||
* "3. Okt., 19:00 – 5. Okt., 12:00".
|
||
*/
|
||
export const describeWhen = (event: CalendarEvent, now: Date): string => {
|
||
const { start, end } = occurrence(event, now);
|
||
const sameDay = berlinDay(start) === berlinDay(end);
|
||
|
||
if (event.wholeDay) {
|
||
return sameDay ? "Ganztägig" : `Ganztägig · ${dayMonthFormat.formatRange(start, end)}`;
|
||
}
|
||
if (sameDay) {
|
||
return `${formatTime(start)}–${formatTime(end)} Uhr`;
|
||
}
|
||
return `${dayMonthFormat.format(start)}, ${formatTime(start)} – ${dayMonthFormat.format(end)}, ${formatTime(end)}`;
|
||
};
|
||
|
||
/** Over before today (Berlin). Repeating events never are. */
|
||
export const isPast = (event: CalendarEvent, now: Date): boolean =>
|
||
!isRecurring(event) && berlinDay(event.end) < berlinDay(now);
|
||
|
||
/**
|
||
* Where "today" falls in a list sorted by start: the index of the event the
|
||
* "Heute" marker goes in front of, or `events.length` for after the last one.
|
||
* Ascending, that is the first event not yet over; descending, the first one
|
||
* that is. Null for a list that isn't ordered by start, or an empty one.
|
||
*/
|
||
export const todayMarkerIndex = (events: readonly CalendarEvent[], sort: Sort, now: Date): number | null => {
|
||
if (events.length === 0 || sort.startsWith("created")) {
|
||
return null;
|
||
}
|
||
const lookingFor = sort === "start_desc";
|
||
const index = events.findIndex((e) => isPast(e, now) === lookingFor);
|
||
return index === -1 ? events.length : index;
|
||
};
|
||
|
||
/** The badge for a repeat frequency; "" for none. Unknown values are shown as they are. */
|
||
export const repeatLabel = (repeatFrequency: string): string => {
|
||
switch (repeatFrequency) {
|
||
case "":
|
||
return "";
|
||
case "YEARLY":
|
||
return "jährlich";
|
||
case "MONTHLY":
|
||
return "monatlich";
|
||
case "WEEKLY":
|
||
return "wöchentlich";
|
||
case "DAILY":
|
||
return "täglich";
|
||
default:
|
||
return repeatFrequency.toLowerCase();
|
||
}
|
||
};
|