events: fix timezone-aware iCal export (local->UTC stamps) + 6 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 41s

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. Caveat: UTC RRULE drifts from wall-clock-stable tz recurrence across
a DST boundary (VTIMEZONE deferred). 366/366 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:34:20 +00:00
parent 3913bc368c
commit 34c9b211ac
5 changed files with 80 additions and 14 deletions

View File

@@ -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))))

View File

@@ -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"
}

View File

@@ -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 |

View File

@@ -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})))

View File

@@ -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,