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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-04 01:16:06 +02:00
1 parent 79a7cc6a3d
commit 0c9cc69493
7 files changed
+608 -23

No files matched your search

@@ -1,7 +1,10 @@
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent, CalendarItem, EventInput, Recurrence } from '../../api/client'
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates'
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 {
@@ -27,6 +30,25 @@ const freqLabels: Record<FreqChoice, string> = {
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)
@@ -61,6 +83,17 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
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() }, [])
@@ -89,9 +122,16 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
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
@@ -234,6 +274,51 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
</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>