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>
108 lines
4.7 KiB
TypeScript
108 lines
4.7 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import { ApiError } from "@/lib/api";
|
||
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
|
||
import { fromWallTime, toWallTime } from "@/lib/dates";
|
||
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel, todayMarkerIndex } from "@/lib/event-display";
|
||
import { makeEvent } from "@/lib/test-fixtures";
|
||
|
||
const now = new Date("2026-09-27T10:00:00Z");
|
||
|
||
describe("dateBlock", () => {
|
||
it("gives weekday, day and month in German, Berlin time", () => {
|
||
// 23:30 UTC on 2 Oct is already Saturday 3 Oct in Berlin.
|
||
expect(dateBlock(new Date("2026-10-02T23:30:00Z"))).toEqual({ weekday: "Sa", day: "3", month: "Okt" });
|
||
});
|
||
});
|
||
|
||
describe("describeWhen", () => {
|
||
it("shows a same-day time range", () => {
|
||
expect(describeWhen(makeEvent(), now)).toBe("19:00–21:00 Uhr");
|
||
});
|
||
|
||
it("shows a multi-day timed event with both dates", () => {
|
||
const event = makeEvent({ start: fromWallTime("2026-10-03", "19:00"), end: fromWallTime("2026-10-05", "12:00") });
|
||
expect(describeWhen(event, now)).toBe("3. Okt., 19:00 – 5. Okt., 12:00");
|
||
});
|
||
|
||
it("shows whole-day events, with the range when they span days", () => {
|
||
const oneDay = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-03", "00:00"), end: fromWallTime("2026-10-03", "23:59") });
|
||
const weekend = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-17", "00:00"), end: fromWallTime("2026-10-19", "23:59") });
|
||
expect(describeWhen(oneDay, now)).toBe("Ganztägig");
|
||
expect(describeWhen(weekend, now)).toBe("Ganztägig · 17.–19. Okt.");
|
||
});
|
||
});
|
||
|
||
describe("occurrence", () => {
|
||
it("moves a birthday's start and end to this year's date together", () => {
|
||
const birthday = makeEvent({
|
||
wholeDay: true,
|
||
repeatFrequency: "YEARLY",
|
||
start: fromWallTime("1994-10-03", "00:00"),
|
||
end: fromWallTime("1994-10-03", "23:59"),
|
||
});
|
||
const { start, end } = occurrence(birthday, now);
|
||
expect(toWallTime(start)).toEqual({ date: "2026-10-03", time: "00:00" });
|
||
expect(toWallTime(end)).toEqual({ date: "2026-10-03", time: "23:59" });
|
||
expect(describeWhen(birthday, now)).toBe("Ganztägig");
|
||
});
|
||
});
|
||
|
||
describe("isPast", () => {
|
||
it("is true only for non-repeating events that ended before today", () => {
|
||
const old = { start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") };
|
||
expect(isPast(makeEvent(old), now)).toBe(true);
|
||
expect(isPast(makeEvent({ ...old, repeatFrequency: "YEARLY" }), now)).toBe(false);
|
||
expect(isPast(makeEvent(), now)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("todayMarkerIndex", () => {
|
||
const past = makeEvent({ eventId: 1, start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") });
|
||
const today = makeEvent({ eventId: 2, start: fromWallTime("2026-09-27", "08:00"), end: fromWallTime("2026-09-27", "09:00") });
|
||
const later = makeEvent({ eventId: 3 });
|
||
|
||
it("sits before the first event not yet over, ascending", () => {
|
||
expect(todayMarkerIndex([past, today, later], "start_asc", now)).toBe(1);
|
||
});
|
||
|
||
it("sits before the first past event, descending", () => {
|
||
expect(todayMarkerIndex([later, today, past], "start_desc", now)).toBe(2);
|
||
});
|
||
|
||
it("goes to the end or the start when everything is on one side", () => {
|
||
expect(todayMarkerIndex([past], "start_asc", now)).toBe(1);
|
||
expect(todayMarkerIndex([later], "start_asc", now)).toBe(0);
|
||
expect(todayMarkerIndex([later], "start_desc", now)).toBe(1);
|
||
});
|
||
|
||
it("has no place in a list sorted by creation, or an empty one", () => {
|
||
expect(todayMarkerIndex([past, later], "created_asc", now)).toBeNull();
|
||
expect(todayMarkerIndex([], "start_asc", now)).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("repeatLabel", () => {
|
||
it("translates known frequencies and passes unknown ones through", () => {
|
||
expect(repeatLabel("")).toBe("");
|
||
expect(repeatLabel("YEARLY")).toBe("jährlich");
|
||
expect(repeatLabel("HOURLY")).toBe("hourly");
|
||
});
|
||
});
|
||
|
||
describe("describeApiError", () => {
|
||
it("explains each kind of failure in German", () => {
|
||
expect(describeApiError(new ApiError(0, {}))).toMatch(/Keine Verbindung/);
|
||
expect(describeApiError(new ApiError(403, {}))).toMatch(/Berechtigung/);
|
||
expect(describeApiError(new ApiError(500, {}))).toMatch(/\(500\)/);
|
||
expect(describeApiError(new ApiError(200, {}, "Der Termin wurde nicht gespeichert."))).toBe(
|
||
"Der Termin wurde nicht gespeichert.",
|
||
);
|
||
expect(describeApiError(new Error("x"))).toMatch(/Unerwarteter Fehler/);
|
||
});
|
||
|
||
it("recognises only 401 as an expired session", () => {
|
||
expect(isSessionExpired(new ApiError(401, {}))).toBe(true);
|
||
expect(isSessionExpired(new ApiError(403, {}))).toBe(false);
|
||
});
|
||
});
|