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,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addDays,
|
||||
berlinDay,
|
||||
daysBetween,
|
||||
formatDateTime,
|
||||
formatMonth,
|
||||
fromWallTime,
|
||||
monthKey,
|
||||
nextFullHour,
|
||||
nextOccurrence,
|
||||
toWallTime,
|
||||
wholeDayRange,
|
||||
} from "@/lib/dates";
|
||||
|
||||
// vitest.config.ts runs this with TZ=America/New_York, so none of these can
|
||||
// pass by accident on a machine that happens to be in Berlin.
|
||||
|
||||
describe("the test environment", () => {
|
||||
it("is not in Berlin", () => {
|
||||
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe("America/New_York");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromWallTime / toWallTime", () => {
|
||||
it("treats wall times as Berlin in summer (UTC+2) and winter (UTC+1)", () => {
|
||||
expect(fromWallTime("2026-06-15", "19:00").toISOString()).toBe("2026-06-15T17:00:00.000Z");
|
||||
expect(fromWallTime("2026-12-15", "19:00").toISOString()).toBe("2026-12-15T18:00:00.000Z");
|
||||
});
|
||||
|
||||
it("round-trips", () => {
|
||||
expect(toWallTime(fromWallTime("2026-10-03", "07:05"))).toEqual({ date: "2026-10-03", time: "07:05" });
|
||||
});
|
||||
|
||||
it("reads an instant back as Berlin, including across midnight", () => {
|
||||
// 23:30 UTC is already the next day in Berlin.
|
||||
expect(toWallTime(new Date("2026-06-15T23:30:00Z"))).toEqual({ date: "2026-06-16", time: "01:30" });
|
||||
});
|
||||
|
||||
it("moves a time in the spring-forward gap to after it", () => {
|
||||
// 02:30 does not exist on 2026-03-29 in Berlin; it becomes 03:30 CEST.
|
||||
const instant = fromWallTime("2026-03-29", "02:30");
|
||||
expect(instant.toISOString()).toBe("2026-03-29T01:30:00.000Z");
|
||||
expect(toWallTime(instant)).toEqual({ date: "2026-03-29", time: "03:30" });
|
||||
});
|
||||
|
||||
it("resolves a time that exists twice in autumn to the second (CET) one", () => {
|
||||
expect(fromWallTime("2026-10-25", "02:30").toISOString()).toBe("2026-10-25T01:30:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("day arithmetic", () => {
|
||||
it("adds days across month and year ends", () => {
|
||||
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
|
||||
expect(addDays("2026-03-01", -1)).toBe("2026-02-28");
|
||||
});
|
||||
|
||||
it("is not thrown off by the DST switch", () => {
|
||||
expect(addDays("2026-03-28", 2)).toBe("2026-03-30");
|
||||
expect(daysBetween("2026-10-24", "2026-10-26")).toBe(2);
|
||||
});
|
||||
|
||||
it("reports the Berlin day", () => {
|
||||
expect(berlinDay(new Date("2026-06-15T22:30:00Z"))).toBe("2026-06-16");
|
||||
});
|
||||
});
|
||||
|
||||
describe("wholeDayRange", () => {
|
||||
it("spans 00:00 on the first day to 23:59 on the last, Berlin", () => {
|
||||
const { start, end } = wholeDayRange("2026-07-04", "2026-07-05");
|
||||
expect(toWallTime(start)).toEqual({ date: "2026-07-04", time: "00:00" });
|
||||
expect(toWallTime(end)).toEqual({ date: "2026-07-05", time: "23:59" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextFullHour", () => {
|
||||
it("rounds up to the next hour in Berlin", () => {
|
||||
expect(nextFullHour(new Date("2026-06-15T17:20:00Z"))).toEqual({ date: "2026-06-15", time: "20:00" });
|
||||
});
|
||||
|
||||
it("rolls over midnight", () => {
|
||||
expect(nextFullHour(new Date("2026-06-15T21:59:00Z"))).toEqual({ date: "2026-06-16", time: "00:00" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextOccurrence", () => {
|
||||
const now = new Date("2026-09-27T10:00:00Z");
|
||||
|
||||
it("leaves a future start alone", () => {
|
||||
const start = fromWallTime("2026-10-01", "00:00");
|
||||
expect(nextOccurrence(start, "YEARLY", now)).toBe(start);
|
||||
});
|
||||
|
||||
it("moves a birthday to this year when it is still ahead", () => {
|
||||
const start = fromWallTime("1990-11-03", "00:00");
|
||||
expect(toWallTime(nextOccurrence(start, "YEARLY", now))).toEqual({ date: "2026-11-03", time: "00:00" });
|
||||
});
|
||||
|
||||
it("moves a birthday to next year when it has passed", () => {
|
||||
const start = fromWallTime("1990-02-10", "00:00");
|
||||
expect(toWallTime(nextOccurrence(start, "YEARLY", now)).date).toBe("2027-02-10");
|
||||
});
|
||||
|
||||
it("counts a birthday today as current", () => {
|
||||
const start = fromWallTime("1990-09-27", "00:00");
|
||||
expect(toWallTime(nextOccurrence(start, "YEARLY", now)).date).toBe("2026-09-27");
|
||||
});
|
||||
|
||||
it("puts 29 February on 28 February in common years", () => {
|
||||
const start = fromWallTime("2000-02-29", "00:00");
|
||||
expect(toWallTime(nextOccurrence(start, "YEARLY", now)).date).toBe("2027-02-28");
|
||||
});
|
||||
|
||||
it("handles weekly and monthly series", () => {
|
||||
const monday = fromWallTime("2026-01-05", "19:30"); // a Monday
|
||||
expect(toWallTime(nextOccurrence(monday, "WEEKLY", now))).toEqual({ date: "2026-09-28", time: "19:30" });
|
||||
const the31st = fromWallTime("2026-01-31", "18:00");
|
||||
expect(toWallTime(nextOccurrence(the31st, "MONTHLY", now)).date).toBe("2026-09-30");
|
||||
});
|
||||
|
||||
it("leaves unknown frequencies alone", () => {
|
||||
const start = fromWallTime("2020-01-01", "00:00");
|
||||
expect(nextOccurrence(start, "SOMETIMES", now)).toBe(start);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatting", () => {
|
||||
it("formats German, in Berlin time", () => {
|
||||
const instant = new Date("2026-10-03T17:00:00Z");
|
||||
expect(formatDateTime(instant)).toBe("03.10.2026, 19:00");
|
||||
expect(formatMonth(instant)).toBe("Oktober 2026");
|
||||
expect(monthKey(new Date("2026-09-30T22:30:00Z"))).toBe("2026-10");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user