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:
PaddyandClaude Opus 5.5 committed 2026-09-28 22:15:59 +02:00
1 parent f82255961b
commit 0cda40f85e
138 files changed
+15942 -24010

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api";
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
import { fromWallTime, toWallTime } from "@/lib/dates";
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel, todayMarkerIndex } from "@/lib/event-display";
import { makeEvent } from "@/lib/test-fixtures";
const now = new Date("2026-09-27T10:00:00Z");
describe("dateBlock", () => {
it("gives weekday, day and month in German, Berlin time", () => {
// 23:30 UTC on 2 Oct is already Saturday 3 Oct in Berlin.
expect(dateBlock(new Date("2026-10-02T23:30:00Z"))).toEqual({ weekday: "Sa", day: "3", month: "Okt" });
});
});
describe("describeWhen", () => {
it("shows a same-day time range", () => {
expect(describeWhen(makeEvent(), now)).toBe("19:00–21:00 Uhr");
});
it("shows a multi-day timed event with both dates", () => {
const event = makeEvent({ start: fromWallTime("2026-10-03", "19:00"), end: fromWallTime("2026-10-05", "12:00") });
expect(describeWhen(event, now)).toBe("3. Okt., 19:00 – 5. Okt., 12:00");
});
it("shows whole-day events, with the range when they span days", () => {
const oneDay = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-03", "00:00"), end: fromWallTime("2026-10-03", "23:59") });
const weekend = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-17", "00:00"), end: fromWallTime("2026-10-19", "23:59") });
expect(describeWhen(oneDay, now)).toBe("Ganztägig");
expect(describeWhen(weekend, now)).toBe("Ganztägig · 17.–19. Okt.");
});
});
describe("occurrence", () => {
it("moves a birthday's start and end to this year's date together", () => {
const birthday = makeEvent({
wholeDay: true,
repeatFrequency: "YEARLY",
start: fromWallTime("1994-10-03", "00:00"),
end: fromWallTime("1994-10-03", "23:59"),
});
const { start, end } = occurrence(birthday, now);
expect(toWallTime(start)).toEqual({ date: "2026-10-03", time: "00:00" });
expect(toWallTime(end)).toEqual({ date: "2026-10-03", time: "23:59" });
expect(describeWhen(birthday, now)).toBe("Ganztägig");
});
});
describe("isPast", () => {
it("is true only for non-repeating events that ended before today", () => {
const old = { start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") };
expect(isPast(makeEvent(old), now)).toBe(true);
expect(isPast(makeEvent({ ...old, repeatFrequency: "YEARLY" }), now)).toBe(false);
expect(isPast(makeEvent(), now)).toBe(false);
});
});
describe("todayMarkerIndex", () => {
const past = makeEvent({ eventId: 1, start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") });
const today = makeEvent({ eventId: 2, start: fromWallTime("2026-09-27", "08:00"), end: fromWallTime("2026-09-27", "09:00") });
const later = makeEvent({ eventId: 3 });
it("sits before the first event not yet over, ascending", () => {
expect(todayMarkerIndex([past, today, later], "start_asc", now)).toBe(1);
});
it("sits before the first past event, descending", () => {
expect(todayMarkerIndex([later, today, past], "start_desc", now)).toBe(2);
});
it("goes to the end or the start when everything is on one side", () => {
expect(todayMarkerIndex([past], "start_asc", now)).toBe(1);
expect(todayMarkerIndex([later], "start_asc", now)).toBe(0);
expect(todayMarkerIndex([later], "start_desc", now)).toBe(1);
});
it("has no place in a list sorted by creation, or an empty one", () => {
expect(todayMarkerIndex([past, later], "created_asc", now)).toBeNull();
expect(todayMarkerIndex([], "start_asc", now)).toBeNull();
});
});
describe("repeatLabel", () => {
it("translates known frequencies and passes unknown ones through", () => {
expect(repeatLabel("")).toBe("");
expect(repeatLabel("YEARLY")).toBe("jährlich");
expect(repeatLabel("HOURLY")).toBe("hourly");
});
});
describe("describeApiError", () => {
it("explains each kind of failure in German", () => {
expect(describeApiError(new ApiError(0, {}))).toMatch(/Keine Verbindung/);
expect(describeApiError(new ApiError(403, {}))).toMatch(/Berechtigung/);
expect(describeApiError(new ApiError(500, {}))).toMatch(/\(500\)/);
expect(describeApiError(new ApiError(200, {}, "Der Termin wurde nicht gespeichert."))).toBe(
"Der Termin wurde nicht gespeichert.",
);
expect(describeApiError(new Error("x"))).toMatch(/Unerwarteter Fehler/);
});
it("recognises only 401 as an expired session", () => {
expect(isSessionExpired(new ApiError(401, {}))).toBe(true);
expect(isSessionExpired(new ApiError(403, {}))).toBe(false);
});
});