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
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:
@@ -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))))
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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})))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user