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