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,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,
|
||||
});
|
||||
Reference in new issue
Block a user