From 78b45a331e43fe9bb1f9059546a9af0ab14141e5 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 10 Jun 2026 20:59:59 +0000 Subject: [PATCH] =?UTF-8?q?events:=20southern-hemisphere=20DST=20(+8)=20?= =?UTF-8?q?=E2=80=94=20384/384?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/events/tests/timezone.sx | 52 ++++++++++++++++++++++++++++++++++++ lib/events/timezone.sx | 36 +++++++++++++++++++++---- plans/events-on-sx.md | 15 ++++++++++- 3 files changed, 97 insertions(+), 6 deletions(-) diff --git a/lib/events/tests/timezone.sx b/lib/events/tests/timezone.sx index 265bd6e7..79b3f126 100644 --- a/lib/events/tests/timezone.sx +++ b/lib/events/tests/timezone.sx @@ -76,6 +76,58 @@ ev-tz-paris (ev-dt 2026 7 15 12 0)) 120) + ;; ---- southern hemisphere (reversed seasons) ---- + (ev-tz-check! + "Sydney January offset is 660 (AEDT, summer DST)" + (ev-tz-offset + ev-tz-sydney + (ev-dt 2026 1 15 12 0)) + 660) + (ev-tz-check! + "Sydney July offset is 600 (AEST, winter std)" + (ev-tz-offset + ev-tz-sydney + (ev-dt 2026 7 15 12 0)) + 600) + (ev-tz-check! + "Sydney DST starts first Sunday of October" + (ev-dt->civil + (+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-start)) 480)) + (list 2026 10 4)) + (ev-tz-check! + "Sydney DST ends first Sunday of April" + (ev-dt->civil + (+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-end)) 480)) + (list 2026 4 5)) + (ev-tz-check! + "09:00 Sydney in summer (AEDT) is previous-day 22:00 UTC" + (ev-tz-local->utc + ev-tz-sydney + (ev-dt 2026 1 15 9 0)) + (ev-dt 2026 1 14 22 0)) + (ev-tz-check! + "09:00 Sydney in winter (AEST) is previous-day 23:00 UTC" + (ev-tz-local->utc + ev-tz-sydney + (ev-dt 2026 7 15 9 0)) + (ev-dt 2026 7 14 23 0)) + (let + ((au (ev-event-tz (quote au) (ev-dt 2026 4 3 9 0) 60 {:freq :daily :count 5} 8 ev-tz-sydney))) + (let + ((occs (ev-expand au (ev-date 2026 3 25) (ev-date 2026 4 12)))) + (do + (ev-tz-check! + "Sydney daily occurrences shift in UTC across the autumn DST end" + (map (fn (o) (ev-dt-tod (get o :start))) occs) + (list 1320 1320 1380 1380 1380)) + (ev-tz-check! + "but every Sydney occurrence stays 09:00 local wall-clock" + (map + (fn + (o) + (first (rest (ev-tz-local-of ev-tz-sydney (get o :start))))) + occs) + (list 540 540 540 540 540))))) (ev-tz-check! "DST starts last Sunday of March" (ev-dt->civil diff --git a/lib/events/timezone.sx b/lib/events/timezone.sx index 6ef9a2a0..a8ef7ecc 100644 --- a/lib/events/timezone.sx +++ b/lib/events/timezone.sx @@ -13,8 +13,11 @@ ;; :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. +;; 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). @@ -58,10 +61,20 @@ (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 - (and (>= utc-dt start) (< utc-dt end)) - (get tz :dst-offset) - (get tz :std-offset))))) + (< 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. @@ -98,6 +111,19 @@ 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 ---- diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index f06b2cf8..d0a695e8 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` → **376/376** (Phases 1-4 + 13 ext + tz iCal export via TZID + VTIMEZONE) +`bash lib/events/conformance.sh` → **384/384** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST) ## Ground rules @@ -88,6 +88,19 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-10 — Southern-hemisphere DST. The `:dst` zone model assumed northern + ordering (dst-start < dst-end, DST = [start, end)); southern zones (DST begins + ~Oct, ends ~Apr) have dst-start > dst-end and so silently never entered DST — + `ev-tz-offset` returned std year-round. Fixed by detecting the ordering: when + start < end DST is the interval [start, end); when start > end DST wraps the + year boundary (active when `utc ≥ start OR utc < end`). Added 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 prior Saturday → rule time + −480). VTIMEZONE export already rule-agnostic, so southern zones round-trip + too (the −480 folds the from-offset back to the correct local 02:00/03:00). + +8 tests (timezone 25): summer/winter offsets, both transition dates, + local→utc both seasons, and a daily expansion crossing the autumn DST-end that + shifts in UTC (1320·1320·1380·1380·1380) yet stays 09:00 local. 384/384 green. - 2026-06-07 — VTIMEZONE iCal export (supersedes the UTC-Z tz fix — full DST fidelity). A tz event now exports DTSTART;TZID=: (+ EXDATE/RDATE in the same TZID-local form; UNTIL stays UTC per RFC), and the VCALENDAR emits