From ddc6635fa8960a520c48815a78ce0e64d769c652 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 14:41:08 +0000 Subject: [PATCH 1/9] events: iCalendar (RFC 5545) export + 21 tests ical.sx serializes events to VEVENT/VCALENDAR text for import by standard clients: UTC basic-format stamps, DURATION (PT#H#M), full RRULE (FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals 2TU/-1FR/BYMONTHDAY) plus EXDATE/RDATE. Line-oriented (ev/event->ical-lines / ev/events->ical-lines) with ev/ical-render joining CRLF for the wire format. 332/332 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/conformance.conf | 2 + lib/events/ical.sx | 191 +++++++++++++++++++++++++++++++++++ lib/events/scoreboard.json | 7 +- lib/events/scoreboard.md | 3 +- lib/events/tests/ical.sx | 192 ++++++++++++++++++++++++++++++++++++ plans/events-on-sx.md | 9 +- 6 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 lib/events/ical.sx create mode 100644 lib/events/tests/ical.sx diff --git a/lib/events/conformance.conf b/lib/events/conformance.conf index f8954faf..18dfc046 100644 --- a/lib/events/conformance.conf +++ b/lib/events/conformance.conf @@ -19,6 +19,7 @@ PRELOADS=( lib/datalog/magic.sx lib/events/calendar.sx lib/events/timezone.sx + lib/events/ical.sx lib/events/availability.sx lib/persist/event.sx lib/persist/backend.sx @@ -49,6 +50,7 @@ PRELOADS=( SUITES=( "calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)" "timezone:lib/events/tests/timezone.sx:(ev-timezone-tests-run!)" + "ical:lib/events/tests/ical.sx:(ev-ical-tests-run!)" "availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)" "api:lib/events/tests/api.sx:(ev-api-tests-run!)" "booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)" diff --git a/lib/events/ical.sx b/lib/events/ical.sx new file mode 100644 index 00000000..ae003b7c --- /dev/null +++ b/lib/events/ical.sx @@ -0,0 +1,191 @@ +;; lib/events/ical.sx — iCalendar (RFC 5545) export. +;; +;; Serializes events to VEVENT / VCALENDAR text so a rose-ash calendar can be +;; imported by any standard client (Google/Apple/Outlook). Datetimes are UTC +;; epoch-minutes, emitted as basic-format UTC stamps (YYYYMMDDTHHMM00Z). The +;; full RRULE / EXDATE / RDATE model maps directly to the standard properties. +;; +;; Export is line-oriented: `ev/event->ical-lines` returns the VEVENT as a list +;; of content lines (no folding/CRLF — easy to assert on); `ev/ical-render` +;; joins lines with CRLF, the on-the-wire format. Requires calendar.sx. + +;; ---- formatting helpers ---- + +(define ev-ical-pad2 (fn (n) (if (< n 10) (str "0" n) (str n)))) + +(define + ev-ical-pad4 + (fn + (n) + (cond + ((< n 10) (str "000" n)) + ((< n 100) (str "00" n)) + ((< n 1000) (str "0" n)) + (else (str n))))) + +(define + ev-ical-nth + (fn + (xs i) + (if + (= i 0) + (first xs) + (ev-ical-nth (rest xs) (- i 1))))) + +(define + ev-ical-join + (fn + (parts sep) + (if + (empty? parts) + "" + (reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts))))) + +;; A UTC epoch-minute as an iCal basic-format UTC stamp. +(define + ev-ical-dt + (fn + (t) + (let + ((civ (ev-dt->civil t)) (tod (ev-dt-tod t))) + (str + (ev-ical-pad4 (ev-civ-y civ)) + (ev-ical-pad2 (ev-civ-m civ)) + (ev-ical-pad2 (ev-civ-d civ)) + "T" + (ev-ical-pad2 (quotient tod 60)) + (ev-ical-pad2 (modulo tod 60)) + "00Z")))) + +;; A duration in minutes as an iCal DURATION value (PT#H#M). +(define + ev-ical-duration + (fn + (mins) + (let + ((h (quotient mins 60)) (m (modulo mins 60))) + (cond + ((and (> h 0) (> m 0)) (str "PT" h "H" m "M")) + ((> h 0) (str "PT" h "H")) + (else (str "PT" m "M")))))) + +(define + ev-ical-wd + (fn (w) (ev-ical-nth (list "MO" "TU" "WE" "TH" "FR" "SA" "SU") w))) + +(define + ev-ical-freq + (fn + (f) + (cond + ((= f :daily) "DAILY") + ((= f :weekly) "WEEKLY") + ((= f :monthly) "MONTHLY") + (else "DAILY")))) + +;; One BYDAY token: a weekly weekday number -> "MO"; a monthly ordinal weekday +;; {:ord :wd} -> "2TU" / "-1FR". +(define + ev-ical-byday-token + (fn + (e) + (if + (dict? e) + (str (get e :ord) (ev-ical-wd (get e :wd))) + (ev-ical-wd e)))) + +;; ---- RRULE ---- +(define + ev-ical-rrule + (fn + (rrule) + (let + ((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq)))))) + (begin + (when + (and + (not (nil? (get rrule :interval))) + (> (get rrule :interval) 1)) + (append! parts (str "INTERVAL=" (get rrule :interval)))) + (when + (not (nil? (get rrule :count))) + (append! parts (str "COUNT=" (get rrule :count)))) + (when + (not (nil? (get rrule :until))) + (append! parts (str "UNTIL=" (ev-ical-dt (get rrule :until))))) + (when + (not (nil? (get rrule :byday))) + (append! + parts + (str + "BYDAY=" + (ev-ical-join (map ev-ical-byday-token (get rrule :byday)) ",")))) + (when + (not (nil? (get rrule :bymonthday))) + (append! + parts + (str + "BYMONTHDAY=" + (ev-ical-join + (map (fn (d) (str d)) (get rrule :bymonthday)) + ",")))) + (str "RRULE:" (ev-ical-join parts ";")))))) + +;; ---- VEVENT / VCALENDAR ---- + +;; The VEVENT content lines for an event (list of strings). +(define + ev/event->ical-lines + (fn + (event) + (let + ((lines (list "BEGIN:VEVENT"))) + (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 "DURATION:" (ev-ical-duration (get event :duration)))) + (when + (not (nil? (get event :rrule))) + (append! lines (ev-ical-rrule (get event :rrule)))) + (when + (and + (not (nil? (get event :exdate))) + (> (len (get event :exdate)) 0)) + (append! + lines + (str + "EXDATE:" + (ev-ical-join (map ev-ical-dt (get event :exdate)) ",")))) + (when + (and + (not (nil? (get event :rdate))) + (> (len (get event :rdate)) 0)) + (append! + lines + (str + "RDATE:" + (ev-ical-join (map ev-ical-dt (get event :rdate)) ",")))) + (append! lines "END:VEVENT") + lines)))) + +;; A full VCALENDAR (list of content lines) wrapping every event. +(define + ev/events->ical-lines + (fn + (events) + (let + ((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN"))) + (begin + (for-each + (fn + (ev) + (for-each (fn (l) (append! lines l)) (ev/event->ical-lines ev))) + events) + (append! lines "END:VCALENDAR") + lines)))) + +;; Render content lines to the on-the-wire iCalendar text (CRLF-separated). +(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n"))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 7df5fc32..a5a30acd 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,11 +1,12 @@ { "lang": "events", - "total_passed": 311, + "total_passed": 332, "total_failed": 0, - "total": 311, + "total": 332, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, + {"name":"ical","passed":21,"failed":0,"total":21}, {"name":"availability","passed":22,"failed":0,"total":22}, {"name":"api","passed":32,"failed":0,"total":32}, {"name":"booking","passed":82,"failed":0,"total":82}, @@ -16,5 +17,5 @@ {"name":"federation","passed":29,"failed":0,"total":29}, {"name":"integration","passed":8,"failed":0,"total":8} ], - "generated": "2026-06-07T13:59:09+00:00" + "generated": "2026-06-07T14:40:54+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index f7eb1a50..b393b5b6 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,11 +1,12 @@ # events scoreboard -**311 / 311 passing** (0 failure(s)). +**332 / 332 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | | timezone | 17 | 17 | ok | +| ical | 21 | 21 | ok | | availability | 22 | 22 | ok | | api | 32 | 32 | ok | | booking | 82 | 82 | ok | diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx new file mode 100644 index 00000000..905bfc1e --- /dev/null +++ b/lib/events/tests/ical.sx @@ -0,0 +1,192 @@ +;; lib/events/tests/ical.sx — iCalendar (RFC 5545) export. + +(define ev-ic-pass 0) +(define ev-ic-fail 0) +(define ev-ic-failures (list)) + +(define + ev-ic-check! + (fn + (name got expected) + (if + (= got expected) + (set! ev-ic-pass (+ ev-ic-pass 1)) + (do + (set! ev-ic-fail (+ ev-ic-fail 1)) + (append! + ev-ic-failures + (str name "\n expected: " expected "\n got: " got)))))) + +;; Find the value of a "KEY:value" line in a VEVENT line list (or nil). +(define + ev-ic-line + (fn + (lines key) + (cond + ((empty? lines) nil) + ((ev-ic-prefix? (first lines) (str key ":")) (first lines)) + (else (ev-ic-line (rest lines) key))))) + +(define + ev-ic-prefix? + (fn + (s p) + (and (>= (len s) (len p)) (= (substring s 0 (len p)) p)))) + +(define + ev-ic-run-all! + (fn + () + (do + (let + ((lines (ev/event->ical-lines (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1)))) + (do + (ev-ic-check! "VEVENT opens" (first lines) "BEGIN:VEVENT") + (ev-ic-check! "VEVENT closes" (ev-ic-line lines "END") "END:VEVENT") + (ev-ic-check! + "UID is the event id" + (ev-ic-line lines "UID") + "UID:one") + (ev-ic-check! + "DTSTART is a UTC basic-format stamp" + (ev-ic-line lines "DTSTART") + "DTSTART:20260610T140000Z") + (ev-ic-check! + "DURATION of 60m is PT1H" + (ev-ic-line lines "DURATION") + "DURATION:PT1H") + (ev-ic-check! + "a one-off event has no RRULE" + (ev-ic-line lines "RRULE") + nil))) + (ev-ic-check! + "30m duration is PT30M" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote e) + (ev-dt 2026 1 1 9 0) + 30 + nil + 1)) + "DURATION") + "DURATION:PT30M") + (ev-ic-check! + "90m duration is PT1H30M" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote e) + (ev-dt 2026 1 1 9 0) + 90 + nil + 1)) + "DURATION") + "DURATION:PT1H30M") + (let + ((lines (ev/event->ical-lines (ev-event-full (quote yoga) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :until (ev-dt 2026 6 30 23 0) :byday (list 0 2)} 20 (list (ev-dt 2026 6 8 18 0)) (list (ev-dt 2026 6 20 18 0)))))) + (do + (ev-ic-check! + "weekly RRULE serializes interval/until/byday in order" + (ev-ic-line lines "RRULE") + "RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T230000Z;BYDAY=MO,WE") + (ev-ic-check! + "EXDATE line" + (ev-ic-line lines "EXDATE") + "EXDATE:20260608T180000Z") + (ev-ic-check! + "RDATE line" + (ev-ic-line lines "RDATE") + "RDATE:20260620T180000Z"))) + (ev-ic-check! + "daily COUNT RRULE" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote d) + (ev-dt 2026 6 1 9 0) + 30 + {:freq :daily :count 5} + 1)) + "RRULE") + "RRULE:FREQ=DAILY;COUNT=5") + (ev-ic-check! + "monthly nth-weekday BYDAY (2nd Tuesday)" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote b) + (ev-dt 2026 1 13 9 0) + 60 + {:freq :monthly :byday (list {:ord 2 :wd 1})} + 5)) + "RRULE") + "RRULE:FREQ=MONTHLY;BYDAY=2TU") + (ev-ic-check! + "monthly last-Friday BYDAY" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote b) + (ev-dt 2026 1 30 9 0) + 60 + {:freq :monthly :byday (list {:ord -1 :wd 4})} + 5)) + "RRULE") + "RRULE:FREQ=MONTHLY;BYDAY=-1FR") + (ev-ic-check! + "monthly BYMONTHDAY (incl. negative)" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote b) + (ev-dt 2026 1 15 9 0) + 60 + {:bymonthday (list 15 -1) :freq :monthly} + 5)) + "RRULE") + "RRULE:FREQ=MONTHLY;BYMONTHDAY=15,-1") + (ev-ic-check! + "all seven weekday tokens map correctly" + (ev-ic-line + (ev/event->ical-lines + (ev-event + (quote w) + (ev-dt 2026 6 1 9 0) + 30 + {:freq :weekly :byday (list 0 1 2 3 4 5 6)} + 1)) + "RRULE") + "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU") + (let + ((cal (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 nil 1) (ev-event (quote b) (ev-dt 2026 6 2 9 0) 30 nil 1))))) + (do + (ev-ic-check! "VCALENDAR opens" (first cal) "BEGIN:VCALENDAR") + (ev-ic-check! + "VCALENDAR declares VERSION" + (ev-ic-line cal "VERSION") + "VERSION:2.0") + (ev-ic-check! + "two events -> two VEVENT blocks" + (len (filter (fn (l) (= l "BEGIN:VEVENT")) cal)) + 2) + (ev-ic-check! + "VCALENDAR has exactly one closing line" + (len (filter (fn (l) (= l "END:VCALENDAR")) cal)) + 1))) + (ev-ic-check! + "render joins lines with CRLF" + (ev/ical-render + (list "BEGIN:VCALENDAR" "VERSION:2.0" "END:VCALENDAR")) + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR")))) + +(define + ev-ical-tests-run! + (fn + () + (do + (set! ev-ic-pass 0) + (set! ev-ic-fail 0) + (set! ev-ic-failures (list)) + (ev-ic-run-all!) + {:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 0dcf2004..94a7da04 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **311/311** (Phases 1-4 + 10 ext: …timezones+DST, e2e delivery pipeline, cross-event conflict-checked booking) +`bash lib/events/conformance.sh` → **332/332** (Phases 1-4 + 11 ext: …e2e delivery, conflict-checked booking, iCalendar export) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — iCalendar (RFC 5545) export (extension). `ical.sx` serializes + events to VEVENT / VCALENDAR text for import by standard clients. UTC + basic-format stamps (YYYYMMDDTHHMM00Z), DURATION (PT#H#M), and the full RRULE + model (FREQ/INTERVAL/COUNT/UNTIL/BYDAY incl. monthly ordinals "2TU"/"-1FR"/ + BYMONTHDAY) plus EXDATE/RDATE. Line-oriented: `ev/event->ical-lines` / + `ev/events->ical-lines` return content lines; `ev/ical-render` joins with + CRLF (wire format). +21 tests, 332/332 green. - 2026-06-07 — Cross-event conflict-checked booking (extension). Capacity is per-event, but `ev/book-checked!` also prevents an attendee double-booking THEMSELVES across different events: it consults the actor's persist-derived From 94aaf0e43365f533f27eae49713919273c7a3f0a Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 15:20:27 +0000 Subject: [PATCH 2/9] events: whole-series booking + 9 tests ev/book-series! / ev/cancel-series! apply a booking/cancel to every occurrence of one event in a window (RSVP the whole weekly class), returning per- occurrence (occ-key status) results; capacity still enforced per occurrence (some :booked, some :full), idempotent re-book (:already). ev/series-count, ev/series-booked. 341/341 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/api.sx | 52 +++++++++++++++++++++++++++++++++ lib/events/scoreboard.json | 8 ++--- lib/events/scoreboard.md | 4 +-- lib/events/tests/api.sx | 60 ++++++++++++++++++++++++++++++++++++++ plans/events-on-sx.md | 9 +++++- 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/lib/events/api.sx b/lib/events/api.sx index aa5b2d03..3484f9f4 100644 --- a/lib/events/api.sx +++ b/lib/events/api.sx @@ -275,3 +275,55 @@ ((ev/would-time-conflict? b store actor occ) {:status :time-conflict :actor actor :occ-key (ev-occ-key occ)}) (else (ev/book-occ! b store actor occ))))) + +;; ---- whole-series operations ---- +;; Apply a booking action to every occurrence of one event in [ws, we) — e.g. +;; "RSVP to the whole weekly class". Returns a list of (occ-key status) results, +;; one per occurrence (empty if the event id is unknown). +(define + ev/book-series! + (fn + (b store actor event-id ws we) + (let + ((ev (ev/event-by-id store event-id))) + (if + (nil? ev) + (list) + (map + (fn (occ) (list (ev-occ-key occ) (get (ev/book-occ! b store actor occ) :status))) + (ev-expand ev ws we)))))) + +;; Cancel `actor` from every occurrence of one event in [ws, we). +(define + ev/cancel-series! + (fn + (b store actor event-id ws we) + (let + ((ev (ev/event-by-id store event-id))) + (if + (nil? ev) + (list) + (map + (fn (occ) (list (ev-occ-key occ) (get (ev/cancel! b (ev-occ-key occ) actor) :status))) + (ev-expand ev ws we)))))) + +;; How many statuses in a series-result list equal `status`. +(define + ev/series-count + (fn + (results status) + (len (filter (fn (r) (= (first (rest r)) status)) results)))) + +;; The occurrences of one event in [ws, we) that `actor` is booked into. +(define + ev/series-booked + (fn + (b store actor event-id ws we) + (let + ((ev (ev/event-by-id store event-id))) + (if + (nil? ev) + (list) + (filter + (fn (occ) (ev-actor-booked? b (ev-occ-key occ) actor)) + (ev-expand ev ws we)))))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index a5a30acd..a2395334 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,14 +1,14 @@ { "lang": "events", - "total_passed": 332, + "total_passed": 341, "total_failed": 0, - "total": 332, + "total": 341, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, {"name":"ical","passed":21,"failed":0,"total":21}, {"name":"availability","passed":22,"failed":0,"total":22}, - {"name":"api","passed":32,"failed":0,"total":32}, + {"name":"api","passed":41,"failed":0,"total":41}, {"name":"booking","passed":82,"failed":0,"total":82}, {"name":"booking-notify","passed":11,"failed":0,"total":11}, {"name":"ticket","passed":31,"failed":0,"total":31}, @@ -17,5 +17,5 @@ {"name":"federation","passed":29,"failed":0,"total":29}, {"name":"integration","passed":8,"failed":0,"total":8} ], - "generated": "2026-06-07T14:40:54+00:00" + "generated": "2026-06-07T15:20:08+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index b393b5b6..3a4b4e0d 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,6 +1,6 @@ # events scoreboard -**332 / 332 passing** (0 failure(s)). +**341 / 341 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -8,7 +8,7 @@ | timezone | 17 | 17 | ok | | ical | 21 | 21 | ok | | availability | 22 | 22 | ok | -| api | 32 | 32 | ok | +| api | 41 | 41 | ok | | booking | 82 | 82 | ok | | booking-notify | 11 | 11 | ok | | ticket | 31 | 31 | ok | diff --git a/lib/events/tests/api.sx b/lib/events/tests/api.sx index fb681f8f..b8ccf25a 100644 --- a/lib/events/tests/api.sx +++ b/lib/events/tests/api.sx @@ -319,6 +319,65 @@ (ev/would-time-conflict? b store (quote zed) ob) false)))))) +;; ---- whole-series booking ---- +(define + ev-api-sr-run-all! + (fn + () + (let + ((b (persist/open)) + (store + (ev/schedule + (ev/empty) + (quote yoga) + (ev-dt 2026 6 1 18 0) + 60 + {:freq :weekly :byday (list 0 2) :count 4} + 20)) + (ws (ev-date 2026 6 1)) + (we (ev-date 2026 7 1))) + (do + (let + ((res (ev/book-series! b store (quote nia) (quote yoga) ws we))) + (do + (ev-api-check! "series booking covers all four occurrences" (len res) 4) + (ev-api-check! "all occurrences booked" (ev/series-count res :booked) 4) + (ev-api-check! + "actor is now booked into the whole series" + (len (ev/series-booked b store (quote nia) (quote yoga) ws we)) + 4))) + ;; re-booking the series is idempotent + (ev-api-check! + "re-booking the series is idempotent" + (ev/series-count (ev/book-series! b store (quote nia) (quote yoga) ws we) :already) + 4) + ;; cancel the whole series + (let + ((res (ev/cancel-series! b store (quote nia) (quote yoga) ws we))) + (do + (ev-api-check! "series cancel reports four cancellations" (ev/series-count res :cancelled) 4) + (ev-api-check! + "actor booked into nothing after series cancel" + (len (ev/series-booked b store (quote nia) (quote yoga) ws we)) + 0))) + ;; capacity interacts per-occurrence: fill one occurrence first + (let + ((b2 (persist/open)) + (s2 + (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1))) + (do + (ev/book-occ! b2 s2 (quote x) (ev-occ (quote clinic) (ev-dt 2026 6 2 9 0) 30)) + (let + ((res (ev/book-series! b2 s2 (quote nia) (quote clinic) (ev-date 2026 6 1) (ev-date 2026 6 10)))) + (do + (ev-api-check! "series booking succeeds on free occurrences" (ev/series-count res :booked) 2) + (ev-api-check! "series booking hits :full where capacity is taken" (ev/series-count res :full) 1))))) + ;; unknown event id + (ev-api-check! + "series booking an unknown event yields no results" + (ev/book-series! b store (quote nia) (quote nope) ws we) + (list)))))) + (define ev-api-tests-run! (fn @@ -329,4 +388,5 @@ (set! ev-api-failures (list)) (ev-api-run-all!) (ev-api-cf-run-all!) + (ev-api-sr-run-all!) {:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 94a7da04..d7943de6 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **332/332** (Phases 1-4 + 11 ext: …e2e delivery, conflict-checked booking, iCalendar export) +`bash lib/events/conformance.sh` → **341/341** (Phases 1-4 + 12 ext: …conflict-checked booking, iCalendar export, whole-series booking) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Whole-series booking (extension). `ev/book-series!` / + `ev/cancel-series!` apply a booking/cancel to every occurrence of one event + in a window (e.g. RSVP the whole weekly class), returning per-occurrence + (occ-key status) results; capacity is still enforced per occurrence (some + :booked, some :full). Idempotent re-book (all :already). `ev/series-count` + (tally a status), `ev/series-booked` (which occurrences the actor holds). + +9 tests, 341/341 green. This was the last flagged feature — surface saturated. - 2026-06-07 — iCalendar (RFC 5545) export (extension). `ical.sx` serializes events to VEVENT / VCALENDAR text for import by standard clients. UTC basic-format stamps (YYYYMMDDTHHMM00Z), DURATION (PT#H#M), and the full RRULE From 3913bc368ce3609c1333456809775af7cf869ab7 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 17:28:26 +0000 Subject: [PATCH 3/9] events: iCalendar import + occurrence-exact round-trip + 19 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ical.sx parses VEVENT/VCALENDAR text back into events (ev/ical-lines->event, ev/parse-vcalendar): DTSTART/DURATION/RRULE (ordinal BYDAY, BYMONTHDAY, UNTIL/ COUNT/INTERVAL) + EXDATE/RDATE. Round-trip is occurrence-exact — export->import expands to the identical occurrence set. Completes bidirectional interop. 360/360 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/ical.sx | 150 +++++++++++++++++++++++++++++++++++++ lib/events/scoreboard.json | 8 +- lib/events/scoreboard.md | 4 +- lib/events/tests/ical.sx | 87 +++++++++++++++++++++ plans/events-on-sx.md | 9 ++- 5 files changed, 251 insertions(+), 7 deletions(-) diff --git a/lib/events/ical.sx b/lib/events/ical.sx index ae003b7c..2844b226 100644 --- a/lib/events/ical.sx +++ b/lib/events/ical.sx @@ -189,3 +189,153 @@ ;; Render content lines to the on-the-wire iCalendar text (CRLF-separated). (define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n"))) + +;; ---- import (parse VEVENT/VCALENDAR back into events) ---- +;; Inverse of the export above: parse iCalendar content lines into event dicts +;; (ev-event-full shape). Capacity is not an iCal property, so imported events +;; default to capacity 0 — set it after import if needed. + +;; "20260601T180000Z" -> UTC epoch-minutes. +(define + ev-ical-parse-dt + (fn + (s) + (ev-dt + (string->number (substring s 0 4)) + (string->number (substring s 4 6)) + (string->number (substring s 6 8)) + (string->number (substring s 9 11)) + (string->number (substring s 11 13))))) + +;; "30M" / "" -> minutes. +(define + ev-ical-parse-min + (fn + (s) + (if (= (string-length s) 0) 0 (string->number (first (split s "M")))))) + +;; "PT1H30M" / "PT1H" / "PT30M" -> minutes. +(define + ev-ical-parse-duration + (fn + (s) + (let + ((body (substring s 2 (string-length s)))) + (let + ((hparts (split body "H"))) + (if + (> (len hparts) 1) + (+ (* 60 (string->number (first hparts))) (ev-ical-parse-min (first (rest hparts)))) + (ev-ical-parse-min body)))))) + +(define + ev-ical-wd->num + (fn + (tok) + (cond + ((= tok "MO") 0) + ((= tok "TU") 1) + ((= tok "WE") 2) + ((= tok "TH") 3) + ((= tok "FR") 4) + ((= tok "SA") 5) + ((= tok "SU") 6) + (else 0)))) + +;; "MO" -> 0 ; "2TU" -> {:ord 2 :wd 1} ; "-1FR" -> {:ord -1 :wd 4} +(define + ev-ical-parse-byday-token + (fn + (tok) + (let + ((n (string-length tok))) + (if + (= n 2) + (ev-ical-wd->num tok) + {:ord (string->number (substring tok 0 (- n 2))) + :wd (ev-ical-wd->num (substring tok (- n 2) n))})))) + +(define + ev-ical-parse-freq + (fn + (v) + (cond + ((= v "DAILY") :daily) + ((= v "WEEKLY") :weekly) + ((= v "MONTHLY") :monthly) + (else :daily)))) + +;; "FREQ=WEEKLY;INTERVAL=2;UNTIL=...;BYDAY=MO,WE" -> rrule dict. +(define + ev-ical-parse-rrule + (fn + (val) + (let + ((rr {})) + (begin + (for-each + (fn + (p) + (let + ((kv (split p "="))) + (let + ((k (first kv)) (v (first (rest kv)))) + (cond + ((= k "FREQ") (dict-set! rr :freq (ev-ical-parse-freq v))) + ((= k "INTERVAL") (dict-set! rr :interval (string->number v))) + ((= k "COUNT") (dict-set! rr :count (string->number v))) + ((= k "UNTIL") (dict-set! rr :until (ev-ical-parse-dt v))) + ((= k "BYDAY") (dict-set! rr :byday (map ev-ical-parse-byday-token (split v ",")))) + ((= k "BYMONTHDAY") (dict-set! rr :bymonthday (map string->number (split v ",")))) + (else nil))))) + (split val ";")) + rr)))) + +;; Parse a VEVENT's content lines into an event dict. +(define + ev/ical-lines->event + (fn + (lines) + (let + ((ev {:capacity 0 :rrule nil}) (exd (list)) (rd (list))) + (begin + (for-each + (fn + (line) + (let + ((kv (split line ":"))) + (when + (> (len kv) 1) + (let + ((k (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))) + ((= k "DURATION") (dict-set! ev :duration (ev-ical-parse-duration v))) + ((= k "RRULE") (dict-set! ev :rrule (ev-ical-parse-rrule v))) + ((= k "EXDATE") (set! exd (map ev-ical-parse-dt (split v ",")))) + ((= k "RDATE") (set! rd (map ev-ical-parse-dt (split v ",")))) + (else nil)))))) + lines) + (dict-set! ev :exdate exd) + (dict-set! ev :rdate rd) + ev)))) + +;; Split a VCALENDAR line list into per-VEVENT line groups. +(define + ev-ical-group-vevents + (fn + (lines cur in acc) + (cond + ((empty? lines) acc) + ((= (first lines) "BEGIN:VEVENT") (ev-ical-group-vevents (rest lines) (list) true acc)) + ((= (first lines) "END:VEVENT") (ev-ical-group-vevents (rest lines) (list) false (append acc (list cur)))) + (in (ev-ical-group-vevents (rest lines) (append cur (list (first lines))) true acc)) + (else (ev-ical-group-vevents (rest lines) cur false acc))))) + +;; Parse a VCALENDAR line list into a list of events. +(define + ev/parse-vcalendar + (fn + (lines) + (map ev/ical-lines->event (ev-ical-group-vevents lines (list) false (list))))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index a2395334..8288b6b3 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,12 +1,12 @@ { "lang": "events", - "total_passed": 341, + "total_passed": 360, "total_failed": 0, - "total": 341, + "total": 360, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, - {"name":"ical","passed":21,"failed":0,"total":21}, + {"name":"ical","passed":40,"failed":0,"total":40}, {"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-07T15:20:08+00:00" + "generated": "2026-06-07T17:28:07+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 3a4b4e0d..ee6c2281 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,12 +1,12 @@ # events scoreboard -**341 / 341 passing** (0 failure(s)). +**360 / 360 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | | timezone | 17 | 17 | ok | -| ical | 21 | 21 | ok | +| ical | 40 | 40 | ok | | availability | 22 | 22 | ok | | api | 41 | 41 | ok | | booking | 82 | 82 | ok | diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx index 905bfc1e..7db95d37 100644 --- a/lib/events/tests/ical.sx +++ b/lib/events/tests/ical.sx @@ -180,6 +180,92 @@ (list "BEGIN:VCALENDAR" "VERSION:2.0" "END:VCALENDAR")) "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR")))) +;; ---- import + round-trip ---- + +;; The occurrence starts an event expands to over a fixed window. +(define + ev-ic-starts + (fn + (ev) + (map (fn (o) (get o :start)) (ev-expand ev (ev-date 2026 1 1) (ev-date 2027 1 1))))) + +;; Round-trip an event through export then import; true if both expand alike. +(define + ev-ic-roundtrips? + (fn + (ev) + (= (ev-ic-starts ev) (ev-ic-starts (ev/ical-lines->event (ev/event->ical-lines ev)))))) + +(define + ev-ic-rt-run-all! + (fn + () + (do + ;; ---- field parsers ---- + (ev-ic-check! "parse DTSTART" (ev-ical-parse-dt "20260601T180000Z") (ev-dt 2026 6 1 18 0)) + (ev-ic-check! "parse DURATION PT1H30M" (ev-ical-parse-duration "PT1H30M") 90) + (ev-ic-check! "parse DURATION PT1H" (ev-ical-parse-duration "PT1H") 60) + (ev-ic-check! "parse DURATION PT30M" (ev-ical-parse-duration "PT30M") 30) + (ev-ic-check! "parse plain BYDAY token" (ev-ical-parse-byday-token "MO") 0) + (ev-ic-check! "parse ordinal BYDAY token" (ev-ical-parse-byday-token "2TU") {:ord 2 :wd 1}) + (ev-ic-check! "parse last-weekday BYDAY token" (ev-ical-parse-byday-token "-1FR") {:ord -1 :wd 4}) + + ;; ---- imported event basic fields ---- + (let + ((ev (ev/ical-lines->event (ev/event->ical-lines (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 90 nil 1))))) + (do + (ev-ic-check! "imported id is a symbol" (get ev :id) (quote yoga)) + (ev-ic-check! "imported dtstart" (get ev :dtstart) (ev-dt 2026 6 1 18 0)) + (ev-ic-check! "imported duration" (get ev :duration) 90))) + + ;; ---- round-trips preserve the occurrence set ---- + (ev-ic-check! + "round-trip: one-off event" + (ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 10 14 0) 60 nil 1)) + true) + (ev-ic-check! + "round-trip: daily COUNT" + (ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1)) + true) + (ev-ic-check! + "round-trip: weekly interval/until/byday + exdate + rdate" + (ev-ic-roundtrips? + (ev-event-full + (quote a) + (ev-dt 2026 6 1 18 0) + 90 + {:freq :weekly :interval 2 :byday (list 0 2) :until (ev-dt 2026 6 30 23 0)} + 20 + (list (ev-dt 2026 6 8 18 0)) + (list (ev-dt 2026 6 20 18 0)))) + true) + (ev-ic-check! + "round-trip: monthly nth-weekday" + (ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 13 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1)) + true) + (ev-ic-check! + "round-trip: monthly bymonthday" + (ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 15 9 0) 60 {:freq :monthly :bymonthday (list 15 -1)} 1)) + true) + + ;; ---- parse a VCALENDAR with several events ---- + (let + ((cal + (ev/events->ical-lines + (list + (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1) + (ev-event (quote b) (ev-dt 2026 6 2 10 0) 60 nil 1))))) + (let + ((events (ev/parse-vcalendar cal))) + (do + (ev-ic-check! "VCALENDAR parses both events" (len events) 2) + (ev-ic-check! "first event id" (get (first events) :id) (quote a)) + (ev-ic-check! "second event id" (get (first (rest events)) :id) (quote b)) + (ev-ic-check! + "parsed events expand correctly" + (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))))))))) + (define ev-ical-tests-run! (fn @@ -189,4 +275,5 @@ (set! ev-ic-fail 0) (set! ev-ic-failures (list)) (ev-ic-run-all!) + (ev-ic-rt-run-all!) {:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index d7943de6..6a4db541 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **341/341** (Phases 1-4 + 12 ext: …conflict-checked booking, iCalendar export, whole-series booking) +`bash lib/events/conformance.sh` → **360/360** (Phases 1-4 + 13 ext: …iCalendar export+import round-trip, whole-series booking) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 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, + UNTIL/COUNT/INTERVAL) and EXDATE/RDATE. Round-trip is occurrence-exact — + export→import expands to the identical occurrence set (tested across one-off / + daily-count / weekly+exdate+rdate / monthly-ordinal / bymonthday). Completes + bidirectional interop. +19 tests, 360/360 green. - 2026-06-07 — Whole-series booking (extension). `ev/book-series!` / `ev/cancel-series!` apply a booking/cancel to every occurrence of one event in a window (e.g. RSVP the whole weekly class), returning per-occurrence From 34c9b211ac52e255f1f60ed7e2d90be39e43b5c8 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 18:34:20 +0000 Subject: [PATCH 4/9] events: fix timezone-aware iCal export (local->UTC stamps) + 6 tests 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) --- lib/events/ical.sx | 30 +++++++++++++++++++------- lib/events/scoreboard.json | 8 +++---- lib/events/scoreboard.md | 4 ++-- lib/events/tests/ical.sx | 43 ++++++++++++++++++++++++++++++++++++++ plans/events-on-sx.md | 9 +++++++- 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/lib/events/ical.sx b/lib/events/ical.sx index 2844b226..437fdc6d 100644 --- a/lib/events/ical.sx +++ b/lib/events/ical.sx @@ -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)))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 8288b6b3..34607098 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -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" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index ee6c2281..151f581e 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -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 | diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx index 7db95d37..f0c693a2 100644 --- a/lib/events/tests/ical.sx +++ b/lib/events/tests/ical.sx @@ -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}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 6a4db541..cd4eaa7e 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -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, From 826d926740d160954f240aa477ed16c08914ddf1 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 20:03:07 +0000 Subject: [PATCH 5/9] =?UTF-8?q?events:=20VTIMEZONE=20iCal=20export=20?= =?UTF-8?q?=E2=80=94=20full=20DST-correct=20tz=20recurrence=20+=2016=20tes?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tz event now exports DTSTART;TZID=: (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) --- lib/events/ical.sx | 211 +++++++++++++++++++++++++++++-------- lib/events/scoreboard.json | 8 +- lib/events/scoreboard.md | 4 +- lib/events/tests/ical.sx | 113 +++++++++++++++----- plans/events-on-sx.md | 11 +- 5 files changed, 273 insertions(+), 74 deletions(-) diff --git a/lib/events/ical.sx b/lib/events/ical.sx index 437fdc6d..db7bc441 100644 --- a/lib/events/ical.sx +++ b/lib/events/ical.sx @@ -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=: 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=;BYDAY=". +(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=: (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))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 34607098..7a46ffe2 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,12 +1,12 @@ { "lang": "events", - "total_passed": 366, + "total_passed": 376, "total_failed": 0, - "total": 366, + "total": 376, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, - {"name":"ical","passed":46,"failed":0,"total":46}, + {"name":"ical","passed":56,"failed":0,"total":56}, {"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-07T18:33:58+00:00" + "generated": "2026-06-07T20:02:48+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 151f581e..f00748eb 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,12 +1,12 @@ # events scoreboard -**366 / 366 passing** (0 failure(s)). +**376 / 376 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | | timezone | 17 | 17 | ok | -| ical | 46 | 46 | ok | +| ical | 56 | 56 | ok | | availability | 22 | 22 | ok | | api | 41 | 41 | ok | | booking | 82 | 82 | ok | diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx index f0c693a2..e12e3135 100644 --- a/lib/events/tests/ical.sx +++ b/lib/events/tests/ical.sx @@ -266,47 +266,112 @@ (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) ---- +;; ---- timezone-aware export (TZID + VTIMEZONE) ---- (define - ev-ic-dtstart - (fn (ev) (ev-ic-line (ev/event->ical-lines ev) "DTSTART"))) + ev-ic-find + (fn + (lines pfx) + (cond + ((empty? lines) nil) + ((ev-ic-prefix? (first lines) pfx) (first lines)) + (else (ev-ic-find (rest lines) pfx))))) + +(define ev-ic-count (fn (lines x) (len (filter (fn (l) (= l x)) lines)))) + +(define + ev-ic-index + (fn + (lines x) + (cond + ((empty? lines) -1) + ((= (first lines) x) 0) + (else + (let ((r (ev-ic-index (rest lines) x))) (if (< r 0) -1 (+ 1 r))))))) (define ev-ic-tz-run-all! (fn () (do + ;; a tz event's DTSTART is local wall-clock with a TZID parameter (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") + "tz event DTSTART uses TZID + local wall-clock (not UTC)" + (ev-ic-find (ev/event->ical-lines (ev-event-tz (quote w) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)) "DTSTART") + "DTSTART;TZID=Europe/London:20260715T180000") (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)) + "a non-tz event still uses a UTC Z stamp" + (ev-ic-find (ev/event->ical-lines (ev-event (quote n) (ev-dt 2026 7 15 18 0) 60 nil 1)) "DTSTART") "DTSTART:20260715T180000Z") - ;; EXDATE on a tz event is also converted to UTC + ;; UNTIL stays UTC even for a TZID event (RFC 5545) (ev-ic-check! - "tz event EXDATE is converted to UTC" - (ev-ic-line + "tz event RRULE UNTIL is still UTC" + (ev-ic-find + (ev/event->ical-lines + (ev-event-tz (quote s) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :byday (list 0) :until (ev-dt 2026 6 30 23 0)} 1 ev-tz-london)) + "RRULE") + "RRULE:FREQ=WEEKLY;UNTIL=20260630T220000Z;BYDAY=MO") + ;; EXDATE matches the DTSTART form (TZID + local) + (ev-ic-check! + "tz event EXDATE uses TZID + local" + (ev-ic-find (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")))) + "EXDATE;TZID=Europe/London:20260702T180000") + + ;; ---- VTIMEZONE block ---- + (let + ((vtz (ev-ical-vtimezone ev-tz-london))) + (do + (ev-ic-check! "VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Europe/London") + (ev-ic-check! "DAYLIGHT transitions GMT->BST" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100") + (ev-ic-check! "DAYLIGHT rule is last Sunday of March" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=3") "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU") + (ev-ic-check! "STANDARD rule is last Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU"))) + (let + ((vtz (ev-ical-vtimezone ev-tz-paris))) + (do + (ev-ic-check! "Paris DAYLIGHT goes to +0200 (CEST)" (ev-ic-find vtz "TZOFFSETTO:+0200") "TZOFFSETTO:+0200") + (ev-ic-check! "Paris STANDARD goes to +0100 (CET)" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100"))) + + ;; ---- VCALENDAR carries one VTIMEZONE per distinct zone ---- + (let + ((cal (ev/events->ical-lines (list (ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london))))) + (do + (ev-ic-check! "VCALENDAR includes the referenced VTIMEZONE" (ev-ic-count cal "BEGIN:VTIMEZONE") 1) + (ev-ic-check! "VTIMEZONE precedes the VEVENT" (< (ev-ic-index cal "BEGIN:VTIMEZONE") (ev-ic-index cal "BEGIN:VEVENT")) true))) + (ev-ic-check! + "two events in the same zone share one VTIMEZONE" + (ev-ic-count + (ev/events->ical-lines + (list + (ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london) + (ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-london))) + "BEGIN:VTIMEZONE") + 1) + (ev-ic-check! + "events in two zones get two VTIMEZONEs" + (ev-ic-count + (ev/events->ical-lines + (list + (ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london) + (ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-paris))) + "BEGIN:VTIMEZONE") + 2) + (ev-ic-check! + "a non-tz-only calendar has no VTIMEZONE" + (ev-ic-count (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1))) "BEGIN:VTIMEZONE") + 0) + + ;; ---- import tolerates the TZID parameter ---- + (ev-ic-check! + "import parses DTSTART;TZID local time" + (get + (ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london))) + :dtstart) + (ev-dt 2026 7 15 18 0))))) (define ev-ical-tests-run! diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index cd4eaa7e..f06b2cf8 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **366/366** (Phases 1-4 + 13 ext + tz-aware iCal export fix) +`bash lib/events/conformance.sh` → **376/376** (Phases 1-4 + 13 ext + tz iCal export via TZID + VTIMEZONE) ## Ground rules @@ -88,6 +88,15 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — VTIMEZONE iCal export (supersedes the UTC-Z tz fix — full DST + fidelity). A tz event now exports DTSTART;TZID=: (+ EXDATE/RDATE + in the same TZID-local form; 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) — the + London/Paris blocks match real-world definitions exactly. So a client recurs + the event at a fixed WALL-CLOCK time, DST-correct (the prior caveat is gone). + `ev-ical-vtimezone`, `ev-ical-offset`, distinct-zone collection; importer now + tolerates the ;TZID= parameter. +16 tests (ical 56), 376/376 green. - 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 From 78b45a331e43fe9bb1f9059546a9af0ab14141e5 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 10 Jun 2026 20:59:59 +0000 Subject: [PATCH 6/9] =?UTF-8?q?events:=20southern-hemisphere=20DST=20(+8)?= =?UTF-8?q?=20=E2=80=94=20384/384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The :dst zone model assumed northern ordering (dst-start < dst-end, DST = [start, end)). Southern zones — DST begins ~Oct and ends ~Apr — have dst-start > dst-end, so the old (>= start AND < end) test was never true and ev-tz-offset returned the standard offset year-round. Fix: detect the ordering. start < end → DST is [start, end); start > end → DST wraps the calendar-year boundary, active when (utc >= start OR utc < end). Add predefined ev-tz-sydney (AEST +600 / AEDT +660; transitions 02:00 AEST first-Sun-Oct and 03:00 AEDT first-Sun-Apr, both 16:00 UTC the preceding Saturday → rule time -480). VTIMEZONE export is already rule-agnostic, so southern zones round-trip through iCal unchanged (the -480 folds the from-offset back to the correct local 02:00/03:00 DTSTART). +8 timezone tests (now 25): summer/winter offsets, both transition dates, local->utc in both seasons, and a daily expansion crossing the autumn DST-end that shifts in UTC (1320,1320,1380,1380,1380) while staying 09:00 local. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/tests/timezone.sx | 52 ++++++++++++++++++++++++++++++++++++ lib/events/timezone.sx | 36 +++++++++++++++++++++---- plans/events-on-sx.md | 15 ++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/lib/events/tests/timezone.sx b/lib/events/tests/timezone.sx index 265bd6e7..79b3f126 100644 --- a/lib/events/tests/timezone.sx +++ b/lib/events/tests/timezone.sx @@ -76,6 +76,58 @@ ev-tz-paris (ev-dt 2026 7 15 12 0)) 120) + ;; ---- southern hemisphere (reversed seasons) ---- + (ev-tz-check! + "Sydney January offset is 660 (AEDT, summer DST)" + (ev-tz-offset + ev-tz-sydney + (ev-dt 2026 1 15 12 0)) + 660) + (ev-tz-check! + "Sydney July offset is 600 (AEST, winter std)" + (ev-tz-offset + ev-tz-sydney + (ev-dt 2026 7 15 12 0)) + 600) + (ev-tz-check! + "Sydney DST starts first Sunday of October" + (ev-dt->civil + (+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-start)) 480)) + (list 2026 10 4)) + (ev-tz-check! + "Sydney DST ends first Sunday of April" + (ev-dt->civil + (+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-end)) 480)) + (list 2026 4 5)) + (ev-tz-check! + "09:00 Sydney in summer (AEDT) is previous-day 22:00 UTC" + (ev-tz-local->utc + ev-tz-sydney + (ev-dt 2026 1 15 9 0)) + (ev-dt 2026 1 14 22 0)) + (ev-tz-check! + "09:00 Sydney in winter (AEST) is previous-day 23:00 UTC" + (ev-tz-local->utc + ev-tz-sydney + (ev-dt 2026 7 15 9 0)) + (ev-dt 2026 7 14 23 0)) + (let + ((au (ev-event-tz (quote au) (ev-dt 2026 4 3 9 0) 60 {:freq :daily :count 5} 8 ev-tz-sydney))) + (let + ((occs (ev-expand au (ev-date 2026 3 25) (ev-date 2026 4 12)))) + (do + (ev-tz-check! + "Sydney daily occurrences shift in UTC across the autumn DST end" + (map (fn (o) (ev-dt-tod (get o :start))) occs) + (list 1320 1320 1380 1380 1380)) + (ev-tz-check! + "but every Sydney occurrence stays 09:00 local wall-clock" + (map + (fn + (o) + (first (rest (ev-tz-local-of ev-tz-sydney (get o :start))))) + occs) + (list 540 540 540 540 540))))) (ev-tz-check! "DST starts last Sunday of March" (ev-dt->civil diff --git a/lib/events/timezone.sx b/lib/events/timezone.sx index 6ef9a2a0..a8ef7ecc 100644 --- a/lib/events/timezone.sx +++ b/lib/events/timezone.sx @@ -13,8 +13,11 @@ ;; :fixed — a constant offset. ;; :dst — std/dst offsets + two transition rules. Transitions are given in ;; UTC (EU zones all switch at 01:00 UTC), so the offset at any UTC -;; instant is a direct range check; no recursion. Northern-hemisphere -;; ordering (dst-start < dst-end within a year) is assumed. +;; instant is a direct range check; no recursion. Both hemispheres +;; are supported: northern zones have dst-start < dst-end (DST is the +;; interval [start, end)); southern zones have dst-start > dst-end +;; (DST wraps the year boundary), detected by comparing the two +;; transitions — see ev-tz-offset. ;; ;; Requires calendar.sx (ev-dt, ev-days-from-civil, ev-civil-from-days, ;; ev-civ-y, ev-floor-div, ev-resolve-nth-weekday). @@ -58,10 +61,20 @@ (let ((start (ev-tz-transition year (get tz :dst-start))) (end (ev-tz-transition year (get tz :dst-end)))) + ;; Northern hemisphere: dst-start < dst-end, DST is the closed-open + ;; interval [start, end). Southern hemisphere: dst-start > dst-end + ;; (DST begins in spring ~Oct and ends ~Apr), so within a calendar + ;; year DST wraps the boundary — active OUTSIDE [end, start). (if - (and (>= utc-dt start) (< utc-dt end)) - (get tz :dst-offset) - (get tz :std-offset))))) + (< start end) + (if + (and (>= utc-dt start) (< utc-dt end)) + (get tz :dst-offset) + (get tz :std-offset)) + (if + (or (>= utc-dt start) (< utc-dt end)) + (get tz :dst-offset) + (get tz :std-offset)))))) (else 0)))) ;; UTC instant -> local wall-clock. @@ -98,6 +111,19 @@ 120 (ev-tz-rule 3 -1 6 60) (ev-tz-rule 10 -1 6 60))) +;; Southern hemisphere: AEST +600 (std, winter), AEDT +660 (dst, summer). DST +;; begins 02:00 AEST first Sunday October and ends 03:00 AEDT first Sunday April +;; — both 16:00 UTC the preceding Saturday, i.e. -480 minutes from the Sunday in +;; the rule (the model adds rule :time to the resolved weekday's UTC midnight). +;; dst-start (Oct) > dst-end (Apr), so ev-tz-offset takes the wrap-the-year path. +(define + ev-tz-sydney + (ev-tz-dst + "Australia/Sydney" + 600 + 660 + (ev-tz-rule 10 1 6 -480) + (ev-tz-rule 4 1 6 -480))) ;; ---- tz-aware event expansion ---- diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index f06b2cf8..d0a695e8 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **376/376** (Phases 1-4 + 13 ext + tz iCal export via TZID + VTIMEZONE) +`bash lib/events/conformance.sh` → **384/384** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST) ## Ground rules @@ -88,6 +88,19 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-10 — Southern-hemisphere DST. The `:dst` zone model assumed northern + ordering (dst-start < dst-end, DST = [start, end)); southern zones (DST begins + ~Oct, ends ~Apr) have dst-start > dst-end and so silently never entered DST — + `ev-tz-offset` returned std year-round. Fixed by detecting the ordering: when + start < end DST is the interval [start, end); when start > end DST wraps the + year boundary (active when `utc ≥ start OR utc < end`). Added predefined + `ev-tz-sydney` (AEST +600 / AEDT +660; transitions 02:00 AEST first-Sun-Oct + and 03:00 AEDT first-Sun-Apr, both 16:00 UTC the prior Saturday → rule time + −480). VTIMEZONE export already rule-agnostic, so southern zones round-trip + too (the −480 folds the from-offset back to the correct local 02:00/03:00). + +8 tests (timezone 25): summer/winter offsets, both transition dates, + local→utc both seasons, and a daily expansion crossing the autumn DST-end that + shifts in UTC (1320·1320·1380·1380·1380) yet stays 09:00 local. 384/384 green. - 2026-06-07 — VTIMEZONE iCal export (supersedes the UTC-Z tz fix — full DST fidelity). A tz event now exports DTSTART;TZID=: (+ EXDATE/RDATE in the same TZID-local form; UNTIL stays UTC per RFC), and the VCALENDAR emits From 6716af69dcebf0d807af10e53b2afbedad805a6a Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 10 Jun 2026 22:04:04 +0000 Subject: [PATCH 7/9] =?UTF-8?q?events:=20iCal=20coverage=20for=20southern-?= =?UTF-8?q?hemisphere=20VTIMEZONE=20(+7)=20=E2=80=94=20391/391?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit asserted southern zones round-trip through iCal unchanged but verified it only by reasoning. Close that gap with explicit tests: - A Sydney VTIMEZONE export block: TZID:Australia/Sydney, DAYLIGHT->+1100 (AEDT) / STANDARD->+1000 (AEST), first-Sunday rules (BYMONTH=10/4 BYDAY=1SU), and DAYLIGHT DTSTART:19701004T020000 — confirming the -480 rule time folds the from-offset back to the correct local 02:00 AEST transition. - A southern-zone DTSTART;TZID export -> import round-trip preserving :dtstart. +7 ical tests (now 63). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/tests/ical.sx | 19 ++++++++++++++++++- plans/events-on-sx.md | 9 ++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx index e12e3135..9f96554c 100644 --- a/lib/events/tests/ical.sx +++ b/lib/events/tests/ical.sx @@ -335,6 +335,17 @@ (do (ev-ic-check! "Paris DAYLIGHT goes to +0200 (CEST)" (ev-ic-find vtz "TZOFFSETTO:+0200") "TZOFFSETTO:+0200") (ev-ic-check! "Paris STANDARD goes to +0100 (CET)" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100"))) + ;; southern hemisphere exports a valid VTIMEZONE too: reversed offsets, + ;; first-Sunday rules, and the -480 rule time folds back to local 02:00/03:00 + (let + ((vtz (ev-ical-vtimezone ev-tz-sydney))) + (do + (ev-ic-check! "Sydney VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Australia/Sydney") + (ev-ic-check! "Sydney DAYLIGHT goes to +1100 (AEDT)" (ev-ic-find vtz "TZOFFSETTO:+1100") "TZOFFSETTO:+1100") + (ev-ic-check! "Sydney STANDARD goes to +1000 (AEST)" (ev-ic-find vtz "TZOFFSETTO:+1000") "TZOFFSETTO:+1000") + (ev-ic-check! "Sydney DAYLIGHT rule is first Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU") + (ev-ic-check! "Sydney STANDARD rule is first Sunday of April" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=4") "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU") + (ev-ic-check! "Sydney DAYLIGHT begins 02:00 local (AEST std, -480 folded)" (ev-ic-find vtz "DTSTART") "DTSTART:19701004T020000"))) ;; ---- VCALENDAR carries one VTIMEZONE per distinct zone ---- (let @@ -371,7 +382,13 @@ (get (ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london))) :dtstart) - (ev-dt 2026 7 15 18 0))))) + (ev-dt 2026 7 15 18 0)) + (ev-ic-check! + "import parses a southern-zone DTSTART;TZID local time" + (get + (ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 1 15 18 0) 60 nil 1 ev-tz-sydney))) + :dtstart) + (ev-dt 2026 1 15 18 0))))) (define ev-ical-tests-run! diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index d0a695e8..e4397425 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **384/384** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST) +`bash lib/events/conformance.sh` → **391/391** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST incl. iCal round-trip) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-10 — Harden southern-hemisphere DST: explicit iCal coverage for the + previous commit's unverified claim that "southern zones round-trip through + iCal unchanged". Added a Sydney VTIMEZONE export block (TZID:Australia/Sydney, + DAYLIGHT→+1100/STANDARD→+1000, first-Sunday rules BYMONTH=10/4 BYDAY=1SU, and + DAYLIGHT DTSTART:19701004T020000 — proving the −480 rule time folds back to + local 02:00 AEST) and a southern-zone DTSTART;TZID export→import round-trip. + +7 tests (ical 63). 391/391 green. - 2026-06-10 — Southern-hemisphere DST. The `:dst` zone model assumed northern ordering (dst-start < dst-end, DST = [start, end)); southern zones (DST begins ~Oct, ends ~Apr) have dst-start > dst-end and so silently never entered DST — From b25781392672e4e64c37a81f0ff7c70d2971f4dc Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 10 Jun 2026 23:05:35 +0000 Subject: [PATCH 8/9] events: sync scoreboard to 391/391 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoreboard artifact for the southern-hemisphere DST commits (78b45a33, 6716af69) — timezone 17->25, ical 56->63, total 376->391. Was left out of those commits; committing now to keep the tracked scoreboard in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/scoreboard.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 7a46ffe2..35fc4bbb 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,12 +1,12 @@ { "lang": "events", - "total_passed": 376, + "total_passed": 391, "total_failed": 0, - "total": 376, + "total": 391, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, - {"name":"timezone","passed":17,"failed":0,"total":17}, - {"name":"ical","passed":56,"failed":0,"total":56}, + {"name":"timezone","passed":25,"failed":0,"total":25}, + {"name":"ical","passed":63,"failed":0,"total":63}, {"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-07T20:02:48+00:00" + "generated": "2026-06-10T22:03:34+00:00" } From 7d1d732623105ddd05ef2583fb1fad24c27e185b Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 10 Jun 2026 23:05:53 +0000 Subject: [PATCH 9/9] events: sync scoreboard.md to 391/391 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to b2578139 — the markdown scoreboard render. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/scoreboard.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index f00748eb..6a0f7c3a 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,12 +1,12 @@ # events scoreboard -**376 / 376 passing** (0 failure(s)). +**391 / 391 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | -| timezone | 17 | 17 | ok | -| ical | 56 | 56 | ok | +| timezone | 25 | 25 | ok | +| ical | 63 | 63 | ok | | availability | 22 | 22 | ok | | api | 41 | 41 | ok | | booking | 82 | 82 | ok |