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

+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchUpcomingPublicEvents,
googleCalendarUrl,
icalUrl,
selectUpcoming,
toGoogleCalendar,
toWebcal,
webcalUrl,
} from "@/lib/public-calendar";
import type { ApiEvent } from "@/lib/types";
const now = new Date("2026-09-28T10:00:00Z");
const row = (eventId: number, start: string, end: string, name = `E${eventId}`): ApiEvent => ({
eventId,
calendarId: 1,
uuid: `u${eventId}`,
name,
description: "",
startDateTime: start,
endDateTime: end,
createdDate: null,
location: "",
url: "",
wholeDay: 0,
repeatFrequency: "",
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("subscription links", () => {
it("builds the iCal, webcal and Google links from the API URL", () => {
expect(icalUrl("https://api.nachklang.art")).toBe("https://api.nachklang.art/calendar/events/public/ical");
expect(webcalUrl("https://api.nachklang.art")).toBe("webcal://api.nachklang.art/calendar/events/public/ical");
expect(webcalUrl("http://localhost:3000")).toBe("webcal://localhost:3000/calendar/events/public/ical");
// The same link the Angular landing page hardcoded.
expect(googleCalendarUrl("https://api.nachklang.art")).toBe(
"https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fapi.nachklang.art%2Fcalendar%2Fevents%2Fpublic%2Fical",
);
});
});
describe("links for a password-protected calendar", () => {
it("keeps the password through the webcal and Google conversions", () => {
const ical = "https://api.nachklang.art/calendar/events/choir/ical?password=a%26b";
expect(toWebcal(ical)).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b");
// Encoded once more as a whole, so Google hands back exactly the URL above.
const cid = new URL(toGoogleCalendar(ical)).searchParams.get("cid");
expect(cid).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b");
});
});
describe("selectUpcoming", () => {
it("keeps events not yet over, earliest first, up to the limit", () => {
const rows = [
row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z"),
row(2, "2026-07-01T18:00:00Z", "2026-07-01T20:00:00Z"), // over
row(3, "2026-09-28T06:00:00Z", "2026-09-28T07:00:00Z"), // earlier today: still listed
row(4, "2026-10-01T18:00:00Z", "2026-10-01T20:00:00Z"),
];
expect(selectUpcoming(rows, now, 2).map((e) => e.eventId)).toEqual([3, 4]);
});
});
describe("fetchUpcomingPublicEvents", () => {
it("returns null instead of throwing when the API is unreachable or unhappy", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed")));
await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 500 })));
await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull();
});
it("parses what the public feed returns", async () => {
const fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify([row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z")]), { status: 200 }),
);
vi.stubGlobal("fetch", fetch);
const events = await fetchUpcomingPublicEvents("https://api.test", now, 3);
expect(events?.map((e) => e.eventId)).toEqual([1]);
expect(fetch).toHaveBeenCalledWith("https://api.test/calendar/events/public/json", { next: { revalidate: 300 } });
});
});