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:
PaddyandClaude Opus 5.5 committed 2026-09-28 22:15:59 +02:00
1 parent f82255961b
commit 0cda40f85e
138 files changed
+15942 -24010

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
import { ADMIN_APP_URL, API_BASE_URL } from "@/lib/env";
/**
* Where signing in and out happens - which is never here. Mirrors
* `lib/admin-auth.ts` in nachklang-feedback; keep the two in step.
*
* The session is an httpOnly cookie that the *API* sets on `.nachklang.art`,
* shared by the calendar, feedback, tickets and admin apps. This app can
* neither read nor mint it, so a 401 is not something it can fix by
* re-authenticating: it hands the user over to admin.nachklang.art and lets
* that app decide.
*/
/**
* The admin app's login URL, carrying an absolute `?redirect=` back to where
* the user is now. The admin app checks that target against its own
* allow-list by exact origin, so an origin missing from its
* NEXT_PUBLIC_ALLOWED_REDIRECT_ORIGINS lands the user on the admin start page
* rather than back here - which is why the dev server has to run on :4200.
*/
export const adminLoginUrl = (returnTo?: string): string => {
const target =
returnTo ?? (typeof window === "undefined" ? "/admin" : `${window.location.pathname}${window.location.search}`);
const absolute = typeof window === "undefined" ? target : new URL(target, window.location.origin).href;
return `${ADMIN_APP_URL}/login?redirect=${encodeURIComponent(absolute)}`;
};
/**
* Hard navigation, not router.push: every target here is a different origin.
* Kept behind a helper so the destination is a value rather than a template
* literal at the assignment - otherwise Next's
* no-location-assign-relative-destination rule reads an absolute URL built from
* an env var as an internal route.
*/
const leaveApp = (url: string): void => {
window.location.href = url;
};
export const goToAdminLogin = (returnTo?: string): void => {
leaveApp(adminLoginUrl(returnTo));
};
/**
* Signs out of every Nachklang app at once - the cookie is shared, so there is
* no signing out of the calendar alone. The redirect happens even if the call
* fails: leaving someone on an admin page that will 401 on its next request is
* worse than a login form they may not have needed.
*
* The login URL carries this page as the return target, so an accidental
* sign-out costs one sign-in to undo.
*/
export const signOut = async (): Promise<void> => {
// Captured before the request, so the target is where the user actually was.
const target = adminLoginUrl();
try {
await fetch(`${API_BASE_URL}/admin/auth/sign-out`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: "{}",
});
} catch {
// Network failure: fall through to the redirect anyway.
}
leaveApp(target);
};
+31
View File
@@ -0,0 +1,31 @@
import { ApiError } from "@/lib/api";
/**
* What to tell an editor when a call fails. German, one sentence, no status
* codes up front - the code is appended for anything unexpected, so a
* screenshot of the message is still useful to whoever debugs it.
*
* 401 is listed for completeness; callers should send the user to the login
* page instead of showing it (see `isSessionExpired`).
*/
export const describeApiError = (error: unknown): string => {
if (!(error instanceof ApiError)) {
return "Unerwarteter Fehler. Bitte versuche es erneut.";
}
switch (error.status) {
case 0:
return "Keine Verbindung zum Server. Bitte prüfe deine Internetverbindung.";
case 401:
return "Deine Sitzung ist abgelaufen.";
case 403:
return "Dein Konto hat keine Berechtigung (mehr) für den Kalender.";
case 200:
// A 200 that the client decided was a failure (e.g. "0 rows were updated") carries its own German message.
return error.message;
default:
return `Der Server hat einen Fehler gemeldet (${error.status}). Bitte versuche es erneut.`;
}
};
/** Nobody is signed in any more - the one case worth a trip to the login page. */
export const isSessionExpired = (error: unknown): boolean => error instanceof ApiError && error.status === 401;
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { api, ApiError } from "@/lib/api";
import type { EventPayload } from "@/lib/types";
const payload: EventPayload = {
calendarId: 1,
name: "Konzert",
description: "",
startDateTime: "2026-10-03T17:00:00.000Z",
endDateTime: "2026-10-03T19:00:00.000Z",
location: "",
url: "",
wholeDay: false,
repeatFrequency: "",
status: "DRAFT",
};
const respond = (status: number, body?: unknown) =>
vi.fn().mockResolvedValue(
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
);
afterEach(() => {
vi.unstubAllGlobals();
});
describe("api", () => {
it("always sends the session cookie", async () => {
const fetch = respond(200, { id: "u", email: "e", fullName: "Name", apps: ["calendar"] });
vi.stubGlobal("fetch", fetch);
await api.me();
expect(fetch).toHaveBeenCalledWith("https://api.test/admin/me", expect.objectContaining({ credentials: "include" }));
});
it("keeps 401 and 403 apart", async () => {
vi.stubGlobal("fetch", respond(401, { message: "Anmeldung erforderlich." }));
await expect(api.me()).rejects.toMatchObject({ status: 401 });
vi.stubGlobal("fetch", respond(403, { message: "nope" }));
await expect(api.me()).rejects.toMatchObject({ status: 403 });
});
it("reports a network failure as status 0", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
const error = await api.me().catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(0);
});
it("parses events and drops deleted ones", async () => {
vi.stubGlobal(
"fetch",
respond(200, [
{ eventId: 1, calendarId: 1, uuid: "a", name: "A", description: "", startDateTime: payload.startDateTime, endDateTime: payload.endDateTime, createdDate: null, location: "", url: "", wholeDay: 0, repeatFrequency: "", status: "PUBLIC" },
{ eventId: 2, calendarId: 1, uuid: "b", name: null, description: null, startDateTime: null, endDateTime: null, createdDate: null, location: null, url: null, wholeDay: null, repeatFrequency: null, status: "DELETED" },
]),
);
const events = await api.getEvents("public");
expect(events.map((e) => e.eventId)).toEqual([1]);
});
it("returns the new id on create", async () => {
const fetch = respond(201, { message: "ok", eventId: 42 });
vi.stubGlobal("fetch", fetch);
await expect(api.createEvent(payload)).resolves.toBe(42);
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual(payload);
});
it("treats an update that wrote nothing as a failure", async () => {
vi.stubGlobal("fetch", respond(200, { message: "0 rows were updated" }));
await expect(api.updateEvent(1, payload)).rejects.toBeInstanceOf(ApiError);
});
it("moves and deletes by id", async () => {
const fetch = respond(200, { message: "ok" });
vi.stubGlobal("fetch", fetch);
await api.moveEvent(9, { ...payload, calendarId: 4 });
await api.deleteEvent(9);
expect(fetch.mock.calls.map((c) => [c[0], c[1].method])).toEqual([
["https://api.test/calendar/events/move/9", "PUT"],
["https://api.test/calendar/events/9", "DELETE"],
]);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { calendarById, type CalendarSlug } from "@/lib/calendars";
import { API_BASE_URL } from "@/lib/env";
import { parseApiEvents } from "@/lib/events";
import type { AdminUser, ApiEvent, CalendarEvent, EventPayload } from "@/lib/types";
/**
* Every call to the API, made from the browser straight to the API's origin.
*
* `credentials: "include"` is mandatory, not a detail: the API is a different
* origin, so without it the browser leaves the session cookie behind and every
* call is anonymous. That fails as a plain 401, which reads like an expired
* login rather than a missing option.
*
* Callers must tell 401 from 403 (see `ApiError.status`): 401 means nobody is
* signed in and is the only status worth a trip to the login page; redirecting
* on 403 loops, because signing in succeeds and lands straight back on the
* refusal.
*/
export class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, body: { message?: string } & Record<string, unknown>, message?: string) {
super(message ?? body?.message ?? `API request failed with ${status}`);
this.status = status;
this.body = body;
}
}
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
let res: Response;
try {
res = await fetch(`${API_BASE_URL}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...init?.headers },
});
} catch {
// Network failure or a CORS refusal - the browser hides which. Status 0
// keeps it apart from anything the API itself answered.
throw new ApiError(0, {}, "Keine Verbindung zum Server.");
}
if (res.status === 204) {
return undefined as T;
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(res.status, data);
}
return data as T;
};
const eventsPath = "/calendar/events";
export const api = {
/** Who is signed in, across all four apps. */
me: (): Promise<AdminUser> => request<AdminUser>("/admin/me"),
/**
* One calendar's events, as the admin view: all statuses, deleted ones
* already dropped by the parser. A signed-in editor gets drafts; the same
* URL without a session returns only published events (or 403 for the
* internal calendars).
*/
getEvents: async (slug: CalendarSlug): Promise<CalendarEvent[]> =>
parseApiEvents(await request<ApiEvent[]>(`${eventsPath}/${slug}/json`)),
createEvent: async (payload: EventPayload): Promise<number> => {
const res = await request<{ eventId?: number }>(eventsPath, { method: "POST", body: JSON.stringify(payload) });
if (typeof res?.eventId !== "number") {
throw new ApiError(200, res ?? {}, "Der Server hat keine Termin-ID zurückgegeben.");
}
return res.eventId;
},
/**
* The API answers 200 even when nothing was written, with
* `{message: "0 rows were updated"}` - so that body is a failure here.
*/
updateEvent: async (eventId: number, payload: EventPayload): Promise<void> => {
const res = await request<{ message?: string }>(`${eventsPath}/${eventId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
if (res?.message === "0 rows were updated") {
throw new ApiError(200, res, "Der Termin wurde nicht gespeichert.");
}
},
/** Moves the event to `payload.calendarId`. Only the calendar changes server-side. */
moveEvent: async (eventId: number, payload: EventPayload): Promise<void> => {
if (!calendarById(payload.calendarId)) {
throw new Error(`Unknown calendar ${payload.calendarId}`);
}
await request(`${eventsPath}/move/${eventId}`, { method: "PUT", body: JSON.stringify(payload) });
},
/**
* Ready-made iCal URLs per calendar, with the shared password in place where
* one is needed. Calendars whose password isn't configured are missing.
*/
getSubscriptions: (): Promise<{ calendar: CalendarSlug; icalUrl: string }[]> =>
request(`${eventsPath}/subscriptions`),
/** A soft delete: the API appends a version with status DELETED. */
deleteEvent: async (eventId: number): Promise<void> => {
await request(`${eventsPath}/${eventId}`, { method: "DELETE" });
},
};
+82
View File
@@ -0,0 +1,82 @@
/**
* The calendars, in one place.
*
* The Angular app kept this mapping three times - a switch in the admin page,
* an index-into-array in the move popup (which only worked because ids 3 and 4
* happen to sit in that order) and `calendarId === 5` checks in the API service
* and the filter - and the copies had drifted: the move popup could not move an
* event to Birthdays.
*
* Ids and slugs mirror `calendarNames` in the API's events.router.ts; the API
* has no endpoint that lists them (docs/api-wishlist.md), so a new calendar
* needs an entry here too.
*
* Class names are written out in full so Tailwind's source scan finds them.
*/
export type CalendarSlug = "public" | "members" | "choir" | "management" | "birthdays";
export interface Calendar {
id: number;
slug: CalendarSlug;
label: string;
/** Shown by default in the admin list. Birthdays would flood it. */
defaultVisible: boolean;
/**
* A recurrence every event in this calendar gets, whatever the form says.
* Replaces the old `calendarId === 5` special case in api.service.ts.
*/
forcedRepeatFrequency?: string;
dotClass: string;
badgeClass: string;
}
export const CALENDARS: readonly Calendar[] = [
{
id: 1,
slug: "public",
label: "Öffentlich",
defaultVisible: true,
dotClass: "bg-sky-500",
badgeClass: "bg-sky-50 text-sky-800 ring-sky-200",
},
{
id: 2,
slug: "members",
label: "Mitglieder",
defaultVisible: true,
dotClass: "bg-emerald-500",
badgeClass: "bg-emerald-50 text-emerald-800 ring-emerald-200",
},
{
id: 4,
slug: "choir",
label: "Chor",
defaultVisible: true,
dotClass: "bg-violet-500",
badgeClass: "bg-violet-50 text-violet-800 ring-violet-200",
},
{
id: 3,
slug: "management",
label: "Management",
defaultVisible: true,
dotClass: "bg-amber-500",
badgeClass: "bg-amber-50 text-amber-900 ring-amber-200",
},
{
id: 5,
slug: "birthdays",
label: "Geburtstage",
defaultVisible: false,
forcedRepeatFrequency: "YEARLY",
dotClass: "bg-rose-500",
badgeClass: "bg-rose-50 text-rose-800 ring-rose-200",
},
];
export const calendarById = (id: number): Calendar | undefined => CALENDARS.find((c) => c.id === id);
export const calendarBySlug = (slug: string): Calendar | undefined => CALENDARS.find((c) => c.slug === slug);
export const DEFAULT_VISIBLE_CALENDAR_IDS: readonly number[] = CALENDARS.filter((c) => c.defaultVisible).map((c) => c.id);
+134
View File
@@ -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");
});
});
+169
View File
@@ -0,0 +1,169 @@
import { TZDate } from "@date-fns/tz";
/**
* Every wall-clock time in this app means Europe/Berlin, whatever time zone the
* editor's browser happens to be in.
*
* The Angular app used the browser's local time, which is only correct while
* the editor is in Germany: an editor abroad would enter "19:00" and save an
* event hours off. The API has no notion of time zones at all - it stores what
* it is sent and writes the iCal feed with the *server's* local `getHours()`,
* labelled `TZID=Europe/Berlin` (icalgenerator.service.ts) - so the frontend is
* the one place that can get this right, by always sending the UTC instant of a
* Berlin wall time.
*
* Dates travel through the form as "YYYY-MM-DD" and times as "HH:mm", exactly
* what <input type="date|time"> produce, so there is no Date object to mutate
* in place (the other Angular bug).
*
* DST, as @date-fns/tz resolves it and the tests pin:
* - a time that does not exist (02:30 on the last Sunday of March) moves
* forward to 03:30 CEST;
* - a time that exists twice (02:30 on the last Sunday of October) is the
* second one, CET.
*/
export const TIME_ZONE = "Europe/Berlin";
const LOCALE = "de-DE";
export interface WallTime {
/** "YYYY-MM-DD" */
date: string;
/** "HH:mm" */
time: string;
}
const pad = (n: number): string => n.toString().padStart(2, "0");
const splitDate = (date: string): [number, number, number] => {
const [y, m, d] = date.split("-").map(Number);
return [y, m, d];
};
/** Berlin wall time → instant. */
export const fromWallTime = (date: string, time: string): Date => {
const [y, m, d] = splitDate(date);
const [hh, mm] = time.split(":").map(Number);
return new Date(new TZDate(y, m - 1, d, hh, mm, TIME_ZONE).getTime());
};
/** Instant → Berlin wall time. */
export const toWallTime = (instant: Date): WallTime => {
const t = new TZDate(instant.getTime(), TIME_ZONE);
return {
date: `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`,
time: `${pad(t.getHours())}:${pad(t.getMinutes())}`,
};
};
/** The Berlin calendar day an instant falls on, as "YYYY-MM-DD" (sorts as a string). */
export const berlinDay = (instant: Date): string => toWallTime(instant).date;
/** Calendar arithmetic on "YYYY-MM-DD", independent of any time zone. */
export const addDays = (date: string, days: number): string => {
const [y, m, d] = splitDate(date);
const u = new Date(Date.UTC(y, m - 1, d + days));
return `${u.getUTCFullYear()}-${pad(u.getUTCMonth() + 1)}-${pad(u.getUTCDate())}`;
};
/** Whole days from `a` to `b` ("YYYY-MM-DD"); negative when b is earlier. */
export const daysBetween = (a: string, b: string): number => {
const [ay, am, ad] = splitDate(a);
const [by, bm, bd] = splitDate(b);
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
};
/**
* A whole-day event's stored range: 00:00 on the first day to 23:59 on the
* last, both Berlin.
*
* The end is the *inclusive* last day because that is what the iCal generator
* assumes - it adds one day to a whole-day end (`formatDate(…, true, true)`).
* 23:59 rather than 00:00 keeps the event "upcoming" for the whole of its last
* day in the list filter.
*/
export const wholeDayRange = (startDate: string, endDate: string): { start: Date; end: Date } => ({
start: fromWallTime(startDate, "00:00"),
end: fromWallTime(endDate, "23:59"),
});
/** The next full hour after `now`, as Berlin wall time. */
export const nextFullHour = (now: Date): WallTime => {
const { date, time } = toWallTime(now);
const hour = Number(time.slice(0, 2)) + 1;
return hour === 24 ? { date: addDays(date, 1), time: "00:00" } : { date, time: `${pad(hour)}:00` };
};
/**
* The first occurrence of a repeating event that starts today (Berlin) or
* later, keeping its wall-clock time. Non-repeating events, and frequencies
* the API may hold but this app does not know, return `start` unchanged.
*
* Only YEARLY is ever written by this app (birthdays), but the API accepts any
* `FREQ` string, so older data may hold others.
*
* A yearly event on 29 February falls on 28 February in other years - the
* choice a birthday list wants, though RFC 5545 would skip those years.
*/
export const nextOccurrence = (start: Date, repeatFrequency: string, now: Date): Date => {
const today = berlinDay(now);
const { date, time } = toWallTime(start);
if (date >= today) {
return start;
}
const [, m, d] = splitDate(date);
switch (repeatFrequency) {
case "YEARLY": {
const year = Number(today.slice(0, 4));
const candidate = (yy: number) => {
const lastDay = new Date(Date.UTC(yy, m, 0)).getUTCDate();
return `${yy}-${pad(m)}-${pad(Math.min(d, lastDay))}`;
};
const thisYear = candidate(year);
return fromWallTime(thisYear >= today ? thisYear : candidate(year + 1), time);
}
case "MONTHLY": {
const [ty, tm] = splitDate(today);
const candidate = (yy: number, mm: number) => {
const lastDay = new Date(Date.UTC(yy, mm, 0)).getUTCDate();
return `${yy}-${pad(mm)}-${pad(Math.min(d, lastDay))}`;
};
const thisMonth = candidate(ty, tm);
if (thisMonth >= today) {
return fromWallTime(thisMonth, time);
}
return fromWallTime(tm === 12 ? candidate(ty + 1, 1) : candidate(ty, tm + 1), time);
}
case "WEEKLY":
case "DAILY": {
const step = repeatFrequency === "WEEKLY" ? 7 : 1;
const steps = Math.ceil(daysBetween(date, today) / step);
return fromWallTime(addDays(date, steps * step), time);
}
default:
return start;
}
};
const dayFormat = new Intl.DateTimeFormat(LOCALE, {
timeZone: TIME_ZONE,
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
});
const timeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, hour: "2-digit", minute: "2-digit" });
const dateTimeFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, dateStyle: "medium", timeStyle: "short" });
const monthFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, month: "long", year: "numeric" });
/** "Sa., 3. Okt. 2026" */
export const formatDay = (instant: Date): string => dayFormat.format(instant);
/** "19:00" */
export const formatTime = (instant: Date): string => timeFormat.format(instant);
/** "03.10.2026, 19:00" */
export const formatDateTime = (instant: Date): string => dateTimeFormat.format(instant);
/** "Oktober 2026" */
export const formatMonth = (instant: Date): string => monthFormat.format(instant);
/** "2026-10" - a sortable month key. */
export const monthKey = (instant: Date): string => berlinDay(instant).slice(0, 7);
+27
View File
@@ -0,0 +1,27 @@
/**
* The `NEXT_PUBLIC_*` values this app needs, read once and validated here
* rather than defaulted at each use site. Mirrors `lib/env.ts` in
* nachklang-feedback, nachklang-admin and nachklang-tickets.
*
* Throwing is the whole point. Next inlines `process.env.NEXT_PUBLIC_*` at
* **build** time, so a value missing when Plesk runs `npm run build` is baked
* into the bundle and cannot be fixed by restarting the app. A fallback to
* localhost would ship an admin area that sends every signed-out user to
* `http://localhost:3002` - no error, no log, just a dead end. A failed build is
* the better outcome, on the machine that can still do something about it.
*
* Referenced by their full literal name because that is what the inlining
* matches - no dynamic lookup, no destructuring of `process.env`.
*/
function requireEnv(name: string, value: string | undefined): string {
if (!value) {
throw new Error(`${name} is not set. Copy .env.local.example and fill it in before building.`);
}
// URL.origin never carries a trailing slash, and neither should anything we concatenate onto.
return value.replace(/\/+$/, "");
}
export const API_BASE_URL = requireEnv("NEXT_PUBLIC_API_BASE_URL", process.env.NEXT_PUBLIC_API_BASE_URL);
export const ADMIN_APP_URL = requireEnv("NEXT_PUBLIC_ADMIN_APP_URL", process.env.NEXT_PUBLIC_ADMIN_APP_URL);
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api";
import { describeApiError, isSessionExpired } from "@/lib/api-errors";
import { fromWallTime, toWallTime } from "@/lib/dates";
import { dateBlock, describeWhen, isPast, occurrence, repeatLabel, todayMarkerIndex } from "@/lib/event-display";
import { makeEvent } from "@/lib/test-fixtures";
const now = new Date("2026-09-27T10:00:00Z");
describe("dateBlock", () => {
it("gives weekday, day and month in German, Berlin time", () => {
// 23:30 UTC on 2 Oct is already Saturday 3 Oct in Berlin.
expect(dateBlock(new Date("2026-10-02T23:30:00Z"))).toEqual({ weekday: "Sa", day: "3", month: "Okt" });
});
});
describe("describeWhen", () => {
it("shows a same-day time range", () => {
expect(describeWhen(makeEvent(), now)).toBe("19:00–21:00 Uhr");
});
it("shows a multi-day timed event with both dates", () => {
const event = makeEvent({ start: fromWallTime("2026-10-03", "19:00"), end: fromWallTime("2026-10-05", "12:00") });
expect(describeWhen(event, now)).toBe("3. Okt., 19:00 – 5. Okt., 12:00");
});
it("shows whole-day events, with the range when they span days", () => {
const oneDay = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-03", "00:00"), end: fromWallTime("2026-10-03", "23:59") });
const weekend = makeEvent({ wholeDay: true, start: fromWallTime("2026-10-17", "00:00"), end: fromWallTime("2026-10-19", "23:59") });
expect(describeWhen(oneDay, now)).toBe("Ganztägig");
expect(describeWhen(weekend, now)).toBe("Ganztägig · 17.–19. Okt.");
});
});
describe("occurrence", () => {
it("moves a birthday's start and end to this year's date together", () => {
const birthday = makeEvent({
wholeDay: true,
repeatFrequency: "YEARLY",
start: fromWallTime("1994-10-03", "00:00"),
end: fromWallTime("1994-10-03", "23:59"),
});
const { start, end } = occurrence(birthday, now);
expect(toWallTime(start)).toEqual({ date: "2026-10-03", time: "00:00" });
expect(toWallTime(end)).toEqual({ date: "2026-10-03", time: "23:59" });
expect(describeWhen(birthday, now)).toBe("Ganztägig");
});
});
describe("isPast", () => {
it("is true only for non-repeating events that ended before today", () => {
const old = { start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") };
expect(isPast(makeEvent(old), now)).toBe(true);
expect(isPast(makeEvent({ ...old, repeatFrequency: "YEARLY" }), now)).toBe(false);
expect(isPast(makeEvent(), now)).toBe(false);
});
});
describe("todayMarkerIndex", () => {
const past = makeEvent({ eventId: 1, start: fromWallTime("2026-07-18", "15:00"), end: fromWallTime("2026-07-18", "22:00") });
const today = makeEvent({ eventId: 2, start: fromWallTime("2026-09-27", "08:00"), end: fromWallTime("2026-09-27", "09:00") });
const later = makeEvent({ eventId: 3 });
it("sits before the first event not yet over, ascending", () => {
expect(todayMarkerIndex([past, today, later], "start_asc", now)).toBe(1);
});
it("sits before the first past event, descending", () => {
expect(todayMarkerIndex([later, today, past], "start_desc", now)).toBe(2);
});
it("goes to the end or the start when everything is on one side", () => {
expect(todayMarkerIndex([past], "start_asc", now)).toBe(1);
expect(todayMarkerIndex([later], "start_asc", now)).toBe(0);
expect(todayMarkerIndex([later], "start_desc", now)).toBe(1);
});
it("has no place in a list sorted by creation, or an empty one", () => {
expect(todayMarkerIndex([past, later], "created_asc", now)).toBeNull();
expect(todayMarkerIndex([], "start_asc", now)).toBeNull();
});
});
describe("repeatLabel", () => {
it("translates known frequencies and passes unknown ones through", () => {
expect(repeatLabel("")).toBe("");
expect(repeatLabel("YEARLY")).toBe("jährlich");
expect(repeatLabel("HOURLY")).toBe("hourly");
});
});
describe("describeApiError", () => {
it("explains each kind of failure in German", () => {
expect(describeApiError(new ApiError(0, {}))).toMatch(/Keine Verbindung/);
expect(describeApiError(new ApiError(403, {}))).toMatch(/Berechtigung/);
expect(describeApiError(new ApiError(500, {}))).toMatch(/\(500\)/);
expect(describeApiError(new ApiError(200, {}, "Der Termin wurde nicht gespeichert."))).toBe(
"Der Termin wurde nicht gespeichert.",
);
expect(describeApiError(new Error("x"))).toMatch(/Unerwarteter Fehler/);
});
it("recognises only 401 as an expired session", () => {
expect(isSessionExpired(new ApiError(401, {}))).toBe(true);
expect(isSessionExpired(new ApiError(403, {}))).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { berlinDay, formatTime, TIME_ZONE } from "@/lib/dates";
import { effectiveStart, isRecurring, type Sort } from "@/lib/filter";
import type { CalendarEvent } from "@/lib/types";
/**
* How an event reads in the list. Pure, so the German wording is testable.
* All of it is about the *effective* occurrence: a birthday shows this year's
* date, not the year of birth.
*/
const LOCALE = "de-DE";
const part = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(LOCALE, { timeZone: TIME_ZONE, ...options });
const weekdayFormat = part({ weekday: "short" });
const dayFormat = part({ day: "numeric" });
const monthFormat = part({ month: "short" });
const dayMonthFormat = part({ day: "numeric", month: "short" });
export interface DateBlock {
/** "Sa" */
weekday: string;
/** "3" */
day: string;
/** "Okt" */
month: string;
}
export const dateBlock = (instant: Date): DateBlock => ({
weekday: weekdayFormat.format(instant),
day: dayFormat.format(instant),
month: monthFormat.format(instant),
});
/** The occurrence the list shows: start moved to the next repeat, end moved by the same amount. */
export const occurrence = (event: CalendarEvent, now: Date): { start: Date; end: Date } => {
const start = effectiveStart(event, now);
const shift = start.getTime() - event.start.getTime();
return { start, end: new Date(event.end.getTime() + shift) };
};
/**
* "19:00–21:00 Uhr", "Ganztägig", "Ganztägig · 17.–19. Okt.",
* "3. Okt., 19:00 – 5. Okt., 12:00".
*/
export const describeWhen = (event: CalendarEvent, now: Date): string => {
const { start, end } = occurrence(event, now);
const sameDay = berlinDay(start) === berlinDay(end);
if (event.wholeDay) {
return sameDay ? "Ganztägig" : `Ganztägig · ${dayMonthFormat.formatRange(start, end)}`;
}
if (sameDay) {
return `${formatTime(start)}–${formatTime(end)} Uhr`;
}
return `${dayMonthFormat.format(start)}, ${formatTime(start)} – ${dayMonthFormat.format(end)}, ${formatTime(end)}`;
};
/** Over before today (Berlin). Repeating events never are. */
export const isPast = (event: CalendarEvent, now: Date): boolean =>
!isRecurring(event) && berlinDay(event.end) < berlinDay(now);
/**
* Where "today" falls in a list sorted by start: the index of the event the
* "Heute" marker goes in front of, or `events.length` for after the last one.
* Ascending, that is the first event not yet over; descending, the first one
* that is. Null for a list that isn't ordered by start, or an empty one.
*/
export const todayMarkerIndex = (events: readonly CalendarEvent[], sort: Sort, now: Date): number | null => {
if (events.length === 0 || sort.startsWith("created")) {
return null;
}
const lookingFor = sort === "start_desc";
const index = events.findIndex((e) => isPast(e, now) === lookingFor);
return index === -1 ? events.length : index;
};
/** The badge for a repeat frequency; "" for none. Unknown values are shown as they are. */
export const repeatLabel = (repeatFrequency: string): string => {
switch (repeatFrequency) {
case "":
return "";
case "YEARLY":
return "jährlich";
case "MONTHLY":
return "monatlich";
case "WEEKLY":
return "wöchentlich";
case "DAILY":
return "täglich";
default:
return repeatFrequency.toLowerCase();
}
};
+222
View File
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import { toWallTime } from "@/lib/dates";
import {
contentChanged,
duplicateForm,
eventFromPayload,
formFromEvent,
isFormDirty,
newEventForm,
normaliseUrl,
payloadFromEvent,
setCalendar,
setStart,
toPayload,
validateEventForm,
type EventForm,
} from "@/lib/event-form";
import { makeEvent } from "@/lib/test-fixtures";
const now = new Date("2026-09-27T10:20:00Z"); // 12:20 in Berlin
const validForm = (overrides: Partial<EventForm> = {}): EventForm => ({
...formFromEvent(makeEvent()),
...overrides,
});
describe("newEventForm", () => {
it("starts at the next full Berlin hour, lasts two hours, and is a draft", () => {
const form = newEventForm(now, 2);
expect(form).toMatchObject({
calendarId: 2,
startDate: "2026-09-27",
startTime: "13:00",
endDate: "2026-09-27",
endTime: "15:00",
status: "DRAFT",
repeatFrequency: "",
});
});
it("makes a new birthday yearly", () => {
expect(newEventForm(now, 5).repeatFrequency).toBe("YEARLY");
});
it("leaves the calendar unchosen when none is given", () => {
expect(validateEventForm({ ...newEventForm(now), name: "x" }).calendarId).toBeDefined();
});
});
describe("duplicateForm", () => {
it("copies the event as a new draft with a marked title", () => {
const form = duplicateForm(makeEvent({ name: "Probe", status: "PUBLIC" }));
expect(form.name).toBe("Probe (Kopie)");
expect(form.status).toBe("DRAFT");
expect(form.startTime).toBe("19:00");
});
});
describe("setStart", () => {
it("moves the end along, keeping a timed event's duration", () => {
const form = setStart(validForm(), "2026-10-10", "18:30");
expect(form).toMatchObject({ startDate: "2026-10-10", startTime: "18:30", endDate: "2026-10-10", endTime: "20:30" });
});
it("keeps the duration in real time across the DST switch", () => {
// 2 h starting 01:00 on 25 Oct: 01:00 CEST + 2 h = 02:00 CET.
const form = setStart(validForm(), "2026-10-25", "01:00");
expect(form).toMatchObject({ endDate: "2026-10-25", endTime: "02:00" });
});
it("keeps a whole-day event's length in days", () => {
const form = setStart(
validForm({ wholeDay: true, startDate: "2026-07-04", endDate: "2026-07-06" }),
"2026-08-01",
"",
);
expect(form).toMatchObject({ startDate: "2026-08-01", endDate: "2026-08-03" });
});
it("does not guess an end while the start is being cleared", () => {
const form = setStart(validForm(), "", "19:00");
expect(form).toMatchObject({ startDate: "", endDate: "2026-10-03" });
});
});
describe("setCalendar", () => {
it("makes an event yearly when it moves into Birthdays", () => {
expect(setCalendar(validForm(), 5).repeatFrequency).toBe("YEARLY");
});
it("drops the forced recurrence when it moves out of Birthdays", () => {
expect(setCalendar(validForm({ calendarId: 5, repeatFrequency: "YEARLY" }), 2).repeatFrequency).toBe("");
});
it("keeps a recurrence that other calendars' events already had", () => {
expect(setCalendar(validForm({ calendarId: 2, repeatFrequency: "WEEKLY" }), 4).repeatFrequency).toBe("WEEKLY");
});
});
describe("normaliseUrl", () => {
it.each([
["", ""],
[" ", ""],
["nachklang.art", "https://nachklang.art"],
["www.nachklang.art/konzert?x=1", "https://www.nachklang.art/konzert?x=1"],
["http://example.org", "http://example.org"],
["https://münchen.de", "https://münchen.de"],
])("accepts %j as %j", (raw, value) => {
expect(normaliseUrl(raw)).toEqual({ ok: true, value });
});
it.each(["nachklang", "javascript:alert(1)", "mailto:info@nachklang.art", "https://", "ht tp://x.de"])(
"rejects %j",
(raw) => {
expect(normaliseUrl(raw).ok).toBe(false);
},
);
});
describe("validateEventForm", () => {
it("accepts a complete event", () => {
expect(validateEventForm(validForm())).toEqual({});
});
it("requires a title and a calendar", () => {
const errors = validateEventForm(validForm({ name: " ", calendarId: 99 }));
expect(Object.keys(errors).sort()).toEqual(["calendarId", "name"]);
});
it("requires times only for timed events", () => {
expect(validateEventForm(validForm({ startTime: "" })).startTime).toBeDefined();
expect(validateEventForm(validForm({ wholeDay: true, startTime: "", endTime: "" }))).toEqual({});
});
it("rejects an end before the start, on the end field", () => {
expect(validateEventForm(validForm({ endTime: "18:00" })).endDate).toBe("Das Ende liegt vor dem Beginn.");
});
it("allows a one-day whole-day event whose hidden times would be backwards", () => {
expect(validateEventForm(validForm({ wholeDay: true, startTime: "22:00", endTime: "08:00" }))).toEqual({});
});
it("flags an unusable URL", () => {
expect(validateEventForm(validForm({ url: "kein link" })).url).toBeDefined();
});
});
describe("toPayload", () => {
it("sends UTC instants of the Berlin wall times, and every field", () => {
const payload = toPayload(validForm({ name: " Konzert ", url: "nachklang.art" }));
expect(payload).toEqual({
calendarId: 1,
name: "Konzert",
description: "",
startDateTime: "2026-10-03T17:00:00.000Z",
endDateTime: "2026-10-03T19:00:00.000Z",
location: "Karlsruhe",
url: "https://nachklang.art",
wholeDay: false,
repeatFrequency: "",
status: "PUBLIC",
});
});
it("saves whole-day events as 00:00-23:59 Berlin, ignoring stale times", () => {
const payload = toPayload(
validForm({ wholeDay: true, startDate: "2026-12-24", endDate: "2026-12-26", startTime: "19:00", endTime: "21:00" }),
);
expect(toWallTime(new Date(payload.startDateTime))).toEqual({ date: "2026-12-24", time: "00:00" });
expect(toWallTime(new Date(payload.endDateTime))).toEqual({ date: "2026-12-26", time: "23:59" });
});
it("keeps the birthday recurrence and any other status", () => {
const payload = toPayload(validForm({ calendarId: 5, repeatFrequency: "YEARLY", status: "PRIVATE" }));
expect(payload).toMatchObject({ repeatFrequency: "YEARLY", status: "PRIVATE" });
});
});
describe("payloadFromEvent", () => {
it("resends the full event with only the status changed", () => {
const event = makeEvent({ status: "DRAFT", repeatFrequency: "YEARLY", calendarId: 5 });
expect(payloadFromEvent(event, "PUBLIC")).toMatchObject({
status: "PUBLIC",
repeatFrequency: "YEARLY",
calendarId: 5,
startDateTime: event.start.toISOString(),
});
});
});
describe("isFormDirty", () => {
it("notices any change and nothing else", () => {
const form = validForm();
expect(isFormDirty(form, { ...form })).toBe(false);
expect(isFormDirty(form, { ...form, location: "Mannheim" })).toBe(true);
});
});
describe("contentChanged", () => {
it("ignores a change of calendar alone", () => {
const form = validForm();
expect(contentChanged(form, { ...form, calendarId: 4 })).toBe(false);
expect(contentChanged(form, { ...form, calendarId: 4, name: "Neu" })).toBe(true);
});
});
describe("eventFromPayload", () => {
const now = new Date("2026-09-28T12:00:00Z");
it("keeps who created an edited event and records who changed it", () => {
const previous = makeEvent({ createdBy: "Anna", uuid: "u-1" });
const saved = eventFromPayload(1, toPayload(validForm({ name: "Neu" })), "Ben", now, previous);
expect(saved).toMatchObject({ eventId: 1, uuid: "u-1", name: "Neu", createdBy: "Anna", lastModifiedBy: "Ben" });
expect(saved.created).toBe(previous.created);
expect(saved.start.toISOString()).toBe("2026-10-03T17:00:00.000Z");
});
it("attributes a new event to the author", () => {
const saved = eventFromPayload(42, toPayload(validForm()), "Ben", now);
expect(saved).toMatchObject({ eventId: 42, createdBy: "Ben", created: now });
});
});
+269
View File
@@ -0,0 +1,269 @@
import { calendarById } from "@/lib/calendars";
import { addDays, daysBetween, fromWallTime, nextFullHour, toWallTime, wholeDayRange } from "@/lib/dates";
import type { CalendarEvent, EventPayload, EventStatus } from "@/lib/types";
/**
* The edit sheet's state, and every rule about it that is not rendering.
*
* Kept as plain strings in the shape the inputs produce, so nothing here ever
* mutates a Date - the Angular form edited `event.startDateTime` in place with
* setFullYear/setHours, which is how a cancelled edit could still leak into the
* list.
*/
export interface EventForm {
calendarId: number;
name: string;
description: string;
location: string;
url: string;
wholeDay: boolean;
/** Berlin wall time. For whole-day events the times are ignored. */
startDate: string;
startTime: string;
endDate: string;
endTime: string;
status: EventStatus;
repeatFrequency: string;
}
export type EventFormField = keyof EventForm;
export type EventFormErrors = Partial<Record<EventFormField, string>>;
const DEFAULT_DURATION_HOURS = 2;
/** Applies the calendar's forced recurrence, if it has one. */
const withCalendarRules = (form: EventForm): EventForm => {
const forced = calendarById(form.calendarId)?.forcedRepeatFrequency;
return forced ? { ...form, repeatFrequency: forced } : form;
};
/**
* A new event: the next full hour, two hours long, as a draft. `calendarId`
* is 0 ("none chosen") when the caller cannot tell which calendar is meant.
*/
export const newEventForm = (now: Date, calendarId = 0): EventForm => {
const start = nextFullHour(now);
const end = toWallTime(new Date(fromWallTime(start.date, start.time).getTime() + DEFAULT_DURATION_HOURS * 3_600_000));
return withCalendarRules({
calendarId,
name: "",
description: "",
location: "",
url: "",
wholeDay: false,
startDate: start.date,
startTime: start.time,
endDate: end.date,
endTime: end.time,
status: "DRAFT",
repeatFrequency: "",
});
};
export const formFromEvent = (event: CalendarEvent): EventForm => {
const start = toWallTime(event.start);
const end = toWallTime(event.end);
return {
calendarId: event.calendarId,
name: event.name,
description: event.description,
location: event.location,
url: event.url,
wholeDay: event.wholeDay,
startDate: start.date,
startTime: start.time,
endDate: end.date,
endTime: end.time,
status: event.status,
repeatFrequency: event.repeatFrequency,
};
};
/** A copy of an existing event as a new, unsaved draft. */
export const duplicateForm = (event: CalendarEvent): EventForm => ({
...formFromEvent(event),
name: `${event.name} (Kopie)`,
status: "DRAFT",
});
/**
* Changing the start moves the end with it, keeping the duration - so
* shifting a rehearsal to another day does not leave its end behind. (The
* Angular form only pulled the end up when it would otherwise precede the
* start.) Whole-day events keep their length in days, timed ones in minutes.
*/
export const setStart = (form: EventForm, startDate: string, startTime: string): EventForm => {
if (!startDate || !form.startDate || !form.endDate) {
return { ...form, startDate, startTime };
}
if (form.wholeDay) {
const days = daysBetween(form.startDate, form.endDate);
return { ...form, startDate, startTime, endDate: addDays(startDate, Math.max(days, 0)) };
}
if (!startTime || !form.startTime || !form.endTime) {
return { ...form, startDate, startTime };
}
const duration =
fromWallTime(form.endDate, form.endTime).getTime() - fromWallTime(form.startDate, form.startTime).getTime();
const end = toWallTime(new Date(fromWallTime(startDate, startTime).getTime() + Math.max(duration, 0)));
return { ...form, startDate, startTime, endDate: end.date, endTime: end.time };
};
/**
* Leaving a calendar that forces a recurrence (Birthdays) also drops it -
* otherwise moving a birthday into another calendar would quietly turn it into
* a yearly event there. A recurrence the event had for any other reason stays.
*/
export const setCalendar = (form: EventForm, calendarId: number): EventForm => {
const leaving = calendarById(form.calendarId)?.forcedRepeatFrequency;
const base = leaving && form.repeatFrequency === leaving ? { ...form, repeatFrequency: "" } : form;
return withCalendarRules({ ...base, calendarId });
};
export type UrlCheck = { ok: true; value: string } | { ok: false };
/**
* Accepts what people actually type: "nachklang.art/konzert" becomes
* "https://nachklang.art/konzert". Only http(s) URLs with a dotted host pass.
*
* Returns the normalised value rather than writing it back - the Angular
* version rewrote the field as a side effect of validating it, which the user
* could see happen on a failed save.
*/
export const normaliseUrl = (raw: string): UrlCheck => {
const trimmed = raw.trim();
if (trimmed === "") {
return { ok: true, value: "" };
}
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const url = new URL(withScheme);
if ((url.protocol !== "https:" && url.protocol !== "http:") || !url.hostname.includes(".")) {
return { ok: false };
}
return { ok: true, value: withScheme };
} catch {
return { ok: false };
}
};
const range = (form: EventForm): { start: Date; end: Date } =>
form.wholeDay
? wholeDayRange(form.startDate, form.endDate)
: { start: fromWallTime(form.startDate, form.startTime), end: fromWallTime(form.endDate, form.endTime) };
/** Field errors in German, keyed by the field they belong next to. Empty = valid. */
export const validateEventForm = (form: EventForm): EventFormErrors => {
const errors: EventFormErrors = {};
if (!calendarById(form.calendarId)) {
errors.calendarId = "Bitte einen Kalender wählen.";
}
if (form.name.trim() === "") {
errors.name = "Bitte einen Titel angeben.";
}
if (!form.startDate) {
errors.startDate = "Bitte ein Startdatum angeben.";
}
if (!form.endDate) {
errors.endDate = "Bitte ein Enddatum angeben.";
}
if (!form.wholeDay) {
if (!form.startTime) {
errors.startTime = "Bitte eine Startzeit angeben.";
}
if (!form.endTime) {
errors.endTime = "Bitte eine Endzeit angeben.";
}
}
if (!errors.startDate && !errors.endDate && !errors.startTime && !errors.endTime) {
const { start, end } = range(form);
if (end.getTime() < start.getTime()) {
errors.endDate = "Das Ende liegt vor dem Beginn.";
}
}
if (!normaliseUrl(form.url).ok) {
errors.url = "Das ist keine gültige Web-Adresse.";
}
return errors;
};
/**
* The full body for create/update/move. Call only on a form that validated.
*
* Whole-day events are saved as 00:00-23:59 Berlin whatever the hidden time
* inputs held; the Angular app kept the stale times, so the same "whole-day"
* event could end at any hour.
*/
export const toPayload = (form: EventForm): EventPayload => {
const { start, end } = range(form);
const url = normaliseUrl(form.url);
return {
calendarId: form.calendarId,
name: form.name.trim(),
description: form.description.trim(),
startDateTime: start.toISOString(),
endDateTime: end.toISOString(),
location: form.location.trim(),
url: url.ok ? url.value : "",
wholeDay: form.wholeDay,
repeatFrequency: form.repeatFrequency,
status: form.status,
};
};
/** The same payload, with the status changed - for the list's quick "Veröffentlichen". */
export const payloadFromEvent = (event: CalendarEvent, status: EventStatus = event.status): EventPayload => ({
calendarId: event.calendarId,
name: event.name,
description: event.description,
startDateTime: event.start.toISOString(),
endDateTime: event.end.toISOString(),
location: event.location,
url: event.url,
wholeDay: event.wholeDay,
repeatFrequency: event.repeatFrequency,
status,
});
export const isFormDirty = (initial: EventForm, current: EventForm): boolean =>
(Object.keys(initial) as EventFormField[]).some((key) => initial[key] !== current[key]);
/**
* Whether anything besides the calendar changed. The move endpoint only
* rewrites `calendar_id`, so a pure move needs no update call - and making
* one anyway would add an event version that changes nothing.
*/
export const contentChanged = (initial: EventForm, current: EventForm): boolean =>
isFormDirty({ ...initial, calendarId: 0 }, { ...current, calendarId: 0 });
/**
* The event as the list should show it right after a save, before the
* re-fetch brings the server's own fields (uuid, exact timestamps). `previous`
* is the event before the edit; absent for a new one.
*/
export const eventFromPayload = (
eventId: number,
payload: EventPayload,
author: string,
now: Date,
previous?: CalendarEvent,
): CalendarEvent => ({
eventId,
calendarId: payload.calendarId,
uuid: previous?.uuid ?? "",
name: payload.name,
description: payload.description,
start: new Date(payload.startDateTime),
end: new Date(payload.endDateTime),
created: previous?.created ?? now,
lastModified: now,
location: payload.location,
createdBy: previous?.createdBy ?? author,
lastModifiedBy: author,
url: payload.url,
wholeDay: payload.wholeDay,
repeatFrequency: payload.repeatFrequency,
status: payload.status,
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { parseApiEvent, parseApiEvents } from "@/lib/events";
import type { ApiEvent } from "@/lib/types";
const row = (overrides: Partial<ApiEvent> = {}): ApiEvent => ({
eventId: 7,
calendarId: 5,
uuid: "u-7",
name: "Anna",
description: null,
startDateTime: "1990-10-02T22:00:00.000Z",
endDateTime: "1990-10-03T21:59:00.000Z",
createdDate: "2024-01-01T10:00:00.000Z",
lastModifiedDate: "2024-02-01T10:00:00.000Z",
location: null,
createdBy: null,
lastModifiedBy: "Paula",
url: null,
wholeDay: 1,
repeatFrequency: "YEARLY",
status: "DRAFT",
...overrides,
});
describe("parseApiEvent", () => {
it("coerces the raw row: 1 → true, nulls → empty strings, ISO → Date", () => {
const event = parseApiEvent(row());
expect(event).toMatchObject({
eventId: 7,
wholeDay: true,
description: "",
location: "",
url: "",
createdBy: "",
lastModifiedBy: "Paula",
repeatFrequency: "YEARLY",
status: "DRAFT",
});
expect(event?.start.toISOString()).toBe("1990-10-02T22:00:00.000Z");
});
it("drops deleted events", () => {
expect(parseApiEvent(row({ status: "DELETED" }))).toBeNull();
});
it("drops a row without a usable start or end - what a deleted version looks like", () => {
expect(parseApiEvent(row({ status: null, startDateTime: null }))).toBeNull();
expect(parseApiEvent(row({ endDateTime: "not a date" }))).toBeNull();
});
it("reads a missing status as PUBLIC (the public listing omits it)", () => {
const withoutStatus = row();
delete withoutStatus.status;
expect(parseApiEvent(withoutStatus)?.status).toBe("PUBLIC");
});
it("never promotes an unknown status to PUBLIC", () => {
expect(parseApiEvent(row({ status: "ARCHIVED" }))?.status).toBe("PRIVATE");
});
it("reads 0 and null as not whole-day", () => {
expect(parseApiEvent(row({ wholeDay: 0 }))?.wholeDay).toBe(false);
expect(parseApiEvent(row({ wholeDay: null }))?.wholeDay).toBe(false);
});
});
describe("parseApiEvents", () => {
it("keeps only the rows worth showing", () => {
const events = parseApiEvents([row({ eventId: 1 }), row({ eventId: 2, status: "DELETED" }), row({ eventId: 3 })]);
expect(events.map((e) => e.eventId)).toEqual([1, 3]);
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { ApiEvent, CalendarEvent, EventStatus } from "@/lib/types";
const STATUSES: readonly EventStatus[] = ["PUBLIC", "PRIVATE", "DRAFT", "DELETED"];
const parseDate = (value: string | null | undefined): Date | null => {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
/**
* Turns one row of the API's JSON into a `CalendarEvent`, or null for a row
* the list should not show.
*
* Null covers deleted events - the admin feed returns them, because deleting
* only appends a version with `status = 'DELETED'` - and anything without a
* usable start and end, which a deleted row also is (all its other columns are
* null). The Angular app filtered on status alone and would have rendered such
* a row as "Invalid Date".
*
* A missing status (the anonymous public listing omits it) means PUBLIC, since
* that listing only returns published events. An unknown one is treated as
* PRIVATE (and saved back as such) - "not public" is the safe reading; it is
* never silently promoted to PUBLIC.
*/
export const parseApiEvent = (row: ApiEvent): CalendarEvent | null => {
const status: EventStatus =
row.status == null ? "PUBLIC" : STATUSES.includes(row.status as EventStatus) ? (row.status as EventStatus) : "PRIVATE";
if (status === "DELETED") {
return null;
}
const start = parseDate(row.startDateTime);
const end = parseDate(row.endDateTime);
if (!start || !end) {
return null;
}
return {
eventId: row.eventId,
calendarId: row.calendarId,
uuid: row.uuid ?? "",
name: row.name ?? "",
description: row.description ?? "",
start,
end,
created: parseDate(row.createdDate),
lastModified: parseDate(row.lastModifiedDate),
location: row.location ?? "",
createdBy: row.createdBy ?? "",
lastModifiedBy: row.lastModifiedBy ?? "",
url: row.url ?? "",
wholeDay: Boolean(row.wholeDay),
repeatFrequency: row.repeatFrequency ?? "",
status,
};
};
export const parseApiEvents = (rows: ApiEvent[]): CalendarEvent[] =>
rows.map(parseApiEvent).filter((e): e is CalendarEvent => e !== null);
+107
View File
@@ -0,0 +1,107 @@
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]],
]);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { berlinDay, formatMonth, monthKey, nextOccurrence } from "@/lib/dates";
import type { CalendarEvent } from "@/lib/types";
/**
* Filtering, sorting and grouping for the admin list. All client-side: the
* Angular app re-fetched from the server every time the filter changed, even
* though the data did not.
*/
export type Range = "future" | "past" | "all";
export type Sort = "start_asc" | "start_desc" | "created_asc" | "created_desc";
export const RANGES: readonly Range[] = ["future", "past", "all"];
export const SORTS: readonly Sort[] = ["start_asc", "start_desc", "created_asc", "created_desc"];
export interface ListFilter {
calendarIds: readonly number[];
range: Range;
query: string;
}
export const isRecurring = (event: CalendarEvent): boolean => event.repeatFrequency !== "";
/**
* When the event next happens, for sorting and grouping. A repeating event's
* original start can be decades back (a birthday is stored with the year of
* birth); what matters in the list is the next time it comes round.
*/
export const effectiveStart = (event: CalendarEvent, now: Date): Date =>
isRecurring(event) ? nextOccurrence(event.start, event.repeatFrequency, now) : event.start;
/**
* Repeating events always count as current - under "future" and "all", never
* under "past". This replaces the Angular rule "calendar 5 ignores the
* filter", which keyed on the calendar rather than on the thing that actually
* makes an event timeless.
*
* "future" compares Berlin days, not instants, so an event that ended earlier
* today is still listed until midnight.
*/
export const matchesRange = (event: CalendarEvent, range: Range, now: Date): boolean => {
if (range === "all") {
return true;
}
if (isRecurring(event)) {
return range === "future";
}
const upcoming = berlinDay(event.end) >= berlinDay(now);
return range === "future" ? upcoming : !upcoming;
};
const normalise = (s: string): string => s.toLocaleLowerCase("de-DE").normalize("NFC");
/** Case-insensitive substring match on title and location; every word must match. */
export const matchesQuery = (event: CalendarEvent, query: string): boolean => {
const words = normalise(query).split(/\s+/).filter(Boolean);
if (words.length === 0) {
return true;
}
const haystack = normalise(`${event.name} ${event.location}`);
return words.every((w) => haystack.includes(w));
};
export const filterEvents = (events: readonly CalendarEvent[], filter: ListFilter, now: Date): CalendarEvent[] =>
events.filter(
(e) => filter.calendarIds.includes(e.calendarId) && matchesRange(e, filter.range, now) && matchesQuery(e, filter.query),
);
const byName = (a: CalendarEvent, b: CalendarEvent): number => a.name.localeCompare(b.name, "de-DE");
/**
* A stable, total order - ties fall back to the title and then the id. The
* Angular comparator returned only 1 or -1, never 0, which is not a valid
* comparator and let equal events swap places between renders.
*/
export const sortEvents = (events: readonly CalendarEvent[], sort: Sort, now: Date): CalendarEvent[] => {
const key = (e: CalendarEvent): number =>
sort.startsWith("created") ? (e.created?.getTime() ?? 0) : effectiveStart(e, now).getTime();
const direction = sort.endsWith("desc") ? -1 : 1;
return [...events].sort((a, b) => (key(a) - key(b)) * direction || byName(a, b) || a.eventId - b.eventId);
};
export interface MonthGroup {
/** "2026-10" */
key: string;
/** "Oktober 2026" */
label: string;
events: CalendarEvent[];
}
/**
* Consecutive runs of events in the same month (by effective start), in the
* order given. Meant for a list sorted by start; with another order, a month
* can legitimately appear more than once.
*/
export const groupByMonth = (events: readonly CalendarEvent[], now: Date): MonthGroup[] => {
const groups: MonthGroup[] = [];
for (const event of events) {
const start = effectiveStart(event, now);
const key = monthKey(start);
const last = groups.at(-1);
if (last && last.key === key) {
last.events.push(event);
} else {
groups.push({ key, label: formatMonth(start), events: [event] });
}
}
return groups;
};
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchUpcomingPublicEvents,
googleCalendarUrl,
icalUrl,
selectUpcoming,
toGoogleCalendar,
toWebcal,
webcalUrl,
} from "@/lib/public-calendar";
import type { ApiEvent } from "@/lib/types";
const now = new Date("2026-09-28T10:00:00Z");
const row = (eventId: number, start: string, end: string, name = `E${eventId}`): ApiEvent => ({
eventId,
calendarId: 1,
uuid: `u${eventId}`,
name,
description: "",
startDateTime: start,
endDateTime: end,
createdDate: null,
location: "",
url: "",
wholeDay: 0,
repeatFrequency: "",
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("subscription links", () => {
it("builds the iCal, webcal and Google links from the API URL", () => {
expect(icalUrl("https://api.nachklang.art")).toBe("https://api.nachklang.art/calendar/events/public/ical");
expect(webcalUrl("https://api.nachklang.art")).toBe("webcal://api.nachklang.art/calendar/events/public/ical");
expect(webcalUrl("http://localhost:3000")).toBe("webcal://localhost:3000/calendar/events/public/ical");
// The same link the Angular landing page hardcoded.
expect(googleCalendarUrl("https://api.nachklang.art")).toBe(
"https://calendar.google.com/calendar/r?cid=webcal%3A%2F%2Fapi.nachklang.art%2Fcalendar%2Fevents%2Fpublic%2Fical",
);
});
});
describe("links for a password-protected calendar", () => {
it("keeps the password through the webcal and Google conversions", () => {
const ical = "https://api.nachklang.art/calendar/events/choir/ical?password=a%26b";
expect(toWebcal(ical)).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b");
// Encoded once more as a whole, so Google hands back exactly the URL above.
const cid = new URL(toGoogleCalendar(ical)).searchParams.get("cid");
expect(cid).toBe("webcal://api.nachklang.art/calendar/events/choir/ical?password=a%26b");
});
});
describe("selectUpcoming", () => {
it("keeps events not yet over, earliest first, up to the limit", () => {
const rows = [
row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z"),
row(2, "2026-07-01T18:00:00Z", "2026-07-01T20:00:00Z"), // over
row(3, "2026-09-28T06:00:00Z", "2026-09-28T07:00:00Z"), // earlier today: still listed
row(4, "2026-10-01T18:00:00Z", "2026-10-01T20:00:00Z"),
];
expect(selectUpcoming(rows, now, 2).map((e) => e.eventId)).toEqual([3, 4]);
});
});
describe("fetchUpcomingPublicEvents", () => {
it("returns null instead of throwing when the API is unreachable or unhappy", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed")));
await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("{}", { status: 500 })));
await expect(fetchUpcomingPublicEvents("https://api.test", now, 3)).resolves.toBeNull();
});
it("parses what the public feed returns", async () => {
const fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify([row(1, "2026-11-01T18:00:00Z", "2026-11-01T20:00:00Z")]), { status: 200 }),
);
vi.stubGlobal("fetch", fetch);
const events = await fetchUpcomingPublicEvents("https://api.test", now, 3);
expect(events?.map((e) => e.eventId)).toEqual([1]);
expect(fetch).toHaveBeenCalledWith("https://api.test/calendar/events/public/json", { next: { revalidate: 300 } });
});
});
+65
View File
@@ -0,0 +1,65 @@
import { parseApiEvents } from "@/lib/events";
import { matchesRange, sortEvents } from "@/lib/filter";
import type { ApiEvent, CalendarEvent } from "@/lib/types";
/**
* The public calendar as the landing page shows it: subscription links and
* the next few events. Pure, apart from `fetchUpcomingPublicEvents`.
*
* The public feed needs no credential at all - nachklang.art reads the same
* endpoint anonymously - so this runs on the server without a session.
*/
const PUBLIC_PATH = "/calendar/events/public";
/** https://api.nachklang.art/calendar/events/public/ical */
export const icalUrl = (apiBaseUrl: string): string => `${apiBaseUrl}${PUBLIC_PATH}/ical`;
/**
* Any iCal URL as a `webcal://` link, which Apple Calendar (and most desktop
* clients) open as "subscribe" rather than "download". The scheme replaces
* http(s) whichever the API uses - calendar apps fetch it over https for an
* https host.
*/
export const toWebcal = (ical: string): string => ical.replace(/^https?:\/\//, "webcal://");
/** Google Calendar's "add by URL" deep link for any iCal URL. It wants the webcal form, URL-encoded. */
export const toGoogleCalendar = (ical: string): string =>
`https://calendar.google.com/calendar/r?cid=${encodeURIComponent(toWebcal(ical))}`;
export const webcalUrl = (apiBaseUrl: string): string => toWebcal(icalUrl(apiBaseUrl));
export const googleCalendarUrl = (apiBaseUrl: string): string => toGoogleCalendar(icalUrl(apiBaseUrl));
/** Upcoming (not yet over, Berlin days) public events, earliest first. */
export const selectUpcoming = (rows: ApiEvent[], now: Date, limit: number): CalendarEvent[] =>
sortEvents(
parseApiEvents(rows).filter((e) => matchesRange(e, "future", now)),
"start_asc",
now,
).slice(0, limit);
/**
* Server-side fetch, cached for five minutes (Next's `revalidate`), so the
* landing page is static HTML that refreshes itself rather than a request to
* the API per visitor.
*
* Null on any failure. The subscription links are the page's real job and
* must not depend on the API answering - that includes `next build` on a
* machine that cannot reach it.
*/
export const fetchUpcomingPublicEvents = async (
apiBaseUrl: string,
now: Date,
limit: number,
): Promise<CalendarEvent[] | null> => {
try {
const res = await fetch(`${apiBaseUrl}${PUBLIC_PATH}/json`, { next: { revalidate: 300 } });
if (!res.ok) {
return null;
}
return selectUpcoming((await res.json()) as ApiEvent[], now, limit);
} catch {
return null;
}
};
+23
View File
@@ -0,0 +1,23 @@
import { fromWallTime } from "@/lib/dates";
import type { CalendarEvent } from "@/lib/types";
/** A plain, published, timed event; override what the test is about. Test-only. */
export const makeEvent = (overrides: Partial<CalendarEvent> = {}): CalendarEvent => ({
eventId: 1,
calendarId: 1,
uuid: "uuid-1",
name: "Konzert",
description: "",
start: fromWallTime("2026-10-03", "19:00"),
end: fromWallTime("2026-10-03", "21:00"),
created: new Date("2026-01-01T12:00:00Z"),
lastModified: null,
location: "Karlsruhe",
createdBy: "",
lastModifiedBy: "",
url: "",
wholeDay: false,
repeatFrequency: "",
status: "PUBLIC",
...overrides,
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Shapes on the wire and in the app.
*
* `ApiEvent` is what `GET /calendar/events/:calendar/json` actually returns,
* which is looser than the API's own `Event` interface claims: the service maps
* raw MariaDB rows straight through (`toEvent` in events.service.ts), so
* `wholeDay` arrives as whatever the driver produced for the column (1/0 rather
* than true/false is the likely case), and a deleted event's latest version is
* a row with only `status` set - every other version column is null. `status`
* is missing entirely from the anonymous public listing.
*
* `CalendarEvent` is the parsed, trustworthy form everything else in the app
* works with. Parsing happens once, in `parseApiEvent`.
*/
export type EventStatus = "PUBLIC" | "PRIVATE" | "DRAFT" | "DELETED";
export interface ApiEvent {
eventId: number;
calendarId: number;
uuid: string;
name: string | null;
description: string | null;
startDateTime: string | null;
endDateTime: string | null;
createdDate: string | null;
lastModifiedDate?: string | null;
location: string | null;
createdBy?: string | null;
lastModifiedBy?: string | null;
url: string | null;
wholeDay: boolean | number | null;
repeatFrequency: string | null;
status?: string | null;
}
export interface CalendarEvent {
eventId: number;
calendarId: number;
uuid: string;
name: string;
description: string;
/** Instants. Wall-clock meaning is always Europe/Berlin, see lib/dates.ts. */
start: Date;
end: Date;
created: Date | null;
lastModified: Date | null;
location: string;
createdBy: string;
lastModifiedBy: string;
url: string;
wholeDay: boolean;
/** "" when the event does not repeat. Sent to iCal verbatim as RRULE:FREQ=… */
repeatFrequency: string;
status: EventStatus;
}
/**
* The body of POST /calendar/events, PUT /calendar/events/:id and
* PUT /calendar/events/move/:id.
*
* Every field is required here on purpose. PUT replaces the whole version and
* defaults whatever is missing - `repeatFrequency ?? ''`, `status ?? 'PUBLIC'`
* (events.router.ts) - so a partial body silently drops a birthday's
* recurrence or publishes a draft.
*/
export interface EventPayload {
calendarId: number;
name: string;
description: string;
startDateTime: string;
endDateTime: string;
location: string;
url: string;
wholeDay: boolean;
repeatFrequency: string;
status: EventStatus;
}
/** GET /admin/me - the signed-in account, shared by all Nachklang apps. */
export interface AdminUser {
id: string;
email: string;
fullName: string;
apps: string[];
}
+76
View File
@@ -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" });
});
});
+111
View File
@@ -0,0 +1,111 @@
import { calendarById, calendarBySlug, DEFAULT_VISIBLE_CALENDAR_IDS } from "@/lib/calendars";
import { RANGES, SORTS, type Range, type Sort } from "@/lib/filter";
/**
* The admin list's view state, kept in the query string so a link reproduces
* a view: `?cal=public,choir&range=past&sort=start_desc&q=probe`.
*
* Defaults are left out of the URL, so the plain `/admin` link means "the
* default view". Anything unrecognised falls back to its default instead of
* failing - these URLs get pasted into chats and bookmarked.
*
* `cal=` with an empty value is kept as "no calendars selected"; only a
* missing `cal` means the default selection.
*
* The default sort depends on the range: looking back ("Vergangen") starts
* with the most recent event, everything else with the earliest. A sort that
* matches its range's default stays out of the URL, so switching ranges
* carries the right default along; a sort someone picked is kept.
*/
export interface ListState {
calendarIds: number[];
range: Range;
sort: Sort;
query: string;
}
export const DEFAULT_LIST_STATE: ListState = {
calendarIds: [...DEFAULT_VISIBLE_CALENDAR_IDS],
range: "future",
sort: "start_asc",
query: "",
};
export const defaultSortFor = (range: Range): Sort => (range === "past" ? "start_desc" : "start_asc");
/**
* The state after switching to `range`: a sort that was only the old range's
* default becomes the new range's default; one that someone picked stays.
*/
export const withRange = (state: ListState, range: Range): ListState => ({
...state,
range,
sort: state.sort === defaultSortFor(state.range) ? defaultSortFor(range) : state.sort,
});
const sameIds = (a: readonly number[], b: readonly number[]): boolean => {
const sortedB = [...b].sort();
return a.length === b.length && [...a].sort().every((id, i) => id === sortedB[i]);
};
export const parseListState = (params: URLSearchParams): ListState => {
const cal = params.get("cal");
const calendarIds =
cal === null
? [...DEFAULT_LIST_STATE.calendarIds]
: [
...new Set(
cal
.split(",")
.map((slug) => calendarBySlug(slug.trim())?.id)
.filter((id): id is number => id !== undefined),
),
];
const rawRange = params.get("range");
const rawSort = params.get("sort");
const range = RANGES.includes(rawRange as Range) ? (rawRange as Range) : DEFAULT_LIST_STATE.range;
return {
calendarIds,
range,
sort: SORTS.includes(rawSort as Sort) ? (rawSort as Sort) : defaultSortFor(range),
query: params.get("q") ?? "",
};
};
export const serializeListState = (state: ListState): URLSearchParams => {
const params = new URLSearchParams();
if (!sameIds(state.calendarIds, DEFAULT_LIST_STATE.calendarIds)) {
params.set(
"cal",
state.calendarIds
.map((id) => calendarById(id)?.slug)
.filter(Boolean)
.join(","),
);
}
if (state.range !== DEFAULT_LIST_STATE.range) {
params.set("range", state.range);
}
if (state.sort !== defaultSortFor(state.range)) {
params.set("sort", state.sort);
}
if (state.query.trim() !== "") {
params.set("q", state.query);
}
return params;
};
/**
* The query string for the address bar, without the leading "?". Commas stay
* literal (`cal=public,choir`) because these URLs are meant to be read and
* shared; URLSearchParams would write `%2C`. A comma inside the search text
* is escaped by hand first, so it cannot be mistaken for a separator - and
* the parser only splits `cal` anyway.
*/
export const toQueryString = (state: ListState): string =>
serializeListState(state)
.toString()
.replace(/(^|&)cal=([^&]*)/, (_, lead: string, value: string) => `${lead}cal=${value.replace(/%2C/gi, ",")}`);
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}