Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
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) <noreply@anthropic.com>
158 lines
5.9 KiB
Plaintext
158 lines
5.9 KiB
Plaintext
;; 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. 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).
|
|
|
|
;; 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))))
|
|
;; 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
|
|
(< 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.
|
|
(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)))
|
|
;; 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 ----
|
|
|
|
;; 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)))))))
|