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:
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)))))))
|
||||
Reference in New Issue
Block a user