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,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toWallTime } from "@/lib/dates";
|
||||
import {
|
||||
contentChanged,
|
||||
duplicateForm,
|
||||
eventFromPayload,
|
||||
formFromEvent,
|
||||
isFormDirty,
|
||||
newEventForm,
|
||||
normaliseUrl,
|
||||
payloadFromEvent,
|
||||
setCalendar,
|
||||
setStart,
|
||||
toPayload,
|
||||
validateEventForm,
|
||||
type EventForm,
|
||||
} from "@/lib/event-form";
|
||||
import { makeEvent } from "@/lib/test-fixtures";
|
||||
|
||||
const now = new Date("2026-09-27T10:20:00Z"); // 12:20 in Berlin
|
||||
|
||||
const validForm = (overrides: Partial<EventForm> = {}): EventForm => ({
|
||||
...formFromEvent(makeEvent()),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("newEventForm", () => {
|
||||
it("starts at the next full Berlin hour, lasts two hours, and is a draft", () => {
|
||||
const form = newEventForm(now, 2);
|
||||
expect(form).toMatchObject({
|
||||
calendarId: 2,
|
||||
startDate: "2026-09-27",
|
||||
startTime: "13:00",
|
||||
endDate: "2026-09-27",
|
||||
endTime: "15:00",
|
||||
status: "DRAFT",
|
||||
repeatFrequency: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("makes a new birthday yearly", () => {
|
||||
expect(newEventForm(now, 5).repeatFrequency).toBe("YEARLY");
|
||||
});
|
||||
|
||||
it("leaves the calendar unchosen when none is given", () => {
|
||||
expect(validateEventForm({ ...newEventForm(now), name: "x" }).calendarId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateForm", () => {
|
||||
it("copies the event as a new draft with a marked title", () => {
|
||||
const form = duplicateForm(makeEvent({ name: "Probe", status: "PUBLIC" }));
|
||||
expect(form.name).toBe("Probe (Kopie)");
|
||||
expect(form.status).toBe("DRAFT");
|
||||
expect(form.startTime).toBe("19:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setStart", () => {
|
||||
it("moves the end along, keeping a timed event's duration", () => {
|
||||
const form = setStart(validForm(), "2026-10-10", "18:30");
|
||||
expect(form).toMatchObject({ startDate: "2026-10-10", startTime: "18:30", endDate: "2026-10-10", endTime: "20:30" });
|
||||
});
|
||||
|
||||
it("keeps the duration in real time across the DST switch", () => {
|
||||
// 2 h starting 01:00 on 25 Oct: 01:00 CEST + 2 h = 02:00 CET.
|
||||
const form = setStart(validForm(), "2026-10-25", "01:00");
|
||||
expect(form).toMatchObject({ endDate: "2026-10-25", endTime: "02:00" });
|
||||
});
|
||||
|
||||
it("keeps a whole-day event's length in days", () => {
|
||||
const form = setStart(
|
||||
validForm({ wholeDay: true, startDate: "2026-07-04", endDate: "2026-07-06" }),
|
||||
"2026-08-01",
|
||||
"",
|
||||
);
|
||||
expect(form).toMatchObject({ startDate: "2026-08-01", endDate: "2026-08-03" });
|
||||
});
|
||||
|
||||
it("does not guess an end while the start is being cleared", () => {
|
||||
const form = setStart(validForm(), "", "19:00");
|
||||
expect(form).toMatchObject({ startDate: "", endDate: "2026-10-03" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCalendar", () => {
|
||||
it("makes an event yearly when it moves into Birthdays", () => {
|
||||
expect(setCalendar(validForm(), 5).repeatFrequency).toBe("YEARLY");
|
||||
});
|
||||
|
||||
it("drops the forced recurrence when it moves out of Birthdays", () => {
|
||||
expect(setCalendar(validForm({ calendarId: 5, repeatFrequency: "YEARLY" }), 2).repeatFrequency).toBe("");
|
||||
});
|
||||
|
||||
it("keeps a recurrence that other calendars' events already had", () => {
|
||||
expect(setCalendar(validForm({ calendarId: 2, repeatFrequency: "WEEKLY" }), 4).repeatFrequency).toBe("WEEKLY");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normaliseUrl", () => {
|
||||
it.each([
|
||||
["", ""],
|
||||
[" ", ""],
|
||||
["nachklang.art", "https://nachklang.art"],
|
||||
["www.nachklang.art/konzert?x=1", "https://www.nachklang.art/konzert?x=1"],
|
||||
["http://example.org", "http://example.org"],
|
||||
["https://münchen.de", "https://münchen.de"],
|
||||
])("accepts %j as %j", (raw, value) => {
|
||||
expect(normaliseUrl(raw)).toEqual({ ok: true, value });
|
||||
});
|
||||
|
||||
it.each(["nachklang", "javascript:alert(1)", "mailto:info@nachklang.art", "https://", "ht tp://x.de"])(
|
||||
"rejects %j",
|
||||
(raw) => {
|
||||
expect(normaliseUrl(raw).ok).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("validateEventForm", () => {
|
||||
it("accepts a complete event", () => {
|
||||
expect(validateEventForm(validForm())).toEqual({});
|
||||
});
|
||||
|
||||
it("requires a title and a calendar", () => {
|
||||
const errors = validateEventForm(validForm({ name: " ", calendarId: 99 }));
|
||||
expect(Object.keys(errors).sort()).toEqual(["calendarId", "name"]);
|
||||
});
|
||||
|
||||
it("requires times only for timed events", () => {
|
||||
expect(validateEventForm(validForm({ startTime: "" })).startTime).toBeDefined();
|
||||
expect(validateEventForm(validForm({ wholeDay: true, startTime: "", endTime: "" }))).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects an end before the start, on the end field", () => {
|
||||
expect(validateEventForm(validForm({ endTime: "18:00" })).endDate).toBe("Das Ende liegt vor dem Beginn.");
|
||||
});
|
||||
|
||||
it("allows a one-day whole-day event whose hidden times would be backwards", () => {
|
||||
expect(validateEventForm(validForm({ wholeDay: true, startTime: "22:00", endTime: "08:00" }))).toEqual({});
|
||||
});
|
||||
|
||||
it("flags an unusable URL", () => {
|
||||
expect(validateEventForm(validForm({ url: "kein link" })).url).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toPayload", () => {
|
||||
it("sends UTC instants of the Berlin wall times, and every field", () => {
|
||||
const payload = toPayload(validForm({ name: " Konzert ", url: "nachklang.art" }));
|
||||
expect(payload).toEqual({
|
||||
calendarId: 1,
|
||||
name: "Konzert",
|
||||
description: "",
|
||||
startDateTime: "2026-10-03T17:00:00.000Z",
|
||||
endDateTime: "2026-10-03T19:00:00.000Z",
|
||||
location: "Karlsruhe",
|
||||
url: "https://nachklang.art",
|
||||
wholeDay: false,
|
||||
repeatFrequency: "",
|
||||
status: "PUBLIC",
|
||||
});
|
||||
});
|
||||
|
||||
it("saves whole-day events as 00:00-23:59 Berlin, ignoring stale times", () => {
|
||||
const payload = toPayload(
|
||||
validForm({ wholeDay: true, startDate: "2026-12-24", endDate: "2026-12-26", startTime: "19:00", endTime: "21:00" }),
|
||||
);
|
||||
expect(toWallTime(new Date(payload.startDateTime))).toEqual({ date: "2026-12-24", time: "00:00" });
|
||||
expect(toWallTime(new Date(payload.endDateTime))).toEqual({ date: "2026-12-26", time: "23:59" });
|
||||
});
|
||||
|
||||
it("keeps the birthday recurrence and any other status", () => {
|
||||
const payload = toPayload(validForm({ calendarId: 5, repeatFrequency: "YEARLY", status: "PRIVATE" }));
|
||||
expect(payload).toMatchObject({ repeatFrequency: "YEARLY", status: "PRIVATE" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("payloadFromEvent", () => {
|
||||
it("resends the full event with only the status changed", () => {
|
||||
const event = makeEvent({ status: "DRAFT", repeatFrequency: "YEARLY", calendarId: 5 });
|
||||
expect(payloadFromEvent(event, "PUBLIC")).toMatchObject({
|
||||
status: "PUBLIC",
|
||||
repeatFrequency: "YEARLY",
|
||||
calendarId: 5,
|
||||
startDateTime: event.start.toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFormDirty", () => {
|
||||
it("notices any change and nothing else", () => {
|
||||
const form = validForm();
|
||||
expect(isFormDirty(form, { ...form })).toBe(false);
|
||||
expect(isFormDirty(form, { ...form, location: "Mannheim" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentChanged", () => {
|
||||
it("ignores a change of calendar alone", () => {
|
||||
const form = validForm();
|
||||
expect(contentChanged(form, { ...form, calendarId: 4 })).toBe(false);
|
||||
expect(contentChanged(form, { ...form, calendarId: 4, name: "Neu" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("eventFromPayload", () => {
|
||||
const now = new Date("2026-09-28T12:00:00Z");
|
||||
|
||||
it("keeps who created an edited event and records who changed it", () => {
|
||||
const previous = makeEvent({ createdBy: "Anna", uuid: "u-1" });
|
||||
const saved = eventFromPayload(1, toPayload(validForm({ name: "Neu" })), "Ben", now, previous);
|
||||
expect(saved).toMatchObject({ eventId: 1, uuid: "u-1", name: "Neu", createdBy: "Anna", lastModifiedBy: "Ben" });
|
||||
expect(saved.created).toBe(previous.created);
|
||||
expect(saved.start.toISOString()).toBe("2026-10-03T17:00:00.000Z");
|
||||
});
|
||||
|
||||
it("attributes a new event to the author", () => {
|
||||
const saved = eventFromPayload(42, toPayload(validForm()), "Ben", now);
|
||||
expect(saved).toMatchObject({ eventId: 42, createdBy: "Ben", created: now });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user