feat(calendar): Phase 3 – Weboberfläche mit Monats-, Wochen- und Listenansicht

Neue Seite CalendarPage (Menüpunkt 'Calendar' in der Seitenleiste,
zwischen Home und Papierkorb) mit Kalenderliste zum Anlegen/Umbenennen/
Löschen/Ein- und Ausblenden, Export/Import als .ics über die
Phase-2-API, und Termine anlegen/ändern/löschen über einen neuen
JSON-Endpunkt.

Backend: neuer events_api.go-Handler unter /api/v1/calendars/{id}/events
(Session-Auth) neben dem bestehenden CalDAV-Protokoll – ein Termin, den
die Web-Oberfläche anlegt, ist ein ganz normales CalDAV-Objekt und
synchronisiert genauso zu DAVx5 wie umgekehrt. Ein per CalDAV mit RRULE
angelegter, wiederkehrender Termin zeigt sich hier nur (recurring:true),
lässt sich aber bewusst nicht über dieses vereinfachte Formular
bearbeiten (409) – das würde die Wiederholungsregel eher beschädigen
als korrekt anpassen.

Gegen den laufenden Container getestet: Kalender/Termin anlegen, per
Web-API ändern und löschen, ein per CalDAV mit RRULE angelegter
Wiederkehrer taucht als recurring:true auf und blockt PATCH mit 409.
TypeScript-Build (tsc + vite build) sauber.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 5 committed 2026-09-03 23:14:28 +02:00
1 parent 4d635fff20
commit 27a026421f
10 files changed
+1163 -6

No files matched your search

+74
View File
@@ -884,4 +884,78 @@ export const adminApi = {
request<AppSettings>('PATCH', '/admin/settings', data),
}
// ── Calendar ─────────────────────────────────────────────────────────────
// Termine selbst laufen für CalDAV-Clients (DAVx5 & Co.) über /caldav/ mit
// App-Passwörtern – ein eigenes, Basic-Auth-geschütztes Protokoll. Diese
// Web-Oberfläche spricht stattdessen /api/v1/calendars mit der normalen
// Session, wie jeder andere Bereich.
export interface CalendarItem {
id: string
owner_id: string
name: string
color: string
description: string
supports_todo: boolean
timezone: string
ctag: number
created_at: string
}
export interface CalendarEvent {
uid: string
summary: string
description: string
location: string
/** ISO 8601, immer UTC ("...Z") – der Server rechnet nicht um, das
* übernimmt der Browser beim Anzeigen (new Date(...) kennt die lokale
* Zeitzone bereits). */
start: string
end?: string
all_day: boolean
/** Über einen echten CalDAV-Client mit Wiederholung angelegt – hier nur
* sichtbar, nicht bearbeitbar (siehe events_api.go, UpdateEvent). */
recurring: boolean
etag: string
}
export interface EventInput {
summary: string
description?: string
location?: string
start: string
end?: string
all_day: boolean
}
export const calendarApi = {
list: () => request<CalendarItem[]>('GET', '/calendars'),
create: (name: string, color: string, description = '') =>
request<CalendarItem>('POST', '/calendars', { name, color, description }),
update: (id: string, patch: Partial<Pick<CalendarItem, 'name' | 'color' | 'description' | 'timezone'>>) =>
request<CalendarItem>('PATCH', `/calendars/${id}`, patch),
remove: (id: string) => request<void>('DELETE', `/calendars/${id}`),
exportUrl: (id: string) => `/api/v1/calendars/${id}/export`,
import: async (id: string, file: File): Promise<{ imported: number }> => {
const headers: Record<string, string> = { 'Content-Type': 'text/calendar' }
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
const res = await fetch(`/api/v1/calendars/${id}/import`, {
method: 'POST', headers, credentials: 'include', body: file,
})
const json = await res.json().catch(() => ({ error: res.statusText }))
if (!res.ok) throw new APIError(res.status, json.error ?? 'Import failed')
return json.data
},
listEvents: (id: string) => request<CalendarEvent[]>('GET', `/calendars/${id}/events`),
createEvent: (id: string, input: EventInput) =>
request<CalendarEvent>('POST', `/calendars/${id}/events`, input),
updateEvent: (id: string, uid: string, input: EventInput) =>
request<CalendarEvent>('PATCH', `/calendars/${id}/events/${encodeURIComponent(uid)}`, input),
deleteEvent: (id: string, uid: string) =>
request<void>('DELETE', `/calendars/${id}/events/${encodeURIComponent(uid)}`),
}
export { APIError }