diff --git a/lib/events/ical.sx b/lib/events/ical.sx index 2844b226..437fdc6d 100644 --- a/lib/events/ical.sx +++ b/lib/events/ical.sx @@ -94,11 +94,27 @@ (str (get e :ord) (ev-ical-wd (get e :wd))) (ev-ical-wd e)))) +;; A datetime converter for an event: tz-aware events store wall-clock LOCAL +;; times, so export converts them to UTC (the `Z` stamps are absolute); +;; non-tz events pass through unchanged. +;; CAVEAT: a UTC RRULE recurs at a fixed UTC offset, whereas a tz event's +;; expansion stays wall-clock-stable across DST — so for a tz recurrence that +;; crosses a DST boundary the exported series drifts by the offset change +;; after the boundary. DTSTART and each individual stamp are correct; full +;; fidelity would need a VTIMEZONE block (deferred). +(define + ev-ical-conv + (fn + (event) + (let + ((tz (get event :tz))) + (if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t)))))) + ;; ---- RRULE ---- (define ev-ical-rrule (fn - (rrule) + (rrule conv) (let ((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq)))))) (begin @@ -112,7 +128,7 @@ (append! parts (str "COUNT=" (get rrule :count)))) (when (not (nil? (get rrule :until))) - (append! parts (str "UNTIL=" (ev-ical-dt (get rrule :until))))) + (append! parts (str "UNTIL=" (ev-ical-dt (conv (get rrule :until)))))) (when (not (nil? (get rrule :byday))) (append! @@ -139,17 +155,17 @@ (fn (event) (let - ((lines (list "BEGIN:VEVENT"))) + ((lines (list "BEGIN:VEVENT")) (conv (ev-ical-conv event))) (begin (append! lines (str "UID:" (get event :id))) (append! lines (str "SUMMARY:" (get event :id))) - (append! lines (str "DTSTART:" (ev-ical-dt (get event :dtstart)))) + (append! lines (str "DTSTART:" (ev-ical-dt (conv (get event :dtstart))))) (append! lines (str "DURATION:" (ev-ical-duration (get event :duration)))) (when (not (nil? (get event :rrule))) - (append! lines (ev-ical-rrule (get event :rrule)))) + (append! lines (ev-ical-rrule (get event :rrule) conv))) (when (and (not (nil? (get event :exdate))) @@ -158,7 +174,7 @@ lines (str "EXDATE:" - (ev-ical-join (map ev-ical-dt (get event :exdate)) ",")))) + (ev-ical-join (map (fn (d) (ev-ical-dt (conv d))) (get event :exdate)) ",")))) (when (and (not (nil? (get event :rdate))) @@ -167,7 +183,7 @@ lines (str "RDATE:" - (ev-ical-join (map ev-ical-dt (get event :rdate)) ",")))) + (ev-ical-join (map (fn (d) (ev-ical-dt (conv d))) (get event :rdate)) ",")))) (append! lines "END:VEVENT") lines)))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 8288b6b3..34607098 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,12 +1,12 @@ { "lang": "events", - "total_passed": 360, + "total_passed": 366, "total_failed": 0, - "total": 360, + "total": 366, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, - {"name":"ical","passed":40,"failed":0,"total":40}, + {"name":"ical","passed":46,"failed":0,"total":46}, {"name":"availability","passed":22,"failed":0,"total":22}, {"name":"api","passed":41,"failed":0,"total":41}, {"name":"booking","passed":82,"failed":0,"total":82}, @@ -17,5 +17,5 @@ {"name":"federation","passed":29,"failed":0,"total":29}, {"name":"integration","passed":8,"failed":0,"total":8} ], - "generated": "2026-06-07T17:28:07+00:00" + "generated": "2026-06-07T18:33:58+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index ee6c2281..151f581e 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,12 +1,12 @@ # events scoreboard -**360 / 360 passing** (0 failure(s)). +**366 / 366 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | | timezone | 17 | 17 | ok | -| ical | 40 | 40 | ok | +| ical | 46 | 46 | ok | | availability | 22 | 22 | ok | | api | 41 | 41 | ok | | booking | 82 | 82 | ok | diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx index 7db95d37..f0c693a2 100644 --- a/lib/events/tests/ical.sx +++ b/lib/events/tests/ical.sx @@ -266,6 +266,48 @@ (ev-ic-starts (first events)) (ev-ic-starts (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1))))))))) +;; ---- timezone-aware export (local wall-clock -> UTC stamps) ---- +(define + ev-ic-dtstart + (fn (ev) (ev-ic-line (ev/event->ical-lines ev) "DTSTART"))) + +(define + ev-ic-tz-run-all! + (fn + () + (do + (ev-ic-check! + "London winter event exports as the same UTC (GMT)" + (ev-ic-dtstart (ev-event-tz (quote w) (ev-dt 2026 1 15 18 0) 60 nil 1 ev-tz-london)) + "DTSTART:20260115T180000Z") + (ev-ic-check! + "London summer event exports one hour earlier in UTC (BST)" + (ev-ic-dtstart (ev-event-tz (quote s) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)) + "DTSTART:20260715T170000Z") + (ev-ic-check! + "Paris winter (CET +1) exports one hour earlier in UTC" + (ev-ic-dtstart (ev-event-tz (quote p) (ev-dt 2026 1 15 18 0) 60 nil 1 ev-tz-paris)) + "DTSTART:20260115T170000Z") + (ev-ic-check! + "Paris summer (CEST +2) exports two hours earlier in UTC" + (ev-ic-dtstart (ev-event-tz (quote p) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-paris)) + "DTSTART:20260715T160000Z") + (ev-ic-check! + "a non-tz event is exported unchanged" + (ev-ic-dtstart (ev-event (quote n) (ev-dt 2026 7 15 18 0) 60 nil 1)) + "DTSTART:20260715T180000Z") + ;; EXDATE on a tz event is also converted to UTC + (ev-ic-check! + "tz event EXDATE is converted to UTC" + (ev-ic-line + (ev/event->ical-lines + (assoc + (ev-event-tz (quote s) (ev-dt 2026 7 1 18 0) 60 {:freq :daily :count 3} 1 ev-tz-london) + :exdate + (list (ev-dt 2026 7 2 18 0)))) + "EXDATE") + "EXDATE:20260702T170000Z")))) + (define ev-ical-tests-run! (fn @@ -276,4 +318,5 @@ (set! ev-ic-failures (list)) (ev-ic-run-all!) (ev-ic-rt-run-all!) + (ev-ic-tz-run-all!) {:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 6a4db541..cd4eaa7e 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **360/360** (Phases 1-4 + 13 ext: …iCalendar export+import round-trip, whole-series booking) +`bash lib/events/conformance.sh` → **366/366** (Phases 1-4 + 13 ext + tz-aware iCal export fix) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Fix: timezone-aware iCal export. Bug — tz events store wall-clock + LOCAL times, but export stamped them with a `Z` (UTC) suffix, so a London + 18:00 event falsely read as 18:00 UTC. `ev-ical-conv` now converts a tz + event's DTSTART / UNTIL / EXDATE / RDATE local→UTC before formatting (London + summer 18:00 → 170000Z; Paris → 160000Z); non-tz events unchanged. Documented + caveat: a UTC RRULE drifts from a wall-clock-stable tz recurrence across a DST + boundary — full fidelity needs VTIMEZONE (deferred). +6 tests, 366/366 green. - 2026-06-07 — iCalendar import / round-trip (extension). `ical.sx` now parses VEVENT/VCALENDAR text back into events (`ev/ical-lines->event`, `ev/parse-vcalendar`): DTSTART/DURATION/RRULE (incl. ordinal BYDAY, BYMONTHDAY,