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

+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { parseApiEvent, parseApiEvents } from "@/lib/events";
import type { ApiEvent } from "@/lib/types";
const row = (overrides: Partial<ApiEvent> = {}): ApiEvent => ({
eventId: 7,
calendarId: 5,
uuid: "u-7",
name: "Anna",
description: null,
startDateTime: "1990-10-02T22:00:00.000Z",
endDateTime: "1990-10-03T21:59:00.000Z",
createdDate: "2024-01-01T10:00:00.000Z",
lastModifiedDate: "2024-02-01T10:00:00.000Z",
location: null,
createdBy: null,
lastModifiedBy: "Paula",
url: null,
wholeDay: 1,
repeatFrequency: "YEARLY",
status: "DRAFT",
...overrides,
});
describe("parseApiEvent", () => {
it("coerces the raw row: 1 → true, nulls → empty strings, ISO → Date", () => {
const event = parseApiEvent(row());
expect(event).toMatchObject({
eventId: 7,
wholeDay: true,
description: "",
location: "",
url: "",
createdBy: "",
lastModifiedBy: "Paula",
repeatFrequency: "YEARLY",
status: "DRAFT",
});
expect(event?.start.toISOString()).toBe("1990-10-02T22:00:00.000Z");
});
it("drops deleted events", () => {
expect(parseApiEvent(row({ status: "DELETED" }))).toBeNull();
});
it("drops a row without a usable start or end - what a deleted version looks like", () => {
expect(parseApiEvent(row({ status: null, startDateTime: null }))).toBeNull();
expect(parseApiEvent(row({ endDateTime: "not a date" }))).toBeNull();
});
it("reads a missing status as PUBLIC (the public listing omits it)", () => {
const withoutStatus = row();
delete withoutStatus.status;
expect(parseApiEvent(withoutStatus)?.status).toBe("PUBLIC");
});
it("never promotes an unknown status to PUBLIC", () => {
expect(parseApiEvent(row({ status: "ARCHIVED" }))?.status).toBe("PRIVATE");
});
it("reads 0 and null as not whole-day", () => {
expect(parseApiEvent(row({ wholeDay: 0 }))?.wholeDay).toBe(false);
expect(parseApiEvent(row({ wholeDay: null }))?.wholeDay).toBe(false);
});
});
describe("parseApiEvents", () => {
it("keeps only the rows worth showing", () => {
const events = parseApiEvents([row({ eventId: 1 }), row({ eventId: 2, status: "DELETED" }), row({ eventId: 3 })]);
expect(events.map((e) => e.eventId)).toEqual([1, 3]);
});
});