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,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_LIST_STATE, parseListState, serializeListState, toQueryString, withRange } from "@/lib/url-state";
|
||||
|
||||
const parse = (qs: string) => parseListState(new URLSearchParams(qs));
|
||||
|
||||
describe("parseListState", () => {
|
||||
it("defaults to every calendar except Birthdays, upcoming, by start", () => {
|
||||
expect(parse("")).toEqual({ calendarIds: [1, 2, 4, 3], range: "future", sort: "start_asc", query: "" });
|
||||
});
|
||||
|
||||
it("reads calendars by slug, dropping unknown ones and duplicates", () => {
|
||||
expect(parse("cal=choir,nope,birthdays,choir").calendarIds).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
it("keeps an explicitly empty selection", () => {
|
||||
expect(parse("cal=").calendarIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("sorts the past newest first by default", () => {
|
||||
expect(parse("range=past").sort).toBe("start_desc");
|
||||
expect(parse("range=past&sort=start_asc").sort).toBe("start_asc");
|
||||
});
|
||||
|
||||
it("falls back to defaults for unknown range and sort", () => {
|
||||
expect(parse("range=soon&sort=random")).toMatchObject({ range: "future", sort: "start_asc" });
|
||||
});
|
||||
|
||||
it("reads range, sort and search", () => {
|
||||
expect(parse("range=past&sort=created_desc&q=probe")).toMatchObject({
|
||||
range: "past",
|
||||
sort: "created_desc",
|
||||
query: "probe",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeListState", () => {
|
||||
it("leaves the default view out of the URL", () => {
|
||||
expect(serializeListState(DEFAULT_LIST_STATE).toString()).toBe("");
|
||||
});
|
||||
|
||||
it("treats the default calendars in another order as the default", () => {
|
||||
expect(serializeListState({ ...DEFAULT_LIST_STATE, calendarIds: [3, 4, 2, 1] }).toString()).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips a non-default view", () => {
|
||||
const state = { calendarIds: [5], range: "all" as const, sort: "start_desc" as const, query: "geburtstag" };
|
||||
expect(parseListState(serializeListState(state))).toEqual(state);
|
||||
});
|
||||
|
||||
it("writes an empty selection as cal=", () => {
|
||||
expect(serializeListState({ ...DEFAULT_LIST_STATE, calendarIds: [] }).toString()).toBe("cal=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRange", () => {
|
||||
it("swaps a default sort for the new range's default, keeping the URL clean", () => {
|
||||
const past = withRange(DEFAULT_LIST_STATE, "past");
|
||||
expect(past.sort).toBe("start_desc");
|
||||
expect(serializeListState(past).toString()).toBe("range=past");
|
||||
expect(withRange(past, "all").sort).toBe("start_asc");
|
||||
});
|
||||
|
||||
it("keeps a sort someone picked", () => {
|
||||
const picked = { ...DEFAULT_LIST_STATE, sort: "created_desc" as const };
|
||||
expect(withRange(picked, "past").sort).toBe("created_desc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toQueryString", () => {
|
||||
it("keeps the calendar list readable but escapes commas in the search", () => {
|
||||
const qs = toQueryString({ ...DEFAULT_LIST_STATE, calendarIds: [1, 4], query: "a,b" });
|
||||
expect(qs).toBe("cal=public,choir&q=a%2Cb");
|
||||
expect(parseListState(new URLSearchParams(qs))).toMatchObject({ calendarIds: [1, 4], query: "a,b" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user