events: timezone + DST support + 17 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
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:
@@ -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.
|
||||
|
||||
@@ -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!)"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
173
lib/events/tests/timezone.sx
Normal file
173
lib/events/tests/timezone.sx
Normal 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
131
lib/events/timezone.sx
Normal 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)))))))
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user