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 { fromWallTime } from "@/lib/dates";
import { filterEvents, groupByMonth, matchesQuery, matchesRange, sortEvents } from "@/lib/filter";
import { makeEvent } from "@/lib/test-fixtures";
const now = new Date("2026-09-27T10:00:00Z"); // 12:00 Berlin
describe("matchesRange", () => {
it("keeps an event that ended earlier today in 'future' until midnight", () => {
const earlierToday = makeEvent({
start: fromWallTime("2026-09-27", "08:00"),
end: fromWallTime("2026-09-27", "09:00"),
});
expect(matchesRange(earlierToday, "future", now)).toBe(true);
expect(matchesRange(earlierToday, "past", now)).toBe(false);
});
it("puts yesterday's event in 'past'", () => {
const yesterday = makeEvent({ start: fromWallTime("2026-09-26", "19:00"), end: fromWallTime("2026-09-26", "21:00") });
expect(matchesRange(yesterday, "past", now)).toBe(true);
expect(matchesRange(yesterday, "future", now)).toBe(false);
});
it("treats any repeating event as current, whatever its calendar", () => {
const old = { start: fromWallTime("1990-01-01", "00:00"), end: fromWallTime("1990-01-01", "23:59") };
const birthday = makeEvent({ ...old, calendarId: 5, repeatFrequency: "YEARLY" });
const weeklyRehearsal = makeEvent({ ...old, calendarId: 4, repeatFrequency: "WEEKLY" });
for (const e of [birthday, weeklyRehearsal]) {
expect(matchesRange(e, "future", now)).toBe(true);
expect(matchesRange(e, "past", now)).toBe(false);
expect(matchesRange(e, "all", now)).toBe(true);
}
});
it("no longer exempts a non-repeating event just for being in Birthdays", () => {
const oneOff = makeEvent({ calendarId: 5, start: fromWallTime("2020-01-01", "00:00"), end: fromWallTime("2020-01-01", "23:59") });
expect(matchesRange(oneOff, "future", now)).toBe(false);
});
});
describe("matchesQuery", () => {
const event = makeEvent({ name: "Probe Weihnachtskonzert", location: "Gemeindehaus Süd" });
it("matches title and location, case-insensitively, all words", () => {
expect(matchesQuery(event, "")).toBe(true);
expect(matchesQuery(event, "weihnacht")).toBe(true);
expect(matchesQuery(event, "PROBE süd")).toBe(true);
expect(matchesQuery(event, "probe nord")).toBe(false);
});
});
describe("filterEvents", () => {
it("combines calendar, range and search", () => {
const events = [
makeEvent({ eventId: 1, calendarId: 1, name: "Konzert" }),
makeEvent({ eventId: 2, calendarId: 2, name: "Konzert" }),
makeEvent({ eventId: 3, calendarId: 1, name: "Probe" }),
];
const result = filterEvents(events, { calendarIds: [1], range: "future", query: "konz" }, now);
expect(result.map((e) => e.eventId)).toEqual([1]);
});
});
describe("sortEvents", () => {
const a = makeEvent({ eventId: 1, name: "B", start: fromWallTime("2026-10-02", "19:00") });
const b = makeEvent({ eventId: 2, name: "A", start: fromWallTime("2026-10-02", "19:00") });
const c = makeEvent({ eventId: 3, name: "C", start: fromWallTime("2026-10-01", "19:00") });
it("orders by start and breaks ties by title", () => {
expect(sortEvents([a, b, c], "start_asc", now).map((e) => e.eventId)).toEqual([3, 2, 1]);
// Descending flips the dates only; equal starts still read A before B.
expect(sortEvents([a, b, c], "start_desc", now).map((e) => e.eventId)).toEqual([2, 1, 3]);
});
it("does not mutate its input", () => {
const input = [a, b, c];
sortEvents(input, "start_asc", now);
expect(input.map((e) => e.eventId)).toEqual([1, 2, 3]);
});
it("sorts a birthday by its next occurrence, not its year", () => {
const birthday = makeEvent({ eventId: 9, repeatFrequency: "YEARLY", start: fromWallTime("1990-10-01", "12:00"), end: fromWallTime("1990-10-01", "23:59") });
const concert = makeEvent({ eventId: 8, start: fromWallTime("2026-10-02", "19:00") });
expect(sortEvents([concert, birthday], "start_asc", now).map((e) => e.eventId)).toEqual([9, 8]);
});
it("sorts by created date, missing ones first", () => {
const x = makeEvent({ eventId: 1, created: new Date("2026-05-01T00:00:00Z") });
const y = makeEvent({ eventId: 2, created: null });
expect(sortEvents([x, y], "created_asc", now).map((e) => e.eventId)).toEqual([2, 1]);
});
});
describe("groupByMonth", () => {
it("groups consecutive events by Berlin month, labelled in German", () => {
const events = [
makeEvent({ eventId: 1, start: fromWallTime("2026-10-01", "00:30") }), // still 30 Sep in UTC
makeEvent({ eventId: 2, start: fromWallTime("2026-10-20", "19:00") }),
makeEvent({ eventId: 3, start: fromWallTime("2026-11-02", "19:00") }),
];
const groups = groupByMonth(events, now);
expect(groups.map((g) => [g.key, g.label, g.events.map((e) => e.eventId)])).toEqual([
["2026-10", "Oktober 2026", [1, 2]],
["2026-11", "November 2026", [3]],
]);
});
});