Files
Calendar_Frontend/lib/event-form.ts
PaddyandClaude Opus 5.5 0cda40f85e Rewrite the calendar frontend in Next.js
Replaces the Angular 18 app with Next.js 16 / React 19 / Tailwind 4 / shadcn,
the same stack and design system as nachklang-feedback, planned in
docs/rewrite-plan.md.

- Admin: all calendars in one mobile-friendly list (month groups, calendar
  chips, Kommend/Vergangen/Alle, search, sort, filters in the URL), an edit
  sheet for create/edit/duplicate/delete, publish confirmation, and an
  "Abonnieren" dialog with subscription links for every calendar.
- Public landing page: subscribe links from env instead of hardcoded, plus
  the next public events, server-rendered and revalidated every 5 minutes.
- All times handled explicitly as Europe/Berlin; whole-day events stored as
  00:00-23:59. Fixes carried over from the Angular app: the move dialog
  leaking a cancelled calendar change, saves that looked saved when they
  failed, in-place Date mutation, and duplicated calendar-id mappings.
- Same shared-session auth as before (proxy.ts gate, /admin/me, 401 vs 403).
- 112 Vitest tests over lib/; docs in docs/ (architecture, features,
  gotchas, API wish list, deployment).

Needs the API branch feature/calendar-ical-escaping-subscriptions deployed
first (GET /calendar/events/subscriptions).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:15:59 +02:00

270 lines
9.2 KiB
TypeScript

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