feat(web): Kalender-Widget neu gebaut mit Monatsraster + Naechste Termine, Mindestgroesse 6x8

- calendar-widget.tsx komplett neu: Monatsraster (Nav-Zeile, Wochentagskopf, 42 Zellen, heutiger Tag, Zaehl-Plakette, Portal-Tooltip) + Block "Naechste Termine"; fetchEvents bekommt immer zwei Tagesgrenzen-ISO-Strings ueber computeFetchWindow
- Komponententest komplett neu geschrieben (9 Tests: Laden/Quellen, Raster, Plakette, Tooltip inkl. 5+-Kuerzung, Naechste-Termine-Filter, showMonth/maxEvents=0, Ladefenster, Blaettern)
- WIDGET_CONSTRAINTS.calendar auf { minW: 6, minH: 8, defaultW: 8, defaultH: 12 } angehoben (quick-260916-htc), Registry-Test A mitgezogen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:10:33 +02:00
parent 0858102439
commit 61996dceef
4 changed files with 493 additions and 156 deletions
@@ -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 },
@@ -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
@@ -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<string, unknown>) => {
const map: Record<string, Record<string, string>> = {
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>): 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(
<CalendarWidget
instanceId="cal-1"
config={{}}
isEditMode={false}
/>,
);
// 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(
<CalendarWidget
instanceId="cal-2"
config={{}}
isEditMode={false}
/>,
);
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(<CalendarWidget instanceId="cal-1" config={{}} isEditMode={false} />);
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(
<CalendarWidget
instanceId="cal-3"
config={{}}
isEditMode={false}
/>,
render(<CalendarWidget instanceId="cal-2" config={{}} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-3" config={{}} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-4" config={{}} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-4b" config={{}} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-5" config={{ maxEvents: 2 }} isEditMode={false} />);
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(
<CalendarWidget instanceId="cal-6a" config={{ showMonth: false, maxEvents: 3 }} isEditMode={false} />,
);
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(<CalendarWidget instanceId="cal-6b" config={{ showMonth: false, maxEvents: 0 }} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-7a" config={{}} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-7b" config={{ lookaheadDays: 90 }} isEditMode={false} />);
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(<CalendarWidget instanceId="cal-8" config={{}} isEditMode={false} />);
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());
});
});
@@ -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<CalendarEvent[]>([]);
const [hasSources, setHasSources] = useState<boolean | null>(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<ReturnType<typeof setInterval> | 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 (
<div className="flex h-full items-center justify-center p-2">
<span className="text-sm text-muted-foreground">
{t('calendar.loading')}
</span>
<span className="text-sm text-muted-foreground">{t('calendar.loading')}</span>
</div>
);
}
@@ -89,74 +152,167 @@ export function CalendarWidget({ config }: WidgetProps) {
if (hasSources === false) {
return (
<div className="flex h-full items-center justify-center p-2">
<span className="text-sm text-muted-foreground">
{t('calendar.emptyNoSources')}
</span>
<span className="text-sm text-muted-foreground">{t('calendar.emptyNoSources')}</span>
</div>
);
}
// Sources exist but no events
if (events.length === 0) {
// Both blocks hidden by configuration
if (!showMonth && maxEvents === 0) {
return (
<div className="flex h-full items-center justify-center p-2">
<span className="text-sm text-muted-foreground">
{t('calendar.emptyNoEvents')}
</span>
<span className="text-sm text-muted-foreground">{t('calendar.nothingSelected')}</span>
</div>
);
}
// Events list
const hoverEvents = hover ? (eventsByDate.get(hover.key) ?? []) : [];
return (
<div className="flex h-full flex-col overflow-y-auto p-1.5">
<ul className="space-y-2">
{events.map((event) => (
<li
key={event.id}
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
<div className="flex h-full flex-col gap-1 overflow-hidden p-1.5">
{showMonth && (
<div data-testid="calendar-month" className="flex shrink-0 flex-col gap-1">
{/* Nav-Zeile */}
<div className="grid grid-cols-[1fr_1.4fr_1fr] gap-1">
<button
type="button"
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
onClick={showPrev}
>
{t('calendar.monthPrev')}
</button>
<button
type="button"
className="widgetNoDrag truncate rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] font-semibold leading-none text-foreground hover:bg-muted"
title={t('calendar.monthToday')}
onClick={showToday}
>
{formatMonthLabel(monthDate)}
</button>
<button
type="button"
className="widgetNoDrag rounded border border-border bg-muted/50 px-1 py-[clamp(2px,0.8cqh,6px)] text-[clamp(10px,2.6cqw,13px)] leading-none text-foreground hover:bg-muted"
onClick={showNext}
>
{t('calendar.monthNext')}
</button>
</div>
{/* Wochentagskopf */}
<div className="grid grid-cols-7 gap-px text-center text-[clamp(9px,2.2cqw,12px)] font-medium text-muted-foreground">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div>
))}
</div>
{/* Raster */}
<div className="grid grid-cols-7 gap-px">
{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 (
<div
key={day.key}
data-testid="calendar-day"
data-date={day.key}
data-today={day.isToday || undefined}
data-outside={!day.inCurrentMonth || undefined}
className={cellClass}
onMouseEnter={(e) =>
hasEvents &&
setHover({ key: day.key, rect: e.currentTarget.getBoundingClientRect() })
}
onMouseLeave={() => setHover(null)}
>
<span>{day.date.getDate()}</span>
{hasEvents && (
<span
data-testid="calendar-day-count"
className="absolute bottom-px right-px flex h-[clamp(10px,3cqw,16px)] min-w-[clamp(10px,3cqw,16px)] items-center justify-center rounded-full bg-primary px-0.5 text-[clamp(7px,1.8cqw,10px)] font-semibold leading-none text-primary-foreground"
>
{day.events.length}
</span>
)}
</div>
);
})}
</div>
</div>
)}
{maxEvents > 0 && (
<section className="flex min-h-0 flex-1 flex-col gap-1">
<h3 className="shrink-0 text-[clamp(10px,2.6cqw,13px)] font-semibold text-foreground">
{t('calendar.upcomingTitle')}
</h3>
{upcoming.length === 0 ? (
<p className="text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
{t('calendar.emptyNoEvents')}
</p>
) : (
<ul data-testid="calendar-upcoming" className="min-h-0 flex-1 space-y-1 overflow-y-auto">
{upcoming.map((event) => (
<li key={event.id} className="flex items-start gap-2 rounded bg-muted/50 px-2 py-1">
<span
data-testid="event-color-dot"
className="mt-1 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[clamp(9px,2.2cqw,12px)] text-muted-foreground">
{formatEventDate(event)}
</p>
<p className="truncate text-[clamp(10px,2.5cqw,14px)] font-semibold text-foreground">
{event.title}
</p>
{event.location && (
<p className="truncate text-[clamp(9px,2.1cqw,12px)] text-muted-foreground">
{event.location}
</p>
)}
</div>
</li>
))}
</ul>
)}
</section>
)}
{hover !== null &&
typeof document !== 'undefined' &&
createPortal(
<div
data-testid="calendar-day-tooltip"
role="tooltip"
className="pointer-events-none fixed z-50 w-60 rounded border border-border bg-card p-2 text-xs text-foreground shadow-lg"
style={{
top: hover.rect.bottom + 4,
left: Math.max(4, Math.min(hover.rect.left, window.innerWidth - 244)),
}}
>
{/* Source color dot (8px, UI-SPEC) */}
<span
data-testid="event-color-dot"
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: event.color || 'var(--muted-foreground)' }}
aria-hidden="true"
/>
<div className="flex-1 min-w-0">
<p className="truncate font-medium text-foreground">
{event.title}
</p>
<p className="text-xs text-muted-foreground">
{formatEventTime(event)}
</p>
</div>
</li>
))}
</ul>
{hoverEvents.slice(0, 5).map((event) => (
<div key={event.id} className="flex gap-2">
<span className="shrink-0 tabular-nums text-muted-foreground">
{event.allDay ? t('calendar.allDay') : formatEventTime(event.start)}
</span>
<span className="truncate">{event.title}</span>
</div>
))}
{hoverEvents.length > 5 && (
<div className="mt-1 text-muted-foreground">{t('calendar.tooltipMore')}</div>
)}
</div>,
document.body,
)}
</div>
);
}
/**
* 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)}`;
}