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)))
|
(str (get e :ord) (ev-ical-wd (get e :wd)))
|
||||||
(ev-ical-wd e))))
|
(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 ----
|
;; ---- RRULE ----
|
||||||
(define
|
(define
|
||||||
ev-ical-rrule
|
ev-ical-rrule
|
||||||
(fn
|
(fn
|
||||||
(rrule)
|
(rrule conv)
|
||||||
(let
|
(let
|
||||||
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
|
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
|
||||||
(begin
|
(begin
|
||||||
@@ -112,7 +128,7 @@
|
|||||||
(append! parts (str "COUNT=" (get rrule :count))))
|
(append! parts (str "COUNT=" (get rrule :count))))
|
||||||
(when
|
(when
|
||||||
(not (nil? (get rrule :until)))
|
(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
|
(when
|
||||||
(not (nil? (get rrule :byday)))
|
(not (nil? (get rrule :byday)))
|
||||||
(append!
|
(append!
|
||||||
@@ -139,17 +155,17 @@
|
|||||||
(fn
|
(fn
|
||||||
(event)
|
(event)
|
||||||
(let
|
(let
|
||||||
((lines (list "BEGIN:VEVENT")))
|
((lines (list "BEGIN:VEVENT")) (conv (ev-ical-conv event)))
|
||||||
(begin
|
(begin
|
||||||
(append! lines (str "UID:" (get event :id)))
|
(append! lines (str "UID:" (get event :id)))
|
||||||
(append! lines (str "SUMMARY:" (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!
|
(append!
|
||||||
lines
|
lines
|
||||||
(str "DURATION:" (ev-ical-duration (get event :duration))))
|
(str "DURATION:" (ev-ical-duration (get event :duration))))
|
||||||
(when
|
(when
|
||||||
(not (nil? (get event :rrule)))
|
(not (nil? (get event :rrule)))
|
||||||
(append! lines (ev-ical-rrule (get event :rrule))))
|
(append! lines (ev-ical-rrule (get event :rrule) conv)))
|
||||||
(when
|
(when
|
||||||
(and
|
(and
|
||||||
(not (nil? (get event :exdate)))
|
(not (nil? (get event :exdate)))
|
||||||
@@ -158,7 +174,7 @@
|
|||||||
lines
|
lines
|
||||||
(str
|
(str
|
||||||
"EXDATE:"
|
"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
|
(when
|
||||||
(and
|
(and
|
||||||
(not (nil? (get event :rdate)))
|
(not (nil? (get event :rdate)))
|
||||||
@@ -167,7 +183,7 @@
|
|||||||
lines
|
lines
|
||||||
(str
|
(str
|
||||||
"RDATE:"
|
"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")
|
(append! lines "END:VEVENT")
|
||||||
lines))))
|
lines))))
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"lang": "events",
|
"lang": "events",
|
||||||
"total_passed": 360,
|
"total_passed": 366,
|
||||||
"total_failed": 0,
|
"total_failed": 0,
|
||||||
"total": 360,
|
"total": 366,
|
||||||
"suites": [
|
"suites": [
|
||||||
{"name":"calendar","passed":51,"failed":0,"total":51},
|
{"name":"calendar","passed":51,"failed":0,"total":51},
|
||||||
{"name":"timezone","passed":17,"failed":0,"total":17},
|
{"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":"availability","passed":22,"failed":0,"total":22},
|
||||||
{"name":"api","passed":41,"failed":0,"total":41},
|
{"name":"api","passed":41,"failed":0,"total":41},
|
||||||
{"name":"booking","passed":82,"failed":0,"total":82},
|
{"name":"booking","passed":82,"failed":0,"total":82},
|
||||||
@@ -17,5 +17,5 @@
|
|||||||
{"name":"federation","passed":29,"failed":0,"total":29},
|
{"name":"federation","passed":29,"failed":0,"total":29},
|
||||||
{"name":"integration","passed":8,"failed":0,"total":8}
|
{"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
|
# events scoreboard
|
||||||
|
|
||||||
**360 / 360 passing** (0 failure(s)).
|
**366 / 366 passing** (0 failure(s)).
|
||||||
|
|
||||||
| Suite | Passed | Total | Status |
|
| Suite | Passed | Total | Status |
|
||||||
|-------|--------|-------|--------|
|
|-------|--------|-------|--------|
|
||||||
| calendar | 51 | 51 | ok |
|
| calendar | 51 | 51 | ok |
|
||||||
| timezone | 17 | 17 | ok |
|
| timezone | 17 | 17 | ok |
|
||||||
| ical | 40 | 40 | ok |
|
| ical | 46 | 46 | ok |
|
||||||
| availability | 22 | 22 | ok |
|
| availability | 22 | 22 | ok |
|
||||||
| api | 41 | 41 | ok |
|
| api | 41 | 41 | ok |
|
||||||
| booking | 82 | 82 | ok |
|
| booking | 82 | 82 | ok |
|
||||||
|
|||||||
@@ -266,6 +266,48 @@
|
|||||||
(ev-ic-starts (first events))
|
(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)))))))))
|
(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
|
(define
|
||||||
ev-ical-tests-run!
|
ev-ical-tests-run!
|
||||||
(fn
|
(fn
|
||||||
@@ -276,4 +318,5 @@
|
|||||||
(set! ev-ic-failures (list))
|
(set! ev-ic-failures (list))
|
||||||
(ev-ic-run-all!)
|
(ev-ic-run-all!)
|
||||||
(ev-ic-rt-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})))
|
{: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)
|
## 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
|
## Ground rules
|
||||||
|
|
||||||
@@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
|||||||
|
|
||||||
## Progress log
|
## 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
|
- 2026-06-07 — iCalendar import / round-trip (extension). `ical.sx` now parses
|
||||||
VEVENT/VCALENDAR text back into events (`ev/ical-lines->event`,
|
VEVENT/VCALENDAR text back into events (`ev/ical-lines->event`,
|
||||||
`ev/parse-vcalendar`): DTSTART/DURATION/RRULE (incl. ordinal BYDAY, BYMONTHDAY,
|
`ev/parse-vcalendar`): DTSTART/DURATION/RRULE (incl. ordinal BYDAY, BYMONTHDAY,
|
||||||
|
|||||||
Reference in New Issue
Block a user