Files
Calendar_Frontend/lib/event-display.test.ts
T
PaddyandClaude Opus 5.5 0cda40f85e 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>
2026-09-28 22:15:59 +02:00

108 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});