Files
Aether/frontend/src/components/calendar/EventDialog.tsx
T
marcandClaude Opus 5 0c9cc69493 feat(calendar): Wiederholungsmuster, Terminfarbe synchronisierbar, Selbsttest
Terminfarbe: Sie kam auf dem Telefon nie an. RFC 7986 schreibt für COLOR
einen CSS3-*Namen* vor, wir schrieben Hex – DAVx5 liest die Eigenschaft
über eine Namenstabelle und verwirft alles andere, also blieb der Termin
im Samsung-Kalender farblos. Jetzt beides nebeneinander:

    COLOR:tomato            für fremde Clients (damit die Farbe ankommt)
    X-AETHER-COLOR:#ea4335  für die eigene Palette (damit sie genau bleibt)

Beim Lesen gewinnt der genaue Wert; ein von außen eingefärbter Termin
wird über die Namenstabelle übersetzt. Nächstgelegener Ton per Abstand im
RGB-Würfel (color.go).

Wiederholungen können jetzt mehr als "alle N":

- wöchentlich an ausgewählten Wochentagen (Mo–So, Mehrfachauswahl). Der
  Wochentag des Starts bleibt fest angehakt – eine Reihe, die ihren
  eigenen ersten Termin auslässt, ist keine.
- monatlich und jährlich wahlweise am festen Kalendertag (am 4.) oder am
  n-ten Wochentag (am 1. Freitag). Beide Angaben leitet der Server aus
  dem Startdatum ab, statt sie getrennt entgegenzunehmen – so kann die
  Regel gar nicht erst auf einen anderen Tag zeigen als der Termin.
- Der fünfte Wochentag eines Monats wird bewusst zu "letzter": Einen
  fünften Freitag hat nicht jeder Monat, BYDAY=5FR ließe die Reihe sonst
  in etwa jedem zweiten Monat stillschweigend ausfallen.

Regeln, die das Formular weiterhin nicht abbildet, bleiben lesbar, aber
nicht bearbeitbar (409) – unverändert.

Dazu scripts/kalender-selbsttest.py, 19 Prüfungen gegen die laufende
Instanz, in TESTING.md verlinkt. Alle grün, unter anderem:

    am 1. Freitag monatlich   -> 04.09. 02.10. 06.11. 04.12.
    am 1. Freitag im September -> 2026-09-04, 2027-09-03
    Mo+Fr wöchentlich          -> 07./11./14./18./21./25.09.
    ganztägig am 3.            -> bleibt der 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:16:06 +02:00

