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