Files
Aether/frontend/src/pages/CalendarPage.tsx
T
marcandClaude Opus 5 4208676aa9 fix(ui): Platzhalter blitzt auch beim Tab-Wechsel nicht mehr auf
Die Frist von 220 ms galt nur beim Wechsel *innerhalb* der Dateiansicht. Beim
Wechsel zwischen den Menüpunkten entsteht die Seite jedes Mal neu – damit war
jeder Tab-Wechsel ein „erstes Laden", und genau dort war die Regel „beim ersten
Mal sofort zeigen" falsch herum. Die Frist gilt jetzt immer; 220 ms Leere ist
das ruhigere Bild, man kommt ohnehin von einer anderen Seite.

Als gemeinsamer Haken `useSkeleton`, statt die Logik in fünf Seiten zu
wiederholen.

Dabei wäre beinahe ein schlimmerer Geist entstanden: Im Wartefenster hat kurz
der **Leerzustand** aufgeblitzt – „Nothing shared with you yet", eine
Zehntelsekunde bevor die Liste kam. Der Platzhalter sieht nur nach Daten aus,
ein Satz behauptet etwas. Das Wartefenster bleibt jetzt überall leer.

Dazu drei Reste der Kalender-Übersetzung, die mein Suchmuster übersehen hatte,
weil sie in Template-Strings standen: der Löschen-Dialog des Kalenders und der
Wochentag-Hinweis im Terminfenster. Und das Datum in der Listenansicht stand
noch im deutschen Format („18. September 2026") in einer englischen
Oberfläche – jetzt „September 18, 2026".

Geprüft: tsc sauber, 41 Vitest-Tests, alle 234 Prüfungen der sieben
Selbsttest-Suiten grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:15:45 +02:00

1050 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { SkeletonRows, useSkeleton } from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
type ViewMode = 'month' | 'week' | 'list'
const weekdayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
/** 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<string, EventWithCalendar[]> {
const map = new Map<string, EventWithCalendar[]>()
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<CalendarItem[]>([])
/** Ausgeblendete Kalender. Bewusst andersherum als „sichtbare": So ist ein
* **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')
const [cursor, setCursor] = useState(new Date())
const [loading, setLoading] = useState(true)
const zeigeSkelett = useSkeleton(loading)
const [error, setError] = useState<string | null>(null)
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)
/** Kalender, dessen Freigaben gerade verwaltet werden. */
const [shareCalendar, setShareCalendar] = useState<CalendarItem | null>(null)
const [deleteEvent, setDeleteEvent] = useState<
{ calId: string; uid: string; summary: string; recurring: boolean; occurrence: string } | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const importTargetRef = useRef<string | null>(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 : 'Could not load the calendar')
} 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 : 'Could not save')
}
}
/** 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 : 'Could not move the event')
}
}
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 : 'Could not delete')
}
}
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 : 'Could not save')
}
}
async function confirmDeleteCalendar() {
if (!deleteCalendar) return
try {
await calendarApi.remove(deleteCalendar.id)
setDeleteCalendar(null)
await loadCalendars()
} catch (e) {
setError(e instanceof APIError ? e.message : 'Could not delete')
}
}
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 refreshEvents()
} catch (err) {
setError(err instanceof APIError ? err.message : 'Import failed')
}
}
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()}`
})()
: 'All events'
if (loading) {
return <div className="aether-content">{zeigeSkelett && <SkeletonRows />}</div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', minWidth: 0 }}>
<PageHeader
title="Calendar"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
}
>
<button onClick={() => setShowList(v => !v)} title="Kalenderliste" style={{
...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">
<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>
</button>
<div style={{ display: 'flex', gap: 4 }}>
{(['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)',
transition: 'background 0.15s, color 0.15s',
}}>
{m === 'month' ? 'Month' : m === 'week' ? 'Week' : 'List'}
</button>
))}
</div>
{viewMode !== 'list' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<button onClick={() => stepPeriod(-1)} style={navBtnStyle} aria-label="back">‹</button>
<span style={{ fontSize: 13, color: 'var(--color-text)', minWidth: 140, textAlign: 'center' }}>{periodLabel}</span>
<button onClick={() => stepPeriod(1)} style={navBtnStyle} aria-label="weiter">›</button>
<button onClick={() => setCursor(new Date())} style={{ ...navBtnStyle, width: 'auto', padding: '0 10px', fontSize: 12 }}>Today</button>
</div>
)}
<button onClick={() => openNewEvent(suggestStart(new Date()))} disabled={!defaultCalendarId} style={{
padding: '7px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600,
cursor: defaultCalendarId ? 'pointer' : 'not-allowed', opacity: defaultCalendarId ? 1 : 0.5,
}}>
+ Termin
</button>
</PageHeader>
{error && (
<div style={{ padding: '8px 16px', background: 'var(--color-danger)', color: '#fff', fontSize: 13, display: 'flex', justifyContent: 'space-between' }}>
<span>{error}</span>
<button onClick={() => setError(null)} style={{ border: 'none', background: 'none', color: '#fff', cursor: 'pointer' }}>✕</button>
</div>
)}
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
{showList && (
<aside style={{
width: 210, 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: 11, fontWeight: 700, color: 'var(--color-text-muted)', letterSpacing: 0.5 }}>CALENDARS</span>
<button onClick={() => setCalDialog({})} title="New calendar" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)',
fontSize: 18, lineHeight: 1, padding: '0 2px',
}}>+</button>
</div>
{drag && (
<p style={{ fontSize: 11.5, color: 'var(--color-text-muted)', margin: '0 0 8px', lineHeight: 1.4 }}>
Auf einen Kalender ziehen zum Verschieben
</p>
)}
{calendars.map(cal => {
const on = !hidden.has(cal.id)
// Ablegefläche fürs Ziehen. Der eigene Kalender des gezogenen
// Termins ist keins – dorthin gibt es nichts zu verschieben.
// Ablegefläche nur, wo auch geschrieben werden darf. Ein Ziel
// anzubieten, das der Server danach ablehnt, ist schlechter als
// gar keines: Man hat den Termin schon losgelassen.
const isTarget = !!drag && drag.ev.calendarId !== cal.id && canEditCalendar(cal)
const isOver = isTarget && drag!.overId === cal.id
return (
<div key={cal.id} className="aether-cal-row"
data-cal-drop={isTarget ? cal.id : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 4px',
fontSize: 13, color: 'var(--color-text)', borderRadius: 6,
outline: isOver ? `2px solid ${cal.color}` : isTarget ? '1px dashed var(--color-border)' : 'none',
background: isOver ? cal.color + '22' : undefined,
}}>
{/* 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 ? 'Hide' : 'Show'}
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}
{/* Fremde Kalender tragen ihren Absender. Zwei „Privat"
nebeneinander wären sonst nicht zu unterscheiden. */}
{cal.shared && cal.owner_name && (
<span style={{ color: 'var(--color-text-muted)', fontSize: 11.5 }}>
{' · '}{cal.owner_name}{cal.permission === 'read' ? ' (read-only)' : ''}
</span>
)}
</span>
<CalMenu
own={!cal.shared}
canWrite={canEditCalendar(cal)}
onRename={() => setCalDialog({ calendar: cal })}
onShare={() => setShareCalendar(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.
</p>
)}
</aside>
)}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onOpenDay={setDayDialog}
onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
{viewMode === 'week' && (
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onOpenDay={setDayDialog}
onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={clickEvent} onEventDown={onPointerDown} />
)}
</div>
</div>
{/* 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 && (
<div style={{
position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 205,
display: 'flex', gap: 8, padding: 12, overflowX: 'auto',
background: 'var(--color-surface)', borderTop: '1px solid var(--color-border)',
boxShadow: '0 -4px 16px rgba(0,0,0,0.3)',
}}>
{calendars.filter(c => c.id !== drag.ev.calendarId && canEditCalendar(c)).map(cal => (
<div key={cal.id} data-cal-drop={cal.id} style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '10px 14px',
borderRadius: 10, fontSize: 13, whiteSpace: 'nowrap', flexShrink: 0,
border: `2px solid ${drag.overId === cal.id ? cal.color : 'var(--color-border)'}`,
background: drag.overId === cal.id ? cal.color + '22' : 'transparent',
color: 'var(--color-text)',
}}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cal.color }} />
{cal.name}
</div>
))}
</div>
)}
{/* Der Termin am Zeiger. `pointerEvents: none`, sonst läge er selbst
unter dem Finger und verdeckte das Ziel darunter. */}
{drag && (
<div style={{
position: 'fixed', left: drag.x + 12, top: drag.y - 14, zIndex: 300,
pointerEvents: 'none', maxWidth: 220, padding: '5px 9px', borderRadius: 6,
fontSize: 12, background: 'var(--color-surface)', color: drag.ev.color,
border: `1px solid ${drag.ev.color}`, boxShadow: '0 6px 18px rgba(0,0,0,0.35)',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{drag.ev.summary || '(untitled)'}{drag.ev.recurring ? ' ↻ (entire series)' : ''}
</div>
)}
<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}
defaultStart={eventDialog.start}
calendars={writableCalendars}
calendarId={eventDialog.calId}
onSave={saveEvent}
onDelete={eventDialog.event ? () => 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 && (
<CalendarDialog calendar={calDialog.calendar} onSave={saveCalendar} onCancel={() => setCalDialog(null)} />
)}
{shareCalendar && (
<ShareWithDialog
resource={{ type: 'calendar', id: shareCalendar.id, name: shareCalendar.name }}
onClose={() => setShareCalendar(null)}
/>
)}
{deleteCalendar && (
<ConfirmDialog
title="Delete calendar"
message={`Permanently delete "${deleteCalendar.name}" and every event in it?`}
confirmLabel="Löschen" danger
onConfirm={confirmDeleteCalendar}
onCancel={() => setDeleteCalendar(null)}
/>
)}
{deleteEvent && (
<DeleteEventDialog
summary={deleteEvent.summary}
recurring={deleteEvent.recurring}
onSingle={() => confirmDeleteEvent('single')}
onSeries={() => confirmDeleteEvent('series')}
onCancel={() => setDeleteEvent(null)}
/>
)}
<style>{`
.aether-cal-row:hover { background: var(--color-surface-2); }
.aether-cal-cell { transition: background 0.12s; }
.aether-cal-cell:hover { background: var(--color-surface-2); }
.aether-cal-chip { transition: filter 0.12s; }
.aether-cal-chip:hover { filter: brightness(1.12); }
`}</style>
</div>
)
}
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<HTMLDivElement>(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 (
<div ref={ref} style={{ position: 'relative', flexShrink: 0 }}>
<button onClick={() => setOpen(o => !o)} aria-label="Kalendermenü" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: '0 2px',
}}>⋮</button>
{open && (
<div style={{
position: 'absolute', right: 0, top: 22, zIndex: 30, minWidth: 165,
background: 'var(--color-surface)', border: '1px solid var(--color-border)', borderRadius: 8,
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
}}>
{own && <MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />}
{own && <MenuRow icon={<PeopleIcon />} label="Freigeben…" onClick={() => { setOpen(false); onShare() }} />}
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
{canWrite && <MenuRow label="Importieren (.ics)" onClick={() => { setOpen(false); onImport() }} />}
{own && <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} className="aether-cal-row" 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 ?? <span style={{ width: 15 }} />}{label}
</button>
)
}
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 (
<div
className="aether-cal-chip"
onClick={(e: MouseEvent) => { 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 && <span style={{ opacity: 0.75 }}>{timeLabel(ev.startsAt)}</span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{ev.summary || '(untitled)'}</span>
{ev.recurring && <span style={{ opacity: 0.7, flexShrink: 0 }}>↻</span>}
</div>
)
}
function MonthGrid({ cursor, byDay, onOpenDay, onEventClick, onEventDown }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
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 (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
{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}, minmax(0, 1fr))`, flex: 1, overflow: 'auto' }}>
{weeks.map((row, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)', minHeight: 96 }}>
{row.map(day => {
const inMonth = day.getMonth() === cursor.getMonth()
const events = byDay.get(dayKey(day)) ?? []
const isToday = isSameDay(day, today)
return (
<div
key={+day}
className="aether-cal-cell"
onClick={() => 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,
}}
>
<span style={{
fontSize: 11.5, fontWeight: isToday ? 700 : 400,
color: isToday ? '#fff' : 'var(--color-text-muted)',
background: isToday ? 'var(--color-primary)' : 'transparent',
borderRadius: 10, padding: '1px 6px', alignSelf: 'flex-start', marginBottom: 3, flexShrink: 0,
}}>
{day.getDate()}
</span>
<div style={{ overflow: 'hidden', minWidth: 0 }}>
{events.slice(0, 3).map(ev => (
<EventChip key={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} onDown={onEventDown} />
))}
{events.length > 3 && (
<span style={{ fontSize: 10.5, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
+{events.length - 3} weitere
</span>
)}
</div>
</div>
)
})}
</div>
))}
</div>
</div>
)
}
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<string, EventWithCalendar[]>
onSlotClick: (start: Date) => void
onOpenDay: (day: Date) => void
onEventClick: (ev: EventWithCalendar) => void
onEventDown: EventDown
}) {
const scrollRef = useRef<HTMLDivElement>(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<HTMLDivElement>, 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 (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
{/* 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 (
<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,
color: isToday ? '#fff' : 'var(--color-text)',
background: isToday ? 'var(--color-primary)' : 'transparent',
borderRadius: 12, padding: '1px 7px', display: 'inline-block',
}}>
{day.getDate()}
</span>
</button>
)
})}
</div>
{/* 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' }}>all-day</div>
{days.map(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={eventKey(ev)} ev={ev} onClick={() => onEventClick(ev)} onDown={onEventDown} />
))}
</div>
))}
</div>
{/* Stundenraster */}
<div ref={scrollRef} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: cols, height: 24 * HOUR_H, position: 'relative' }}>
<div style={{ position: 'relative' }}>
{hours.map(h => (
<div key={h} style={{
position: 'absolute', top: h * HOUR_H - 6, right: 6,
fontSize: 10, color: 'var(--color-text-muted)',
}}>
{h > 0 ? `${String(h).padStart(2, '0')}:00` : ''}
</div>
))}
</div>
{days.map(day => {
const slots = layoutDay(byDay.get(dayKey(day)) ?? [], day)
const isToday = isSameDay(day, today)
return (
<div
key={+day}
className="aether-cal-cell"
onClick={e => onSlotClick(slotFromClick(e, day))}
style={{ position: 'relative', borderLeft: '1px solid var(--color-border)', cursor: 'pointer', minWidth: 0 }}
>
{hours.map(h => (
<div key={h} style={{
position: 'absolute', top: h * HOUR_H, left: 0, right: 0, height: HOUR_H,
borderTop: h === 0 ? 'none' : '1px solid var(--color-border)',
opacity: 0.6, pointerEvents: 'none',
}} />
))}
{isToday && (
<div style={{
position: 'absolute', top: nowTop, left: 0, right: 0, height: 2,
background: 'var(--color-danger)', zIndex: 3, pointerEvents: 'none',
}} />
)}
{slots.map(({ ev, top, height, col, cols: n }) => (
<div
key={eventKey(ev)}
className="aether-cal-chip"
onClick={e => { 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,
}}
>
<div style={{ fontSize: 10.5, color: ev.color, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ev.summary || '(untitled)'}{ev.recurring ? ' ↻' : ''}
</div>
{height > 28 && (
<div style={{ fontSize: 9.5, color: 'var(--color-text-muted)' }}>{timeLabel(ev.startsAt)}</div>
)}
</div>
))}
</div>
)
})}
</div>
</div>
</div>
)
}
function ListAgenda({ events, onEventClick, onEventDown }: {
events: EventWithCalendar[]
onEventClick: (ev: EventWithCalendar) => void
onEventDown: EventDown
}) {
const groups = useMemo(() => {
const map = new Map<string, { day: Date; evs: EventWithCalendar[] }>()
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 (
<EmptyState
icon="📅"
title="No events"
hint="Nothing scheduled in this period."
/>
)
}
const today = new Date()
return (
<div style={{ padding: '12px 20px', overflowY: 'auto', flex: 1 }}>
{groups.map(({ day, evs }) => (
<div key={dayKey(day)} style={{ marginBottom: 18 }}>
<div style={{
fontSize: 12.5, fontWeight: 600, marginBottom: 6,
color: isSameDay(day, today) ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{weekdayLabels[(day.getDay() + 6) % 7]}, {monthLabels[day.getMonth()]} {day.getDate()}, {day.getFullYear()}
{isSameDay(day, today) && ' · today'}
</div>
{evs.map(ev => (
<div key={eventKey(ev)} className="aether-cal-cell"
onClick={() => 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.
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: ev.color, flexShrink: 0 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)', width: 62, flexShrink: 0 }}>
{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 || '(untitled)'}{ev.recurring ? ' ↻' : ''}
</span>
{ev.location && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.location}
</span>
)}
</div>
))}
</div>
))}
</div>
)
}