Files
Calendar_Frontend/lib/event-display.ts
PaddyandClaude Opus 5.5 0cda40f85e 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>
2026-09-28 22:15:59 +02:00

93 lines
3.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
};