From c0f2f4ca5186f37b8c2393c3484fddb57f37cb6e Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 24 Jun 2026 15:23:27 +0200 Subject: [PATCH] feat(05-04): calendar API client, calendar widget, registry wiring - calendar-api.ts: fetchSources/addSource/updateSource/deleteSource/testSource/fetchEvents with credentials:'include' - calendar-widget.tsx: upcoming-events list with source color dots, three empty states (no sources/no events/loading) - widget-registry.tsx: wireCalendarWidget() replaces placeholder with real CalendarWidget - page.tsx: wires CalendarWidget into registry on mount - i18n: added calendar.loading key to de.json and en.json Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/(portal)/page.tsx | 4 +- .../components/dashboard/widget-registry.tsx | 10 + .../dashboard/widgets/calendar-widget.tsx | 162 ++++++++++++++++ apps/web/src/lib/calendar-api.ts | 178 ++++++++++++++++++ apps/web/src/messages/de.json | 1 + apps/web/src/messages/en.json | 1 + 6 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/dashboard/widgets/calendar-widget.tsx create mode 100644 apps/web/src/lib/calendar-api.ts diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 4cb2299..0b8973d 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,9 +5,10 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireNoteWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; +import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -15,6 +16,7 @@ import type { WidgetType } from '@/components/dashboard/widget-registry'; // Wire widget components into the registry (deferred to avoid circular deps) wireClockWidget(ClockWidget); wireSearchWidget(SearchWidget); +wireCalendarWidget(CalendarWidget); wireNoteWidget(NoteWidget); export default function DashboardPage() { diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index 5cd0349..0ade741 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -213,6 +213,16 @@ export function wireSearchWidget( } } +let calendarWired = false; +export function wireCalendarWidget( + component: ComponentType, +) { + if (!calendarWired) { + WIDGET_REGISTRY.calendar.component = component; + calendarWired = true; + } +} + let noteWired = false; export function wireNoteWidget( component: ComponentType, diff --git a/apps/web/src/components/dashboard/widgets/calendar-widget.tsx b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx new file mode 100644 index 0000000..5914c49 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/calendar-widget.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +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'; + +/** + * Calendar widget (DASH-05, D-10 read-only). + * + * 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 + * + * 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 intervalRef = useRef | undefined>(undefined); + + 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 from visible sources + const eventData = await fetchEvents(); + 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); + } + }; + }, []); + + // Loading state + if (isLoading) { + return ( +
+ + {t('calendar.loading')} + +
+ ); + } + + // No sources configured + if (hasSources === false) { + return ( +
+ + {t('calendar.emptyNoSources')} + +
+ ); + } + + // Sources exist but no events + if (events.length === 0) { + return ( +
+ + {t('calendar.emptyNoEvents')} + +
+ ); + } + + // Events list + return ( +
+
    + {events.map((event) => ( +
  • + {/* Source color dot (8px, UI-SPEC) */} +
  • + ))} +
