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>
395 lines
18 KiB
TypeScript
395 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|