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