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>
73 lines
2.3 KiB
TypeScript
73 lines
2.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseApiEvent, parseApiEvents } from "@/lib/events";
|
|
import type { ApiEvent } from "@/lib/types";
|
|
|
|
const row = (overrides: Partial<ApiEvent> = {}): ApiEvent => ({
|
|
eventId: 7,
|
|
calendarId: 5,
|
|
uuid: "u-7",
|
|
name: "Anna",
|
|
description: null,
|
|
startDateTime: "1990-10-02T22:00:00.000Z",
|
|
endDateTime: "1990-10-03T21:59:00.000Z",
|
|
createdDate: "2024-01-01T10:00:00.000Z",
|
|
lastModifiedDate: "2024-02-01T10:00:00.000Z",
|
|
location: null,
|
|
createdBy: null,
|
|
lastModifiedBy: "Paula",
|
|
url: null,
|
|
wholeDay: 1,
|
|
repeatFrequency: "YEARLY",
|
|
status: "DRAFT",
|
|
...overrides,
|
|
});
|
|
|
|
describe("parseApiEvent", () => {
|
|
it("coerces the raw row: 1 → true, nulls → empty strings, ISO → Date", () => {
|
|
const event = parseApiEvent(row());
|
|
expect(event).toMatchObject({
|
|
eventId: 7,
|
|
wholeDay: true,
|
|
description: "",
|
|
location: "",
|
|
url: "",
|
|
createdBy: "",
|
|
lastModifiedBy: "Paula",
|
|
repeatFrequency: "YEARLY",
|
|
status: "DRAFT",
|
|
});
|
|
expect(event?.start.toISOString()).toBe("1990-10-02T22:00:00.000Z");
|
|
});
|
|
|
|
it("drops deleted events", () => {
|
|
expect(parseApiEvent(row({ status: "DELETED" }))).toBeNull();
|
|
});
|
|
|
|
it("drops a row without a usable start or end - what a deleted version looks like", () => {
|
|
expect(parseApiEvent(row({ status: null, startDateTime: null }))).toBeNull();
|
|
expect(parseApiEvent(row({ endDateTime: "not a date" }))).toBeNull();
|
|
});
|
|
|
|
it("reads a missing status as PUBLIC (the public listing omits it)", () => {
|
|
const withoutStatus = row();
|
|
delete withoutStatus.status;
|
|
expect(parseApiEvent(withoutStatus)?.status).toBe("PUBLIC");
|
|
});
|
|
|
|
it("never promotes an unknown status to PUBLIC", () => {
|
|
expect(parseApiEvent(row({ status: "ARCHIVED" }))?.status).toBe("PRIVATE");
|
|
});
|
|
|
|
it("reads 0 and null as not whole-day", () => {
|
|
expect(parseApiEvent(row({ wholeDay: 0 }))?.wholeDay).toBe(false);
|
|
expect(parseApiEvent(row({ wholeDay: null }))?.wholeDay).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseApiEvents", () => {
|
|
it("keeps only the rows worth showing", () => {
|
|
const events = parseApiEvents([row({ eventId: 1 }), row({ eventId: 2, status: "DELETED" }), row({ eventId: 3 })]);
|
|
expect(events.map((e) => e.eventId)).toEqual([1, 3]);
|
|
});
|
|
});
|