Rewrite the calendar frontend in Next.js
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>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
@@ -0,0 +1,92 @@
|
||||
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();
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user