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>
135 lines
4.9 KiB
TypeScript
135 lines
4.9 KiB
TypeScript
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");
|
|
});
|
|
});
|