import type { CalendarEvent } from '../../types'; /** * Das Backend liefert DATETIME als String (`dateStrings: true`), also lokale * Wandzeit ohne Zeitzone. `new Date('2026-08-04 10:00:00')` ist nicht in allen * Browsern gueltig - deshalb wird hier von Hand geparst. Das haelt Termine * unabhaengig von der Zeitzone des Clients an der gespeicherten Uhrzeit. */ export function parseSqlDateTime(value: string): Date { const match = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value ?? ''); if (!match) return new Date(NaN); const [, year, month, day, hour = '0', minute = '0', second = '0'] = match; return new Date( Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second) ); } const pad = (value: number) => String(value).padStart(2, '0'); /** `YYYY-MM-DD HH:MM:SS` - das Format, das das Backend erwartet. */ export function toSqlDateTime(date: Date): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad( date.getHours() )}:${pad(date.getMinutes())}:00`; } /** `YYYY-MM-DDTHH:MM` fuer */ export function toDateTimeInput(date: Date): string { return `${toDateInput(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** `YYYY-MM-DD` fuer */ export function toDateInput(date: Date): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; } export function startOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } export function addDays(date: Date, days: number): Date { const next = new Date(date); next.setDate(next.getDate() + days); return next; } export function addMonths(date: Date, months: number): Date { // Auf den Ersten setzen, damit der 31. nicht in den Folgemonat rutscht. return new Date(date.getFullYear(), date.getMonth() + months, 1); } /** Wochenstart Montag - deutsche Konvention. */ export function startOfWeek(date: Date): Date { const start = startOfDay(date); const shift = (start.getDay() + 6) % 7; return addDays(start, -shift); } export function isSameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const; /** * 6 Wochen ab dem Montag vor dem Monatsersten - feste Hoehe, damit das Raster * beim Blaettern nicht springt. */ export function monthMatrix(month: Date): Date[] { const first = startOfWeek(new Date(month.getFullYear(), month.getMonth(), 1)); return Array.from({ length: 42 }, (_, index) => addDays(first, index)); } export function weekDays(date: Date): Date[] { const first = startOfWeek(date); return Array.from({ length: 7 }, (_, index) => addDays(first, index)); } export function formatTime(date: Date): string { return `${pad(date.getHours())}:${pad(date.getMinutes())}`; } export function formatDayLong(date: Date): string { return date.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric', }); } export function formatMonthYear(date: Date): string { return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); } export const DAY_MS = 24 * 60 * 60 * 1000; /** Mindesthoehe eines Termins im Zeitraster, sonst ist er nicht lesbar. */ const MIN_SLOT_MS = 15 * 60 * 1000; export interface PositionedEvent { event: CalendarEvent; topPercent: number; heightPercent: number; leftPercent: number; widthPercent: number; } /** * Positioniert die Termine eines Tages im Zeitraster. Ueberlappende Termine * werden in Spalten aufgeteilt, damit keiner verdeckt wird; Prozentwerte * beziehen sich auf die 24-Stunden-Spalte. */ export function positionEvents(events: CalendarEvent[], day: Date): PositionedEvent[] { const dayStart = startOfDay(day).getTime(); const dayEnd = dayStart + DAY_MS; const items = events .map((event) => { const rawStart = parseSqlDateTime(event.starts_at).getTime(); const rawEnd = parseSqlDateTime(event.ends_at).getTime(); const start = Math.max(rawStart, dayStart); const end = Math.min(Math.max(rawEnd, start + MIN_SLOT_MS), dayEnd); return { event, start, end, column: 0 }; }) .sort((a, b) => a.start - b.start || a.end - b.end); const positioned: PositionedEvent[] = []; // Ein Cluster ist eine Kette sich beruehrender Termine; nur innerhalb eines // Clusters muss die Breite geteilt werden. const flush = (cluster: typeof items) => { if (cluster.length === 0) return; const columnEnds: number[] = []; for (const item of cluster) { let column = columnEnds.findIndex((end) => end <= item.start); if (column === -1) column = columnEnds.length; columnEnds[column] = item.end; item.column = column; } const columns = columnEnds.length; for (const item of cluster) { positioned.push({ event: item.event, topPercent: ((item.start - dayStart) / DAY_MS) * 100, heightPercent: ((item.end - item.start) / DAY_MS) * 100, leftPercent: (item.column / columns) * 100, widthPercent: 100 / columns, }); } }; let cluster: typeof items = []; let clusterEnd = -Infinity; for (const item of items) { if (cluster.length > 0 && item.start >= clusterEnd) { flush(cluster); cluster = []; clusterEnd = -Infinity; } cluster.push(item); clusterEnd = Math.max(clusterEnd, item.end); } flush(cluster); return positioned; } /** Sortierung im Raster: ganztaegig zuerst, danach nach Startzeit. */ export function compareEvents(a: CalendarEvent, b: CalendarEvent): number { if (a.all_day !== b.all_day) return a.all_day ? -1 : 1; const diff = parseSqlDateTime(a.starts_at).getTime() - parseSqlDateTime(b.starts_at).getTime(); return diff !== 0 ? diff : a.id - b.id; } /** Zeitspanne als Text fuer Karten und Listen. */ export function formatEventRange(event: CalendarEvent): string { const start = parseSqlDateTime(event.starts_at); const end = parseSqlDateTime(event.ends_at); if (event.all_day) { return isSameDay(start, end) ? 'Ganztaegig' : `Ganztaegig · bis ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}`; } if (isSameDay(start, end)) return `${formatTime(start)} – ${formatTime(end)}`; return `${formatTime(start)} – ${end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', })} ${formatTime(end)}`; }