Rewrite the calendar frontend in Next.js
Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn, the same stack and design system as nachklang-feedback, planned in docs/rewrite-plan.md. - Admin: all calendars in one mobile-friendly list (month groups, calendar chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit sheet for create/edit/duplicate/delete, publish confirmation, and an "Abonnieren" dialog with subscription links for every calendar. - Public landing page: subscribe links from env instead of hardcoded, plus the next public events, server-rendered and revalidated every 5 minutes. - All times handled explicitly as Europe/Berlin; whole-day events stored as 00:00-23:59. Fixes carried over from the Angular app: the move dialog leaking a cancelled calendar change, saves that looked saved when they failed, in-place Date mutation, and duplicated calendar-id mappings. - Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403). - 112 Vitest tests over lib/; docs in docs/ (architecture, features, gotchas, API wish list, deployment). Needs the API branch feature/calendar-ical-escaping-subscriptions deployed first (GET /calendar/events/subscriptions). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
138 files changed
+15942
-24010
No files matched your search
@@ -0,0 +1,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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
@@ -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" });
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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, ",")}`);
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in new issue
Block a user