Files
Calendar_Frontend/lib/dates.test.ts
T
PaddyandClaude Opus 5.5 0cda40f85e 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>
2026-09-28 22:15:59 +02:00

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");
});
});