import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent as ReactPointerEvent, } from 'react' import { calendarApi, APIError, canEditCalendar, 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 DayDialog, { type DayEvent } from '../components/calendar/DayDialog' import DeleteEventDialog from '../components/calendar/DeleteEventDialog' import { addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek, } from '../components/calendar/dates' import { useEventDrag } from '../components/calendar/useEventDrag' import { TrashIcon, RenameIcon, DownloadIcon, PeopleIcon } from '../components/icons' import ShareWithDialog from '../components/ShareWithDialog' 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', ] /** Höhe einer Stunde im Wochenraster. */ const HOUR_H = 44 /** Breite der Stundenspalte links im Wochenraster. */ const GUTTER = 52 type EventWithCalendar = DayEvent /** Aufnehmen zum Ziehen – siehe components/calendar/useEventDrag.ts. */ type EventDown = (ev: EventWithCalendar, e: ReactPointerEvent) => void function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar { return { ...ev, calendarId: cal.id, calendarName: cal.name, // Eigene Terminfarbe schlägt die Kalenderfarbe – aber nur, wenn eine // gesetzt ist. Sonst erbt der Termin sie weiterhin. color: ev.color || cal.color, startsAt: eventDay(ev.start, ev.all_day), endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null, } } /** Eindeutig auch für aufgeklappte Wiederholungen: die teilen sich die UID * und unterscheiden sich nur im Anfangszeitpunkt. */ function eventKey(ev: EventWithCalendar): string { return `${ev.calendarId}|${ev.uid}|${ev.start}` } /** Termine je Tag. Ein mehrtägiger Termin steht in **jeder** Zelle, die er * belegt – vorher nur am Starttag, was ihn ab dem zweiten Tag verschwinden * ließ. */ function groupByDay(events: EventWithCalendar[]): Map { const map = new Map() for (const ev of events) { for (const day of spannedDays(ev.startsAt, ev.endsAt, ev.all_day)) { const key = dayKey(day) if (!map.has(key)) map.set(key, []) map.get(key)!.push(ev) } } for (const list of map.values()) { list.sort((a, b) => { // Ganztägige zuerst, danach nach Uhrzeit – so wie jeder Kalender es zeigt. if (a.all_day !== b.all_day) return a.all_day ? -1 : 1 return +a.startsAt - +b.startsAt }) } return map } /** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an * anderen Tagen 9 Uhr. Mitternacht als Vorgabe trifft fast nie zu. */ function suggestStart(day: Date): Date { const now = new Date() if (isSameDay(day, now)) { const next = new Date(now) next.setMinutes(0, 0, 0) next.setHours(next.getHours() + 1) if (isSameDay(next, day)) return next } const d = startOfDay(day) d.setHours(9) return d } export default function CalendarPage() { const [calendars, setCalendars] = useState([]) /** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein * **neu angelegter** Kalender automatisch sichtbar. */ const [hidden, setHidden] = useState>(new Set()) const [eventsByCal, setEventsByCal] = useState>({}) const [viewMode, setViewMode] = useState('month') const [cursor, setCursor] = useState(new Date()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showList, setShowList] = useState(() => typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches) const [dayDialog, setDayDialog] = useState(null) const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; start: Date } | null>(null) const [calDialog, setCalDialog] = useState<{ calendar?: CalendarItem } | null>(null) const [deleteCalendar, setDeleteCalendar] = useState(null) /** Kalender, dessen Freigaben gerade verwaltet werden. */ const [shareCalendar, setShareCalendar] = useState(null) const [deleteEvent, setDeleteEvent] = useState< { calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null) const importInputRef = useRef(null) const importTargetRef = useRef(null) /** * Sichtbares Zeitfenster. Der Server klappt Wiederholungen **darin** auf – * eine wöchentliche Reihe ohne Ende hat kein natürliches Ende, also muss * die Ansicht sagen, welchen Ausschnitt sie braucht. */ const range = useMemo(() => { if (viewMode === 'month') { const s = startOfWeek(startOfMonth(cursor)) return { from: addDays(s, -1), to: addDays(s, 43) } } if (viewMode === 'week') { const s = startOfWeek(cursor) return { from: addDays(s, -1), to: addDays(s, 8) } } const today = startOfDay(new Date()) return { from: addDays(today, -31), to: addDays(today, 365) } }, [viewMode, cursor.getFullYear(), cursor.getMonth(), viewMode === 'week' ? +startOfWeek(cursor) : 0]) const loadEvents = useCallback(async (list: CalendarItem[], from: Date, to: Date) => { const pairs = await Promise.all(list.map(async c => { try { return [c.id, await calendarApi.listEvents(c.id, from, to)] as const } catch { return [c.id, [] as CalendarEvent[]] as const } })) setEventsByCal(Object.fromEntries(pairs)) }, []) const loadCalendars = useCallback(async () => { setError(null) try { // ?? []: Eine Liste, die als null ankommt, legte die ganze Seite lahm // (das ist einmal passiert). Der Server schickt das nicht mehr – aber // eine Ansicht sollte an einer unerwarteten Antwort nicht zerbrechen. setCalendars(await calendarApi.list() ?? []) } catch (e) { setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen') } finally { setLoading(false) } }, []) useEffect(() => { loadCalendars() }, [loadCalendars]) // Termine hängen an Kalenderliste **und** Zeitfenster: Beim Blättern in // einen anderen Monat müssen die Wiederholungen dort neu aufgeklappt werden. useEffect(() => { if (calendars.length === 0) { setEventsByCal({}); return } loadEvents(calendars, range.from, range.to) }, [calendars, range.from, range.to, loadEvents]) const refreshEvents = useCallback( () => loadEvents(calendars, range.from, range.to), [calendars, range.from, range.to, loadEvents]) const allEvents = useMemo(() => { const out: EventWithCalendar[] = [] for (const cal of calendars) { if (hidden.has(cal.id)) continue for (const ev of eventsByCal[cal.id] ?? []) out.push(decorate(ev, cal)) } return out }, [calendars, hidden, eventsByCal]) const byDay = useMemo(() => groupByDay(allEvents), [allEvents]) /** * Kalender, in die dieses Konto schreiben darf. * * Die Kalenderwahl im Termin-Dialog zeigt nur diese. Einen nur lesbaren * Kalender dort anzubieten hieße, jemanden ein Formular ausfüllen zu lassen, * das der Server danach ablehnt. */ const writableCalendars = useMemo(() => calendars.filter(canEditCalendar), [calendars]) function toggleVisible(id: string) { setHidden(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(start: Date) { if (!defaultCalendarId) return setEventDialog({ calId: defaultCalendarId, start }) } function openEditEvent(ev: EventWithCalendar) { setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt }) } async function saveEvent(input: EventInput, calId: string) { if (!eventDialog) return try { if (eventDialog.event) { // Erst die Änderungen, dann der Umzug. Andersherum stünde der Termin // nach einem Fehler im neuen Kalender, aber unverändert – die // verwirrendere Hälfte. So bleibt er im Zweifel liegen, wo er war, // und die Eingaben sind trotzdem gesichert. await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input) if (calId !== eventDialog.calId) { await calendarApi.moveEvent(eventDialog.calId, eventDialog.event.uid, calId) } } else { await calendarApi.createEvent(calId, input) } setEventDialog(null) await refreshEvents() } catch (e) { setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen') } } /** Termin auf einen Kalender in der Seitenleiste gezogen. */ async function moveEventTo(ev: EventWithCalendar, targetId: string) { try { await calendarApi.moveEvent(ev.calendarId, ev.uid, targetId) // Ein Kalender, in den man gerade etwas hineingezogen hat, soll das // auch zeigen – sonst verschwindet der Termin scheinbar spurlos. setHidden(prev => { if (!prev.has(targetId)) return prev const next = new Set(prev) next.delete(targetId) return next }) await refreshEvents() } catch (e) { setError(e instanceof APIError ? e.message : 'Verschieben fehlgeschlagen') } } const { drag, onPointerDown, dragged } = useEventDrag(moveEventTo) /** Klick auf einen Termin – aber nicht der, der nur einem Ziehen hinterherkommt. */ function clickEvent(ev: EventWithCalendar) { if (dragged()) return openEditEvent(ev) } /** `scope: 'single'` streicht nur diesen einen Termin aus der Reihe * (EXDATE), `'series'` löscht sie ganz. Bei Einzelterminen sind beide * Wege gleichbedeutend. */ async function confirmDeleteEvent(scope: 'single' | 'series') { if (!deleteEvent) return try { await calendarApi.deleteEvent( deleteEvent.calId, deleteEvent.uid, scope === 'single' && deleteEvent.recurring ? deleteEvent.occurrence : undefined) setDeleteEvent(null) setEventDialog(null) await refreshEvents() } 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 loadCalendars() } 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 loadCalendars() } 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) { const file = e.target.files?.[0] const calId = importTargetRef.current e.target.value = '' if (!file || !calId) return try { await calendarApi.import(calId, file) await refreshEvents() } 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()}` })() : 'Alle Termine' if (loading) { return
Lädt…
} return (
} >
{(['month', 'week', 'list'] as ViewMode[]).map(m => ( ))}
{viewMode !== 'list' && (
{periodLabel}
)}
{error && (
{error}
)}
{showList && ( )}
{viewMode === 'month' && ( )} {viewMode === 'week' && ( )} {viewMode === 'list' && ( )}
{/* Ist die Kalenderliste zugeklappt – am Handy der Regelfall –, fehlte sonst jedes Ziel, und die Geste liefe ins Leere. Die Leiste taucht deshalb nur während des Ziehens auf, unten in Daumennähe. */} {drag && !showList && (
{calendars.filter(c => c.id !== drag.ev.calendarId && canEditCalendar(c)).map(cal => (
{cal.name}
))}
)} {/* Der Termin am Zeiger. `pointerEvents: none`, sonst läge er selbst unter dem Finger und verdeckte das Ziel darunter. */} {drag && (
{drag.ev.summary || '(ohne Titel)'}{drag.ev.recurring ? ' ↻ (ganze Reihe)' : ''}
)} {dayDialog && ( { setDayDialog(null); openEditEvent(ev) }} onCreate={() => { const d = dayDialog; setDayDialog(null); openNewEvent(suggestStart(d)) }} onClose={() => setDayDialog(null)} /> )} {eventDialog && ( setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid, summary: eventDialog.event!.summary, recurring: eventDialog.event!.recurring, // Der angeklickte Einzeltermin – nur er wird gestrichen, wenn // man sich für „nur diesen" entscheidet. occurrence: eventDialog.event!.start, }) : undefined} onCancel={() => setEventDialog(null)} /> )} {calDialog && ( setCalDialog(null)} /> )} {shareCalendar && ( setShareCalendar(null)} /> )} {deleteCalendar && ( setDeleteCalendar(null)} /> )} {deleteEvent && ( confirmDeleteEvent('single')} onSeries={() => confirmDeleteEvent('series')} onCancel={() => setDeleteEvent(null)} /> )}
) } const navBtnStyle: CSSProperties = { width: 26, height: 26, borderRadius: 6, border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text)', cursor: 'pointer', fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, } /** * Das Menü eines Kalenders. * * Bei einem **fremden** Kalender bleiben nur die Punkte stehen, die auch * gehen: ansehen und ausgeben. Bearbeiten, Löschen, Freigeben gehören dem * Eigentümer – ein Menüpunkt, der immer scheitert, ist schlimmer als keiner. * Der Server lehnt sie ohnehin ab; hier geht es darum, gar nicht erst * hinzuführen. */ function CalMenu({ onRename, onExport, onImport, onDelete, onShare, own, canWrite }: { onRename: () => void; onExport: () => void; onImport: () => void onDelete: () => void; onShare: () => void own: boolean; canWrite: boolean }) { const [open, setOpen] = useState(false) const ref = useRef(null) useEffect(() => { if (!open) return function onDoc(e: globalThis.MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', onDoc) window.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onDoc); window.removeEventListener('keydown', onKey) } }, [open]) return (
{open && (
{own && } label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />} {own && } label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />} } label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} /> {canWrite && { setOpen(false); onImport() }} />} {own && } label="Löschen" danger onClick={() => { setOpen(false); onDelete() }} />}
)}
) } function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) { return ( ) } function timeLabel(d: Date): string { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } /** Ein Termin im Monatsraster bzw. in der ganztägigen Zeile der Woche. * `stopPropagation` ist hier das Entscheidende: Ohne das reichte der Klick * an die Tageszelle darunter durch und öffnete den Tag statt den Termin. */ function EventChip({ ev, onClick, onDown }: { ev: EventWithCalendar; onClick: () => void; onDown: EventDown }) { return (
{ e.stopPropagation(); onClick() }} onPointerDown={e => { e.stopPropagation(); onDown(ev, e) }} title={ev.summary} style={{ // Ohne das scrollt die Ansicht mit, sobald der Finger auf einem // Termin liegt – und das lange Drücken käme nie zustande. touchAction: 'none', userSelect: 'none', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: ev.color + '26', color: ev.color, cursor: 'pointer', marginBottom: 2, borderLeft: `2px solid ${ev.color}`, overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', flexShrink: 0, }} > {!ev.all_day && {timeLabel(ev.startsAt)}} {ev.summary || '(ohne Titel)'} {ev.recurring && ↻}
) } function MonthGrid({ cursor, byDay, onOpenDay, onEventClick, onEventDown }: { cursor: Date byDay: Map onOpenDay: (day: Date) => void onEventClick: (ev: EventWithCalendar) => void onEventDown: EventDown }) { const gridStart = startOfWeek(startOfMonth(cursor)) const today = new Date() // So viele Wochen, wie der Monat wirklich berührt – nicht immer sechs. const nextMonth = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1) const weekCount = Math.ceil((+nextMonth - +gridStart) / (7 * 86400000)) const weeks = Array.from({ length: weekCount }, (_, w) => Array.from({ length: 7 }, (_, i) => addDays(gridStart, w * 7 + i))) return (
{weekdayLabels.map(l => (
{l}
))}
{weeks.map((row, i) => (
{row.map(day => { const inMonth = day.getMonth() === cursor.getMonth() const events = byDay.get(dayKey(day)) ?? [] const isToday = isSameDay(day, today) return (
onOpenDay(day)} style={{ borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer', background: inMonth ? 'transparent' : 'var(--color-surface-2)', opacity: inMonth ? 1 : 0.55, display: 'flex', flexDirection: 'column', overflow: 'hidden', minWidth: 0, }} > {day.getDate()}
{events.slice(0, 3).map(ev => ( onEventClick(ev)} onDown={onEventDown} /> ))} {events.length > 3 && ( +{events.length - 3} weitere )}
) })}
))}
) } interface Slot { ev: EventWithCalendar top: number height: number col: number cols: number } /** Überlappende Termine nebeneinander statt übereinander. Ohne das liegt der * zweite Termin einer Stunde unsichtbar unter dem ersten. */ function layoutDay(events: EventWithCalendar[], day: Date): Slot[] { const dayStart = +startOfDay(day) const items = events .filter(ev => !ev.all_day) .map(ev => { const from = Math.max(0, (+ev.startsAt - dayStart) / 60000) const rawTo = ev.endsAt ? (+ev.endsAt - dayStart) / 60000 : from + 60 const to = Math.min(24 * 60, Math.max(from + 20, rawTo)) // Mindesthöhe, sonst unklickbar return { ev, from, to, col: 0, cols: 1 } }) .sort((a, b) => a.from - b.from || a.to - b.to) let cluster: typeof items = [] let clusterEnd = -1 const colEnds: number[] = [] const flush = () => { const cols = Math.max(...cluster.map(s => s.col)) + 1 cluster.forEach(s => { s.cols = cols }) cluster = [] colEnds.length = 0 } for (const s of items) { if (cluster.length && s.from >= clusterEnd) flush() let c = colEnds.findIndex(e => e <= s.from) if (c === -1) { c = colEnds.length; colEnds.push(s.to) } else { colEnds[c] = s.to } s.col = c cluster.push(s) clusterEnd = Math.max(clusterEnd, s.to) } if (cluster.length) flush() return items.map(s => ({ ev: s.ev, top: (s.from / 60) * HOUR_H, height: ((s.to - s.from) / 60) * HOUR_H, col: s.col, cols: s.cols, })) } function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick, onEventDown }: { cursor: Date byDay: Map onSlotClick: (start: Date) => void onOpenDay: (day: Date) => void onEventClick: (ev: EventWithCalendar) => void onEventDown: EventDown }) { const scrollRef = useRef(null) const start = startOfWeek(cursor) const days = useMemo(() => Array.from({ length: 7 }, (_, i) => addDays(start, i)), [+start]) const today = new Date() const hours = Array.from({ length: 24 }, (_, h) => h) // Beim Öffnen auf den Morgen springen – 0 Uhr oben zu zeigen verschenkt // den halben Bildschirm an eine Zeit, in der nichts stattfindet. useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H }, []) const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))` const nowTop = (today.getHours() * 60 + today.getMinutes()) / 60 * HOUR_H /** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */ function slotFromClick(e: MouseEvent, day: Date) { const rect = e.currentTarget.getBoundingClientRect() const minutes = ((e.clientY - rect.top) / HOUR_H) * 60 const d = startOfDay(day) // Auf halbe Stunden runden – minutengenau anzuklicken ist Glückssache. d.setMinutes(Math.max(0, Math.min(23 * 60 + 30, Math.round(minutes / 30) * 30))) return d } return (
{/* Kopfzeile – der Tag darin öffnet den Tag, wie im Monatsraster. */}
{days.map(day => { const isToday = isSameDay(day, today) return ( ) })}
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */}
ganztg.
{days.map(day => (
onOpenDay(day)} style={{ borderLeft: '1px solid var(--color-border)', padding: 2, cursor: 'pointer', minWidth: 0 }}> {(byDay.get(dayKey(day)) ?? []).filter(ev => ev.all_day).map(ev => ( onEventClick(ev)} onDown={onEventDown} /> ))}
))}
{/* Stundenraster */}
{hours.map(h => (
{h > 0 ? `${String(h).padStart(2, '0')}:00` : ''}
))}
{days.map(day => { const slots = layoutDay(byDay.get(dayKey(day)) ?? [], day) const isToday = isSameDay(day, today) return (
onSlotClick(slotFromClick(e, day))} style={{ position: 'relative', borderLeft: '1px solid var(--color-border)', cursor: 'pointer', minWidth: 0 }} > {hours.map(h => (
))} {isToday && (
)} {slots.map(({ ev, top, height, col, cols: n }) => (
{ e.stopPropagation(); onEventClick(ev) }} onPointerDown={e => { e.stopPropagation(); onEventDown(ev, e) }} title={`${ev.summary} – ${timeLabel(ev.startsAt)}`} style={{ position: 'absolute', top, height, touchAction: 'none', userSelect: 'none', left: `calc(${(col / n) * 100}% + 2px)`, width: `calc(${100 / n}% - 4px)`, background: ev.color + '2e', borderLeft: `3px solid ${ev.color}`, borderRadius: 4, padding: '2px 4px', overflow: 'hidden', cursor: 'pointer', zIndex: 2, }} >
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
{height > 28 && (
{timeLabel(ev.startsAt)}
)}
))}
) })}
) } function ListAgenda({ events, onEventClick, onEventDown }: { events: EventWithCalendar[] onEventClick: (ev: EventWithCalendar) => void onEventDown: EventDown }) { const groups = useMemo(() => { const map = new Map() for (const ev of events) { for (const day of spannedDays(ev.startsAt, ev.endsAt, ev.all_day)) { const key = dayKey(day) if (!map.has(key)) map.set(key, { day, evs: [] }) map.get(key)!.evs.push(ev) } } return [...map.values()] .sort((a, b) => +a.day - +b.day) .map(g => ({ ...g, evs: g.evs.sort((a, b) => (a.all_day === b.all_day ? +a.startsAt - +b.startsAt : a.all_day ? -1 : 1)) })) }, [events]) if (groups.length === 0) { return

Keine Termine.

} const today = new Date() return (
{groups.map(({ day, evs }) => (
{weekdayLabels[(day.getDay() + 6) % 7]}, {day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()} {isSameDay(day, today) && ' · heute'}
{evs.map(ev => (
onEventClick(ev)} onPointerDown={e => onEventDown(ev, e)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8, cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6, // Hier bewusst **kein** touchAction: 'none' wie bei den // Terminen im Raster: Die Zeilen füllen die ganze Breite, und // eine Liste, die sich nicht mehr scrollen lässt, wäre ein // schlechter Tausch für eine Zieh-Geste. Mit der Maus ziehen // geht trotzdem. }}> {ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)} {ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''} {ev.location && ( {ev.location} )}
))}
))}
) }