diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 7a46ffe2..35fc4bbb 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,12 +1,12 @@ { "lang": "events", - "total_passed": 376, + "total_passed": 391, "total_failed": 0, - "total": 376, + "total": 391, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, - {"name":"timezone","passed":17,"failed":0,"total":17}, - {"name":"ical","passed":56,"failed":0,"total":56}, + {"name":"timezone","passed":25,"failed":0,"total":25}, + {"name":"ical","passed":63,"failed":0,"total":63}, {"name":"availability","passed":22,"failed":0,"total":22}, {"name":"api","passed":41,"failed":0,"total":41}, {"name":"booking","passed":82,"failed":0,"total":82}, @@ -17,5 +17,5 @@ {"name":"federation","passed":29,"failed":0,"total":29}, {"name":"integration","passed":8,"failed":0,"total":8} ], - "generated": "2026-06-07T20:02:48+00:00" + "generated": "2026-06-10T22:03:34+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index f00748eb..6a0f7c3a 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,12 +1,12 @@ # events scoreboard -**376 / 376 passing** (0 failure(s)). +**391 / 391 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 51 | 51 | ok | -| timezone | 17 | 17 | ok | -| ical | 56 | 56 | ok | +| timezone | 25 | 25 | ok | +| ical | 63 | 63 | ok | | availability | 22 | 22 | ok | | api | 41 | 41 | ok | | booking | 82 | 82 | ok | diff --git a/lib/events/tests/ical.sx b/lib/events/tests/ical.sx index e12e3135..9f96554c 100644 --- a/lib/events/tests/ical.sx +++ b/lib/events/tests/ical.sx @@ -335,6 +335,17 @@ (do (ev-ic-check! "Paris DAYLIGHT goes to +0200 (CEST)" (ev-ic-find vtz "TZOFFSETTO:+0200") "TZOFFSETTO:+0200") (ev-ic-check! "Paris STANDARD goes to +0100 (CET)" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100"))) + ;; southern hemisphere exports a valid VTIMEZONE too: reversed offsets, + ;; first-Sunday rules, and the -480 rule time folds back to local 02:00/03:00 + (let + ((vtz (ev-ical-vtimezone ev-tz-sydney))) + (do + (ev-ic-check! "Sydney VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Australia/Sydney") + (ev-ic-check! "Sydney DAYLIGHT goes to +1100 (AEDT)" (ev-ic-find vtz "TZOFFSETTO:+1100") "TZOFFSETTO:+1100") + (ev-ic-check! "Sydney STANDARD goes to +1000 (AEST)" (ev-ic-find vtz "TZOFFSETTO:+1000") "TZOFFSETTO:+1000") + (ev-ic-check! "Sydney DAYLIGHT rule is first Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU") + (ev-ic-check! "Sydney STANDARD rule is first Sunday of April" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=4") "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU") + (ev-ic-check! "Sydney DAYLIGHT begins 02:00 local (AEST std, -480 folded)" (ev-ic-find vtz "DTSTART") "DTSTART:19701004T020000"))) ;; ---- VCALENDAR carries one VTIMEZONE per distinct zone ---- (let @@ -371,7 +382,13 @@ (get (ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london))) :dtstart) - (ev-dt 2026 7 15 18 0))))) + (ev-dt 2026 7 15 18 0)) + (ev-ic-check! + "import parses a southern-zone DTSTART;TZID local time" + (get + (ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 1 15 18 0) 60 nil 1 ev-tz-sydney))) + :dtstart) + (ev-dt 2026 1 15 18 0))))) (define ev-ical-tests-run! 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..e4397425 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` → **391/391** (Phases 1-4 + 14 ext + tz iCal export via TZID + VTIMEZONE + southern-hemisphere DST incl. iCal round-trip) ## Ground rules @@ -88,6 +88,26 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-10 — Harden southern-hemisphere DST: explicit iCal coverage for the + previous commit's unverified claim that "southern zones round-trip through + iCal unchanged". Added a Sydney VTIMEZONE export block (TZID:Australia/Sydney, + DAYLIGHT→+1100/STANDARD→+1000, first-Sunday rules BYMONTH=10/4 BYDAY=1SU, and + DAYLIGHT DTSTART:19701004T020000 — proving the −480 rule time folds back to + local 02:00 AEST) and a southern-zone DTSTART;TZID export→import round-trip. + +7 tests (ical 63). 391/391 green. +- 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