From bf7bd380102a811a150425c475c8b36a63019190 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 09:31:11 +0000 Subject: [PATCH] events: timezone + DST support + 17 tests timezone.sx: wall-clock LOCAL <-> absolute UTC. :fixed + :dst zones (std/dst offsets + UTC transition rules, EU-style, no IANA DB) computed via calendar helpers. ev-event-tz authors in local time; ev-expand expands tz events in LOCAL time then converts each occurrence to UTC, so a 09:00 weekly meeting stays 09:00 across a DST change (UTC instant shifts). Predefined utc/london/ paris. Plain events unaffected. 295/295 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/calendar.sx | 19 +++- lib/events/conformance.conf | 2 + lib/events/scoreboard.json | 7 +- lib/events/scoreboard.md | 3 +- lib/events/tests/timezone.sx | 173 +++++++++++++++++++++++++++++++++++ lib/events/timezone.sx | 131 ++++++++++++++++++++++++++ plans/events-on-sx.md | 12 ++- 7 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 lib/events/tests/timezone.sx create mode 100644 lib/events/timezone.sx diff --git a/lib/events/calendar.sx b/lib/events/calendar.sx index 2e7ed0aa..eb90b5e8 100644 --- a/lib/events/calendar.sx +++ b/lib/events/calendar.sx @@ -545,10 +545,9 @@ (list orig-start {:start new-start :duration new-duration}) (ev-or (get event :overrides) (list)))))) -;; Expand an event into occurrence dicts within the window, applying any -;; EXDATE/RDATE exceptions and per-occurrence overrides. Public entry point. +;; Naive (single time-domain) expansion: RRULE + EXDATE/RDATE + overrides. (define - ev-expand + ev-expand-naive (fn (event win-start win-end) (let @@ -567,6 +566,20 @@ (fn (o) (and (>= (get o :start) win-start) (<= (get o :start) win-end))) (ev-sort-occs (ev-apply-overrides id excepted overrides))))))) +;; Public entry point. A tz-aware event (`:tz` set) expands in local wall-clock +;; time and converts each occurrence to UTC (ev-expand-tz, timezone.sx); a plain +;; event expands naively in a single time domain. The window is UTC either way. +(define + ev-expand + (fn + (event win-start win-end) + (let + ((tz (get event :tz))) + (if + (nil? tz) + (ev-expand-naive event win-start win-end) + (ev-expand-tz event tz win-start win-end))))) + ;; ---- multi-event expansion (sorted by start) ---- ;; Insertion of one occurrence into a start-ascending list. diff --git a/lib/events/conformance.conf b/lib/events/conformance.conf index 12710d4c..78767d29 100644 --- a/lib/events/conformance.conf +++ b/lib/events/conformance.conf @@ -18,6 +18,7 @@ PRELOADS=( lib/datalog/api.sx lib/datalog/magic.sx lib/events/calendar.sx + lib/events/timezone.sx lib/events/availability.sx lib/persist/event.sx lib/persist/backend.sx @@ -47,6 +48,7 @@ PRELOADS=( SUITES=( "calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)" + "timezone:lib/events/tests/timezone.sx:(ev-timezone-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/scoreboard.json b/lib/events/scoreboard.json index 37cb49ad..35a7ffb0 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,10 +1,11 @@ { "lang": "events", - "total_passed": 278, + "total_passed": 295, "total_failed": 0, - "total": 278, + "total": 295, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, + {"name":"timezone","passed":17,"failed":0,"total":17}, {"name":"availability","passed":22,"failed":0,"total":22}, {"name":"api","passed":24,"failed":0,"total":24}, {"name":"booking","passed":82,"failed":0,"total":82}, @@ -14,5 +15,5 @@ {"name":"reminders","passed":21,"failed":0,"total":21}, {"name":"federation","passed":29,"failed":0,"total":29} ], - "generated": "2026-06-07T08:12:04+00:00" + "generated": "2026-06-07T09:30:28+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index bdbd5fc5..28d99a92 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,10 +1,11 @@ # events scoreboard -**278 / 278 passing** (0 failure(s)). +**295 / 295 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | +| timezone | 17 | 17 | ok | | availability | 22 | 22 | ok | | api | 24 | 24 | ok | | booking | 82 | 82 | ok | diff --git a/lib/events/tests/timezone.sx b/lib/events/tests/timezone.sx new file mode 100644 index 00000000..265bd6e7 --- /dev/null +++ b/lib/events/tests/timezone.sx @@ -0,0 +1,173 @@ +;; lib/events/tests/timezone.sx — timezones + DST. + +(define ev-tz-pass 0) +(define ev-tz-fail 0) +(define ev-tz-failures (list)) + +(define + ev-tz-check! + (fn + (name got expected) + (if + (= got expected) + (set! ev-tz-pass (+ ev-tz-pass 1)) + (do + (set! ev-tz-fail (+ ev-tz-fail 1)) + (append! + ev-tz-failures + (str name "\n expected: " expected "\n got: " got)))))) + +;; Wall-clock (civil + minute-of-day) an occurrence's UTC start maps to in a tz. +(define + ev-tz-local-of + (fn + (tz utc-dt) + (let + ((l (ev-tz-utc->local tz utc-dt))) + (list (ev-dt->civil l) (ev-dt-tod l))))) + +(define + ev-tz-run-all! + (fn + () + (do + (let + ((nyc (ev-tz-fixed "EST" -300))) + (do + (ev-tz-check! + "fixed zone: utc -> local subtracts 5h" + (ev-tz-utc->local + nyc + (ev-dt 2026 1 1 17 0)) + (ev-dt 2026 1 1 12 0)) + (ev-tz-check! + "fixed zone: local -> utc adds 5h back" + (ev-tz-local->utc + nyc + (ev-dt 2026 1 1 12 0)) + (ev-dt 2026 1 1 17 0)) + (ev-tz-check! + "UTC zone is identity" + (ev-tz-local->utc + ev-tz-utc + (ev-dt 2026 6 1 9 0)) + (ev-dt 2026 6 1 9 0)))) + (ev-tz-check! + "London winter offset is 0 (GMT)" + (ev-tz-offset + ev-tz-london + (ev-dt 2026 1 15 12 0)) + 0) + (ev-tz-check! + "London summer offset is 60 (BST)" + (ev-tz-offset + ev-tz-london + (ev-dt 2026 7 15 12 0)) + 60) + (ev-tz-check! + "Paris winter offset is 60 (CET)" + (ev-tz-offset + ev-tz-paris + (ev-dt 2026 1 15 12 0)) + 60) + (ev-tz-check! + "Paris summer offset is 120 (CEST)" + (ev-tz-offset + ev-tz-paris + (ev-dt 2026 7 15 12 0)) + 120) + (ev-tz-check! + "DST starts last Sunday of March" + (ev-dt->civil + (ev-tz-transition + 2026 + (ev-tz-rule 3 -1 6 60))) + (list 2026 3 29)) + (ev-tz-check! + "DST ends last Sunday of October" + (ev-dt->civil + (ev-tz-transition + 2026 + (ev-tz-rule 10 -1 6 60))) + (list 2026 10 25)) + (ev-tz-check! + "09:00 London in winter is 09:00 UTC" + (ev-tz-local->utc + ev-tz-london + (ev-dt 2026 1 15 9 0)) + (ev-dt 2026 1 15 9 0)) + (ev-tz-check! + "09:00 London in summer is 08:00 UTC" + (ev-tz-local->utc + ev-tz-london + (ev-dt 2026 7 15 9 0)) + (ev-dt 2026 7 15 8 0)) + (ev-tz-check! + "round trip utc -> local -> utc" + (ev-tz-local->utc + ev-tz-london + (ev-tz-utc->local + ev-tz-london + (ev-dt 2026 7 15 8 0))) + (ev-dt 2026 7 15 8 0)) + (let + ((ev (ev-event-tz (quote standup) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 5} 10 ev-tz-london))) + (let + ((occs (ev-expand ev (ev-date 2026 3 1) (ev-date 2026 4 5)))) + (do + (ev-tz-check! + "daily occurrences shift in UTC across the DST boundary" + (map (fn (o) (ev-dt-tod (get o :start))) occs) + (list 540 540 480 480 480)) + (ev-tz-check! + "but every occurrence stays 09:00 local wall-clock" + (map + (fn + (o) + (first + (rest (ev-tz-local-of ev-tz-london (get o :start))))) + occs) + (list 540 540 540 540 540)) + (ev-tz-check! + "occurrence dates are stable in local time" + (map + (fn + (o) + (ev-civ-d + (first (ev-tz-local-of ev-tz-london (get o :start))))) + occs) + (list 27 28 29 30 31))))) + (let + ((wk (ev-event-tz (quote class) (ev-dt 2026 3 23 18 0) 90 {:freq :weekly :byday (list 0)} 5 ev-tz-london))) + (let + ((occs (ev-expand wk (ev-date 2026 3 1) (ev-date 2026 4 20)))) + (ev-tz-check! + "weekly Monday 18:00 London stays 18:00 local each week" + (map + (fn + (o) + (first (rest (ev-tz-local-of ev-tz-london (get o :start))))) + occs) + (list 1080 1080 1080 1080)))) + (let + ((plain (ev-event (quote p) (ev-dt 2026 3 27 9 0) 60 {:freq :daily :count 3} 1))) + (ev-tz-check! + "plain event expands naively (no UTC shift)" + (map + (fn (o) (ev-dt-tod (get o :start))) + (ev-expand + plain + (ev-date 2026 3 1) + (ev-date 2026 4 5))) + (list 540 540 540)))))) + +(define + ev-timezone-tests-run! + (fn + () + (do + (set! ev-tz-pass 0) + (set! ev-tz-fail 0) + (set! ev-tz-failures (list)) + (ev-tz-run-all!) + {:failures ev-tz-failures :total (+ ev-tz-pass ev-tz-fail) :passed ev-tz-pass :failed ev-tz-fail}))) diff --git a/lib/events/timezone.sx b/lib/events/timezone.sx new file mode 100644 index 00000000..6ef9a2a0 --- /dev/null +++ b/lib/events/timezone.sx @@ -0,0 +1,131 @@ +;; lib/events/timezone.sx — timezones + DST for the calendar. +;; +;; Datetimes in calendar.sx are naive epoch-minutes (wall clock). A timezone +;; maps between wall-clock LOCAL time and absolute UTC. An event is authored in +;; local time + a tz; recurrence is expanded in local time (so a "09:00 weekly" +;; meeting stays 09:00 across a DST change), then each occurrence is converted +;; to UTC for storage/comparison. +;; +;; Offset convention: offset = local - utc (minutes). London summer (BST) = +60. +;; UTC = local - offset; local = utc + offset. +;; +;; Two kinds of zone, no IANA database: +;; :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. +;; +;; Requires calendar.sx (ev-dt, ev-days-from-civil, ev-civil-from-days, +;; ev-civ-y, ev-floor-div, ev-resolve-nth-weekday). + +;; A DST transition rule: the ord-th weekday `wd` (0=Mon..6=Sun) of `month`, at +;; `time` minutes-of-day UTC. EU: last Sunday (ord -1, wd 6) at 01:00 UTC. +(define ev-tz-rule (fn (month ord wd time) {:ord ord :wd wd :month month :time time})) + +(define ev-tz-fixed (fn (name offset) {:name name :offset offset :kind :fixed})) + +(define ev-tz-dst (fn (name std dst start-rule end-rule) {:name name :kind :dst :dst-end end-rule :dst-start start-rule :std-offset std :dst-offset dst})) + +;; Standard (winter) offset — the initial guess when inverting local -> utc. +(define + ev-tz-std-offset + (fn + (tz) + (if (= (get tz :kind) :fixed) (get tz :offset) (get tz :std-offset)))) + +;; The UTC instant (epoch-minutes) of a transition rule in a given year. +(define + ev-tz-transition + (fn + (year rule) + (let + ((day (ev-resolve-nth-weekday year (get rule :month) (get rule :ord) (get rule :wd)))) + (+ + (* (ev-days-from-civil year (get rule :month) day) 1440) + (get rule :time))))) + +;; The offset (minutes) in effect at a UTC instant. +(define + ev-tz-offset + (fn + (tz utc-dt) + (cond + ((= (get tz :kind) :fixed) (get tz :offset)) + ((= (get tz :kind) :dst) + (let + ((year (ev-civ-y (ev-civil-from-days (ev-floor-div utc-dt 1440))))) + (let + ((start (ev-tz-transition year (get tz :dst-start))) + (end (ev-tz-transition year (get tz :dst-end)))) + (if + (and (>= utc-dt start) (< utc-dt end)) + (get tz :dst-offset) + (get tz :std-offset))))) + (else 0)))) + +;; UTC instant -> local wall-clock. +(define + ev-tz-utc->local + (fn (tz utc-dt) (+ utc-dt (ev-tz-offset tz utc-dt)))) + +;; Local wall-clock -> UTC instant. The offset depends on the instant, so we +;; guess with the standard offset and refine once (correct except within the +;; one-hour DST gap/overlap, where it resolves to the pre-transition offset). +(define + ev-tz-local->utc + (fn + (tz local-dt) + (let + ((utc1 (- local-dt (ev-tz-offset tz (- local-dt (ev-tz-std-offset tz)))))) + (- local-dt (ev-tz-offset tz utc1))))) + +;; ---- predefined zones ---- +(define ev-tz-utc (ev-tz-fixed "UTC" 0)) +(define + ev-tz-london + (ev-tz-dst + "Europe/London" + 0 + 60 + (ev-tz-rule 3 -1 6 60) + (ev-tz-rule 10 -1 6 60))) +(define + ev-tz-paris + (ev-tz-dst + "Europe/Paris" + 60 + 120 + (ev-tz-rule 3 -1 6 60) + (ev-tz-rule 10 -1 6 60))) + +;; ---- tz-aware event expansion ---- + +;; An event authored in local time + a tz. dtstart-local / rrule / exceptions +;; are all wall-clock in `tz`; expansion converts each occurrence to UTC. +(define + ev-event-tz + (fn (id dtstart-local duration rrule capacity tz) {:id id :duration duration :dtstart dtstart-local :rrule rrule :capacity capacity :tz tz})) + +;; Expand a tz-aware event over a UTC window. Local recurrence is expanded over +;; a window widened by a day each side (to catch occurrences whose UTC lands in +;; range), converted to UTC, then filtered to [win-start, win-end]. +(define + ev-expand-tz + (fn + (event tz win-start win-end) + (let + ((local-ws (- (ev-tz-utc->local tz win-start) 1440)) + (local-we (+ (ev-tz-utc->local tz win-end) 1440))) + (let + ((local-occs (ev-expand-naive event local-ws local-we))) + (let + ((utc-occs (map (fn (o) (let ((u (ev-tz-local->utc tz (get o :start))) (dur (- (get o :end) (get o :start)))) {:id (get o :id) :start u :end (+ u dur)})) local-occs))) + (ev-sort-occs + (filter + (fn + (o) + (and + (>= (get o :start) win-start) + (<= (get o :start) win-end))) + utc-occs))))))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 519eabe1..385129fa 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` → **278/278** (Phases 1-4 + 7 ext: fed f/b, waitlist, EXDATE/RDATE, overrides, booking/reschedule-notify, fed transport) +`bash lib/events/conformance.sh` → **295/295** (Phases 1-4 + 8 ext: fed f/b, waitlist, EXDATE/RDATE, overrides, booking/reschedule-notify, fed transport, timezones+DST) ## Ground rules @@ -86,6 +86,16 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Timezone + DST support (user request). `timezone.sx`: a tz maps + wall-clock LOCAL ↔ absolute UTC (offset = local-utc). :fixed (constant) and + :dst (std/dst offsets + two UTC transition rules, e.g. EU last-Sun-Mar/Oct + 01:00 UTC) zones, no IANA DB — transitions computed via calendar helpers + (ev-resolve-nth-weekday). `ev-event-tz` authors an event in local time + a tz; + `ev-expand` dispatches: tz events expand in LOCAL time (recurrence + EXDATE/ + RDATE + overrides all wall-clock), then each occurrence converts to UTC, so a + "09:00 weekly" meeting stays 09:00 across a DST change (its UTC instant + shifts). Predefined ev-tz-utc/london/paris. local->utc inverts with a one-step + refinement. Plain events unaffected (ev-expand-naive). +17 tests, 295/295 green. - 2026-06-07 — Injected federation transport (last plan item). `fetch` abstracts how a peer's agenda arrives: (fetch peer-id ws we) -> {:status :ok :occurrences} | {:status :error}. `ev/federated-agenda-via` merges local + each trusted