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

+550
View File
@@ -0,0 +1,550 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
calendarApi, APIError,
type CalendarItem, type CalendarEvent, type EventInput,
} from '../api/client'
import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import EventDialog from '../components/calendar/EventDialog'
import CalendarDialog from '../components/calendar/CalendarDialog'
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
type ViewMode = 'month' | 'week' | 'list'
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const monthLabels = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function addDays(d: Date, n: number): Date {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
/** Montag der Woche, in der `d` liegt. */
function startOfWeek(d: Date): Date {
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const dow = (r.getDay() + 6) % 7 // 0 = Montag
return addDays(r, -dow)
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
interface EventWithCalendar extends CalendarEvent {
calendarId: string
color: string
}
/** Ein flacher Zeiger von jedem Tag auf seine Termine – Grundlage für Monats-,
* Wochen- und Listenansicht gleichermaßen. Mehrtägige Termine erscheinen nur
* am Starttag; eine echte Spanne über mehrere Zellen ist für eine erste
* Fassung bewusst zurückgestellt. */
function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[]> {
const map = new Map<string, EventWithCalendar[]>()
for (const ev of events) {
const d = new Date(ev.start)
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
for (const list of map.values()) list.sort((a, b) => a.start.localeCompare(b.start))
return map
}
function dayKey(d: Date): string {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
export default function CalendarPage() {
const [calendars, setCalendars] = useState<CalendarItem[]>([])
const [visible, setVisible] = useState<Set<string>>(new Set())
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
const [viewMode, setViewMode] = useState<ViewMode>('month')
const [cursor, setCursor] = useState(new Date())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; date: Date } | null>(null)
const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null)
const [deleteCalendar, setDeleteCalendar] = useState<CalendarItem | null>(null)
const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const importTargetRef = useRef<string | null>(null)
const loadCalendars = useCallback(async () => {
const list = await calendarApi.list()
setCalendars(list)
setVisible(prev => {
// Neue Kalender starten sichtbar; einmal ausgeblendete bleiben es beim Neuladen.
const next = new Set(prev)
for (const c of list) if (!prev.has(c.id) && prev.size === 0) next.add(c.id)
if (prev.size === 0) for (const c of list) next.add(c.id)
return next
})
return list
}, [])
const loadEvents = useCallback(async (list: CalendarItem[]) => {
const pairs = await Promise.all(list.map(async c => {
try {
return [c.id, await calendarApi.listEvents(c.id)] as const
} catch {
return [c.id, []] as const
}
}))
setEventsByCal(Object.fromEntries(pairs))
}, [])
const reloadAll = useCallback(async () => {
setError(null)
try {
const list = await loadCalendars()
await loadEvents(list)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [loadCalendars, loadEvents])
useEffect(() => { reloadAll() }, [reloadAll])
const allEvents: EventWithCalendar[] = useMemo(() => {
const out: EventWithCalendar[] = []
for (const cal of calendars) {
if (!visible.has(cal.id)) continue
for (const ev of eventsByCal[cal.id] ?? []) {
out.push({ ...ev, calendarId: cal.id, color: cal.color })
}
}
return out
}, [calendars, visible, eventsByCal])
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
function toggleVisible(id: string) {
setVisible(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const defaultCalendarId = calendars[0]?.id
function openNewEvent(date: Date) {
if (!defaultCalendarId) return
setEventDialog({ calId: defaultCalendarId, date })
}
function openEditEvent(ev: EventWithCalendar) {
setEventDialog({ calId: ev.calendarId, event: ev, date: new Date(ev.start) })
}
async function saveEvent(input: EventInput) {
if (!eventDialog) return
try {
if (eventDialog.event) {
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
} else {
await calendarApi.createEvent(eventDialog.calId, input)
}
setEventDialog(null)
await loadEvents(calendars)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
}
async function confirmDeleteEvent() {
if (!deleteEvent) return
try {
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
setDeleteEvent(null)
setEventDialog(null)
await loadEvents(calendars)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
}
}
async function saveCalendar(name: string, color: string) {
try {
if (calDialog?.calendar) {
await calendarApi.update(calDialog.calendar.id, { name, color })
} else {
await calendarApi.create(name, color)
}
setCalDialog(null)
await reloadAll()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
}
async function confirmDeleteCalendar() {
if (!deleteCalendar) return
try {
await calendarApi.remove(deleteCalendar.id)
setDeleteCalendar(null)
await reloadAll()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
}
}
function triggerImport(calId: string) {
importTargetRef.current = calId
importInputRef.current?.click()
}
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
const calId = importTargetRef.current
e.target.value = ''
if (!file || !calId) return
try {
await calendarApi.import(calId, file)
await loadEvents(calendars)
} catch (err) {
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
}
}
function stepPeriod(dir: 1 | -1) {
setCursor(prev => {
if (viewMode === 'month') return new Date(prev.getFullYear(), prev.getMonth() + dir, 1)
if (viewMode === 'week') return addDays(prev, dir * 7)
return addDays(prev, dir * 30)
})
}
const periodLabel = viewMode === 'month'
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
: viewMode === 'week'
? (() => {
const s = startOfWeek(cursor)
const e = addDays(s, 6)
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
})()
: 'Anstehend'
if (loading) {
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
<PageHeader title="Kalender">
<div style={{ display: 'flex', gap: 6 }}>
{(['month', 'week', 'list'] as ViewMode[]).map(m => (
<button key={m} onClick={() => setViewMode(m)} style={{
padding: '6px 12px', borderRadius: 8, fontSize: 12.5, cursor: 'pointer',
border: '1px solid var(--color-border)',
background: viewMode === m ? 'var(--color-primary)' : 'transparent',
color: viewMode === m ? '#fff' : 'var(--color-text-muted)',
}}>
{m === 'month' ? 'Monat' : m === 'week' ? 'Woche' : 'Liste'}
</button>
))}
</div>
{viewMode !== 'list' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => stepPeriod(-1)} style={navBtnStyle}>‹</button>
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 130, textAlign: 'center' }}>{periodLabel}</span>
<button onClick={() => stepPeriod(1)} style={navBtnStyle}>›</button>
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Heute</button>
</div>
)}
<button onClick={() => openNewEvent(new Date())} disabled={!defaultCalendarId} style={{
padding: '7px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
opacity: defaultCalendarId ? 1 : 0.5,
}}>
+ Termin
</button>
</PageHeader>
{error && (
<div style={{ padding: '8px 16px', background: 'var(--color-danger)', color: '#fff', fontSize: 13 }}>
{error}
</div>
)}
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<aside style={{ width: 220, flexShrink: 0, borderRight: '1px solid var(--color-border)', padding: 14, overflowY: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase' }}>Kalender</span>
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)', fontSize: 16, lineHeight: 1,
}}>+</button>
</div>
{calendars.map(cal => (
<div key={cal.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0', fontSize: 13, color: 'var(--color-text)' }}>
<input type="checkbox" checked={visible.has(cal.id)} onChange={() => toggleVisible(cal.id)} />
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color, flexShrink: 0 }} />
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{cal.name}</span>
<CalMenu
onRename={() => setCalDialog({ calendar: cal })}
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
onImport={() => triggerImport(cal.id)}
onDelete={() => setDeleteCalendar(cal)}
/>
</div>
))}
{calendars.length === 0 && (
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Noch kein Kalender – leg einen an.</p>
)}
</aside>
<div style={{ flex: 1, minWidth: 0, overflow: 'auto' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
)}
{viewMode === 'week' && (
<WeekColumns cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
)}
</div>
</div>
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
{eventDialog && (
<EventDialog
event={eventDialog.event}
defaultDate={eventDialog.date}
onSave={saveEvent}
onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined}
onCancel={() => setEventDialog(null)}
/>
)}
{calDialog && (
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
)}
{deleteCalendar && (
<ConfirmDialog
title="Kalender löschen"
message={`"${deleteCalendar.name}" mit allen Terminen darin unwiderruflich löschen?`}
confirmLabel="Löschen" danger
onConfirm={confirmDeleteCalendar}
onCancel={() => setDeleteCalendar(null)}
/>
)}
{deleteEvent && (
<ConfirmDialog
title="Termin löschen"
message="Diesen Termin unwiderruflich löschen?"
confirmLabel="Löschen" danger
onConfirm={confirmDeleteEvent}
onCancel={() => setDeleteEvent(null)}
/>
)}
</div>
)
}
const navBtnStyle: React.CSSProperties = {
width: 26, height: 26, borderRadius: 6, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text)', cursor: 'pointer', fontSize: 14,
}
function CalMenu({ onRename, onExport, onImport, onDelete }: {
onRename: () => void; onExport: () => void; onImport: () => void; onDelete: () => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
return (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button onClick={() => setOpen(o => !o)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 2 }}>⋮</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 22, zIndex: 30, minWidth: 150,
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.2)', overflow: 'hidden',
}}>
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
<MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />
<MenuRow icon={<TrashIcon />} label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />
</div>
)}
</div>
)
}
function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 12px',
border: 'none', background: 'none', cursor: 'pointer', fontSize: 12.5, textAlign: 'left',
color: danger ? 'var(--color-danger)' : 'var(--color-text)',
}}>
{icon}{label}
</button>
)
}
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
return (
<div onClick={onClick} title={ev.summary} style={{
display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 4px', borderRadius: 4,
background: ev.color + '22', color: ev.color, cursor: 'pointer', marginBottom: 2,
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}}>
{!ev.all_day && <span style={{ opacity: 0.8 }}>{new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary}</span>
</div>
)
}
function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (d: Date) => void
onEventClick: (ev: EventWithCalendar) => void
}) {
const first = startOfMonth(cursor)
const gridStart = startOfWeek(first)
const today = new Date()
const weeks: Date[][] = []
let d = gridStart
for (let w = 0; w < 6; w++) {
const row: Date[] = []
for (let i = 0; i < 7; i++) { row.push(d); d = addDays(d, 1) }
weeks.push(row)
if (d > addDays(startOfMonth(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)), 0) && w >= 3) break
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
{weekdayLabels.map(l => (
<div key={l} style={{ padding: '6px 8px', fontSize: 11, color: 'var(--color-text-muted)', fontWeight: 600 }}>{l}</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateRows: `repeat(${weeks.length}, 1fr)`, flex: 1 }}>
{weeks.map((row, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
{row.map(day => {
const inMonth = day.getMonth() === cursor.getMonth()
const events = byDay.get(dayKey(day)) ?? []
return (
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
borderRight: '1px solid var(--color-border)', padding: 4, minHeight: 90, cursor: 'pointer',
opacity: inMonth ? 1 : 0.4, display: 'flex', flexDirection: 'column', overflow: 'hidden',
}}>
<span style={{
fontSize: 11.5, color: isSameDay(day, today) ? '#fff' : 'var(--color-text-muted)',
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
borderRadius: 10, padding: '1px 6px', alignSelf: 'flex-start', marginBottom: 3,
}}>
{day.getDate()}
</span>
{events.slice(0, 4).map(ev => (
<EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
))}
{events.length > 4 && (
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>+{events.length - 4} mehr</span>
)}
</div>
)
})}
</div>
))}
</div>
</div>
)
}
function WeekColumns({ cursor, byDay, onDayClick, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (d: Date) => void
onEventClick: (ev: EventWithCalendar) => void
}) {
const start = startOfWeek(cursor)
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
const today = new Date()
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', height: '100%' }}>
{days.map(day => {
const events = byDay.get(dayKey(day)) ?? []
return (
<div key={day.toISOString()} onClick={() => onDayClick(day)} style={{
borderRight: '1px solid var(--color-border)', padding: 8, cursor: 'pointer', minHeight: '100%',
}}>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
<span style={{
fontSize: 14, fontWeight: 600,
color: isSameDay(day, today) ? '#fff' : 'var(--color-text)',
background: isSameDay(day, today) ? 'var(--color-primary)' : 'transparent',
borderRadius: 12, padding: '1px 8px',
}}>
{day.getDate()}
</span>
</div>
{events.map(ev => <EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />)}
</div>
)
})}
</div>
)
}
function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onEventClick: (ev: EventWithCalendar) => void }) {
const sorted = useMemo(() => [...events].sort((a, b) => a.start.localeCompare(b.start)), [events])
const groups = useMemo(() => {
const map = new Map<string, EventWithCalendar[]>()
for (const ev of sorted) {
const d = new Date(ev.start)
const key = dayKey(d)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(ev)
}
return [...map.entries()]
}, [sorted])
if (groups.length === 0) {
return <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
}
return (
<div style={{ padding: '12px 20px' }}>
{groups.map(([key, evs]) => {
const d = new Date(evs[0].start)
return (
<div key={key} style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{weekdayLabels[(d.getDay() + 6) % 7]}, {d.getDate()}. {monthLabels[d.getMonth()]} {d.getFullYear()}
</div>
{evs.map(ev => (
<div key={ev.uid} onClick={() => onEventClick(ev)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', borderRadius: 8,
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 50, flexShrink: 0 }}>
{ev.all_day ? 'Ganztägig' : new Date(ev.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
<span style={{ fontSize: 13.5, color: 'var(--color-text)' }}>{ev.summary}</span>
</div>
))}
</div>
)
})}
</div>
)
}