diff --git a/TESTING.md b/TESTING.md index 449aa31..4a924ad 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/backend/internal/calendar/color.go b/backend/internal/calendar/color.go new file mode 100644 index 0000000..1d59d52 --- /dev/null +++ b/backend/internal/calendar/color.go @@ -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) +} diff --git a/backend/internal/calendar/events_api.go b/backend/internal/calendar/events_api.go index f0165e5..7179a97 100644 --- a/backend/internal/calendar/events_api.go +++ b/backend/internal/calendar/events_api.go @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 03bea84..c4fab6c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 { diff --git a/frontend/src/components/calendar/EventDialog.tsx b/frontend/src/components/calendar/EventDialog.tsx index 79a9550..ab95cda 100644 --- a/frontend/src/components/calendar/EventDialog.tsx +++ b/frontend/src/components/calendar/EventDialog.tsx @@ -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 = { 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(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(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 + {/* Wöchentlich: an welchen Tagen. Der Starttag ist fest an – + eine Reihe, die ihren eigenen ersten Termin auslässt, ist keine. */} + {freq === 'WEEKLY' && ( +
+ +
+ {weekdayCodes.map((code, i) => { + const on = activeDays.includes(code) + const fixed = code === startWeekday + return ( + + ) + })} +
+
+ )} + + {/* Monatlich/jährlich: am festen Tag oder am n-ten Wochentag. */} + {(freq === 'MONTHLY' || freq === 'YEARLY') && ( +
+ + +
+ )} + {freq !== 'NONE' && (
diff --git a/frontend/src/components/calendar/dates.ts b/frontend/src/components/calendar/dates.ts index 5ae8ed6..399ecc3 100644 --- a/frontend/src/components/calendar/dates.ts +++ b/frontend/src/components/calendar/dates.ts @@ -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 . */ export function toDateInput(d: Date): string { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` diff --git a/scripts/kalender-selbsttest.py b/scripts/kalender-selbsttest.py new file mode 100755 index 0000000..61ee245 --- /dev/null +++ b/scripts/kalender-selbsttest.py @@ -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)