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:
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>
|
||||
|
||||
@@ -12,6 +12,37 @@
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
export const weekdayCodes = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const
|
||||
export type WeekdayCode = typeof weekdayCodes[number]
|
||||
|
||||
/** 0 = Montag. JavaScript fängt beim Sonntag an – diese Zeile ist der
|
||||
* Übersetzer, und sie steht genau einmal. */
|
||||
export function weekdayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7
|
||||
}
|
||||
|
||||
export function weekdayCodeOf(d: Date): WeekdayCode {
|
||||
return weekdayCodes[weekdayIndex(d)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Der wievielte Wochentag seiner Art der Tag im Monat ist – 1 bis 4, und
|
||||
* −1 für „letzter".
|
||||
*
|
||||
* Der fünfte wird bewusst zu −1: Einen fünften Freitag hat nicht jeder
|
||||
* Monat, eine Reihe darauf fiele in etwa jedem zweiten Monat still aus.
|
||||
*/
|
||||
export function nthOfMonth(d: Date): number {
|
||||
const n = Math.floor((d.getDate() - 1) / 7) + 1
|
||||
return n < 5 ? n : -1
|
||||
}
|
||||
|
||||
/** Der Tag, den die Datumsfelder gerade zeigen – 12 Uhr mittags, damit
|
||||
* Sommerzeitsprünge das Datum nicht kippen können. */
|
||||
export function dateFromInput(value: string): Date {
|
||||
return new Date(`${value}T12:00:00`)
|
||||
}
|
||||
|
||||
/** 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())}`
|
||||
|
||||
Reference in new issue
Block a user