events: timezone + DST support + 17 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 09:31:11 +00:00
parent c991c7c3d3
commit bf7bd38010
7 changed files with 339 additions and 8 deletions

View File

@@ -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.

View File

@@ -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!)"

View File

@@ -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"
}

View File

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

View File

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

131
lib/events/timezone.sx Normal file
View File

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

View File

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