events: VTIMEZONE iCal export — full DST-correct tz recurrence + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s

A tz event now exports DTSTART;TZID=<name>:<local> (EXDATE/RDATE likewise;
UNTIL stays UTC per RFC), and the VCALENDAR emits a VTIMEZONE per distinct zone
with DAYLIGHT/STANDARD sub-components generated from the zone's transition rules
(offsets + FREQ=YEARLY;BYMONTH;BYDAY) — London/Paris blocks match real-world
definitions. Clients recur at fixed wall-clock time, DST-correct (prior caveat
gone). Importer tolerates ;TZID= params. 376/376 green.

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

View File

@@ -41,9 +41,9 @@
""
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
;; A UTC epoch-minute as an iCal basic-format UTC stamp.
;; An epoch-minute as an iCal basic-format stamp (no zone suffix).
(define
ev-ical-dt
ev-ical-dt-stamp
(fn
(t)
(let
@@ -55,7 +55,25 @@
"T"
(ev-ical-pad2 (quotient tod 60))
(ev-ical-pad2 (modulo tod 60))
"00Z"))))
"00"))))
;; A UTC epoch-minute as a UTC stamp (trailing Z).
(define ev-ical-dt (fn (t) (str (ev-ical-dt-stamp t) "Z")))
;; A local epoch-minute as a floating/local stamp (no Z) — used with TZID.
(define ev-ical-dt-local ev-ical-dt-stamp)
;; A UTC offset in minutes as "+HHMM" / "-HHMM".
(define
ev-ical-offset
(fn
(mins)
(let
((a (abs mins)))
(str
(if (< mins 0) "-" "+")
(ev-ical-pad2 (quotient a 60))
(ev-ical-pad2 (modulo a 60))))))
;; A duration in minutes as an iCal DURATION value (PT#H#M).
(define
@@ -94,14 +112,8 @@
(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).
;; UNTIL converter: per RFC 5545, even a TZID DTSTART requires UNTIL in UTC, so
;; a tz event converts its (local) UNTIL to UTC; a non-tz event passes through.
(define
ev-ical-conv
(fn
@@ -110,6 +122,74 @@
((tz (get event :tz)))
(if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t))))))
;; ---- VTIMEZONE ----
;; A tz event exports DTSTART;TZID=<name>:<local time> and the VCALENDAR carries
;; a VTIMEZONE block defining the zone's DST rules, so a client recurs at a
;; fixed WALL-CLOCK time (DST-correct) rather than fixed UTC.
;; A DST transition rule -> "FREQ=YEARLY;BYMONTH=<m>;BYDAY=<ord><WD>".
(define
ev-ical-vtz-rrule
(fn
(rule)
(str
"FREQ=YEARLY;BYMONTH="
(get rule :month)
";BYDAY="
(get rule :ord)
(ev-ical-wd (get rule :wd)))))
;; The transition's DTSTART (local time of the FROM offset) in a reference year.
(define
ev-ical-vtz-dtstart
(fn
(rule from-offset)
(let
((day (ev-resolve-nth-weekday 1970 (get rule :month) (get rule :ord) (get rule :wd))))
(ev-ical-dt-local
(+ (* (ev-days-from-civil 1970 (get rule :month) day) 1440)
(get rule :time)
from-offset)))))
;; The VTIMEZONE content lines for a zone (DAYLIGHT + STANDARD for :dst; a
;; single STANDARD for :fixed).
(define
ev-ical-vtimezone
(fn
(tz)
(if
(= (get tz :kind) :dst)
(let
((std (get tz :std-offset))
(dst (get tz :dst-offset))
(sr (get tz :dst-start))
(er (get tz :dst-end)))
(list
"BEGIN:VTIMEZONE"
(str "TZID:" (get tz :name))
"BEGIN:DAYLIGHT"
(str "DTSTART:" (ev-ical-vtz-dtstart sr std))
(str "TZOFFSETFROM:" (ev-ical-offset std))
(str "TZOFFSETTO:" (ev-ical-offset dst))
(str "RRULE:" (ev-ical-vtz-rrule sr))
"END:DAYLIGHT"
"BEGIN:STANDARD"
(str "DTSTART:" (ev-ical-vtz-dtstart er dst))
(str "TZOFFSETFROM:" (ev-ical-offset dst))
(str "TZOFFSETTO:" (ev-ical-offset std))
(str "RRULE:" (ev-ical-vtz-rrule er))
"END:STANDARD"
"END:VTIMEZONE"))
(list
"BEGIN:VTIMEZONE"
(str "TZID:" (get tz :name))
"BEGIN:STANDARD"
"DTSTART:19700101T000000"
(str "TZOFFSETFROM:" (ev-ical-offset (get tz :offset)))
(str "TZOFFSETTO:" (ev-ical-offset (get tz :offset)))
"END:STANDARD"
"END:VTIMEZONE"))))
;; ---- RRULE ----
(define
ev-ical-rrule
@@ -149,45 +229,84 @@
;; ---- VEVENT / VCALENDAR ----
;; The VEVENT content lines for an event (list of strings).
;; The VEVENT content lines for an event (list of strings). A tz event uses
;; DTSTART;TZID=<name>:<local> (matched by a VTIMEZONE at the VCALENDAR level)
;; with EXDATE/RDATE in the same TZID-local form; UNTIL is always UTC. A non-tz
;; event uses UTC `Z` stamps throughout.
(define
ev/event->ical-lines
(fn
(event)
(let
((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 (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) conv)))
(when
(and
(not (nil? (get event :exdate)))
(> (len (get event :exdate)) 0))
((lines (list "BEGIN:VEVENT"))
(conv (ev-ical-conv event))
(tz (get event :tz)))
(let
((dtparam (if (nil? tz) "" (str ";TZID=" (get tz :name))))
(fmt (if (nil? tz) ev-ical-dt ev-ical-dt-local)))
(begin
(append! lines (str "UID:" (get event :id)))
(append! lines (str "SUMMARY:" (get event :id)))
(append! lines (str "DTSTART" dtparam ":" (fmt (get event :dtstart))))
(append!
lines
(str
"EXDATE:"
(ev-ical-join (map (fn (d) (ev-ical-dt (conv d))) (get event :exdate)) ","))))
(when
(and
(not (nil? (get event :rdate)))
(> (len (get event :rdate)) 0))
(append!
lines
(str
"RDATE:"
(ev-ical-join (map (fn (d) (ev-ical-dt (conv d))) (get event :rdate)) ","))))
(append! lines "END:VEVENT")
lines))))
(str "DURATION:" (ev-ical-duration (get event :duration))))
(when
(not (nil? (get event :rrule)))
(append! lines (ev-ical-rrule (get event :rrule) conv)))
(when
(and
(not (nil? (get event :exdate)))
(> (len (get event :exdate)) 0))
(append!
lines
(str
"EXDATE"
dtparam
":"
(ev-ical-join (map fmt (get event :exdate)) ","))))
(when
(and
(not (nil? (get event :rdate)))
(> (len (get event :rdate)) 0))
(append!
lines
(str
"RDATE"
dtparam
":"
(ev-ical-join (map fmt (get event :rdate)) ","))))
(append! lines "END:VEVENT")
lines)))))
;; A full VCALENDAR (list of content lines) wrapping every event.
;; Collect the distinct timezones used by a list of events (by :name).
(define
ev-ical-distinct-tzs
(fn
(events)
(reduce
(fn
(acc ev)
(let
((tz (get ev :tz)))
(if
(or (nil? tz) (ev-ical-tz-seen? acc (get tz :name)))
acc
(append acc (list tz)))))
(list)
events)))
(define
ev-ical-tz-seen?
(fn
(tzs name)
(cond
((empty? tzs) false)
((= (get (first tzs) :name) name) true)
(else (ev-ical-tz-seen? (rest tzs) name)))))
;; A full VCALENDAR (list of content lines): a VTIMEZONE block for each distinct
;; zone the events reference, then every VEVENT.
(define
ev/events->ical-lines
(fn
@@ -195,6 +314,11 @@
(let
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
(begin
(for-each
(fn
(tz)
(for-each (fn (l) (append! lines l)) (ev-ical-vtimezone tz)))
(ev-ical-distinct-tzs events))
(for-each
(fn
(ev)
@@ -323,7 +447,8 @@
(when
(> (len kv) 1)
(let
((k (first kv)) (v (first (rest kv))))
;; strip any property parameters (e.g. ";TZID=...") from the key
((k (first (split (first kv) ";"))) (v (first (rest kv))))
(cond
((= k "UID") (dict-set! ev :id (string->symbol v)))
((= k "DTSTART") (dict-set! ev :dtstart (ev-ical-parse-dt v)))