fix(calendar): Termine anklickbar, Ganztags-Datum korrekt, Wochenraster mit Stunden

Drei gemeldete Fehler, alle nachgestellt:

1. Ein vorhandener Termin ließ sich nicht öffnen. Der Klick auf den
   Termin reichte an die Tageszelle darunter durch, die sofort 'Neuer
   Termin' öffnete und den Bearbeiten-Dialog überschrieb. Fehlendes
   stopPropagation.

2. Klick auf den 3. legte den Termin am 2. an. Ein ganztägiger Termin
   ist ein *Datum*, kein Zeitpunkt - die Oberfläche rechnete ihn über
   new Date(...).toISOString() trotzdem in UTC um, was östlich von UTC
   den Vortag ergibt. Nachgestellt und bestätigt: DTSTART landete als
   20260902 statt 20260903. Ganztägige Termine laufen jetzt über
   dates.ts (allDayISO/eventDay), damit Dialog und Raster dieselbe
   Rechnung benutzen. Nebenbefund mitgefixt: DTEND war gleich DTSTART,
   was RFC 5545 verbietet (DTEND ist exklusiv) - ein eintägiger Termin
   am 3. schreibt jetzt DTEND 20260904, nach außen bleibt die
   Schnittstelle beim letzten belegten Tag.

3. 'Fühlt sich starr an': Wochenansicht ist jetzt ein echtes
   Stundenraster (Klick trifft die Stunde statt Mitternacht, ganztägige
   in eigener Zeile, überlappende Termine nebeneinander, Jetzt-Linie),
   mehrtägige Termine stehen in jeder Zelle statt nur am Starttag, neue
   Termine schlagen die nächste volle Stunde bzw. 9 Uhr vor,
   Kalenderauswahl im Termindialog, Escape schließt, Hover-Rückmeldung,
   Kalenderliste auf schmalen Schirmen einklappbar.

Außerdem: ein neu angelegter Kalender blieb unsichtbar, weil er in
keiner Sichtbar-Liste stand - die Liste merkt sich jetzt die
ausgeblendeten statt der sichtbaren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-03 23:47:08 +02:00
1 parent ba33706d2e
commit 105ce8de4a
5 files changed
+663 -201

No files matched your search

