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:
1 parent
ba33706d2e
commit
105ce8de4a
5 files changed
+702
-240
No files matched your search
@@ -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}> </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}> </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={{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user