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
+17
@@ -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")
|
||||
|
||||
Betrifft jede künftige Migration, nicht nur eine bestimmte. Nachgestellt und
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -15,14 +15,6 @@ import (
|
||||
"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
|
||||
// Zeitfenster beisteuern darf. Ein täglicher Termin über zehn Jahre wäre
|
||||
// sonst allein genug, um eine Monatsansicht lahmzulegen.
|
||||
@@ -40,6 +32,58 @@ type RecurrenceDTO struct {
|
||||
Interval int `json:"interval"` // >= 1
|
||||
Count int `json:"count,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.
|
||||
@@ -115,24 +159,66 @@ func recurrenceFromRule(opt *rrule.ROption, start time.Time) *RecurrenceDTO {
|
||||
if !ok {
|
||||
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 ||
|
||||
len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 || len(opt.Byhour) > 0 ||
|
||||
len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 || len(opt.Byeaster) > 0 {
|
||||
// Diese Bestandteile kennt das Formular in keiner Betriebsart.
|
||||
if len(opt.Bysetpos) > 0 || len(opt.Byyearday) > 0 || len(opt.Byweekno) > 0 ||
|
||||
len(opt.Byhour) > 0 || len(opt.Byminute) > 0 || len(opt.Bysecond) > 0 ||
|
||||
len(opt.Byeaster) > 0 {
|
||||
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
|
||||
// andere (mehrere Tage, Wochentag ≠ Start) ist eine echte Erweiterung.
|
||||
if len(opt.Byweekday) > 0 {
|
||||
if freq != "WEEKLY" || len(opt.Byweekday) != 1 {
|
||||
|
||||
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||||
|
||||
switch freq {
|
||||
case "DAILY":
|
||||
if len(opt.Byweekday) > 0 || len(opt.Bymonth) > 0 || len(opt.Bymonthday) > 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
dto := &RecurrenceDTO{Freq: freq, Interval: opt.Interval, Count: opt.Count}
|
||||
if dto.Interval < 1 {
|
||||
dto.Interval = 1
|
||||
}
|
||||
@@ -157,7 +243,22 @@ func eventFromObject(o *Object) (*EventDTO, error) {
|
||||
summary, _ := ev.Props.Text(ical.PropSummary)
|
||||
description, _ := ev.Props.Text(ical.PropDescription)
|
||||
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{
|
||||
UID: o.UID, Summary: summary, Description: description, Location: location,
|
||||
@@ -342,8 +443,18 @@ func buildEventICal(uid string, body eventBody) *ical.Calendar {
|
||||
if body.Location != "" {
|
||||
ev.Props.SetText(ical.PropLocation, body.Location)
|
||||
}
|
||||
if body.Color != "" {
|
||||
ev.Props.SetText(propColor, body.Color)
|
||||
if hex := normalizeHex(body.Color); hex != "" {
|
||||
// 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 {
|
||||
@@ -396,6 +507,40 @@ func ruleFromRecurrence(body eventBody) *rrule.ROption {
|
||||
interval = 1
|
||||
}
|
||||
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 {
|
||||
// Bis zum **Ende** des genannten Tages, damit ein Termin an diesem
|
||||
// Tag noch dazugehört. Mit reiner Mitternacht fiele der letzte
|
||||
|
||||
@@ -906,12 +906,22 @@ export interface CalendarItem {
|
||||
* Jahre, wahlweise mit Ende. Verwickeltere Regeln aus fremden Clients
|
||||
* bleiben erhalten, kommen hier aber als `recurrence: undefined` an und
|
||||
* sind dann nur lesbar (siehe events_api.go). */
|
||||
export type WeekdayCode = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
|
||||
|
||||
export interface Recurrence {
|
||||
freq: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'
|
||||
interval: number
|
||||
count?: number
|
||||
/** ISO 8601. Der Tag, an dem die Reihe endet (einschließlich). */
|
||||
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 {
|
||||
|
||||
@@ -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())}`
|
||||
|
||||
Executable
+186
@@ -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)
|
||||
Reference in new issue
Block a user