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 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:23:27 +02:00
parent 2d8699e45c
commit c0f2f4ca51
6 changed files with 355 additions and 1 deletions
+178
View File
@@ -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<CalendarSource[]> {
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<CalendarSource> {
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<CalendarSource> {
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<void> {
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<CalendarEvent[]> {
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();
}