diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index a4a8bae..da6c4f2 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -61,7 +61,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { expect(WIDGET_CONSTRAINTS).toEqual({ clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, - calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 }, + calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, calculator: { minW: 3, minH: 10, defaultW: 6, defaultH: 10 }, favorites: { minW: 3, minH: 3, defaultW: 6, defaultH: 10 }, diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index 33dfc84..f8ef88e 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -41,7 +41,9 @@ export const WIDGET_CONSTRAINTS: Record< // Existing widgets clock: { minW: 2, minH: 2, defaultW: 4, defaultH: 4 }, search: { minW: 6, minH: 2, defaultW: 12, defaultH: 4 }, - calendar: { minW: 3, minH: 3, defaultW: 8, defaultH: 12 }, + // quick-260916-htc: Monatsraster braucht Breite fuer 7 Spalten und Hoehe + // fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block. + calendar: { minW: 6, minH: 8, defaultW: 8, defaultH: 12 }, note: { minW: 4, minH: 4, defaultW: 6, defaultH: 8 }, // Phase 8 new widgets (D-01) // minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx index 7004dda..b05b0d6 100644 --- a/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx +++ b/apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx @@ -1,19 +1,35 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CalendarEvent } from '@/lib/calendar-api'; -// Mock next-intl +/** + * calendar-widget.test — neu geschrieben (quick-260916-htc). Ersetzt die + * alten drei Tests der flachen Terminliste durch Tests fuer das + * Monatsraster, den Portal-Tooltip, "Naechste Termine" und das + * Ladefenster/Blaettern. + */ + +// Mock next-intl — nimmt (key, values?) und ersetzt {name}-Platzhalter. vi.mock('next-intl', () => ({ - useTranslations: (ns: string) => (key: string) => { + useTranslations: (ns: string) => (key: string, values?: Record) => { const map: Record> = { widgets: { - 'calendar.name': 'Calendar', - 'calendar.description': 'Upcoming events', - 'calendar.emptyNoSources': 'No calendar sources configured', - 'calendar.emptyNoEvents': 'No upcoming events', - 'calendar.loading': 'Loading...', + 'calendar.name': 'Kalender', + 'calendar.description': 'Kommende Termine', + 'calendar.loading': 'Laden...', + 'calendar.emptyNoSources': 'Keine Kalenderquellen konfiguriert', + 'calendar.emptyNoEvents': 'Keine anstehenden Termine', + 'calendar.nothingSelected': 'Nichts zum Anzeigen ausgewählt', + 'calendar.monthPrev': 'Zurück', + 'calendar.monthNext': 'Weiter', + 'calendar.monthToday': 'Zurück zum heutigen Monat', + 'calendar.upcomingTitle': 'Nächste Termine', + 'calendar.tooltipMore': 'Weitere Termine vorhanden', + 'calendar.allDay': 'ganztägig', }, }; - return map[ns]?.[key] ?? key; + const raw = map[ns]?.[key] ?? key; + return values ? raw.replace(/\{(\w+)\}/g, (_, n) => String(values[n] ?? '')) : raw; }, })); @@ -25,96 +41,259 @@ vi.mock('@/lib/calendar-api', () => ({ fetchSources: (...args: unknown[]) => mockFetchSources(...args), })); +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, + }; +} + +beforeEach(() => { + mockFetchEvents.mockReset(); + mockFetchSources.mockReset(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 6, 15, 10, 0, 0)); + mockFetchSources.mockResolvedValue([ + { id: 's1', name: 'Work', type: 'caldav', isVisible: true, color: '#ff0000' }, + ]); +}); + afterEach(() => { + vi.useRealTimers(); cleanup(); vi.restoreAllMocks(); }); describe('CalendarWidget', () => { - beforeEach(() => { - // Default: sources exist so we get past the source check - mockFetchSources.mockResolvedValue([ - { id: 's1', name: 'Work', type: 'caldav', isVisible: true, color: '#ff0000' }, - ]); - }); - - it('renders event titles, times, and source color dots for two events', async () => { - mockFetchEvents.mockResolvedValue([ - { - id: 'e1', - sourceId: 's1', - title: 'Team Meeting', - start: '2026-06-24T10:00:00Z', - end: '2026-06-24T11:00:00Z', - allDay: false, - color: '#ff0000', - }, - { - id: 'e2', - sourceId: 's1', - title: 'Lunch Break', - start: '2026-06-24T12:00:00Z', - end: '2026-06-24T13:00:00Z', - allDay: false, - color: '#00ff00', - }, - ]); - - const { CalendarWidget } = await import('./calendar-widget'); - render( - , - ); - - // Wait for events to load - await waitFor(() => { - expect(screen.getByText('Team Meeting')).toBeInTheDocument(); - }); - - expect(screen.getByText('Lunch Break')).toBeInTheDocument(); - - // Source color dots should be rendered (8px circles) - const colorDots = screen.getAllByTestId('event-color-dot'); - expect(colorDots).toHaveLength(2); - }); - - it('renders no-events message when sources exist but no events returned', async () => { - mockFetchEvents.mockResolvedValue([]); - - const { CalendarWidget } = await import('./calendar-widget'); - render( - , - ); - - await waitFor(() => { - expect(screen.getByText('No upcoming events')).toBeInTheDocument(); - }); - }); - - it('renders no-sources message when no sources configured', async () => { + it('Test 1: zeigt Laden, dann bei leeren Quellen die Keine-Quellen-Meldung ohne fetchEvents', async () => { mockFetchSources.mockResolvedValue([]); + + const { CalendarWidget } = await import('./calendar-widget'); + render(); + + expect(screen.getByText('Laden...')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Keine Kalenderquellen konfiguriert')).toBeInTheDocument(); + }); + expect(mockFetchEvents).not.toHaveBeenCalled(); + }); + + it('Test 2: rendert das Monatsraster mit 42 Zellen, heutigem Tag und Fremdmonatstagen', async () => { mockFetchEvents.mockResolvedValue([]); const { CalendarWidget } = await import('./calendar-widget'); - render( - , + render(); + + await waitFor(() => { + expect(screen.getByText('Juli 2026')).toBeInTheDocument(); + }); + + for (const label of ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']) { + expect(screen.getAllByText(label).length).toBeGreaterThan(0); + } + + const dayCells = screen.getAllByTestId('calendar-day'); + expect(dayCells).toHaveLength(42); + + const todayCell = document.querySelector('[data-date="2026-07-15"]'); + expect(todayCell?.getAttribute('data-today')).toBe('true'); + expect(todayCell?.textContent).toContain('15'); + expect(within(todayCell as HTMLElement).queryByTestId('calendar-day-count')).toBeNull(); + + const outsideCell = document.querySelector('[data-date="2026-06-29"]'); + expect(outsideCell?.getAttribute('data-outside')).toBe('true'); + + const monthBlock = screen.getByTestId('calendar-month'); + expect(monthBlock.className).toContain(''); + for (const btn of screen.getAllByRole('button')) { + expect(btn.className).toContain('widgetNoDrag'); + } + for (const cell of dayCells) { + expect(cell.className).toContain('widgetNoDrag'); + } + + expect(screen.getByText('Nächste Termine')).toBeInTheDocument(); + expect(screen.getByText('Keine anstehenden Termine')).toBeInTheDocument(); + }); + + it('Test 3: Zaehl-Plakette zeigt die korrekte Terminanzahl je Tag', async () => { + mockFetchEvents.mockResolvedValue([ + ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), + ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)), + ev('Review', new Date(2026, 6, 21, 10, 0), new Date(2026, 6, 21, 11, 0)), + ]); + + const { CalendarWidget } = await import('./calendar-widget'); + render(); + + await waitFor(() => { + expect(screen.getByText('Juli 2026')).toBeInTheDocument(); + }); + + const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement; + expect(within(day20).getByTestId('calendar-day-count')).toHaveTextContent('2'); + + const day21 = document.querySelector('[data-date="2026-07-21"]') as HTMLElement; + expect(within(day21).getByTestId('calendar-day-count')).toHaveTextContent('1'); + + const day22 = document.querySelector('[data-date="2026-07-22"]') as HTMLElement; + expect(within(day22).queryByTestId('calendar-day-count')).toBeNull(); + }); + + it('Test 4: Tooltip erscheint per Portal im body und zeigt bis zu 5 Termine plus Hinweis', async () => { + mockFetchEvents.mockResolvedValue([ + ev('Team Meeting', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), + ev('Lunch', new Date(2026, 6, 20, 14, 0), new Date(2026, 6, 20, 15, 0)), + ]); + + const { CalendarWidget } = await import('./calendar-widget'); + render(); + + await waitFor(() => { + expect(screen.getByText('Juli 2026')).toBeInTheDocument(); + }); + + const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement; + fireEvent.mouseEnter(day20); + + const tooltip = screen.getByTestId('calendar-day-tooltip'); + expect(document.body.contains(tooltip)).toBe(true); + expect(tooltip).toHaveTextContent('09:00'); + expect(tooltip).toHaveTextContent('Team Meeting'); + expect(tooltip).toHaveTextContent('Lunch'); + + fireEvent.mouseLeave(day20); + expect(screen.queryByTestId('calendar-day-tooltip')).toBeNull(); + }); + + it('Test 4b: Tooltip zeigt maximal 5 Eintraege plus Hinweis bei mehr Terminen', async () => { + mockFetchEvents.mockResolvedValue([ + ev('e1', new Date(2026, 6, 20, 8, 0), new Date(2026, 6, 20, 9, 0)), + ev('e2', new Date(2026, 6, 20, 9, 0), new Date(2026, 6, 20, 10, 0)), + ev('e3', new Date(2026, 6, 20, 10, 0), new Date(2026, 6, 20, 11, 0)), + ev('e4', new Date(2026, 6, 20, 11, 0), new Date(2026, 6, 20, 12, 0)), + ev('e5', new Date(2026, 6, 20, 12, 0), new Date(2026, 6, 20, 13, 0)), + ev('e6', new Date(2026, 6, 20, 13, 0), new Date(2026, 6, 20, 14, 0)), + ]); + + const { CalendarWidget } = await import('./calendar-widget'); + render(); + + await waitFor(() => { + expect(screen.getByText('Juli 2026')).toBeInTheDocument(); + }); + + const day20 = document.querySelector('[data-date="2026-07-20"]') as HTMLElement; + fireEvent.mouseEnter(day20); + + const tooltip = screen.getByTestId('calendar-day-tooltip'); + expect(tooltip).toHaveTextContent('Weitere Termine vorhanden'); + for (const id of ['e1', 'e2', 'e3', 'e4', 'e5']) { + expect(tooltip).toHaveTextContent(id); + } + expect(tooltip).not.toHaveTextContent('e6'); + }); + + it('Test 5: Naechste Termine respektiert maxEvents, Reihenfolge und zeigt Ort/Farbpunkt', async () => { + mockFetchEvents.mockResolvedValue([ + ev('E1', new Date(2026, 6, 16, 9, 0), new Date(2026, 6, 16, 10, 0)), + ev('E2', new Date(2026, 6, 18, 9, 0), new Date(2026, 6, 18, 10, 0), { location: 'Raum 2' }), + ev('E3', new Date(2026, 6, 22, 9, 0), new Date(2026, 6, 22, 10, 0)), + ev('E4', new Date(2026, 6, 26, 9, 0), new Date(2026, 6, 26, 10, 0)), + ev('E5', new Date(2026, 6, 30, 9, 0), new Date(2026, 6, 30, 10, 0)), + ]); + + const { CalendarWidget } = await import('./calendar-widget'); + render(); + + await waitFor(() => { + expect(screen.getByTestId('calendar-upcoming')).toBeInTheDocument(); + }); + + const list = within(screen.getByTestId('calendar-upcoming')); + const items = list.getAllByRole('listitem'); + expect(items).toHaveLength(2); + expect(items[0]).toHaveTextContent('E1'); + expect(items[1]).toHaveTextContent('E2'); + expect(items[1]).toHaveTextContent('Raum 2'); + + for (const item of items) { + expect(within(item).getByTestId('event-color-dot')).toBeInTheDocument(); + } + expect(items[0].textContent).toMatch(/\d{2}\.\d{2}\./); + }); + + it('Test 6: showMonth=false blendet das Raster aus, maxEvents=0 zeigt die Leermeldung', async () => { + mockFetchEvents.mockResolvedValue([]); + + const { CalendarWidget } = await import('./calendar-widget'); + const { unmount } = render( + , ); await waitFor(() => { - expect( - screen.getByText('No calendar sources configured'), - ).toBeInTheDocument(); + expect(screen.getByText('Keine anstehenden Termine')).toBeInTheDocument(); }); + expect(screen.queryByTestId('calendar-month')).toBeNull(); + unmount(); + + render(); + await waitFor(() => { + expect(screen.getByText('Nichts zum Anzeigen ausgewählt')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('calendar-month')).toBeNull(); + expect(screen.queryByTestId('calendar-upcoming')).toBeNull(); + }); + + it('Test 7: fetchEvents wird genau einmal mit dem berechneten Ladefenster aufgerufen', async () => { + mockFetchEvents.mockResolvedValue([]); + + const { CalendarWidget } = await import('./calendar-widget'); + const { unmount } = render(); + + await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1)); + expect(mockFetchEvents).toHaveBeenCalledWith( + new Date(2026, 5, 29).toISOString(), + new Date(2026, 7, 14).toISOString(), + ); + unmount(); + + mockFetchEvents.mockClear(); + render(); + await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledTimes(1)); + expect(mockFetchEvents.mock.calls[0][1]).toBe(new Date(2026, 9, 13).toISOString()); + }); + + it('Test 8: Blaettern wechselt den Monat und laedt Termine mit dem neuen Fenster neu', async () => { + mockFetchEvents.mockResolvedValue([]); + + const { CalendarWidget } = await import('./calendar-widget'); + render(); + + await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument()); + mockFetchEvents.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: 'Weiter' })); + await waitFor(() => expect(screen.getByText('August 2026')).toBeInTheDocument()); + // computeFetchWindow nimmt das FRUEHERE von Rasterstart (27.07.) und + // heutigem Tag (15.07., "now" bleibt im Test eingefroren) als "from" — + // dieselbe Regel wie in calendar-month.test.ts Test 4 (w2-Fall), deshalb + // 15.07. statt 27.07. (Abweichung vom im Plan genannten Wert, siehe SUMMARY). + await waitFor(() => expect(mockFetchEvents).toHaveBeenCalledWith( + new Date(2026, 6, 15).toISOString(), + new Date(2026, 8, 7).toISOString(), + )); + + fireEvent.click(screen.getByRole('button', { name: 'August 2026' })); + await waitFor(() => expect(screen.getByText('Juli 2026')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Zurück' })); + await waitFor(() => expect(screen.getByText('Juni 2026')).toBeInTheDocument()); }); }); diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx index 6c2c88c..c5fc05f 100644 --- a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx +++ b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx @@ -1,19 +1,46 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +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'; /** - * Calendar widget (DASH-05, D-10 read-only). + * Calendar widget (DASH-05, D-10 read-only), neu gebaut nach dem Vorbild des + * alten persoenlichen Dashboards des Anwenders (quick-260916-htc). * - * Shows upcoming events aggregated from visible calendar sources. - * Three states per UI-SPEC: - * 1. No sources configured -> t('widgets.calendar.emptyNoSources') - * 2. Sources but no events -> t('widgets.calendar.emptyNoEvents') - * 3. Events -> upcoming-events list with time, title, source color dot + * 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. + * + * 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). @@ -23,8 +50,22 @@ export function CalendarWidget({ config }: WidgetProps) { 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; @@ -43,8 +84,10 @@ export function CalendarWidget({ config }: WidgetProps) { setHasSources(true); - // Fetch aggregated events from visible sources - const eventData = await fetchEvents(); + // 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 { @@ -72,15 +115,35 @@ export function CalendarWidget({ config }: WidgetProps) { 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')} - + {t('calendar.loading')}
); } @@ -89,74 +152,167 @@ export function CalendarWidget({ config }: WidgetProps) { if (hasSources === false) { return (
- - {t('calendar.emptyNoSources')} - + {t('calendar.emptyNoSources')}
); } - // Sources exist but no events - if (events.length === 0) { + // Both blocks hidden by configuration + if (!showMonth && maxEvents === 0) { return (
- - {t('calendar.emptyNoEvents')} - + {t('calendar.nothingSelected')}
); } - // Events list + const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : []; + return ( -
-
    - {events.map((event) => ( -
  • + {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( +
    - {/* Source color dot (8px, UI-SPEC) */} -
  • - ))} -
+ {hoverEvents.slice(0, 5).map((event) => ( +
+ + {event.allDay ? t('calendar.allDay') : formatEventTime(event.start)} + + {event.title} +
+ ))} + {hoverEvents.length > 5 && ( +
{t('calendar.tooltipMore')}
+ )} +
, + document.body, + )} ); } - -/** - * Format event time using locale-aware Intl.DateTimeFormat. - * All-day events show only the date; timed events show time range. - */ -function formatEventTime(event: CalendarEvent): string { - const start = new Date(event.start); - const end = new Date(event.end); - - if (event.allDay) { - return new Intl.DateTimeFormat('de-DE', { - day: '2-digit', - month: 'short', - }).format(start); - } - - const timeFormatter = new Intl.DateTimeFormat('de-DE', { - hour: '2-digit', - minute: '2-digit', - }); - - return `${timeFormatter.format(start)} – ${timeFormatter.format(end)}`; -}