'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslations } from 'next-intl'; import type { WidgetProps } from '../widget-registry'; import { fetchEvents, fetchSources } from '@/lib/calendar-api'; import type { CalendarEvent } from '@/lib/calendar-api'; import { buildCalendarDays, buildDayBadges, computeFetchWindow, eventDayOffset, formatEventDate, formatEventDay, formatEventTime, formatMonthLabel, groupEventsByDate, resolveCalendarConfig, selectUpcomingEvents, WEEKDAY_LABELS, } from './calendar-month'; // quick-260916-j4f: Breite und Rand-Klemmung des Termin-Tooltips aus einer // Quelle, damit Breite und Klemmwert nicht auseinanderlaufen; 288 px // entspricht Tailwind w-72. const TOOLTIP_WIDTH_PX = 288; const TOOLTIP_EDGE_PX = 4; /** * Calendar widget (DASH-05, D-10 read-only), neu gebaut nach dem Vorbild des * alten persoenlichen Dashboards des Anwenders (quick-260916-htc). * * Oben ein Monatsraster (Nav-Zeile Zurueck/Monat/Weiter, Wochentagskopf, * 42 Zellen ab Montag, Faehrmonatstage gedaempft, heutiger Tag hervorgehoben, * Zaehl-Plakette an Tagen mit Terminen in der Farbe des Kalenders (ein * Kalender am Tag); bei mehreren Kalendern am selben Tag je ein kleiner * Kreis pro Kalender (max. drei, danach zwei plus grauer Restkreis mit * Summe, Logik in `buildDayBadges`, quick-260916-k2z), Tooltip beim * Ueberfahren mit Farbpunkt je Zeile (quick-260916-jvj)), darunter * der Block "Naechste Termine". Beide Bloecke lassen sich unabhaengig * ein-/ausblenden (Config `showMonth`/`maxEvents`, siehe calendar-month.ts). * * Der Tooltip wird per `createPortal` in `document.body` gerendert, weil die * Karte in widget-wrapper.tsx `overflow-hidden` traegt und ein absolut * positionierter Tooltip sonst abgeschnitten wuerde. Titel im Tooltip * brechen um (kein truncate); Breite/Klemmung kommen aus * `TOOLTIP_WIDTH_PX`/`TOOLTIP_EDGE_PX` (quick-260916-j4f). * * Das Ladefenster ist IMMER auf lokale Tagesgrenzen gerundet * (`computeFetchWindow`), damit der Backend-Cache-Schluessel * (`${userId}:${from.toISOString()}:${to.toISOString()}`) ueber den * 5-Minuten-Refresh hinweg stabil bleibt — auch wenn die Monatsansicht * ausgeblendet ist, deckt das Fenster weiterhin das 42-Tage-Raster des * (dann immer heutigen) Monats ab, siehe SUMMARY. * * Mehrtaegige/ganztaegige Termine werden im Raster nur am Starttag gezaehlt * (siehe calendar-month.ts). * * Refreshes every 5 minutes to match the backend cache TTL. * NEVER fetches external calendars directly (RESEARCH anti-pattern). */ export function CalendarWidget({ config }: WidgetProps) { const t = useTranslations('widgets'); const [events, setEvents] = useState([]); const [hasSources, setHasSources] = useState(null); const [isLoading, setIsLoading] = useState(true); const [monthDate, setMonthDate] = useState(() => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), 1); }); const [hover, setHover] = useState<{ key: string; rect: { top: number; left: number; bottom: number; right: number }; } | null>(null); const intervalRef = useRef | undefined>(undefined); // Restposten 3a/3b (quick-260921-jt4): zwei Merkungen ausserhalb des // Render-Zustands, damit ein Monatswechsel, der weder die Quellenliste noch // das Termin-Ladefenster tatsaechlich aendert, keinen erneuten Abruf // ausloest — der 5-Minuten-Auffrischer (siehe `force` unten) ruft // unabhaengig davon immer beide ab, sonst veraltet die Anzeige still. // hasSourcesRef: `null` heisst "noch nie erfolgreich geholt". const hasSourcesRef = useRef(null); // lastFetchWindowRef: die beiden ISO-Zeichenketten, die zuletzt // TATSAECHLICH an fetchEvents gingen (nicht nur berechnet) — derselbe // Vergleichswert, den auch die API sieht. const lastFetchWindowRef = useRef<{ from: string; to: string } | null>(null); // Befund 1/2 (quick-260921-gof): keine Merkung noetig — resolveCalendarConfig // ist eine reine Funktion, die nur drei einfache Werte liefert; eine // Merkung um das ganze config-Objekt hat hier nie etwas gespart. const { showMonth, maxEvents, lookaheadDays } = resolveCalendarConfig(config); useEffect(() => { let cancelled = false; // `force`: true nur fuer den 5-Minuten-Auffrischer. Ein durch // Monatswechsel ausgeloester Lauf (force=false) darf beide Sperrgriffe // unten greifen lassen; der Auffrischer muss sie UNBEDINGT umgehen, // sonst friert die Anzeige ein. async function loadData(force: boolean) { try { // Restposten 3b: die Quellenliste haengt nicht vom angezeigten Monat // ab — nur holen, wenn sie noch unbekannt ist ODER der Lauf // erzwungen wurde (Aufbau: hasSourcesRef ist null, greift also // ohnehin; Auffrischer: force=true). if (hasSourcesRef.current === null || force) { const sources = await fetchSources(); if (cancelled) return; hasSourcesRef.current = sources.length > 0; } if (!hasSourcesRef.current) { setHasSources(false); setEvents([]); setIsLoading(false); return; } setHasSources(true); // Fetch aggregated events for the current grid/lookahead window — // ALWAYS with both day-boundary ISO strings (T-HTC-03). const { from, to } = computeFetchWindow(monthDate, lookaheadDays); const fromIso = from.toISOString(); const toIso = to.toISOString(); // Restposten 3a: das neu berechnete Fenster deckt sich bei einem // Monatswechsel manchmal mit dem zuletzt tatsaechlich geholten // (siehe Kopfkommentar computeFetchWindow/SUMMARY) — dann ist der // Abruf verschwendet und faellt bis zum naechsten Server durch. const windowUnchanged = !force && lastFetchWindowRef.current !== null && lastFetchWindowRef.current.from === fromIso && lastFetchWindowRef.current.to === toIso; if (windowUnchanged) { setIsLoading(false); return; } const eventData = await fetchEvents(fromIso, toIso); if (cancelled) return; lastFetchWindowRef.current = { from: fromIso, to: toIso }; setEvents(eventData); } catch { // Silent fail — show empty state if (!cancelled) { setEvents([]); } } finally { if (!cancelled) { setIsLoading(false); } } } // Aufbau bzw. Monatswechsel: nicht erzwungen — die beiden Sperrgriffe // oben entscheiden selbst, ob wirklich etwas Neues zu holen ist (beim // allerersten Aufbau sind beide Merkungen leer, holen also ohnehin). loadData(false); // Refresh every 5 minutes (300000ms) to match backend cache TTL — IMMER // erzwungen, sonst bemerkt die Kachel weder eine neue Quelle noch neue // Termine innerhalb des Fensters. intervalRef.current = setInterval(() => { loadData(true); }, 300_000); return () => { cancelled = true; if (intervalRef.current) { clearInterval(intervalRef.current); } }; // Befund 3/4 (quick-260921-gof): haengt jetzt direkt an monthDate statt an // monthDate.getTime(). Das ist nur deshalb gefahrlos, weil showToday // (unten) das Datum identitaetserhaltend setzt, wenn der gewuenschte // Monat bereits angezeigt wird — sonst wuerde jeder Druck auf den // Monatsknopf im laufenden Monat einen neuen Termin-Abruf ausloesen, der // ueber die API bis zum Exchange-Server durchschlaegt (D-04). Das // Ladefenster bleibt weiterhin auf lokale Tagesgrenzen gerundet, der // Backend-Cache-Schluessel aendert sich dadurch nicht. }, [monthDate, lookaheadDays]); const showPrev = useCallback(() => { setHover(null); setMonthDate((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1)); }, []); const showNext = useCallback(() => { setHover(null); setMonthDate((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1)); }, []); const showToday = useCallback(() => { setHover(null); // Identitaetserhaltend: wird der aktuelle Monat bereits angezeigt, bleibt // dieselbe monthDate-Referenz stehen, statt ein frisches Date-Objekt zu // erzeugen. Der Ladeeffekt haengt jetzt direkt an monthDate — ohne diese // Stabilisierung wuerde jeder Druck auf den Knopf einen neuen // Termin-Abruf ausloesen (Befund 3/4, D-04). setMonthDate((d) => { const now = new Date(); if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) { return d; } return new Date(now.getFullYear(), now.getMonth(), 1); }); }, []); const eventsByDate = useMemo(() => groupEventsByDate(events), [events]); const days = useMemo(() => buildCalendarDays(monthDate, eventsByDate), [monthDate, eventsByDate]); const upcoming = useMemo( () => selectUpcomingEvents(events, lookaheadDays, maxEvents), [events, lookaheadDays, maxEvents], ); // Loading state if (isLoading) { return (
{t('calendar.loading')}
); } // No sources configured if (hasSources === false) { return (
{t('calendar.emptyNoSources')}
); } // Both blocks hidden by configuration if (!showMonth && maxEvents === 0) { return (
{t('calendar.nothingSelected')}
); } const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : []; return (
{showMonth && (
{/* Nav-Zeile: Pfeilknoepfe links/rechts, Monatsname mittig */}
{/* Wochentagskopf */}
{WEEKDAY_LABELS.map((label) => (
{label}
))}
{/* Raster */}
{days.map((day) => { const hasEvents = day.events.length > 0; const badges = buildDayBadges(day.events); // Tag als Kreis (Design „Mosaik“): heute gelb gefuellt, Termine // als kleine Punkte unter der Zahl — je Kalenderquelle einer, // in deren Farbe; ohne Farbe im Akzentton, Rest grau. const cellClass = [ 'widgetNoDrag relative mx-auto flex h-[clamp(20px,7cqw,28px)] w-[clamp(20px,7cqw,28px)] items-center justify-center rounded-full text-[clamp(11px,2.9cqw,13px)] leading-none tabular-nums', day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/70', day.isToday ? 'bg-primary font-semibold text-primary-foreground' : '', hasEvents && !day.isToday ? 'cursor-default font-medium hover:bg-muted' : '', ] .filter(Boolean) .join(' '); const cellContent = ( <> {day.date.getDate()} {hasEvents && ( {badges.map((badge) => { const badgeClass = [ 'block h-1 w-1 rounded-full', badge.rest ? 'bg-muted-foreground' : badge.color ? '' : day.isToday ? 'bg-primary-foreground' : 'bg-primary-strong', ] .filter(Boolean) .join(' '); return ( {badge.count} ); })} )} ); // Nur Tage MIT Terminen werden zu Tab-Stopps (D-01) — ein // Widget mit 42 zusaetzlichen Tab-Stopps waere eine // Verschlechterung. Wer nicht mit der Maus arbeitet, bekam // die Termin-Einblendung bislang gar nicht zu sehen; die // Schaltflaeche traegt deshalb zusaetzlich onFocus/onBlur mit // demselben Rumpf wie onMouseEnter/onMouseLeave. if (hasEvents) { const dayLabel = new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', }).format(day.date); const eventCount = day.events.length; const ariaLabel = eventCount === 1 ? t('calendar.dayEventsOne', { date: dayLabel }) : t('calendar.dayEventsMany', { date: dayLabel, count: eventCount }); return ( ); } return (
{cellContent}
); })}
)} {maxEvents > 0 && (

{t('calendar.upcomingTitle')}

{upcoming.length === 0 ? (

{t('calendar.emptyNoEvents')}

) : ( // Einzeilige Terminliste (Design „Mosaik“, Runde 3): Farbstrich, // Tag, Titel, Uhrzeit rechts — rund 26 px je Termin statt 40, so // passen auch in eine kleine Kachel mehrere Termine. „Heute“ und // „Morgen“ stehen in der Akzentfarbe.
    {upcoming.map((event) => { const offset = eventDayOffset(event); const dayLabel = offset === 0 ? t('calendar.today') : offset === 1 ? t('calendar.tomorrow') : formatEventDay(event); const soon = offset === 0 || offset === 1; return (
  • ); })}
)}
)} {hover !== null && typeof document !== 'undefined' && createPortal(
{hoverEvents.slice(0, 5).map((event) => (
))} {hoverEvents.length > 5 && (
{t('calendar.tooltipMore')}
)}
, document.body, )}
); }