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

+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, ApiError } from "@/lib/api";
import type { EventPayload } from "@/lib/types";
const payload: EventPayload = {
calendarId: 1,
name: "Konzert",
description: "",
startDateTime: "2026-10-03T17:00:00.000Z",
endDateTime: "2026-10-03T19:00:00.000Z",
location: "",
url: "",
wholeDay: false,
repeatFrequency: "",
status: "DRAFT",
};
const respond = (status: number, body?: unknown) =>
vi.fn().mockResolvedValue(
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
);
afterEach(() => {
vi.unstubAllGlobals();
});
describe("api", () => {
it("always sends the session cookie", async () => {
const fetch = respond(200, { id: "u", email: "e", fullName: "Name", apps: ["calendar"] });
vi.stubGlobal("fetch", fetch);
await api.me();
expect(fetch).toHaveBeenCalledWith("https://api.test/admin/me", expect.objectContaining({ credentials: "include" }));
});
it("keeps 401 and 403 apart", async () => {
vi.stubGlobal("fetch", respond(401, { message: "Anmeldung erforderlich." }));
await expect(api.me()).rejects.toMatchObject({ status: 401 });
vi.stubGlobal("fetch", respond(403, { message: "nope" }));
await expect(api.me()).rejects.toMatchObject({ status: 403 });
});
it("reports a network failure as status 0", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const error = await api.me().catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(0);
});
it("parses events and drops deleted ones", async () => {
vi.stubGlobal(
"fetch",
respond(200, [
{ eventId: 1, calendarId: 1, uuid: "a", name: "A", description: "", startDateTime: payload.startDateTime, endDateTime: payload.endDateTime, createdDate: null, location: "", url: "", wholeDay: 0, repeatFrequency: "", status: "PUBLIC" },
{ eventId: 2, calendarId: 1, uuid: "b", name: null, description: null, startDateTime: null, endDateTime: null, createdDate: null, location: null, url: null, wholeDay: null, repeatFrequency: null, status: "DELETED" },
]),
);
const events = await api.getEvents("public");
expect(events.map((e) => e.eventId)).toEqual([1]);
});
it("returns the new id on create", async () => {
const fetch = respond(201, { message: "ok", eventId: 42 });
vi.stubGlobal("fetch", fetch);
await expect(api.createEvent(payload)).resolves.toBe(42);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(payload);
});
it("treats an update that wrote nothing as a failure", async () => {
vi.stubGlobal("fetch", respond(200, { message: "0 rows were updated" }));
await expect(api.updateEvent(1, payload)).rejects.toBeInstanceOf(ApiError);
});
it("moves and deletes by id", async () => {
const fetch = respond(200, { message: "ok" });
vi.stubGlobal("fetch", fetch);
await api.moveEvent(9, { ...payload, calendarId: 4 });
await api.deleteEvent(9);
expect(fetch.mock.calls.map((c) => [c[0], c[1].method])).toEqual([
["https://api.test/calendar/events/move/9", "PUT"],
["https://api.test/calendar/events/9", "DELETE"],
]);
});
});