diff --git a/apps/web/src/components/dashboard/widgets/calendar-month.test.ts b/apps/web/src/components/dashboard/widgets/calendar-month.test.ts new file mode 100644 index 0000000..49bc607 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/calendar-month.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import type { CalendarEvent } from '@/lib/calendar-api'; +import { + addDays, + buildCalendarDays, + computeFetchWindow, + dateKey, + formatEventDate, + formatEventTime, + formatMonthLabel, + gridStartFor, + groupEventsByDate, + resolveCalendarConfig, + selectUpcomingEvents, + startOfLocalDay, +} from './calendar-month'; + +/** Kleine Fabrik fuer Testtermine (Muster aus dem Plan). */ +function ev(id: string, start: Date, end: Date, extra?: Partial): CalendarEvent { + return { + id, + sourceId: 's1', + title: id, + start: start.toISOString(), + end: end.toISOString(), + allDay: false, + ...extra, + }; +} + +describe('calendar-month', () => { + it('Test 1: buildCalendarDays liefert 42 Zellen ab Montag mit korrektem Monatsflag und heutigem Tag', () => { + const days = buildCalendarDays(new Date(2026, 6, 1), new Map(), new Date(2026, 6, 15)); + + expect(days).toHaveLength(42); + expect(days[0].key).toBe('2026-06-29'); + expect(days[0].inCurrentMonth).toBe(false); + expect(days[2].key).toBe('2026-07-01'); + expect(days[2].inCurrentMonth).toBe(true); + expect(days[41].key).toBe('2026-08-09'); + + const todayCells = days.filter((d) => d.isToday); + expect(todayCells).toHaveLength(1); + expect(todayCells[0].key).toBe('2026-07-15'); + + const outside = days.filter((d) => !d.inCurrentMonth); + expect(outside).toHaveLength(11); + }); + + it('Test 2: groupEventsByDate gruppiert nach lokalem Startdatum, buildCalendarDays uebernimmt die Zaehlung', () => { + const events = [ + ev('e1', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), + ev('e2', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)), + ev('e3', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0)), + ]; + + const grouped = groupEventsByDate(events); + expect(grouped.size).toBe(2); + expect(grouped.get('2026-07-20')).toHaveLength(2); + + const days = buildCalendarDays(new Date(2026, 6, 1), grouped); + const day20 = days.find((d) => d.key === '2026-07-20'); + expect(day20?.events.length).toBe(2); + }); + + it('Test 3: resolveCalendarConfig klemmt ungueltige Werte auf die Vorgaben/Grenzen zurueck', () => { + expect(resolveCalendarConfig({})).toEqual({ showMonth: true, maxEvents: 3, lookaheadDays: 30 }); + expect(resolveCalendarConfig({ showMonth: false }).showMonth).toBe(false); + expect(resolveCalendarConfig({ showMonth: 'nein' }).showMonth).toBe(true); + expect(resolveCalendarConfig({ maxEvents: 99 }).maxEvents).toBe(10); + expect(resolveCalendarConfig({ maxEvents: -1 }).maxEvents).toBe(0); + expect(resolveCalendarConfig({ maxEvents: 4.7 }).maxEvents).toBe(4); + expect(resolveCalendarConfig({ maxEvents: '5' }).maxEvents).toBe(3); + expect(resolveCalendarConfig({ lookaheadDays: 45 }).lookaheadDays).toBe(30); + expect(resolveCalendarConfig({ lookaheadDays: 90 }).lookaheadDays).toBe(90); + }); + + it('Test 4: computeFetchWindow deckt Raster und Vorschau-Zeitraum als Tagesgrenzen ab', () => { + const w1 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 6, 15, 10, 30)); + expect(w1.from.getTime()).toBe(new Date(2026, 5, 29).getTime()); + expect(w1.to.getTime()).toBe(new Date(2026, 7, 14).getTime()); + + const w2 = computeFetchWindow(new Date(2026, 6, 1), 30, new Date(2026, 4, 1, 8, 0)); + expect(w2.from.getTime()).toBe(new Date(2026, 4, 1).getTime()); + expect(w2.to.getTime()).toBe(new Date(2026, 7, 10).getTime()); + + const w3 = computeFetchWindow(new Date(2026, 6, 1), 90, new Date(2026, 6, 15, 10, 30)); + expect(w3.to.getTime()).toBe(new Date(2026, 9, 13).getTime()); + + for (const w of [w1, w2, w3]) { + for (const d of [w.from, w.to]) { + expect(d.getHours()).toBe(0); + expect(d.getMinutes()).toBe(0); + expect(d.getSeconds()).toBe(0); + expect(d.getMilliseconds()).toBe(0); + } + } + }); + + it('Test 5: selectUpcomingEvents filtert Vergangenheit/Horizont, sortiert nach Start und kuerzt auf maxEvents', () => { + const now = new Date(2026, 6, 15, 10, 0); + const events = [ + ev('gestern', new Date(2026, 6, 14, 11, 0), new Date(2026, 6, 14, 12, 0)), + ev('läuft', new Date(2026, 6, 15, 9, 0), new Date(2026, 6, 15, 11, 0)), + ev('heute 15', new Date(2026, 6, 15, 15, 0), new Date(2026, 6, 15, 16, 0)), + ev('in 5 Tagen', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), + ev('in 10 Tagen', new Date(2026, 6, 25, 9, 0), new Date(2026, 6, 25, 10, 0)), + ]; + + const result = selectUpcomingEvents(events, 7, 2, now); + expect(result.map((e) => e.title)).toEqual(['läuft', 'heute 15']); + + expect(selectUpcomingEvents(events, 7, 0, now)).toEqual([]); + }); + + it('Test 6: formatEventDate/formatEventTime/formatMonthLabel formatieren de-DE', () => { + const timed = ev('t1', new Date(2026, 6, 20, 9, 5), new Date(2026, 6, 20, 10, 0)); + const timedText = formatEventDate(timed); + expect(timedText).toMatch(/20\.07\./); + expect(timedText).toMatch(/09:05/); + + const allDay = ev('t2', new Date(2026, 6, 20, 0, 0), new Date(2026, 6, 20, 23, 59), { allDay: true }); + const allDayText = formatEventDate(allDay); + expect(allDayText).toMatch(/20\.07\./); + expect(allDayText).not.toMatch(/\d{2}:\d{2}/); + + expect(formatMonthLabel(new Date(2026, 6, 1))).toBe('Juli 2026'); + expect(formatEventTime(new Date(2026, 6, 20, 9, 5).toISOString())).toBe('09:05'); + }); + + it('Test 7: gridStartFor findet den Montag am oder vor dem 1. des Monats', () => { + expect(gridStartFor(new Date(2026, 7, 1)).getTime()).toBe(new Date(2026, 6, 27).getTime()); + expect(gridStartFor(new Date(2026, 5, 1)).getTime()).toBe(new Date(2026, 5, 1).getTime()); + }); + + it('Zusatz: dateKey/startOfLocalDay/addDays arbeiten lokal ohne UTC-Versatz', () => { + expect(dateKey(new Date(2026, 6, 5))).toBe('2026-07-05'); + const start = startOfLocalDay(new Date(2026, 6, 5, 14, 30, 15)); + expect(start.getHours()).toBe(0); + expect(addDays(new Date(2026, 6, 30), 2).getDate()).toBe(1); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/calendar-month.ts b/apps/web/src/components/dashboard/widgets/calendar-month.ts new file mode 100644 index 0000000..ceb887e --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/calendar-month.ts @@ -0,0 +1,222 @@ +/** + * 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. + */ + +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). + */ +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]); + } + return groupedEvents; +} + +/** 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); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index e53cf4f..329c343 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -208,6 +208,22 @@ "loading": "Laden...", "emptyNoSources": "Keine Kalenderquellen konfiguriert", "emptyNoEvents": "Keine anstehenden Termine", + "nothingSelected": "Nichts zum Anzeigen ausgewählt", + "monthPrev": "Zurück", + "monthNext": "Weiter", + "monthToday": "Zurück zum heutigen Monat", + "upcomingTitle": "Nächste Termine", + "tooltipMore": "Weitere Termine vorhanden", + "allDay": "ganztägig", + "configShowMonth": "Monatsansicht anzeigen", + "configMaxEvents": "Anzahl Termine", + "configMaxEventsNone": "Ausblenden", + "configMaxEventsOne": "1 Termin", + "configMaxEventsMany": "{count} Termine", + "configLookahead": "Zeitraum", + "configLookaheadOption": "Nächste {days} Tage", + "configSourcesHint": "Kalenderquellen verwalten Sie unter", + "configSourcesLink": "Einstellungen → Dashboard → Kalender", "connectionSuccess": "Verbindung erfolgreich", "connectionError": "Verbindung fehlgeschlagen. Bitte überprüfen Sie die URL und Zugangsdaten.", "addSource": "Quelle hinzufügen", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index b4ab0e9..ccc6bed 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -208,6 +208,22 @@ "loading": "Loading...", "emptyNoSources": "No calendar sources configured", "emptyNoEvents": "No upcoming events", + "nothingSelected": "Nothing selected to display", + "monthPrev": "Back", + "monthNext": "Next", + "monthToday": "Back to the current month", + "upcomingTitle": "Upcoming events", + "tooltipMore": "More events available", + "allDay": "all day", + "configShowMonth": "Show month view", + "configMaxEvents": "Number of events", + "configMaxEventsNone": "Hide", + "configMaxEventsOne": "1 event", + "configMaxEventsMany": "{count} events", + "configLookahead": "Time range", + "configLookaheadOption": "Next {days} days", + "configSourcesHint": "Manage calendar sources under", + "configSourcesLink": "Settings → Dashboard → Calendar", "connectionSuccess": "Connection successful", "connectionError": "Connection failed. Please check the URL and credentials.", "addSource": "Add source",