'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, computeFetchWindow, formatEventDate, 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, Tooltip beim Ueberfahren), 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); const { showMonth, maxEvents, lookaheadDays } = useMemo( () => resolveCalendarConfig(config), // eslint-disable-next-line react-hooks/exhaustive-deps [config.showMonth, config.maxEvents, config.lookaheadDays], ); useEffect(() => { let cancelled = false; async function loadData() { try { // Check if user has any sources configured const sources = await fetchSources(); if (cancelled) return; if (sources.length === 0) { 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 eventData = await fetchEvents(from.toISOString(), to.toISOString()); if (cancelled) return; setEvents(eventData); } catch { // Silent fail — show empty state if (!cancelled) { setEvents([]); } } finally { if (!cancelled) { setIsLoading(false); } } } loadData(); // Refresh every 5 minutes (300000ms) to match backend cache TTL intervalRef.current = setInterval(() => { loadData(); }, 300_000); return () => { cancelled = true; if (intervalRef.current) { clearInterval(intervalRef.current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [monthDate.getTime(), 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); const now = new Date(); setMonthDate(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 */}
{/* Wochentagskopf */}
{WEEKDAY_LABELS.map((label) => (
{label}
))}
{/* Raster */}
{days.map((day) => { const hasEvents = day.events.length > 0; const cellClass = [ 'widgetNoDrag relative flex min-h-[clamp(16px,5.5cqh,40px)] items-start rounded bg-muted/50 px-1 py-0.5 text-[clamp(9px,2.4cqw,13px)] leading-none', day.inCurrentMonth ? 'text-foreground' : 'text-muted-foreground/60', day.isToday ? 'ring-1 ring-primary font-semibold text-primary' : '', hasEvents ? 'cursor-default hover:bg-muted' : '', ] .filter(Boolean) .join(' '); return (
hasEvents && setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() }) } onMouseLeave={() => setHover(null)} > {day.date.getDate()} {hasEvents && ( {day.events.length} )}
); })}
)} {maxEvents > 0 && (

{t('calendar.upcomingTitle')}

{upcoming.length === 0 ? (

{t('calendar.emptyNoEvents')}

) : (
    {upcoming.map((event) => (
  • ))}
)}
)} {hover !== null && typeof document !== 'undefined' && createPortal(
{hoverEvents.slice(0, 5).map((event) => (
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)} {event.title}
))} {hoverEvents.length > 5 && (
{t('calendar.tooltipMore')}
)}
, document.body, )}
); }