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
+702 -240

No files matched your search

@@ -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={{