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:
138 files changed
+15942
-24010
No files matched your search
@@ -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 } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user