feat(calendar): Wiederholungen, Tagesansicht, Terminfarben, Farbpunkt als Schalter

Fünf Wünsche aus dem Durchtesten:

1. Wiederholungen einstellbar (täglich/wöchentlich/monatlich/jährlich,
   alle N, Ende nie/nach Anzahl/an einem Datum). Der eigentliche Aufwand
   lag nicht im Formular, sondern im Aufklappen: eine wöchentliche Reihe
   erschien sonst als *ein* Eintrag. Der Server klappt sie jetzt im
   angefragten Zeitfenster auf (?from=&to=, rrule-go über go-icals
   RecurrenceSet), die Ansichten fragen ihren sichtbaren Ausschnitt an.
   Geprüft: 5 Termine wöchentlich, nach Umstellung auf 14-tägig korrekt
   neu gerechnet, Fenster einer Woche liefert genau einen.

   Regeln, die das Formular nicht abbildet (etwa jeder zweite Dienstag
   im Monat), werden weiterhin korrekt *angezeigt* -- geprüft, die vier
   Termine stimmen -- aber nicht zum Bearbeiten angeboten (409). Ein
   halb verstandenes Speichern würde die Regel sonst still beschneiden.

2. Tag wird erst geöffnet, statt sofort einen Termin anzulegen: Klick
   auf einen Tag zeigt dessen Termine als Liste, dort wählt man einen
   aus oder legt bewusst einen neuen an.

3. Eigene Farbe je Termin (COLOR nach RFC 7986), leer = Kalenderfarbe.

4. Kalenderliste ohne Kästchen: der Farbpunkt selbst ist der Schalter --
   gefüllt heißt sichtbar, leerer Ring ausgeblendet.

5. Ganztägig als Schiebeschalter statt Kästchen -- es stellt das
   Formular um, das ist keine Nebenangabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 00:56:28 +02:00
1 parent 105ce8de4a
commit 79a7cc6a3d
8 files changed
+795 -218

No files matched your search