395 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode } from '../../api/client'
import {
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
} from './dates'
import { ColorSwatches, Toggle } from './controls'
interface Props {
/** Fehlt er, entsteht ein neuer Termin. */
event?: CalendarEvent
/** 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
}
type FreqChoice = 'NONE' | Recurrence['freq']
type EndChoice = 'NEVER' | 'COUNT' | 'UNTIL'
const freqLabels: Record<FreqChoice, string> = {
NONE: 'Einmalig',
DAILY: 'Täglich',
WEEKLY: 'Wöchentlich',
MONTHLY: 'Monatlich',
YEARLY: 'Jährlich',
}
const weekdayShort = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const weekdayLong = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag']
const monthNames = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
/** „Am 4." bzw. „Am 1. Freitag" – beide aus dem Startdatum abgeleitet, wie
* man es aus anderen Kalendern kennt. Bei jährlich kommt der Monat dazu. */
function monthlyModeLabels(start: Date, yearly: boolean): { day: string; weekday: string } {
const n = nthOfMonth(start)
const nth = n === -1 ? 'letzten' : `${n}.`
const wd = weekdayLong[(start.getDay() + 6) % 7]
const month = monthNames[start.getMonth()]
return yearly
? { day: `Am ${start.getDate()}. ${month}`, weekday: `Am ${nth} ${wd} im ${month}` }
: { day: `Am ${start.getDate()}.`, weekday: `Am ${nth} ${wd}` }
}
export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel)
const titleRef = useRef<HTMLInputElement>(null)
const allDayInit = event?.all_day ?? false
// Bei einer Reihe bearbeitet das Formular die **Reihe**, nicht den
// angeklickten Einzeltermin – deshalb series_start, wo vorhanden.
const baseStartISO = event?.series_start ?? event?.start
const baseEndISO = event?.series_end ?? event?.end
const start = baseStartISO ? eventDay(baseStartISO, allDayInit) : defaultStart
const startTimed = baseStartISO && !allDayInit ? new Date(baseStartISO) : defaultStart
const end = baseEndISO
? eventDay(baseEndISO, 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 [color, setColor] = useState(event?.color ?? '')
const [allDay, setAllDay] = useState(allDayInit)
const [startDate, setStartDate] = useState(toDateInput(start))
const [startTime, setStartTime] = useState(toTimeInput(startTimed))
const [endDate, setEndDate] = useState(toDateInput(end))
const [endTime, setEndTime] = useState(toTimeInput(end))
const rec = event?.recurrence
const [freq, setFreq] = useState<FreqChoice>(rec?.freq ?? 'NONE')
const [interval, setInterval] = useState(String(rec?.interval ?? 1))
const [endChoice, setEndChoice] = useState<EndChoice>(
rec?.count ? 'COUNT' : rec?.until ? 'UNTIL' : 'NEVER')
const [count, setCount] = useState(String(rec?.count ?? 10))
const [until, setUntil] = useState(
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90)))
const [byday, setByday] = useState<WeekdayCode[]>(rec?.byday ?? [])
const [monthMode, setMonthMode] = useState<'DAY' | 'WEEKDAY'>(rec?.mode === 'WEEKDAY' ? 'WEEKDAY' : 'DAY')
// Bezugstag für alles, was sich aus dem Start ableitet („am 1. Freitag").
// Mittags, damit ein Sommerzeitsprung das Datum nicht kippt.
const startDay = dateFromInput(startDate)
const startWeekday = weekdayCodeOf(startDay)
// Der Wochentag des Starts ist immer dabei und lässt sich nicht abwählen –
// sonst zeigte die Reihe nicht mehr auf ihren eigenen ersten Termin.
const activeDays: WeekdayCode[] = Array.from(new Set([...byday, startWeekday]))
const modeLabels = monthlyModeLabels(startDay, freq === 'YEARLY')
useEffect(() => { titleRef.current?.focus() }, [])
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onCancel() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
// Nur Regeln, die dieses Formular vollständig abbildet, sind bearbeitbar.
// Alles andere ("jeden zweiten Dienstag im Monat") bliebe beim Speichern
// auf der Strecke – der Server lehnt es deshalb auch ab.
const readOnly = !!event?.recurring && !event?.recurrence
const isSeries = !!event?.recurring
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 buildRecurrence(): Recurrence | null {
if (freq === 'NONE') return null
const r: Recurrence = { freq, interval: Math.max(1, parseInt(interval, 10) || 1) }
if (endChoice === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
if (endChoice === 'UNTIL') r.until = allDayISO(until)
if (freq === 'WEEKLY') r.byday = activeDays
if (freq === 'MONTHLY' || freq === 'YEARLY') r.mode = monthMode
return r
}
function toggleDay(code: WeekdayCode) {
if (code === startWeekday) return
setByday(prev => prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code])
}
function handleSubmit(e: FormEvent) {
e.preventDefault()
if (readOnly || !summary.trim()) return
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,
color: color || undefined,
recurrence: buildRecurrence(),
start: startISO,
end: endISO,
}, calId)
}
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: CSSProperties = {
fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4, display: 'block',
}
const noteStyle: CSSProperties = {
background: 'var(--color-surface-2)', border: '1px solid var(--color-border)',
borderRadius: 8, padding: '8px 10px', fontSize: 12.5,
color: 'var(--color-text-muted)', marginBottom: 14, lineHeight: 1.45,
}
const calColor = calendars.find(c => c.id === calId)?.color ?? '#4285f4'
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}>
<form onSubmit={handleSubmit} style={{
background: 'var(--color-surface)', border: '1px solid var(--color-border)',
borderRadius: 14, padding: 24, width: 440, maxWidth: '100%', maxHeight: '90vh', overflowY: 'auto',
}}>
<h2 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600, color: 'var(--color-text)' }}>
{event ? (isSeries ? 'Terminreihe bearbeiten' : 'Termin bearbeiten') : 'Neuer Termin'}
</h2>
{readOnly && (
<div style={noteStyle}>
Diese Wiederholungsregel kann dieses Formular nicht vollständig
abbilden – zum Ändern einen CalDAV-Client verwenden (z. B. DAVx5).
Der Termin bleibt unverändert erhalten.
</div>
)}
{isSeries && !readOnly && (
<div style={noteStyle}>
Terminreihe: Änderungen gelten für <strong>alle</strong> Termine der
Reihe. Angezeigt wird deshalb ihr Beginn, nicht der angeklickte Tag.
</div>
)}
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Titel</label>
<input ref={titleRef} type="text" value={summary} disabled={readOnly}
onChange={e => setSummary(e.target.value)} style={inputStyle} required />
</div>
{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>
)}
<div style={{ marginBottom: 14 }}>
<Toggle checked={allDay} onChange={setAllDay} disabled={readOnly} label="Ganztägig" />
</div>
<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 => changeStartDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<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>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 14 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Ende</label>
<input type="date" value={endDate} min={startDate} disabled={readOnly}
onChange={e => setEndDate(e.target.value)} style={inputStyle} />
</div>
{!allDay && (
<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>
)}
</div>
{/* ── Wiederholung ───────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Wiederholung</label>
<div style={{ display: 'flex', gap: 8 }}>
<select value={freq} disabled={readOnly}
onChange={e => setFreq(e.target.value as FreqChoice)}
style={{ ...inputStyle, flex: 1 }}>
{(Object.keys(freqLabels) as FreqChoice[]).map(f => (
<option key={f} value={f}>{freqLabels[f]}</option>
))}
</select>
{freq !== 'NONE' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>alle</span>
<input type="number" min={1} max={99} value={interval} disabled={readOnly}
onChange={e => setInterval(e.target.value)}
style={{ ...inputStyle, width: 60 }} />
</div>
)}
</div>
</div>
{/* Wöchentlich: an welchen Tagen. Der Starttag ist fest an –
eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */}
{freq === 'WEEKLY' && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>An diesen Tagen</label>
<div style={{ display: 'flex', gap: 5 }}>
{weekdayCodes.map((code, i) => {
const on = activeDays.includes(code)
const fixed = code === startWeekday
return (
<button
key={code} type="button" disabled={readOnly || fixed}
onClick={() => toggleDay(code)}
title={fixed ? `${weekdayLong[i]} – Tag des Starts` : weekdayLong[i]}
style={{
flex: 1, padding: '6px 0', borderRadius: 7, fontSize: 12,
border: '1px solid var(--color-border)',
background: on ? 'var(--color-primary)' : 'transparent',
color: on ? '#fff' : 'var(--color-text-muted)',
cursor: readOnly || fixed ? 'default' : 'pointer',
opacity: fixed && !readOnly ? 0.85 : 1,
transition: 'background 0.12s, color 0.12s',
}}
>
{weekdayShort[i]}
</button>
)
})}
</div>
</div>
)}
{/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */}
{(freq === 'MONTHLY' || freq === 'YEARLY') && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Wiederholt sich</label>
<select value={monthMode} disabled={readOnly}
onChange={e => setMonthMode(e.target.value as 'DAY' | 'WEEKDAY')}
style={inputStyle}>
<option value="DAY">{modeLabels.day}</option>
<option value="WEEKDAY">{modeLabels.weekday}</option>
</select>
</div>
)}
{freq !== 'NONE' && (
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Endet</label>
<div style={{ display: 'flex', gap: 8 }}>
<select value={endChoice} disabled={readOnly}
onChange={e => setEndChoice(e.target.value as EndChoice)}
style={{ ...inputStyle, flex: 1 }}>
<option value="NEVER">Nie</option>
<option value="COUNT">Nach Anzahl</option>
<option value="UNTIL">An einem Datum</option>
</select>
{endChoice === 'COUNT' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<input type="number" min={1} max={999} value={count} disabled={readOnly}
onChange={e => setCount(e.target.value)} style={{ ...inputStyle, width: 70 }} />
<span style={{ fontSize: 12.5, color: 'var(--color-text-muted)' }}>Mal</span>
</div>
)}
{endChoice === 'UNTIL' && (
<input type="date" value={until} min={startDate} disabled={readOnly}
onChange={e => setUntil(e.target.value)} style={{ ...inputStyle, width: 160 }} />
)}
</div>
</div>
)}
{/* ── Farbe ──────────────────────────────────────────────────── */}
<div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Farbe</label>
<ColorSwatches value={color} onChange={setColor} inherit={calColor} disabled={readOnly} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>Ort</label>
<input type="text" value={location} disabled={readOnly} onChange={e => setLocation(e.target.value)} style={inputStyle} />
</div>
<div style={{ marginBottom: 20 }}>
<label style={labelStyle}>Beschreibung</label>
<textarea value={description} disabled={readOnly} onChange={e => setDescription(e.target.value)}
rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
{onDelete ? (
<button type="button" onClick={onDelete} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: 'var(--color-danger)', color: '#fff', fontSize: 13, cursor: 'pointer',
}}>
{isSeries ? 'Reihe löschen' : 'Löschen'}
</button>
) : <span />}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={onCancel} style={{
padding: '8px 16px', borderRadius: 8, border: '1px solid var(--color-border)',
background: 'transparent', color: 'var(--color-text-muted)', fontSize: 13, cursor: 'pointer',
}}>
{readOnly ? 'Schließen' : 'Abbrechen'}
</button>
{!readOnly && (
<button type="submit" style={{
padding: '8px 16px', borderRadius: 8, border: 'none',
background: 'var(--color-primary)', color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>
Speichern
</button>
)}
</div>
</div>
</form>
</div>
)
}