/** * calendar-month — reine Hilfsfunktionen fuer das Kalender-Widget * (quick-260916-htc). * * Kein React, keine DOM-Zugriffe: dieses Modul wird sowohl vom Widget * (calendar-widget.tsx) als auch vom Einstellungsfeld * (widget-settings-panel.tsx, `CalendarConfig`) importiert, damit beide * Stellen dieselben Vorgaben/Grenzen anwenden (Muster clock-font-size.ts, * T-BWO-01 uebertragen auf T-HTC-01). * * Vorlage fuer Monatsraster/Terminliste: das alte persoenliche Dashboard * des Anwenders (user-files/personal-dashboard/src/app/page.tsx). * * Starttag-Regel: mehrtaegige und ganztaegige Termine werden im Monatsraster * bewusst NUR am Starttag gezaehlt/angezeigt (`groupEventsByDate` gruppiert * ausschliesslich nach `event.start`) — eine Terminleiste ueber mehrere Tage * ist nicht Teil dieses Auftrags. Jede Tagesgruppe ist zusaetzlich nach Start * aufsteigend sortiert, damit die Plakettenfarbe (Farbe des ERSTEN Termins) * und die Tooltip-Reihenfolge deterministisch uebereinstimmen (quick-260916-jvj). * * Liegen an einem Tag Termine aus mehreren Kalendern, gruppiert * `groupDayBySource` sie nach `sourceId` (nicht nach Farbe) in der Reihenfolge * ihres ersten Auftretens in der bereits start-sortierten Tagesgruppe; * `buildDayBadges` macht daraus bis zu drei Kreise, ab dem vierten Kalender * einen grauen Restkreis mit der Summe der uebrigen (quick-260916-k2z). */ import type { CalendarEvent } from '@/lib/calendar-api'; export const CALENDAR_LOOKAHEAD_OPTIONS: readonly number[] = [7, 14, 30, 60, 90]; export const CALENDAR_MAX_EVENTS_LIMIT = 10; export const CALENDAR_DEFAULTS = { showMonth: true, maxEvents: 3, lookaheadDays: 30 } as const; export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const; export interface CalendarWidgetConfig { showMonth: boolean; maxEvents: number; lookaheadDays: number; } /** * Loest die Kalender-Widget-Konfiguration aus dem rohen `config`-JSON auf und * klemmt ungueltige Werte auf die Vorgaben zurueck (T-HTC-01). API prueft * Config-Felder nicht (`@IsObject()`), deshalb entscheidet ALLEIN diese * Funktion, was ein gueltiger Wert ist. */ export function resolveCalendarConfig(config: Record): CalendarWidgetConfig { const showMonth = config.showMonth !== false; let maxEvents: number = CALENDAR_DEFAULTS.maxEvents; if (typeof config.maxEvents === 'number' && Number.isFinite(config.maxEvents)) { maxEvents = Math.min(CALENDAR_MAX_EVENTS_LIMIT, Math.max(0, Math.trunc(config.maxEvents))); } let lookaheadDays: number = CALENDAR_DEFAULTS.lookaheadDays; if ( typeof config.lookaheadDays === 'number' && CALENDAR_LOOKAHEAD_OPTIONS.includes(config.lookaheadDays) ) { lookaheadDays = config.lookaheadDays; } return { showMonth, maxEvents, lookaheadDays }; } /** Lokaler Tagesschluessel `YYYY-MM-DD` (kein UTC-Versatz). */ export function dateKey(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** Kopie von `date` auf die lokale Tagesgrenze 00:00:00.000 gesetzt. */ export function startOfLocalDay(date: Date): Date { const copy = new Date(date); copy.setHours(0, 0, 0, 0); return copy; } /** * Kopie von `date`, um `days` Tage verschoben. Rechnet ueber `setDate`, NICHT * ueber Millisekunden — das haelt die lokale Wanduhrzeit ueber * Sommerzeitwechsel hinweg stabil. */ export function addDays(date: Date, days: number): Date { const copy = new Date(date); copy.setDate(copy.getDate() + days); return copy; } /** Montag am oder vor dem 1. des Monats von `monthDate`, 00:00 lokal. */ export function gridStartFor(monthDate: Date): Date { const firstDayOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7; const gridStart = new Date(firstDayOfMonth); gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset); gridStart.setHours(0, 0, 0, 0); return gridStart; } export interface CalendarDay { key: string; date: Date; inCurrentMonth: boolean; isToday: boolean; events: CalendarEvent[]; } /** * Gruppiert Termine nach lokalem Startdatum. Mehrtaegige/ganztaegige Termine * zaehlen bewusst nur am Starttag (siehe Kopfkommentar). Jede Tagesgruppe * wird nach Start aufsteigend sortiert (quick-260916-jvj), damit der erste * Eintrag zuverlaessig der fruehesten Termin des Tages ist. */ export function groupEventsByDate(events: CalendarEvent[]): Map { const groupedEvents = new Map(); for (const event of events) { const key = dateKey(new Date(event.start)); const existingEvents = groupedEvents.get(key) ?? []; groupedEvents.set(key, [...existingEvents, event]); } for (const dayEvents of groupedEvents.values()) { dayEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); } return groupedEvents; } export interface DaySourceGroup { sourceId: string; color?: string; count: number; } /** * Gruppiert die Termine EINES Tages nach `sourceId` (nicht nach Farbe). * Reihenfolge = erstes Auftreten in `events` — die Funktion sortiert selbst * NICHT, sie uebernimmt die Reihenfolge von `groupEventsByDate` (dort bereits * nach Start sortiert), damit Kreis- und Tooltip-Reihenfolge uebereinstimmen. * Die Farbe einer Gruppe ist die `color` ihres ERSTEN Termins. */ export function groupDayBySource(events: CalendarEvent[]): DaySourceGroup[] { const groups = new Map(); for (const event of events) { const existing = groups.get(event.sourceId); if (existing) { existing.count += 1; } else { groups.set(event.sourceId, { sourceId: event.sourceId, color: event.color, count: 1 }); } } return Array.from(groups.values()); } export const CALENDAR_DAY_BADGE_MAX = 3; export interface DayBadge { key: string; color?: string; count: number; rest: boolean; } /** * Baut die Plaketten-Kreise fuer einen Tag: bis `max` Kalender bekommen je * einen Kreis, ab `max` Kalendern fassen die ersten `max - 1` Kreise je einen * Kalender, der letzte Eintrag (`rest: true`) summiert die uebrigen Kalender * grau (kein Farbwert). */ export function buildDayBadges( events: CalendarEvent[], max: number = CALENDAR_DAY_BADGE_MAX, ): DayBadge[] { const groups = groupDayBySource(events); if (groups.length <= max) { return groups.map((group) => ({ key: group.sourceId, color: group.color, count: group.count, rest: false, })); } const visible = groups.slice(0, max - 1).map((group) => ({ key: group.sourceId, color: group.color, count: group.count, rest: false, })); const restCount = groups.slice(max - 1).reduce((sum, group) => sum + group.count, 0); return [...visible, { key: '__rest__', color: undefined, count: restCount, rest: true }]; } /** 42 Rasterzellen ab `gridStartFor(monthDate)`, Montag-basiert. */ export function buildCalendarDays( monthDate: Date, eventsByDate: Map, today: Date = new Date(), ): CalendarDay[] { const month = monthDate.getMonth(); const gridStart = gridStartFor(monthDate); const todayKey = dateKey(today); const days: CalendarDay[] = []; for (let index = 0; index < 42; index += 1) { const date = addDays(gridStart, index); const key = dateKey(date); days.push({ key, date, inCurrentMonth: date.getMonth() === month, isToday: key === todayKey, events: eventsByDate.get(key) ?? [], }); } return days; } /** * Ladefenster fuer `fetchEvents(from, to)`: deckt sowohl das 42-Tage-Raster * als auch den Vorschau-Zeitraum (lookaheadDays ab heute) ab. Alle vier * Zwischenwerte sind Tagesgrenzen 00:00 lokal, deshalb ist `toISOString()` * innerhalb eines Tages konstant — das haelt den Backend-Cache-Schluessel * (`${userId}:${from.toISOString()}:${to.toISOString()}`) ueber den * 5-Minuten-Refresh hinweg stabil. */ export function computeFetchWindow( monthDate: Date, lookaheadDays: number, now: Date = new Date(), ): { from: Date; to: Date } { const gridStart = gridStartFor(monthDate); const gridEnd = addDays(gridStart, 42); const todayStart = startOfLocalDay(now); const lookEnd = addDays(todayStart, lookaheadDays); const from = gridStart.getTime() <= todayStart.getTime() ? gridStart : todayStart; const to = gridEnd.getTime() >= lookEnd.getTime() ? gridEnd : lookEnd; return { from, to }; } /** * Waehlt die naechsten anstehenden Termine (laufend oder in der Zukunft, * innerhalb `lookaheadDays`), sortiert nach Start aufsteigend, gekuerzt auf * `maxEvents`. */ export function selectUpcomingEvents( events: CalendarEvent[], lookaheadDays: number, maxEvents: number, now: Date = new Date(), ): CalendarEvent[] { const horizon = addDays(startOfLocalDay(now), lookaheadDays).getTime(); const nowTime = now.getTime(); return events .filter((event) => { const endTime = new Date(event.end).getTime(); const startTime = new Date(event.start).getTime(); return endTime >= nowTime && startTime < horizon; }) .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()) .slice(0, maxEvents); } /** Datum+Uhrzeit (bzw. nur Datum bei ganztaegig) im Format „Mi., 20.07., 09:05“. */ export function formatEventDate(event: CalendarEvent): string { const start = new Date(event.start); if (event.allDay) { return new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', }).format(start); } return new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }).format(start); } /** Nur die Uhrzeit eines ISO-Zeitstempels, „09:05“. */ export function formatEventTime(iso: string): string { return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', }).format(new Date(iso)); } /** Monat+Jahr, „Juli 2026“. */ export function formatMonthLabel(date: Date): string { return new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric', }).format(date); }