+132 -88
View File
@@ -7,6 +7,7 @@ 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 {
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
} from '../components/calendar/dates'
@@ -25,24 +26,27 @@ const HOUR_H = 44
/** Breite der Stundenspalte links im Wochenraster. */
const GUTTER = 52
interface EventWithCalendar extends CalendarEvent {
calendarId: string
color: string
/** Anfang als örtlicher Zeitpunkt – ganztägige Termine als Tagesbeginn. */
startsAt: Date
endsAt: Date | null
}
type EventWithCalendar = DayEvent
function decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
return {
...ev,
calendarId: cal.id,
color: cal.color,
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ß. */
@@ -66,7 +70,7 @@ function groupByDay(events: EventWithCalendar[]): Map<string, EventWithCalendar[
}
/** Vorschlag für einen neuen Termin: heute die nächste volle Stunde, an
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe (vorher) trifft fast nie zu. */
* anderen Tagen 9 Uhr. Mitternacht als Vorgabe trifft fast nie zu. */
function suggestStart(day: Date): Date {
const now = new Date()
if (isSameDay(day, now)) {
@@ -83,8 +87,7 @@ function suggestStart(day: Date): Date {
export default function CalendarPage() {
const [calendars, setCalendars] = useState<CalendarItem[]>([])
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
* **neu angelegter** Kalender automatisch sichtbar. Vorher landete er in
* keiner Sichtbar-Liste und blieb nach dem Anlegen unsichtbar. */
* **neu angelegter** Kalender automatisch sichtbar. */
const [hidden, setHidden] = useState<Set<string>>(new Set())
const [eventsByCal, setEventsByCal] = useState<Record<string, CalendarEvent[]>>({})
const [viewMode, setViewMode] = useState<ViewMode>('month')
@@ -94,6 +97,7 @@ export default function CalendarPage() {
const [showList, setShowList] = useState(() =>
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
const [dayDialog, setDayDialog] = useState<Date | null>(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<CalendarItem | null>(null)
@@ -102,10 +106,28 @@ export default function CalendarPage() {
const importInputRef = useRef<HTMLInputElement>(null)
const importTargetRef = useRef<string | null>(null)
const loadEvents = useCallback(async (list: CalendarItem[]) => {
/**
* 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)] as const
return [c.id, await calendarApi.listEvents(c.id, from, to)] as const
} catch {
return [c.id, [] as CalendarEvent[]] as const
}
@@ -113,20 +135,29 @@ export default function CalendarPage() {
setEventsByCal(Object.fromEntries(pairs))
}, [])
const reloadAll = useCallback(async () => {
const loadCalendars = useCallback(async () => {
setError(null)
try {
const list = await calendarApi.list()
setCalendars(list)
await loadEvents(list)
setCalendars(await calendarApi.list())
} catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [loadEvents])
}, [])
useEffect(() => { reloadAll() }, [reloadAll])
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[] = []
@@ -149,13 +180,10 @@ export default function CalendarPage() {
const defaultCalendarId = calendars[0]?.id
const openNewEvent = useCallback((start: Date) => {
setCalendars(cals => {
const first = cals[0]
if (first) setEventDialog({ calId: first.id, start })
return cals
})
}, [])
function openNewEvent(start: Date) {
if (!defaultCalendarId) return
setEventDialog({ calId: defaultCalendarId, start })
}
function openEditEvent(ev: EventWithCalendar) {
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
@@ -170,7 +198,7 @@ export default function CalendarPage() {
await calendarApi.createEvent(calId, input)
}
setEventDialog(null)
await loadEvents(calendars)
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
@@ -182,7 +210,7 @@ export default function CalendarPage() {
await calendarApi.deleteEvent(deleteEvent.calId, deleteEvent.uid)
setDeleteEvent(null)
setEventDialog(null)
await loadEvents(calendars)
await refreshEvents()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
}
@@ -196,7 +224,7 @@ export default function CalendarPage() {
await calendarApi.create(name, color)
}
setCalDialog(null)
await reloadAll()
await loadCalendars()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Speichern fehlgeschlagen')
}
@@ -207,7 +235,7 @@ export default function CalendarPage() {
try {
await calendarApi.remove(deleteCalendar.id)
setDeleteCalendar(null)
await reloadAll()
await loadCalendars()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Löschen fehlgeschlagen')
}
@@ -224,7 +252,7 @@ export default function CalendarPage() {
if (!file || !calId) return
try {
await calendarApi.import(calId, file)
await loadEvents(calendars)
await refreshEvents()
} catch (err) {
setError(err instanceof APIError ? err.message : 'Import fehlgeschlagen')
}
@@ -238,11 +266,6 @@ export default function CalendarPage() {
})
}
function jumpToDay(day: Date) {
setCursor(day)
setViewMode('week')
}
const periodLabel = viewMode === 'month'
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
: viewMode === 'week'
@@ -272,7 +295,7 @@ export default function CalendarPage() {
...navBtnStyle, width: 30,
color: showList ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ display: 'block', margin: '0 auto' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<circle cx="3.5" cy="6" r="1.2" fill="currentColor" /><circle cx="3.5" cy="12" r="1.2" fill="currentColor" /><circle cx="3.5" cy="18" r="1.2" fill="currentColor" />
</svg>
@@ -330,27 +353,42 @@ export default function CalendarPage() {
fontSize: 18, lineHeight: 1, padding: '0 2px',
}}>+</button>
</div>
{calendars.map(cal => (
<div key={cal.id} className="aether-cal-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 4px',
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
}}>
<input type="checkbox" checked={!hidden.has(cal.id)} onChange={() => toggleVisible(cal.id)}
style={{ accentColor: cal.color, cursor: 'pointer' }} />
<span onClick={() => toggleVisible(cal.id)} style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
opacity: hidden.has(cal.id) ? 0.45 : 1,
{calendars.map(cal => {
const on = !hidden.has(cal.id)
return (
<div key={cal.id} className="aether-cal-row" style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 4px',
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
}}>
{cal.name}
</span>
<CalMenu
onRename={() => setCalDialog({ calendar: cal })}
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
onImport={() => triggerImport(cal.id)}
onDelete={() => setDeleteCalendar(cal)}
/>
</div>
))}
{/* Der Farbpunkt **ist** der Schalter: gefüllt = wird
angezeigt, leerer Ring = ausgeblendet. Ein Kästchen
daneben sagte dasselbe zweimal. */}
<button
onClick={() => toggleVisible(cal.id)}
role="switch" aria-checked={on}
title={on ? 'Ausblenden' : 'Einblenden'}
style={{
width: 15, height: 15, borderRadius: '50%', flexShrink: 0, padding: 0,
border: `2px solid ${cal.color}`,
background: on ? cal.color : 'transparent',
cursor: 'pointer', transition: 'background 0.12s',
}}
/>
<span onClick={() => toggleVisible(cal.id)} style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
opacity: on ? 1 : 0.45,
}}>
{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)', lineHeight: 1.5 }}>
Noch kein Kalender – über <strong>+</strong> einen anlegen.
@@ -361,10 +399,10 @@ export default function CalendarPage() {
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} onMore={jumpToDay} />
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
)}
{viewMode === 'week' && (
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onEventClick={openEditEvent} />
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog} onEventClick={openEditEvent} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
@@ -374,6 +412,15 @@ export default function CalendarPage() {
<input ref={importInputRef} type="file" accept=".ics,text/calendar" style={{ display: 'none' }} onChange={handleImportFile} />
{dayDialog && (
<DayDialog
day={dayDialog}
events={byDay.get(dayKey(dayDialog)) ?? []}
onPick={ev => { setDayDialog(null); openEditEvent(ev) }}
onCreate={() => { const d = dayDialog; setDayDialog(null); openNewEvent(suggestStart(d)) }}
onClose={() => setDayDialog(null)}
/>
)}
{eventDialog && (
<EventDialog
event={eventDialog.event}
@@ -400,7 +447,7 @@ export default function CalendarPage() {
{deleteEvent && (
<ConfirmDialog
title="Termin löschen"
message="Diesen Termin unwiderruflich löschen?"
message="Diesen Termin unwiderruflich löschen? Bei einer Reihe verschwinden alle Termine der Reihe."
confirmLabel="Löschen" danger
onConfirm={confirmDeleteEvent}
onCancel={() => setDeleteEvent(null)}
@@ -478,8 +525,7 @@ function timeLabel(d: Date): string {
/** 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, die sofort den Dialog „Neuer Termin"
* öffnete – ein vorhandener Termin ließ sich dadurch gar nicht öffnen. */
* an die Tageszelle darunter durch und öffnete den Tag statt den Termin. */
function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void }) {
return (
<div
@@ -495,16 +541,16 @@ function EventChip({ ev, onClick }: { ev: EventWithCalendar; onClick: () => void
>
{!ev.all_day && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(ohne Titel)'}</span>
{ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
</div>
)
}
function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
function MonthGrid({ cursor, byDay, onOpenDay, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (start: Date) => void
onOpenDay: (day: Date) => void
onEventClick: (ev: EventWithCalendar) => void
onMore: (day: Date) => void
}) {
const gridStart = startOfWeek(startOfMonth(cursor))
const today = new Date()
@@ -532,7 +578,7 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
<div
key={+day}
className="aether-cal-cell"
onClick={() => onDayClick(suggestStart(day))}
onClick={() => onOpenDay(day)}
style={{
borderRight: '1px solid var(--color-border)', padding: 4, cursor: 'pointer',
background: inMonth ? 'transparent' : 'var(--color-surface-2)',
@@ -550,18 +596,12 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
</span>
<div style={{ overflow: 'hidden', minWidth: 0 }}>
{events.slice(0, 3).map(ev => (
<EventChip key={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
))}
{events.length > 3 && (
<button
onClick={e => { e.stopPropagation(); onMore(day) }}
style={{
border: 'none', background: 'none', padding: '0 4px', cursor: 'pointer',
fontSize: 10.5, color: 'var(--color-text-muted)',
}}
>
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
+{events.length - 3} weitere
</button>
</span>
)}
</div>
</div>
@@ -624,10 +664,11 @@ function layoutDay(events: EventWithCalendar[], day: Date): Slot[] {
}))
}
function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
function WeekGrid({ cursor, byDay, onSlotClick, onOpenDay, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onSlotClick: (start: Date) => void
onOpenDay: (day: Date) => void
onEventClick: (ev: EventWithCalendar) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
@@ -643,7 +684,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
}, [])
const cols = `${GUTTER}px repeat(7, minmax(0, 1fr))`
const nowTop = ((now: Date) => (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_H)(today)
const nowTop = (today.getHours() * 60 + today.getMinutes()) / 60 * HOUR_H
/** Klick ins Raster: die angeklickte Stunde, nicht Mitternacht. */
function slotFromClick(e: MouseEvent<HTMLDivElement>, day: Date) {
@@ -657,13 +698,17 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
{/* Kopfzeile */}
{/* Kopfzeile – der Tag darin öffnet den Tag, wie im Monatsraster. */}
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
<div />
{days.map(day => {
const isToday = isSameDay(day, today)
return (
<div key={+day} style={{ padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)' }}>
<button key={+day} onClick={() => onOpenDay(day)} className="aether-cal-cell" style={{
padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)',
background: 'transparent', border: 'none', borderLeftWidth: 1, borderLeftStyle: 'solid',
borderLeftColor: 'var(--color-border)', cursor: 'pointer',
}}>
<div style={{ fontSize: 10.5, color: 'var(--color-text-muted)' }}>{weekdayLabels[(day.getDay() + 6) % 7]}</div>
<span style={{
fontSize: 14, fontWeight: isToday ? 700 : 500,
@@ -673,20 +718,19 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
}}>
{day.getDate()}
</span>
</div>
</button>
)
})}
</div>
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster, so wie sie
auch gemeint sind: ohne Uhrzeit. */}
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster. */}
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0, minHeight: 26 }}>
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)', padding: '4px 6px', textAlign: 'right' }}>ganztg.</div>
{days.map(day => (
<div key={+day} className="aether-cal-cell" onClick={() => onSlotClick(suggestStart(day))}
<div key={+day} className="aether-cal-cell" onClick={() => 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 => (
<EventChip key={ev.calendarId + ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} />
))}
</div>
))}
@@ -733,7 +777,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
{slots.map(({ ev, top, height, col, cols: n }) => (
<div
key={ev.calendarId + ev.uid}
key={eventKey(ev)}
className="aether-cal-chip"
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
@@ -746,7 +790,7 @@ function WeekGrid({ cursor, byDay, onSlotClick, onEventClick }: {
}}
>
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ev.summary || '(ohne Titel)'}
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
</div>
{height > 28 && (
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
@@ -794,7 +838,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
{isSameDay(day, today) && ' · heute'}
</div>
{evs.map(ev => (
<div key={ev.calendarId + ev.uid} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
<div key={eventKey(ev)} className="aether-cal-cell" onClick={() => onEventClick(ev)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px', borderRadius: 8,
cursor: 'pointer', border: '1px solid var(--color-border)', marginBottom: 6,
}}>
@@ -803,7 +847,7 @@ function ListAgenda({ events, onEventClick }: { events: EventWithCalendar[]; onE
{ev.all_day ? 'ganztg.' : timeLabel(ev.startsAt)}
</span>
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.summary || '(ohne Titel)'}
{ev.summary || '(ohne Titel)'}{ev.recurring ? ' ↻' : ''}
</span>
{ev.location && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>