From 105ce8de4a0dbeaef38e56c070f2e457863e0eb4 Mon Sep 17 00:00:00 2001 From: marc Date: Thu, 3 Sep 2026 23:47:08 +0200 Subject: [PATCH] fix(calendar): Termine anklickbar, Ganztags-Datum korrekt, Wochenraster mit Stunden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/calendar/events_api.go | 67 +- .../components/calendar/CalendarDialog.tsx | 8 +- .../src/components/calendar/EventDialog.tsx | 117 +++- frontend/src/components/calendar/dates.ts | 103 +++ frontend/src/pages/CalendarPage.tsx | 647 ++++++++++++------ 5 files changed, 702 insertions(+), 240 deletions(-) create mode 100644 frontend/src/components/calendar/dates.ts diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go index 9e7c28c..d31e723 100644 --- a/backend/internal/calendar/events_api.go +++ b/backend/internal/calendar/events_api.go @@ -23,15 +23,19 @@ import ( // eine RRULE über dieses vereinfachte Formular zu verändern, würde sie eher // beschädigen als korrekt anpassen. type EventDTO struct { - UID string `json:"uid"` - Summary string `json:"summary"` - Description string `json:"description"` - Location string `json:"location"` - Start time.Time `json:"start"` - End *time.Time `json:"end,omitempty"` - AllDay bool `json:"all_day"` - Recurring bool `json:"recurring"` - ETag string `json:"etag"` + UID string `json:"uid"` + 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"` + Recurring bool `json:"recurring"` + ETag string `json:"etag"` } func eventFromObject(o *Object) (*EventDTO, error) { @@ -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 { diff --git a/frontend/src/components/calendar/CalendarDialog.tsx b/frontend/src/components/calendar/CalendarDialog.tsx index d8ec4d1..0aae29e 100644 --- a/frontend/src/components/calendar/CalendarDialog.tsx +++ b/frontend/src/components/calendar/CalendarDialog.tsx @@ -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 diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx index 443e2f1..48e6f87 100644 --- a/frontend/src/components/calendar/EventDialog.tsx +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -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(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 ( -
+
setSummary(e.target.value)} style={inputStyle} required />
-