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

+17
View File
@@ -9,6 +9,23 @@ Manuelle Test-Checkliste für die fertigen Phasen. Abhaken beim Durchtesten.
--- ---
## Kalender: Selbsttest per Skript
Die Kalender-Schnittstelle hat einen automatischen Selbsttest – schneller und
gründlicher als Klicken, und er prüft genau die Stellen, an denen schon
einmal etwas schiefging:
python3 scripts/kalender-selbsttest.py
19 Prüfungen: Ganztags-Datum (der 3. muss der 3. bleiben – der Fehler, bei dem
ein Termin östlich von UTC einen Tag zurücksprang), mehrtägige Termine,
Terminfarbe in beiden Schreibweisen, alle Wiederholungsmuster (wöchentlich an
bestimmten Tagen, monatlich/jährlich am festen Tag bzw. am n-ten Wochentag),
Bearbeiten einer Reihe, und dass das Zeitfenster wirklich begrenzt.
**Legt Termine an und löscht am Ende alle Termine im ersten Kalender des
Kontos.** Also nur gegen eine Entwicklungsinstanz laufen lassen.
## Notfall: Migration hängt fest ("Dirty database version") ## Notfall: Migration hängt fest ("Dirty database version")
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
+111
View File
@@ -0,0 +1,111 @@
package calendar
import (
"fmt"
"strconv"
"strings"
)
// Terminfarben und die Synchronisierung.
//
// RFC 7986 schreibt für COLOR einen **CSS3-Farbnamen** vor ("tomato"), keinen
// Hex-Wert. Genau daran hängt, ob die Farbe eines Termins auf dem Telefon
// ankommt: DAVx5 liest die Eigenschaft über eine Namenstabelle und wirft
// alles weg, was es dort nicht findet – ein "#ea4335" landet also nirgends,
// und im Samsung-Kalender bliebe der Termin farblos.
//
// Deshalb zwei Eigenschaften nebeneinander:
//
// COLOR:tomato – der Standard, damit fremde Clients es verstehen
// X-AETHER-COLOR:#ea4335 – der genaue Wert, damit *hier* die Palette stimmt
//
// Beim Lesen gewinnt X-AETHER-COLOR, wenn vorhanden; sonst wird der Name in
// Hex zurückübersetzt. So geht in beide Richtungen nichts verloren: Ein
// Termin, den das Telefon einfärbt, kommt hier mit seiner Farbe an, und
// unsere Palette bleibt auf dem Telefon erkennbar – wenn auch auf den
// nächstgelegenen benannten Ton gerundet.
const (
propColor = "COLOR"
propColorX = "X-AETHER-COLOR"
)
// css3Colors ist eine Auswahl aus der CSS3-Namensliste – über den Farbkreis
// verteilt, dazu die Töne, die Kalender-Clients üblicherweise selbst
// vergeben. Nicht die vollständige Liste (147 Namen): Für "nächstgelegener
// Ton" bringt jeder weitere Zwischenwert kaum etwas, und die Liste will
// gepflegt werden.
var css3Colors = map[string]string{
"black": "#000000", "dimgray": "#696969", "gray": "#808080",
"darkgray": "#a9a9a9", "silver": "#c0c0c0", "white": "#ffffff",
"maroon": "#800000", "brown": "#a52a2a", "firebrick": "#b22222",
"crimson": "#dc143c", "red": "#ff0000", "tomato": "#ff6347",
"coral": "#ff7f50", "orangered": "#ff4500", "darkorange": "#ff8c00",
"orange": "#ffa500", "goldenrod": "#daa520", "gold": "#ffd700",
"yellow": "#ffff00", "olive": "#808000", "yellowgreen": "#9acd32",
"darkolivegreen": "#556b2f", "lawngreen": "#7cfc00", "limegreen": "#32cd32",
"green": "#008000", "darkgreen": "#006400", "seagreen": "#2e8b57",
"mediumseagreen": "#3cb371", "teal": "#008080", "darkcyan": "#008b8b",
"lightseagreen": "#20b2aa", "turquoise": "#40e0d0", "cyan": "#00ffff",
"deepskyblue": "#00bfff", "steelblue": "#4682b4", "cornflowerblue": "#6495ed",
"royalblue": "#4169e1", "dodgerblue": "#1e90ff", "blue": "#0000ff",
"mediumblue": "#0000cd", "navy": "#000080", "slateblue": "#6a5acd",
"mediumpurple": "#9370db", "blueviolet": "#8a2be2", "darkviolet": "#9400d3",
"purple": "#800080", "orchid": "#da70d6", "magenta": "#ff00ff",
"mediumvioletred": "#c71585", "deeppink": "#ff1493", "hotpink": "#ff69b4",
"pink": "#ffc0cb", "slategray": "#708090", "lightslategray": "#778899",
"tan": "#d2b48c", "peru": "#cd853f", "sienna": "#a0522d",
}
func parseHex(s string) (r, g, b int, ok bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(s), "#"))
if len(s) == 3 { // Kurzform #abc
s = string([]byte{s[0], s[0], s[1], s[1], s[2], s[2]})
}
if len(s) == 8 { // mit Alpha – der Kanal interessiert hier nicht
s = s[:6]
}
if len(s) != 6 {
return 0, 0, 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, 0, 0, false
}
return int(v>>16) & 0xff, int(v>>8) & 0xff, int(v) & 0xff, true
}
// nearestCSS3 findet den nächstgelegenen benannten Ton. Der Abstand wird im
// RGB-Würfel gemessen – grob, aber für "welcher Name kommt dem am nächsten"
// vollkommen ausreichend, und ohne Farbmetrik-Bibliothek im Gepäck.
func nearestCSS3(hex string) string {
r, g, b, ok := parseHex(hex)
if !ok {
return ""
}
best, bestDist := "", 1<<30
for name, ref := range css3Colors {
rr, rg, rb, _ := parseHex(ref)
d := (r-rr)*(r-rr) + (g-rg)*(g-rg) + (b-rb)*(b-rb)
// Bei gleichem Abstand der alphabetisch erste Name – sonst wechselt
// die Ausgabe zwischen zwei Läufen, weil map-Reihenfolge zufällig ist.
if d < bestDist || (d == bestDist && name < best) {
best, bestDist = name, d
}
}
return best
}
// css3ToHex übersetzt zurück. Unbekannter Name: leer – der Termin erbt dann
// die Kalenderfarbe, was allemal besser ist als eine geratene.
func css3ToHex(name string) string {
return css3Colors[strings.ToLower(strings.TrimSpace(name))]
}
// normalizeHex bringt eine Farbe auf die Schreibweise "#rrggbb".
func normalizeHex(s string) string {
r, g, b, ok := parseHex(s)
if !ok {
return ""
}
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
+166 -21
View File
@@ -15,14 +15,6 @@ import (
"aether/pkg/response" "aether/pkg/response"
) )
// propColor ist die Farbe eines einzelnen Termins (RFC 7986). Der Standard
// sieht dort einen CSS3-*Namen* vor, wir schreiben einen Hex-Wert – so wie
// es auch Nextcloud und Thunderbird tun, weil eine Palette aus benannten
// Farben in einer Oberfläche niemand pflegen will. Clients, die die
// Eigenschaft nicht verstehen, überspringen sie; niemand zeigt deswegen
// etwas Falsches an.
const propColor = "COLOR"
// maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem // maxOccurrences begrenzt, wie viele Einzeltermine eine Reihe in einem
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre // Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
// sonst allein genug, um eine Monatsansicht lahmzulegen. // sonst allein genug, um eine Monatsansicht lahmzulegen.
@@ -40,6 +32,58 @@ type RecurrenceDTO struct {
Interval int `json:"interval"` // >= 1 Interval int `json:"interval"` // >= 1
Count int `json:"count,omitempty"` Count int `json:"count,omitempty"`
Until *time.Time `json:"until,omitempty"` Until *time.Time `json:"until,omitempty"`
// WEEKLY: an welchen Wochentagen (MO…SU). Leer = Wochentag des Starts.
Byday []string `json:"byday,omitempty"`
// MONTHLY/YEARLY: "DAY" wiederholt am festen Kalendertag (am 4.),
// "WEEKDAY" am n-ten Wochentag (am 1. Freitag). Leer zählt als DAY.
Mode string `json:"mode,omitempty"`
// Nur bei Mode=WEEKDAY und nur zum **Anzeigen**: der wievielte (−1 =
// letzter) und welcher Wochentag. Beim Schreiben rechnet der Server
// beides aus dem Startdatum aus – zwei Quellen für dieselbe Angabe wären
// eine zu viel, und sie liefen irgendwann auseinander.
Nth int `json:"nth,omitempty"`
Weekday string `json:"weekday,omitempty"`
}
var weekdayCodes = []string{"MO", "TU", "WE", "TH", "FR", "SA", "SU"}
// weekdayIndex: 0 = Montag. Gos time.Weekday fängt beim Sonntag an, rrule-go
// beim Montag – diese eine Zeile ist der Übersetzer dazwischen.
func weekdayIndex(t time.Time) int { return (int(t.Weekday()) + 6) % 7 }
func weekdayCode(t time.Time) string { return weekdayCodes[weekdayIndex(t)] }
func rruleWeekday(code string) (rrule.Weekday, bool) {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "MO":
return rrule.MO, true
case "TU":
return rrule.TU, true
case "WE":
return rrule.WE, true
case "TH":
return rrule.TH, true
case "FR":
return rrule.FR, true
case "SA":
return rrule.SA, true
case "SU":
return rrule.SU, true
}
return rrule.MO, false
}
// nthOfMonth: der wievielte Wochentag seiner Art der Tag im Monat ist.
//
// Der fünfte wird zu −1 ("letzter"), und das ist kein Schönheitsfehler: Einen
// fünften Freitag hat nicht jeder Monat. FREQ=MONTHLY;BYDAY=5FR ließe die
// Reihe in etwa der Hälfte aller Monate stillschweigend ausfallen – ein
// Fehler, den man erst Wochen später bemerkt.
func nthOfMonth(t time.Time) int {
if n := (t.Day()-1)/7 + 1; n < 5 {
return n
}
return -1
} }
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche. // EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
@@ -115,24 +159,66 @@ func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
if !ok { if !ok {
return nil // stündlich und feiner bietet die Oberfläche nicht an return nil // stündlich und feiner bietet die Oberfläche nicht an
} }
if len(opt.Bysetpos) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 || // Diese Bestandteile kennt das Formular in keiner Betriebsart.
len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 || if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 { len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
len(opt.Byeaster) > 0 {
return nil return nil
} }
// BYDAY mit genau dem Wochentag des Starts ist die Schreibweise vieler
// Clients für „jede Woche" und ändert nichts an der Bedeutung. Alles dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
// andere (mehrere Tage, Wochentag ≠ Start) ist eine echte Erweiterung.
if len(opt.Byweekday) > 0 { switch freq {
if freq != "WEEKLY" || len(opt.Byweekday) != 1 { case "DAILY":
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
return nil return nil
} }
if opt.Byweekday[0].Day() != int(start.Weekday()+6)%7 {
case "WEEKLY":
if len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
return nil
}
for _, wd := range opt.Byweekday {
if wd.N() != 0 {
return nil // "jeder 2. Dienstag" ist keine Wochenregel
}
dto.Byday = append(dto.Byday, weekdayCodes[wd.Day()])
}
if len(dto.Byday) == 0 {
dto.Byday = []string{weekdayCode(start)}
}
case "MONTHLY", "YEARLY":
// Bei jährlich darf der Monat genannt sein – aber nur der des Starts.
if freq == "YEARLY" {
if len(opt.Bymonth) > 1 || (len(opt.Bymonth) == 1 && opt.Bymonth[0] != int(start.Month())) {
return nil
}
} else if len(opt.Bymonth) > 0 {
return nil
}
switch {
case len(opt.Byweekday) == 1 && opt.Byweekday[0].N() != 0:
// „am n-ten Wochentag". Nur bearbeitbar, wenn die Regel auch
// wirklich auf den Starttag zeigt – sonst zeigte das Formular
// etwas anderes an, als gespeichert ist.
wd := opt.Byweekday[0]
if len(opt.Bymonthday) > 0 || wd.Day() != weekdayIndex(start) || wd.N() != nthOfMonth(start) {
return nil
}
dto.Mode, dto.Nth, dto.Weekday = "WEEKDAY", wd.N(), weekdayCode(start)
case len(opt.Byweekday) == 0:
// „am festen Kalendertag" – ausgeschrieben oder implizit.
if len(opt.Bymonthday) > 1 || (len(opt.Bymonthday) == 1 && opt.Bymonthday[0] != start.Day()) {
return nil
}
dto.Mode = "DAY"
default:
return nil return nil
} }
} }
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
if dto.Interval < 1 { if dto.Interval < 1 {
dto.Interval = 1 dto.Interval = 1
} }
@@ -157,7 +243,22 @@ func eventFromObject(o *Object) (*EventDTO, error) {
summary, _ := ev.Props.Text(ical.PropSummary) summary, _ := ev.Props.Text(ical.PropSummary)
description, _ := ev.Props.Text(ical.PropDescription) description, _ := ev.Props.Text(ical.PropDescription)
location, _ := ev.Props.Text(ical.PropLocation) location, _ := ev.Props.Text(ical.PropLocation)
color, _ := ev.Props.Text(propColor)
// Farbe: unser genauer Wert schlägt den benannten Ton, siehe color.go.
// Ein Termin, den ein anderer Client eingefärbt hat, bringt nur COLOR
// mit – der wird dann übersetzt.
color, _ := ev.Props.Text(propColorX)
if color == "" {
if name, _ := ev.Props.Text(propColor); name != "" {
if strings.HasPrefix(strings.TrimSpace(name), "#") {
color = normalizeHex(name)
} else {
color = css3ToHex(name)
}
}
} else {
color = normalizeHex(color)
}
dto := &EventDTO{ dto := &EventDTO{
UID: o.UID, Summary: summary, Description: description, Location: location, UID: o.UID, Summary: summary, Description: description, Location: location,
@@ -342,8 +443,18 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
if body.Location != "" { if body.Location != "" {
ev.Props.SetText(ical.PropLocation, body.Location) ev.Props.SetText(ical.PropLocation, body.Location)
} }
if body.Color != "" { if hex := normalizeHex(body.Color); hex != "" {
ev.Props.SetText(propColor, body.Color) // Beides schreiben: den benannten Ton für fremde Clients (DAVx5
// überträgt daraus die Terminfarbe ins Telefon), den genauen Wert
// für die eigene Palette. Siehe color.go.
if name := nearestCSS3(hex); name != "" {
ev.Props.SetText(propColor, name)
}
// Ohne SetText geschrieben: Das hängte an die unbekannte
// X-Eigenschaft ein überflüssiges VALUE=TEXT an.
p := ical.NewProp(propColorX)
p.Value = hex
ev.Props.Set(p)
} }
if body.AllDay { if body.AllDay {
@@ -396,6 +507,40 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
interval = 1 interval = 1
} }
opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count} opt := &rrule.ROption{Freq: freq, Interval: interval, Count: rec.Count}
// Der Start ist die Bezugsgröße für alle BY-Angaben: „am 4." und „am 1.
// Freitag" leiten sich beide aus ihm ab, statt getrennt mitgeschickt zu
// werden. So kann die Regel gar nicht erst auf einen anderen Tag zeigen
// als der Termin selbst.
start := body.Start.UTC()
switch strings.ToUpper(rec.Freq) {
case "WEEKLY":
codes := rec.Byday
if len(codes) == 0 {
codes = []string{weekdayCode(start)}
}
seen := map[string]bool{}
for _, c := range codes {
wd, ok := rruleWeekday(c)
if !ok || seen[strings.ToUpper(c)] {
continue
}
seen[strings.ToUpper(c)] = true
opt.Byweekday = append(opt.Byweekday, wd)
}
case "MONTHLY", "YEARLY":
if strings.EqualFold(rec.Mode, "WEEKDAY") {
wd, _ := rruleWeekday(weekdayCode(start))
opt.Byweekday = []rrule.Weekday{wd.Nth(nthOfMonth(start))}
} else {
opt.Bymonthday = []int{start.Day()}
}
if strings.EqualFold(rec.Freq, "YEARLY") {
opt.Bymonth = []int{int(start.Month())}
}
}
if rec.Until != nil { if rec.Until != nil {
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem // Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte // Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
+10
View File
@@ -906,12 +906,22 @@ export interface CalendarItem {
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients * Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und * bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
* sind dann nur lesbar (siehe events_api.go). */ * sind dann nur lesbar (siehe events_api.go). */
export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
export interface Recurrence { export interface Recurrence {
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
interval: number interval: number
count?: number count?: number
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */ /** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
until?: string until?: string
/** Nur WEEKLY: an welchen Wochentagen. Leer = Wochentag des Starts. */
byday?: WeekdayCode[]
/** Nur MONTHLY/YEARLY: am festen Kalendertag (am 4.) oder am n-ten
* Wochentag (am 1. Freitag). */
mode?: 'DAY' | 'WEEKDAY'
/** Nur zum Anzeigen – der Server rechnet beides aus dem Startdatum aus. */
nth?: number
weekday?: WeekdayCode
} }
export interface CalendarEvent { export interface CalendarEvent {
@@ -1,7 +1,10 @@
import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react' import { useEffect, useRef, useState, type CSSProperties, type FormEvent } from 'react'
import { useOverlayDismiss } from '../../hooks/useOverlayDismiss' import { useOverlayDismiss } from '../../hooks/useOverlayDismiss'
import type { CalendarEvent, CalendarItem, EventInput, Recurrence } from '../../api/client' import type { CalendarEvent, CalendarItem, EventInput, Recurrence, WeekdayCode } from '../../api/client'
import { addDays, allDayISO, eventDay, timedISO, toDateInput, toTimeInput } from './dates' import {
addDays, allDayISO, dateFromInput, eventDay, nthOfMonth, timedISO,
toDateInput, toTimeInput, weekdayCodeOf, weekdayCodes,
} from './dates'
import { ColorSwatches, Toggle } from './controls' import { ColorSwatches, Toggle } from './controls'
interface Props { interface Props {
@@ -27,6 +30,25 @@ const freqLabels: Record<FreqChoice, string> = {
YEARLY: 'Jährlich', 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) { export default function EventDialog({ event, defaultStart, calendars, calendarId, onSave, onDelete, onCancel }: Props) {
const overlay = useOverlayDismiss(onCancel) const overlay = useOverlayDismiss(onCancel)
const titleRef = useRef<HTMLInputElement>(null) 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 [count, setCount] = useState(String(rec?.count ?? 10))
const [until, setUntil] = useState( const [until, setUntil] = useState(
rec?.until ? toDateInput(eventDay(rec.until, true)) : toDateInput(addDays(start, 90))) 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(() => { 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) } 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 === 'COUNT') r.count = Math.max(1, parseInt(count, 10) || 1)
if (endChoice === 'UNTIL') r.until = allDayISO(until) if (endChoice === 'UNTIL') r.until = allDayISO(until)
if (freq === 'WEEKLY') r.byday = activeDays
if (freq === 'MONTHLY' || freq === 'YEARLY') r.mode = monthMode
return r 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) { function handleSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
if (readOnly || !summary.trim()) return if (readOnly || !summary.trim()) return
@@ -234,6 +274,51 @@ export default function EventDialog({ event, defaultStart, calendars, calendarId
</div> </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' && ( {freq !== 'NONE' && (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<label style={labelStyle}>Endet</label> <label style={labelStyle}>Endet</label>
+31
View File
@@ -12,6 +12,37 @@
const pad2 = (n: number) => String(n).padStart(2, '0') 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">. */ /** Ortszeit → "YYYY-MM-DD" für <input type="date">. */
export function toDateInput(d: Date): string { export function toDateInput(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Selbsttest der Kalender-Schnittstelle gegen eine laufende Instanz.
python3 scripts/kalender-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Voreinstellung: http://localhost:8090, admin/admin123 (Entwicklungsumgebung).
Prüft die Stellen, an denen bereits einmal etwas schiefging – allen voran das
Ganztags-Datum, das in einer Zeitzone östlich von UTC einen Tag zurücksprang –
und die Wiederholungsmuster, deren Fehler man sonst erst Wochen später
bemerkt ("jeder 1. Freitag" darf nicht im Monat ohne fünften Freitag
ausfallen).
**Achtung: legt Termine im ersten Kalender des Kontos an** und löscht am Ende
*alle* Termine darin. Nicht gegen einen Kalender laufen lassen, in dem echte
Termine stehen.
"""
import http.cookiejar
import json
import sys
import urllib.request
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090"
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
ok_count = 0
fail = []
def api(method, path, body=None, headers=None, raw=False):
url = BASE + path
data = None
hdrs = dict(headers or {})
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
with OPENER.open(req) as r:
payload = r.read().decode()
if raw:
return r.status, payload
return r.status, (json.loads(payload).get("data") if payload else None)
except urllib.error.HTTPError as e:
return e.code, None
def check(name, cond, detail=""):
global ok_count
if cond:
ok_count += 1
print(f" \033[32mok\033[0m {name}")
else:
fail.append(name)
print(f" \033[31mFEHL\033[0m {name} {detail}")
jar = http.cookiejar.CookieJar()
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
# ── Anmelden ────────────────────────────────────────────────────────────
st, data = api("POST", "/api/v1/auth/login",
{"username": USER, "password": PASSWORT})
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
CSRF = {"X-CSRF-Token": data["csrf_token"]}
st, cals = api("GET", "/api/v1/calendars")
CAL = cals[0]["id"]
EV = f"/api/v1/calendars/{CAL}/events"
print(f"Kalender: {cals[0]['name']}\n")
def create(**body):
st, d = api("POST", EV, body, CSRF)
assert st == 201, f"Anlegen fehlgeschlagen ({st}): {body.get('summary')}"
return d
def events(frm, to):
st, d = api("GET", f"{EV}?from={frm}&to={to}")
return d or []
def starts(summary, frm="2026-01-01T00:00:00Z", to="2027-12-31T00:00:00Z"):
return [e["start"][:10] for e in events(frm, to) if e["summary"] == summary]
print("── Ganztägig: Datum darf nicht verrutschen ──")
e = create(summary="T ganztag", start="2026-09-03T00:00:00Z",
end="2026-09-03T00:00:00Z", all_day=True)
check("ein Tag bleibt der 3.", e["start"][:10] == "2026-09-03", e["start"])
check("Ende ist derselbe Tag", e["end"][:10] == "2026-09-03", e["end"])
e2 = create(summary="T mehrtags", start="2026-09-10T00:00:00Z",
end="2026-09-12T00:00:00Z", all_day=True)
check("mehrtägig 10.–12.", e2["start"][:10] == "2026-09-10" and e2["end"][:10] == "2026-09-12",
f'{e2["start"]}..{e2["end"]}')
print("\n── Uhrzeit: unverändert durchreichen ──")
e3 = create(summary="T uhrzeit", start="2026-09-15T12:30:00Z",
end="2026-09-15T13:30:00Z", all_day=False)
check("Zeitpunkt unverändert", e3["start"] == "2026-09-15T12:30:00Z", e3["start"])
check("nicht als ganztägig gelesen", e3["all_day"] is False)
print("\n── Farbe: Name für fremde Clients, Hex für uns ──")
e4 = create(summary="T farbe", start="2026-09-16T10:00:00Z",
end="2026-09-16T11:00:00Z", all_day=False, color="#ea4335")
check("Hex kommt unverändert zurück", e4.get("color") == "#ea4335", e4.get("color"))
st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True)
block = [b for b in ics.split("BEGIN:VEVENT") if "T farbe" in b]
check("COLOR als CSS3-Name im .ics", block and "COLOR:tomato" in block[0],
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
check("X-AETHER-COLOR mit genauem Wert",
block and "#ea4335" in "".join(l for l in block[0].splitlines() if "X-AETHER-COLOR" in l),
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
print("\n── Wöchentlich an bestimmten Tagen ──")
# 7. Sept 2026 ist ein Montag. Reihe Mo+Fr, 3 Wochen.
create(summary="T wochentage", start="2026-09-07T08:00:00Z",
end="2026-09-07T09:00:00Z", all_day=False,
recurrence={"freq": "WEEKLY", "interval": 1, "count": 6,
"byday": ["MO", "FR"]})
got = starts("T wochentage")
check("Mo+Fr, 6 Termine", got == ["2026-09-07", "2026-09-11", "2026-09-14",
"2026-09-18", "2026-09-21", "2026-09-25"], got)
print("\n── Monatlich: am festen Tag vs. am n-ten Wochentag ──")
# 4. Sept 2026 ist der 1. Freitag des Monats.
create(summary="T monat tag", start="2026-09-04T08:00:00Z",
end="2026-09-04T09:00:00Z", all_day=False,
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "DAY"})
got = starts("T monat tag")
check("am 4. jedes Monats", got == ["2026-09-04", "2026-10-04", "2026-11-04", "2026-12-04"], got)
create(summary="T monat wochentag", start="2026-09-04T10:00:00Z",
end="2026-09-04T11:00:00Z", all_day=False,
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "WEEKDAY"})
got = starts("T monat wochentag")
# jeweils der 1. Freitag: 4.9. / 2.10. / 6.11. / 4.12.
check("am 1. Freitag jedes Monats", got == ["2026-09-04", "2026-10-02", "2026-11-06", "2026-12-04"], got)
print("\n── Jährlich: beide Muster ──")
create(summary="T jahr tag", start="2026-09-04T08:00:00Z",
end="2026-09-04T09:00:00Z", all_day=False,
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "DAY"})
check("am 4. September", starts("T jahr tag") == ["2026-09-04", "2027-09-04"], starts("T jahr tag"))
create(summary="T jahr wochentag", start="2026-09-04T12:00:00Z",
end="2026-09-04T13:00:00Z", all_day=False,
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "WEEKDAY"})
# 1. Freitag im September: 2026-09-04, 2027-09-03
check("am 1. Freitag im September",
starts("T jahr wochentag") == ["2026-09-04", "2027-09-03"], starts("T jahr wochentag"))
print("\n── Regel bleibt beim Bearbeiten erhalten ──")
ser = [e for e in events("2026-09-01T00:00:00Z", "2026-12-31T00:00:00Z")
if e["summary"] == "T monat wochentag"][0]
check("Regel wird als bearbeitbar gemeldet", ser.get("recurrence", {}).get("mode") == "WEEKDAY",
ser.get("recurrence"))
check("Reihenbeginn mitgeliefert", ser.get("series_start", "")[:10] == "2026-09-04",
ser.get("series_start"))
st, _ = api("PATCH", f"{EV}/{ser['uid']}",
{"summary": "T monat wochentag", "start": "2026-09-04T10:00:00Z",
"end": "2026-09-04T11:00:00Z", "all_day": False,
"recurrence": {"freq": "MONTHLY", "interval": 2, "count": 3, "mode": "WEEKDAY"}}, CSRF)
check("Bearbeiten erlaubt (200)", st == 200, st)
got = starts("T monat wochentag")
check("jetzt jeden 2. Monat, 1. Freitag", got == ["2026-09-04", "2026-11-06", "2027-01-01"], got)
print("\n── Zeitfenster begrenzt wirklich ──")
win = [e for e in events("2026-09-14T00:00:00Z", "2026-09-20T00:00:00Z")
if e["summary"] == "T wochentage"]
check("nur die eine Woche", [e["start"][:10] for e in win] == ["2026-09-14", "2026-09-18"],
[e["start"][:10] for e in win])
print("\n── Aufräumen ──")
seen = {e["uid"] for e in events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")}
for uid in seen:
api("DELETE", f"{EV}/{uid}", None, CSRF)
rest = events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")
check("Kalender wieder leer", rest == [], rest)
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)