+
+ ); +} + +/** + * 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)}`; +} diff --git a/apps/web/src/lib/calendar-api.ts b/apps/web/src/lib/calendar-api.ts new file mode 100644 index 0000000..8d54151 --- /dev/null +++ b/apps/web/src/lib/calendar-api.ts @@ -0,0 +1,178 @@ +/** + * Calendar API client functions. + * Consumes the calendar backend built in 05-03. + * All calls use credentials: 'include' for cookie-based auth. + * + * Anti-pattern (RESEARCH): The browser NEVER fetches external calendars directly. + * All event data flows through /api/calendar/events which proxies via the backend. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +// --- Types mirroring the 05-03 backend contract --- + +/** + * Calendar source as returned by GET /calendar/sources. + * Note: encryptedPassword is NEVER returned (T-05-09). + */ +export interface CalendarSource { + id: string; + userId: string; + tenantId: string; + name: string; + type: 'caldav' | 'ics' | 'exchange'; + exchangeMode?: string | null; + url: string; + username?: string | null; + color?: string | null; + isVisible: boolean; + syncIntervalMin: number; + lastSyncAt?: string | null; + lastSyncError?: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Normalized calendar event from GET /calendar/events. + * Mirrors CalendarEvent from calendar.service.ts. + */ +export interface CalendarEvent { + id: string; + sourceId: string; + title: string; + start: string; + end: string; + allDay: boolean; + location?: string; + description?: string; + color?: string; +} + +/** + * Payload for creating a new calendar source. + */ +export interface CreateSourcePayload { + name: string; + type: 'caldav' | 'ics' | 'exchange'; + url: string; + username?: string; + password?: string; + exchangeMode?: string; + color?: string; +} + +/** + * Payload for updating an existing calendar source. + * All fields are optional. + */ +export interface UpdateSourcePayload { + name?: string; + type?: 'caldav' | 'ics' | 'exchange'; + url?: string; + username?: string; + password?: string; + exchangeMode?: string; + color?: string; + isVisible?: boolean; +} + +// --- API functions --- + +/** + * Fetch all calendar sources for the current user. + * GET /calendar/sources + */ +export async function fetchSources(): Promise { + const res = await fetch(`${API_URL}/calendar/sources`, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch calendar sources'); + return res.json(); +} + +/** + * Add a new calendar source. + * POST /calendar/sources + */ +export async function addSource( + payload: CreateSourcePayload, +): Promise { + const res = await fetch(`${API_URL}/calendar/sources`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to add calendar source'); + return res.json(); +} + +/** + * Update an existing calendar source (including isVisible toggle for CAL-02). + * PATCH /calendar/sources/:id + */ +export async function updateSource( + id: string, + payload: UpdateSourcePayload, +): Promise { + const res = await fetch(`${API_URL}/calendar/sources/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to update calendar source'); + return res.json(); +} + +/** + * Delete a calendar source. + * DELETE /calendar/sources/:id + */ +export async function deleteSource(id: string): Promise { + const res = await fetch(`${API_URL}/calendar/sources/${id}`, { + method: 'DELETE', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to delete calendar source'); +} + +/** + * Test connection to a calendar source. + * POST /calendar/sources/:id/test + * Returns { success: boolean } from the backend. + */ +export async function testSource( + id: string, +): Promise<{ success: boolean }> { + const res = await fetch(`${API_URL}/calendar/sources/${id}/test`, { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to test calendar source'); + return res.json(); +} + +/** + * Fetch aggregated events from all visible sources. + * GET /calendar/events?from=...&to=... + * Default window: now to now + 30 days (backend default). + */ +export async function fetchEvents( + from?: string, + to?: string, +): Promise { + const params = new URLSearchParams(); + if (from) params.set('from', from); + if (to) params.set('to', to); + + const queryStr = params.toString(); + const url = `${API_URL}/calendar/events${queryStr ? `?${queryStr}` : ''}`; + + const res = await fetch(url, { + credentials: 'include', + }); + if (!res.ok) throw new Error('Failed to fetch calendar events'); + return res.json(); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 615ec11..8139167 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -130,6 +130,7 @@ "search.placeholder": "Suchen...", "calendar.name": "Kalender", "calendar.description": "Kommende Termine", + "calendar.loading": "Laden...", "calendar.emptyNoSources": "Keine Kalenderquellen konfiguriert", "calendar.emptyNoEvents": "Keine anstehenden Termine", "calendar.connectionSuccess": "Verbindung erfolgreich", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 5e9e697..b5fb275 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -130,6 +130,7 @@ "search.placeholder": "Search...", "calendar.name": "Calendar", "calendar.description": "Upcoming events", + "calendar.loading": "Loading...", "calendar.emptyNoSources": "No calendar sources configured", "calendar.emptyNoEvents": "No upcoming events", "calendar.connectionSuccess": "Connection successful",