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>
108 lines
4.9 KiB
TypeScript
108 lines
4.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { fromWallTime } from "@/lib/dates";
|
|
import { filterEvents, groupByMonth, matchesQuery, matchesRange, sortEvents } from "@/lib/filter";
|
|
import { makeEvent } from "@/lib/test-fixtures";
|
|
|
|
const now = new Date("2026-09-27T10:00:00Z"); // 12:00 Berlin
|
|
|
|
describe("matchesRange", () => {
|
|
it("keeps an event that ended earlier today in 'future' until midnight", () => {
|
|
const earlierToday = makeEvent({
|
|
start: fromWallTime("2026-09-27", "08:00"),
|
|
end: fromWallTime("2026-09-27", "09:00"),
|
|
});
|
|
expect(matchesRange(earlierToday, "future", now)).toBe(true);
|
|
expect(matchesRange(earlierToday, "past", now)).toBe(false);
|
|
});
|
|
|
|
it("puts yesterday's event in 'past'", () => {
|
|
const yesterday = makeEvent({ start: fromWallTime("2026-09-26", "19:00"), end: fromWallTime("2026-09-26", "21:00") });
|
|
expect(matchesRange(yesterday, "past", now)).toBe(true);
|
|
expect(matchesRange(yesterday, "future", now)).toBe(false);
|
|
});
|
|
|
|
it("treats any repeating event as current, whatever its calendar", () => {
|
|
const old = { start: fromWallTime("1990-01-01", "00:00"), end: fromWallTime("1990-01-01", "23:59") };
|
|
const birthday = makeEvent({ ...old, calendarId: 5, repeatFrequency: "YEARLY" });
|
|
const weeklyRehearsal = makeEvent({ ...old, calendarId: 4, repeatFrequency: "WEEKLY" });
|
|
for (const e of [birthday, weeklyRehearsal]) {
|
|
expect(matchesRange(e, "future", now)).toBe(true);
|
|
expect(matchesRange(e, "past", now)).toBe(false);
|
|
expect(matchesRange(e, "all", now)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("no longer exempts a non-repeating event just for being in Birthdays", () => {
|
|
const oneOff = makeEvent({ calendarId: 5, start: fromWallTime("2020-01-01", "00:00"), end: fromWallTime("2020-01-01", "23:59") });
|
|
expect(matchesRange(oneOff, "future", now)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("matchesQuery", () => {
|
|
const event = makeEvent({ name: "Probe Weihnachtskonzert", location: "Gemeindehaus Süd" });
|
|
|
|
it("matches title and location, case-insensitively, all words", () => {
|
|
expect(matchesQuery(event, "")).toBe(true);
|
|
expect(matchesQuery(event, "weihnacht")).toBe(true);
|
|
expect(matchesQuery(event, "PROBE süd")).toBe(true);
|
|
expect(matchesQuery(event, "probe nord")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("filterEvents", () => {
|
|
it("combines calendar, range and search", () => {
|
|
const events = [
|
|
makeEvent({ eventId: 1, calendarId: 1, name: "Konzert" }),
|
|
makeEvent({ eventId: 2, calendarId: 2, name: "Konzert" }),
|
|
makeEvent({ eventId: 3, calendarId: 1, name: "Probe" }),
|
|
];
|
|
const result = filterEvents(events, { calendarIds: [1], range: "future", query: "konz" }, now);
|
|
expect(result.map((e) => e.eventId)).toEqual([1]);
|
|
});
|
|
});
|
|
|
|
describe("sortEvents", () => {
|
|
const a = makeEvent({ eventId: 1, name: "B", start: fromWallTime("2026-10-02", "19:00") });
|
|
const b = makeEvent({ eventId: 2, name: "A", start: fromWallTime("2026-10-02", "19:00") });
|
|
const c = makeEvent({ eventId: 3, name: "C", start: fromWallTime("2026-10-01", "19:00") });
|
|
|
|
it("orders by start and breaks ties by title", () => {
|
|
expect(sortEvents([a, b, c], "start_asc", now).map((e) => e.eventId)).toEqual([3, 2, 1]);
|
|
// Descending flips the dates only; equal starts still read A before B.
|
|
expect(sortEvents([a, b, c], "start_desc", now).map((e) => e.eventId)).toEqual([2, 1, 3]);
|
|
});
|
|
|
|
it("does not mutate its input", () => {
|
|
const input = [a, b, c];
|
|
sortEvents(input, "start_asc", now);
|
|
expect(input.map((e) => e.eventId)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("sorts a birthday by its next occurrence, not its year", () => {
|
|
const birthday = makeEvent({ eventId: 9, repeatFrequency: "YEARLY", start: fromWallTime("1990-10-01", "12:00"), end: fromWallTime("1990-10-01", "23:59") });
|
|
const concert = makeEvent({ eventId: 8, start: fromWallTime("2026-10-02", "19:00") });
|
|
expect(sortEvents([concert, birthday], "start_asc", now).map((e) => e.eventId)).toEqual([9, 8]);
|
|
});
|
|
|
|
it("sorts by created date, missing ones first", () => {
|
|
const x = makeEvent({ eventId: 1, created: new Date("2026-05-01T00:00:00Z") });
|
|
const y = makeEvent({ eventId: 2, created: null });
|
|
expect(sortEvents([x, y], "created_asc", now).map((e) => e.eventId)).toEqual([2, 1]);
|
|
});
|
|
});
|
|
|
|
describe("groupByMonth", () => {
|
|
it("groups consecutive events by Berlin month, labelled in German", () => {
|
|
const events = [
|
|
makeEvent({ eventId: 1, start: fromWallTime("2026-10-01", "00:30") }), // still 30 Sep in UTC
|
|
makeEvent({ eventId: 2, start: fromWallTime("2026-10-20", "19:00") }),
|
|
makeEvent({ eventId: 3, start: fromWallTime("2026-11-02", "19:00") }),
|
|
];
|
|
const groups = groupByMonth(events, now);
|
|
expect(groups.map((g) => [g.key, g.label, g.events.map((e) => e.eventId)])).toEqual([
|
|
["2026-10", "Oktober 2026", [1, 2]],
|
|
["2026-11", "November 2026", [3]],
|
|
]);
|
|
});
|
|
});
|