+46 -3
View File
@@ -27,6 +27,10 @@ type EventDTO struct {
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
// Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten
// belegten** Tages – in beide Richtungen gleich. Die exklusive
// DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig
// hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag.
Start time.Time `json:"start"`
End *time.Time `json:"end,omitempty"`
AllDay bool `json:"all_day"`
@@ -63,19 +67,42 @@ func eventFromObject(o *Object) (*EventDTO, error) {
startProp := ev.Props.Get(ical.PropDateTimeStart)
if startProp != nil {
dto.AllDay = startProp.ValueType() == ical.ValueDate
dto.AllDay = isDateOnly(startProp)
if t, err := startProp.DateTime(time.UTC); err == nil {
dto.Start = t
}
}
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
if t, err := endProp.DateTime(time.UTC); err == nil {
// Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein
// eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt
// diese Schnittstelle den letzten *belegten* Tag zurück – sonst
// müsste jede Oberfläche dieselbe Rechnung noch einmal machen
// und eine davon würde sie vergessen.
if dto.AllDay {
t = t.AddDate(0, 0, -1)
if t.Before(dto.Start) {
t = dto.Start
}
}
dto.End = &t
}
}
if dto.AllDay && dto.End == nil {
// Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag.
end := dto.Start
dto.End = &end
}
return dto, nil
}
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
func isDateOnly(prop *ical.Prop) bool {
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
}
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
type icalError struct{ msg string }
@@ -137,10 +164,26 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
ev.Props.SetText(ical.PropLocation, body.Location)
}
if body.AllDay {
ev.Props.SetDate(ical.PropDateTimeStart, body.Start)
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
// Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine
// Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der
// 3. wurde zum 2.).
start := body.Start.UTC()
ev.Props.SetDate(ical.PropDateTimeStart, start)
// DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten
// belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen
// (siehe eventFromObject), also hier +1. Ohne das verletzt ein
// eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und
// Clients zeigen ihn unterschiedlich an.
last := start
if body.End != nil {
ev.Props.SetDate(ical.PropDateTimeEnd, *body.End)
last = body.End.UTC()
if last.Before(start) {
last = start
}
}
ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1))
} else {
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
if body.End != nil {
@@ -1,4 +1,4 @@
import { useRef, useState, type FormEvent } from 'react'
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarItem } from '../../api/client'
@@ -16,6 +16,12 @@ export default function CalendarDialog({ calendar, onSave, onCancel }: Props) {
const [name, setName] = useState(calendar?.name ?? '')
const [color, setColor] = useState(calendar?.color ?? palette[0])
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!name.trim()) return
@@ -1,70 +1,105 @@
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent, EventInput } from '../../api/client'
import type { CalendarEvent, CalendarItem, EventInput } from '../../api/client'
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
interface Props {
/** Fehlt sie, entsteht ein neuer Termin. */
/** Fehlt er, entsteht ein neuer Termin. */
event?: CalendarEvent
/** Vorbelegter Starttag – aus dem angeklickten Tag der Monats-/Wochenansicht. */
defaultDate: Date
onSave: (input: EventInput) => void
/** Vorbelegter Start – aus dem angeklickten Tag bzw. der Stunde. */
defaultStart: Date
calendars: CalendarItem[]
calendarId: string
onSave: (input: EventInput, calendarId: string) => void
onDelete?: () => void
onCancel: () => void
}
function toDateInput(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
function toTimeInput(d: Date): string {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
export default function EventDialog({ event, defaultDate, onSave, onDelete, onCancel }: Props) {
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel)
const titleRef = useRef<HTMLInputElement>(null)
const start = event ? new Date(event.start) : defaultDate
const end = event?.end ? new Date(event.end) : new Date(start.getTime() + 60 * 60 * 1000)
const allDayInit = event?.all_day ?? false
const start = event ? eventDay(event.start, allDayInit) : defaultStart
const startTimed = event && !allDayInit ? new Date(event.start) : defaultStart
// Bei ganztägigen Terminen liefert die Schnittstelle den letzten *belegten*
// Tag – genau das, was im Formular stehen soll (siehe events_api.go).
const end = event?.end
? eventDay(event.end, allDayInit)
: new Date(startTimed.getTime() + 60 * 60 * 1000)
const [calId, setCalId] = useState(calendarId)
const [summary, setSummary] = useState(event?.summary ?? '')
const [description, setDescription] = useState(event?.description ?? '')
const [location, setLocation] = useState(event?.location ?? '')
const [allDay, setAllDay] = useState(event?.all_day ?? false)
const [allDay, setAllDay] = useState(allDayInit)
const [startDate, setStartDate] = useState(toDateInput(start))
const [startTime, setStartTime] = useState(toTimeInput(start))
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
const [endDate, setEndDate] = useState(toDateInput(end))
const [endTime, setEndTime] = useState(toTimeInput(end))
useEffect(() => { titleRef.current?.focus() }, [])
// Escape schließt – erwartet man bei jedem Dialog, und ohne fühlt sich die
// Oberfläche zäh an.
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
const readOnly = !!event?.recurring
/** Ende darf nicht vor dem Start liegen – statt einer Fehlermeldung zieht
* das Ende einfach mit, so wie man es von anderen Kalendern kennt. */
function changeStartDate(v: string) {
const shift = (+new Date(v) - +new Date(startDate)) / 86400000
setStartDate(v)
if (Number.isFinite(shift) && shift !== 0) {
setEndDate(toDateInput(addDays(new Date(`${endDate}T12:00:00`), shift)))
}
}
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (readOnly || !summary.trim()) return
const s = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`)
const en = allDay ? new Date(`${endDate}T00:00:00`) : new Date(`${endDate}T${endTime}:00`)
let startISO: string, endISO: string
if (allDay) {
startISO = allDayISO(startDate)
endISO = allDayISO(endDate < startDate ? startDate : endDate)
} else {
startISO = timedISO(startDate, startTime)
endISO = timedISO(endDate, endTime)
if (endISO <= startISO) {
endISO = new Date(+new Date(startISO) + 60 * 60 * 1000).toISOString()
}
}
onSave({
summary: summary.trim(),
description: description.trim() || undefined,
location: location.trim() || undefined,
all_day: allDay,
start: s.toISOString(),
end: en.toISOString(),
})
start: startISO,
end: endISO,
}, calId)
}
const inputStyle: React.CSSProperties = {
const inputStyle: CSSProperties = {
width: '100%', boxSizing: 'border-box',
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '8px 10px', fontSize: 13.5, color: 'var(--color-text)',
outline: 'none',
}
const labelStyle: React.CSSProperties = {
const labelStyle: CSSProperties = {
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
}
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16 }} {...overlay}>
<div style={{
position: 'fixed', inset: 0, zIndex: 200, display: 'flex',
alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.6)', padding: 16,
}} {...overlay}>
<form onSubmit={handleSubmit} style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, padding: 24, width: 420, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
@@ -88,7 +123,19 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)' }}>
{calendars.length > 1 && (
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Kalender</label>
{/* Beim Bearbeiten fest: Ein Umhängen wäre Löschen und Neuanlegen
unter neuer Kennung – das gehört nicht versteckt in ein
Auswahlfeld. */}
<select value={calId} disabled={!!event} onChange={e => setCalId(e.target.value)} style={inputStyle}>
{calendars.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, fontSize: 13, color: 'var(--color-text)', cursor: readOnly ? 'default' : 'pointer' }}>
<input type="checkbox" checked={allDay} disabled={readOnly} onChange={e => setAllDay(e.target.checked)} />
Ganztägig
</label>
@@ -96,11 +143,12 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Start</label>
<input type="date" value={startDate} disabled={readOnly} onChange={e => setStartDate(e.target.value)} style={inputStyle} />
<input type="date" value={startDate} disabled={readOnly}
onChange={e => changeStartDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<div style={{ width: 110 }}>
<label style={labelStyle}>&nbsp;</label>
<div style={{ width: 116 }}>
<label style={labelStyle}>Uhrzeit</label>
<input type="time" value={startTime} disabled={readOnly} onChange={e => setStartTime(e.target.value)} style={inputStyle} />
</div>
)}
@@ -109,11 +157,12 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Ende</label>
<input type="date" value={endDate} disabled={readOnly} onChange={e => setEndDate(e.target.value)} style={inputStyle} />
<input type="date" value={endDate} min={startDate} disabled={readOnly}
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<div style={{ width: 110 }}>
<label style={labelStyle}>&nbsp;</label>
<div style={{ width: 116 }}>
<label style={labelStyle}>Uhrzeit</label>
<input type="time" value={endTime} disabled={readOnly} onChange={e => setEndTime(e.target.value)} style={inputStyle} />
</div>
)}
@@ -144,7 +193,7 @@ export default function EventDialog({ event, defaultDate, onSave, onDelete, onCa
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}>
Abbrechen
{readOnly ? 'Schließen' : 'Abbrechen'}
</button>
{!readOnly && (
<button type="submit" style={{
+103
View File
@@ -0,0 +1,103 @@
/**
* Datumsrechnung für den Kalender – eine Stelle für alle Ansichten und den
* Termindialog.
*
* Der Grund, warum das ein eigenes Modul ist: Ein ganztägiger Termin hat
* **kein** Zeitzonen-Bezugssystem. Er ist ein Datum, kein Zeitpunkt. Rechnet
* man ihn wie einen Zeitpunkt (`new Date('2026-09-03T00:00:00').toISOString()`),
* kommt in einer Zeitzone östlich von UTC der Vortag heraus – aus dem 3.
* wurde der 2. Genau das ist passiert, und zwar getrennt in Dialog und
* Raster. Deshalb kommen beide jetzt hier vorbei.
*/
const pad2 = (n: number) => String(n).padStart(2, '0')
/** Ortszeit → "YYYY-MM-DD" für <input type="date">. */
export function toDateInput(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
/** Ortszeit → "HH:MM" für <input type="time">. */
export function toTimeInput(d: Date): string {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
export function addDays(d: Date, n: number): Date {
const r = new Date(d)
r.setDate(r.getDate() + n)
return r
}
/** Mitternacht Ortszeit desselben Tages. */
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
/** Montag der Woche, in der `d` liegt. */
export function startOfWeek(d: Date): Date {
const r = startOfDay(d)
return addDays(r, -((r.getDay() + 6) % 7))
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1)
}
export function dayKey(d: Date): string {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
}
/**
* Der Tag, an dem ein Termin im Raster steht.
*
* Ganztägig: Der Server liefert Mitternacht **UTC** als reine Datumsangabe.
* Die Y/M/D werden deshalb aus den UTC-Feldern gelesen und als *örtlicher*
* Tag neu aufgebaut. Über `new Date(iso)` zu gehen wäre falsch: westlich von
* UTC landete man auf dem Vortag, östlich zur falschen Stunde.
*
* Mit Uhrzeit: ein echter Zeitpunkt, den der Browser in Ortszeit umrechnet –
* hier ist `new Date(iso)` genau richtig.
*/
export function eventDay(iso: string, allDay: boolean): Date {
const d = new Date(iso)
if (!allDay) return d
return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
}
/** "2026-09-03" → Mitternacht UTC, wie die Schnittstelle es für ganztägige
* Termine erwartet (erster bzw. letzter belegter Tag). */
export function allDayISO(dateInput: string): string {
const [y, m, d] = dateInput.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d)).toISOString()
}
/** "2026-09-03" + "09:30" (Ortszeit) → Zeitpunkt in ISO/UTC. */
export function timedISO(dateInput: string, timeInput: string): string {
const [y, m, d] = dateInput.split('-').map(Number)
const [hh, mm] = timeInput.split(':').map(Number)
return new Date(y, m - 1, d, hh, mm, 0, 0).toISOString()
}
/**
* Alle Tage, die ein Termin belegt – Grundlage dafür, dass ein mehrtägiger
* Termin in jeder Zelle auftaucht statt nur am Starttag.
*/
export function spannedDays(start: Date, end: Date | null, allDay: boolean): Date[] {
const first = startOfDay(start)
if (!end) return [first]
let last = startOfDay(end)
// Ein Termin, der um Mitternacht endet, belegt den Folgetag nicht mehr.
if (!allDay && end.getHours() === 0 && end.getMinutes() === 0 && +last > +first) {
last = addDays(last, -1)
}
if (+last < +first) last = first
const out: Date[] = []
// Obergrenze gegen kaputte Daten: ein Termin über Jahre hinweg würde sonst
// das Raster fluten.
for (let d = first, i = 0; +d <= +last && i < 400; d = addDays(d, 1), i++) out.push(d)
return out
}
+424 -163
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from 'react'
import {
calendarApi, APIError,
type CalendarItem, type CalendarEvent, type EventInput,
@@ -7,6 +7,9 @@ import PageHeader from '../components/PageHeader'
import ConfirmDialog from '../components/ConfirmDialog'
import EventDialog from '../components/calendar/EventDialog'
import CalendarDialog from '../components/calendar/CalendarDialog'
import {
addDays, dayKey, eventDay, isSameDay, spannedDays, startOfDay, startOfMonth, startOfWeek,
} from '../components/calendar/dates'
import { TrashIcon, RenameIcon, DownloadIcon } from '../components/icons'
type ViewMode = 'month' | 'week' | 'list'
@@ -17,58 +20,81 @@ const monthLabels = [
'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)
}
/** Höhe einer Stunde im Wochenraster. */
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
}
/** 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 decorate(ev: CalendarEvent, cal: CalendarItem): EventWithCalendar {
return {
...ev,
calendarId: cal.id,
color: cal.color,
startsAt: eventDay(ev.start, ev.all_day),
endsAt: ev.end ? eventDay(ev.end, ev.all_day) : null,
}
}
/** 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) {
const d = new Date(ev.start)
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
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) => a.start.localeCompare(b.start))
}
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
}
function dayKey(d: Date): string {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
/** 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. */
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[]>([])
const [visible, setVisible] = useState<Set<string>>(new Set())
/** 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. */
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 [error, setError] = useState<string | null>(null)
const [showList, setShowList] = useState(() =>
typeof window === 'undefined' ? true : window.matchMedia('(min-width: 900px)').matches)
const [eventDialog, setEventDialog] = useState<{ calId: string; event?: CalendarEvent; date: 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)
const [deleteEvent, setDeleteEvent] = useState<{ calId: string; uid: string } | null>(null)
@@ -76,25 +102,12 @@ export default function CalendarPage() {
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
return [c.id, [] as CalendarEvent[]] as const
}
}))
setEventsByCal(Object.fromEntries(pairs))
@@ -103,32 +116,31 @@ export default function CalendarPage() {
const reloadAll = useCallback(async () => {
setError(null)
try {
const list = await loadCalendars()
const list = await calendarApi.list()
setCalendars(list)
await loadEvents(list)
} catch (e) {
setError(e instanceof APIError ? e.message : 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [loadCalendars, loadEvents])
}, [loadEvents])
useEffect(() => { reloadAll() }, [reloadAll])
const allEvents: EventWithCalendar[] = useMemo(() => {
const allEvents = 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 })
}
if (hidden.has(cal.id)) continue
for (const ev of eventsByCal[cal.id] ?? []) out.push(decorate(ev, cal))
}
return out
}, [calendars, visible, eventsByCal])
}, [calendars, hidden, eventsByCal])
const byDay = useMemo(() => groupByDay(allEvents), [allEvents])
function toggleVisible(id: string) {
setVisible(prev => {
setHidden(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
@@ -137,21 +149,25 @@ export default function CalendarPage() {
const defaultCalendarId = calendars[0]?.id
function openNewEvent(date: Date) {
if (!defaultCalendarId) return
setEventDialog({ calId: defaultCalendarId, date })
}
const openNewEvent = useCallback((start: Date) => {
setCalendars(cals => {
const first = cals[0]
if (first) setEventDialog({ calId: first.id, start })
return cals
})
}, [])
function openEditEvent(ev: EventWithCalendar) {
setEventDialog({ calId: ev.calendarId, event: ev, date: new Date(ev.start) })
setEventDialog({ calId: ev.calendarId, event: ev, start: ev.startsAt })
}
async function saveEvent(input: EventInput) {
async function saveEvent(input: EventInput, calId: string) {
if (!eventDialog) return
try {
if (eventDialog.event) {
await calendarApi.updateEvent(eventDialog.calId, eventDialog.event.uid, input)
} else {
await calendarApi.createEvent(eventDialog.calId, input)
await calendarApi.createEvent(calId, input)
}
setEventDialog(null)
await loadEvents(calendars)
@@ -222,6 +238,11 @@ export default function CalendarPage() {
})
}
function jumpToDay(day: Date) {
setCursor(day)
setViewMode('week')
}
const periodLabel = viewMode === 'month'
? `${monthLabels[cursor.getMonth()]} ${cursor.getFullYear()}`
: viewMode === 'week'
@@ -230,7 +251,7 @@ export default function CalendarPage() {
const e = addDays(s, 6)
return `${s.getDate()}.${s.getMonth() + 1}. – ${e.getDate()}.${e.getMonth() + 1}.${e.getFullYear()}`
})()
: 'Anstehend'
: 'Alle Termine'
if (loading) {
return <div style={{ padding: 24, color: 'var(--color-text-muted)' }}>Lädt…</div>
@@ -247,54 +268,81 @@ export default function CalendarPage() {
</svg>
}
>
<div style={{ display: 'flex', gap: 6 }}>
<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" style={{ display: 'block', margin: '0 auto' }}>
<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' ? '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={() => stepPeriod(-1)} style={navBtnStyle} aria-label="zurück">‹</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 }}>Heute</button>
</div>
)}
<button onClick={() => openNewEvent(new Date())} disabled={!defaultCalendarId} style={{
<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: 'pointer',
opacity: defaultCalendarId ? 1 : 0.5,
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 }}>
{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 }}>
<aside style={{ width: 220, flexShrink: 0, borderRight: '1px solid var(--color-border)', padding: 14, overflowY: 'auto' }}>
{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: 12, fontWeight: 600, color: 'var(--color-text-muted)', textTransform: 'uppercase' }}>Kalender</span>
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--color-text-muted)', letterSpacing: 0.5 }}>KALENDER</span>
<button onClick={() => setCalDialog({})} title="Neuer Kalender" style={{
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)', fontSize: 16, lineHeight: 1,
border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-primary)',
fontSize: 18, lineHeight: 1, padding: '0 2px',
}}>+</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>
<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,
}}>
{cal.name}
</span>
<CalMenu
onRename={() => setCalDialog({ calendar: cal })}
onExport={() => window.open(calendarApi.exportUrl(cal.id), '_blank')}
@@ -304,16 +352,19 @@ export default function CalendarPage() {
</div>
))}
{calendars.length === 0 && (
<p style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Noch kein Kalender – leg einen an.</p>
<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: 'auto' }}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', display: 'flex' }}>
{viewMode === 'month' && (
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
<MonthGrid cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} onMore={jumpToDay} />
)}
{viewMode === 'week' && (
<WeekColumns cursor={cursor} byDay={byDay} onDayClick={openNewEvent} onEventClick={openEditEvent} />
<WeekGrid cursor={cursor} byDay={byDay} onSlotClick={openNewEvent} onEventClick={openEditEvent} />
)}
{viewMode === 'list' && (
<ListAgenda events={allEvents} onEventClick={openEditEvent} />
@@ -326,7 +377,9 @@ export default function CalendarPage() {
{eventDialog && (
<EventDialog
event={eventDialog.event}
defaultDate={eventDialog.date}
defaultStart={eventDialog.start}
calendars={calendars}
calendarId={eventDialog.calId}
onSave={saveEvent}
onDelete={eventDialog.event ? () => setDeleteEvent({ calId: eventDialog.calId, uid: eventDialog.event!.uid }) : undefined}
onCancel={() => setEventDialog(null)}
@@ -353,13 +406,22 @@ export default function CalendarPage() {
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: React.CSSProperties = {
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,
}
function CalMenu({ onRename, onExport, onImport, onDelete }: {
@@ -368,20 +430,25 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
function onDoc(e: MouseEvent) {
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)
return () => document.removeEventListener('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)} style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', padding: 2 }}>⋮</button>
<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: 150,
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 12px rgba(0,0,0,0.2)', overflow: 'hidden',
boxShadow: '0 4px 14px rgba(0,0,0,0.25)', overflow: 'hidden',
}}>
<MenuRow icon={<RenameIcon />} label="Bearbeiten" onClick={() => { setOpen(false); onRename() }} />
<MenuRow icon={<DownloadIcon />} label="Exportieren (.ics)" onClick={() => { setOpen(false); onExport() }} />
@@ -392,81 +459,112 @@ function CalMenu({ onRename, onExport, onImport, onDelete }: {
</div>
)
}
function MenuRow({ icon, label, onClick, danger }: { icon?: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) {
return (
<button onClick={onClick} style={{
<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}{label}
{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, die sofort den Dialog „Neuer Termin"
* öffnete – ein vorhandener Termin ließ sich dadurch gar nicht öffnen. */
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
className="aether-cal-chip"
onClick={(e: MouseEvent) => { e.stopPropagation(); onClick() }}
title={ev.summary}
style={{
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 || '(ohne Titel)'}</span>
</div>
)
}
function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
function MonthGrid({ cursor, byDay, onDayClick, onEventClick, onMore }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (d: Date) => void
onDayClick: (start: Date) => void
onEventClick: (ev: EventWithCalendar) => void
onMore: (day: Date) => void
}) {
const first = startOfMonth(cursor)
const gridStart = startOfWeek(first)
const gridStart = startOfWeek(startOfMonth(cursor))
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
}
// 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', height: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', borderBottom: '1px solid var(--color-border)' }}>
<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}, 1fr)`, flex: 1 }}>
<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)' }}>
<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.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',
}}>
<div
key={+day}
className="aether-cal-cell"
onClick={() => onDayClick(suggestStart(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, 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,
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>
{events.slice(0, 4).map(ev => (
<EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />
<div style={{ overflow: 'hidden', minWidth: 0 }}>
{events.slice(0, 3).map(ev => (
<EventChip key={ev.calendarId + 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>
{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)',
}}
>
+{events.length - 3} weitere
</button>
)}
</div>
</div>
)
})}
</div>
@@ -476,83 +574,246 @@ function MonthGrid({ cursor, byDay, onDayClick, onEventClick }: {
)
}
function WeekColumns({ cursor, byDay, onDayClick, onEventClick }: {
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, onEventClick }: {
cursor: Date
byDay: Map<string, EventWithCalendar[]>
onDayClick: (d: Date) => void
onSlotClick: (start: Date) => void
onEventClick: (ev: EventWithCalendar) => void
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const start = startOfWeek(cursor)
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
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 = ((now: Date) => (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_H)(today)
/** 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: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 }}>
{/* Kopfzeile */}
<div style={{ display: 'grid', gridTemplateColumns: cols, borderBottom: '1px solid var(--color-border)', flexShrink: 0 }}>
<div />
{days.map(day => {
const events = byDay.get(dayKey(day)) ?? []
const isToday = isSameDay(day, today)
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>
<div key={+day} style={{ padding: '6px 4px', textAlign: 'center', borderLeft: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 10.5, 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',
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>
</div>
{events.map(ev => <EventChip key={ev.uid} ev={ev} onClick={() => onEventClick(ev)} />)}
)
})}
</div>
{/* Ganztägige Termine – eigene Zeile über dem Stundenraster, so wie sie
auch gemeint sind: ohne Uhrzeit. */}
<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))}
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)} />
))}
</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={ev.calendarId + ev.uid}
className="aether-cal-chip"
onClick={e => { e.stopPropagation(); onEventClick(ev) }}
title={`${ev.summary} – ${timeLabel(ev.startsAt)}`}
style={{
position: 'absolute', top, height,
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 || '(ohne Titel)'}
</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 }: { 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)
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.entries()]
}, [sorted])
}
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 <p style={{ padding: 24, color: 'var(--color-text-muted)', fontSize: 13 }}>Keine Termine.</p>
}
const today = new Date()
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 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]}, {day.getDate()}. {monthLabels[day.getMonth()]} {day.getFullYear()}
{isSameDay(day, today) && ' · heute'}
</div>
{evs.map(ev => (
<div key={ev.uid} onClick={() => onEventClick(ev)} style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '7px 10px', borderRadius: 8,
<div key={ev.calendarId + ev.uid} 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,
}}>
<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 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)' }}>{ev.summary}</span>
<span style={{ fontSize: 13.5, color: 'var(--color-text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ev.summary || '(ohne Titel)'}
</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>
)
})}
</div>
)
}