sigil-caldav v0.10.1: fix DST-naive recurrence expansion
ical-expand-recurrence walked recurrence by adding 86400-second multiples in UTC. For an event tagged with TZID=Europe/Athens at a fixed wall-clock time, this preserved the UTC instant across DST transitions and drifted the wall-clock by 1h after spring/fall shifts. A weekly meeting scheduled in winter at 17:00 EET (15:00 UTC) walked to summer rendered as 18:00 EEST instead of the 17:00 EEST anchor the calendar invitation specifies.
Per RFC 5545 §3.6, a TZID-tagged DTSTART anchors the wall-clock in that timezone. The recurrence walker must re-resolve each occurrence's UTC instant via the OS zoneinfo for the occurrence's specific date.
Adds add-days-in-tz / add-months-in-tz / timestamp-weekday-in-tz which: 1. Decompose the UTC ts into wall components in the tz (single round-trip through list->time-in-tz — no new C native needed). 2. Walk the wall-clock by calendar day/month math. 3. Re-resolve to UTC via list->time-in-tz so DST is applied per occurrence date.
Falls through to the prior UTC arithmetic when tz is #f (UTC-anchored DTSTART or all-day events) so the existing semantics are preserved.
Adds three regression tests for cross-DST TZID recurrence (weekly, daily, monthly) plus a sanity test for UTC-anchored events. The new tests + the 5 cross-tz parsing tests added in v0.10.0 all require sigil >= 0.14.4 to pass — the v0.14.3 binary's broken list->time-in-tz returns UTC for all IANA names. Built-from-source binaries pick up sigil-stdlib v0.14.4+ via deps and pass cleanly.
package.sgl | 2 +-
src/sigil/caldav/ical.sgl | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
test/test-caldav.sgl | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 219 insertions(+), 20 deletions(-)package.sglmodified
(package name: "sigil-caldav" version: "0.10.0" version: "0.10.1" sigil: "^0.14" description: "CalDAV calendar client library for Sigil" url: "https://codeberg.org/sigil/sigil-caldav"src/sigil/caldav/ical.sglmodified
((equal? abbrev "SA") 6) (else #f))) ;; Get the day of week (0=Sunday) for a Unix timestamp ;; Get the day of week (0=Sunday) for a Unix timestamp. ;; Operates on the UTC value directly; for TZID-tagged events, callers ;; should pass a wall-as-UTC instant via wall->wall-as-utc so the local ;; calendar day is what gets classified. (define (timestamp-weekday ts) ;; 1970-01-01 was a Thursday (4) (modulo (+ (quotient ts 86400) 4) 7)) ;; Add N days to a timestamp ;; Add N days to a timestamp (UTC arithmetic). (define (add-days ts n) (+ ts (* n 86400))) (new-d (min d max-d))) (utc->timestamp new-y new-m new-d h mi s))) ;; ---------------------------------------------------------------- ;; TZID-aware date arithmetic ;; ;; Recurring events tagged with a TZID anchor each occurrence to a ;; fixed wall-clock in that tz. Walking the recurrence by adding ;; 86400-second multiples in UTC drifts the wall-clock by the DST ;; shift across spring/fall transitions. The helpers below walk by ;; wall-clock components in the tz and re-resolve to UTC at each ;; step via list->time-in-tz so the OS zoneinfo provides the ;; correct offset for the occurrence's actual fire date. ;; ;; The conversion utc-instant->wall uses a single round-trip ;; through list->time-in-tz to derive wall components without ;; needing a `time->list-in-tz` native. The math: ;; sys-wall = decompose(ts + sys-offset(ts)) ;; try-ts = compose(sys-wall, tz) ; treats sys-wall as wall-in-tz ;; delta = try-ts - ts ; = sys-offset - tz-offset ;; wall-in-tz = decompose((ts - delta) + sys-offset(ts - delta)) ;; = decompose(ts + tz-offset(ts)) QED ;; ---------------------------------------------------------------- ;; Take the first 6 components of (time->list ts) — (s mi h d mo y). (define (time-wall-components ts) (let ((parts (time->list ts))) (list (list-ref parts 0) (list-ref parts 1) (list-ref parts 2) (list-ref parts 3) (list-ref parts 4) (list-ref parts 5)))) ;; Decompose a UTC instant into (s mi h d mo y) as observed in tz. (define (utc-instant->wall ts tz) (if (not tz) (time-wall-components ts) (let* ((sys-wall (time-wall-components ts)) (try-ts (list->time-in-tz sys-wall tz)) (delta (- try-ts ts))) (time-wall-components (- ts delta))))) ;; Wall-clock (s mi h d mo y) -> Unix instant whose decomposition ;; in 'utc->timestamp' would give those components. Used as a ;; canonical key for date arithmetic — the "wall instant" is ;; not a real UTC instant, just a positional encoding of the ;; wall-clock that lets us add days/weeks/months with the ;; existing arithmetic. (define (wall-as-utc-instant wall) (utc->timestamp (list-ref wall 5) (list-ref wall 4) (list-ref wall 3) (list-ref wall 2) (list-ref wall 1) (list-ref wall 0))) ;; Inverse of wall-as-utc-instant: decompose to (s mi h d mo y). (define (utc-instant->wall-components t) (let ((parts (timestamp->utc t))) (list (list-ref parts 5) (list-ref parts 4) (list-ref parts 3) (list-ref parts 2) (list-ref parts 1) (list-ref parts 0)))) ;; Resolve wall components in tz back to a real UTC instant. (define (wall->utc wall tz) (if tz (list->time-in-tz wall tz) (list->time wall))) ;; TZID-aware add-days. Returns a real UTC instant whose wall-clock ;; in tz is the master's wall-clock advanced by n calendar days. (define (add-days-in-tz ts n tz) (if (not tz) (add-days ts n) (let* ((wall (utc-instant->wall ts tz)) (anchor (wall-as-utc-instant wall)) (advanced (+ anchor (* n 86400))) (new-wall (utc-instant->wall-components advanced))) (wall->utc new-wall tz)))) ;; TZID-aware add-months. Same idea, calendar-month math on the wall. (define (add-months-in-tz ts n tz) (if (not tz) (add-months ts n) (let* ((wall (utc-instant->wall ts tz)) (s (list-ref wall 0)) (mi (list-ref wall 1)) (h (list-ref wall 2)) (d (list-ref wall 3)) (mo (list-ref wall 4)) (y (list-ref wall 5)) (total-months (+ (* y 12) (- mo 1) n)) (new-y (quotient total-months 12)) (new-mo (+ (modulo total-months 12) 1)) (max-d (days-in-month new-y new-mo)) (new-d (min d max-d))) (wall->utc (list s mi h new-d new-mo new-y) tz)))) ;; Day-of-week for a real UTC instant as observed in tz. (define (timestamp-weekday-in-tz ts tz) (if (not tz) (timestamp-weekday ts) (timestamp-weekday (wall-as-utc-instant (utc-instant->wall ts tz))))) ;;; Expand a recurring event into individual occurrences within a date range. ;;; ;;; Supports FREQ=DAILY, FREQ=WEEKLY (with BYDAY), and FREQ=MONTHLY (ev-start (ical-event-dtstart event)) (duration (if (ical-event-dtend event) (- (ical-event-dtend event) ev-start) 3600))) 3600)) ;; All-day events have date-only DTSTART (no TZID ;; semantics); UTC-anchored events ignore TZID by spec. ;; Only honor the tz for timed events whose DTSTART ;; carried a TZID parameter. (tz (and (not (ical-event-all-day? event)) (ical-event-timezone event)))) (cond ((equal? freq "DAILY") (expand-daily event ev-start duration interval count until start end)) (expand-daily event ev-start duration interval count until start end tz)) ((equal? freq "WEEKLY") (let ((byday (rrule-ref rrule "BYDAY"))) (expand-weekly event ev-start duration interval count until (if byday (string-split byday ",") '()) start end))) start end tz))) ((equal? freq "MONTHLY") (expand-monthly event ev-start duration interval count until start end)) (expand-monthly event ev-start duration interval count until start end tz)) (else (list event))))))) ;; Clone an event with a new dtstart/dtend ;; Maximum instances to generate (safety limit) (define MAX-INSTANCES 1000) (define (expand-daily event ev-start duration interval count until range-start range-end) (define (expand-daily event ev-start duration interval count until range-start range-end tz) (let loop ((ts ev-start) (n 0) (results '())) (cond ((and count (>= n count)) (reverse results)) ((and range-end (> ts range-end)) (reverse results)) ((>= n MAX-INSTANCES) (reverse results)) (else (let ((next (add-days ts interval))) (let ((next (add-days-in-tz ts interval tz))) (if (in-range? ts range-start range-end) (loop next (+ n 1) (cons (clone-event event ts duration) results)) (loop next (+ n 1) results))))))) (define (expand-weekly event ev-start duration interval count until byday range-start range-end) (define (expand-weekly event ev-start duration interval count until byday range-start range-end tz) (let ((target-days (if (null? byday) (list (timestamp-weekday ev-start)) (list (timestamp-weekday-in-tz ev-start tz)) (let loop ((rest byday) (acc '())) (if (null? rest) (reverse acc) (cond ((>= day-offset 7) ;; Move to next week (skip by interval) (loop (add-days ts (* interval 7)) n results)) (loop (add-days-in-tz ts (* interval 7) tz) n results)) ((and count (>= n count)) (reverse results)) ((and until (> day-ts until)) (reverse results)) ((and range-end (> day-ts range-end)) (reverse results)) (else (if (memv (timestamp-weekday day-ts) target-days) (if (memv (timestamp-weekday-in-tz day-ts tz) target-days) (if (in-range? day-ts range-start range-end) (week-loop (add-days day-ts 1) (+ day-offset 1) (+ n 1) (week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) (+ n 1) (cons (clone-event event day-ts duration) results)) (week-loop (add-days day-ts 1) (+ day-offset 1) (+ n 1) results)) (week-loop (add-days day-ts 1) (+ day-offset 1) n results)))))))))) (week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) (+ n 1) results)) (week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) n results)))))))))) (define (expand-monthly event ev-start duration interval count until range-start range-end) (define (expand-monthly event ev-start duration interval count until range-start range-end tz) (let loop ((ts ev-start) (month-offset 0) (n 0) (results '())) (cond ((and count (>= n count)) (reverse results)) ((>= n MAX-INSTANCES) (reverse results)) (else (if (in-range? ts range-start range-end) (loop (add-months ev-start (+ month-offset interval)) (loop (add-months-in-tz ev-start (+ month-offset interval) tz) (+ month-offset interval) (+ n 1) (cons (clone-event event ts duration) results)) (loop (add-months ev-start (+ month-offset interval)) (loop (add-months-in-tz ev-start (+ month-offset interval) tz) (+ month-offset interval) (+ n 1) results))))))test/test-caldav.sglmodified
end: (+ start (* 7 86400))))) ;; Should only include events within the 7-day range (assert-true (<= (length expanded) 7)) (assert-true (> (length expanded) 0)))))) (assert-true (> (length expanded) 0))))) ;; TZID-aware recurrence: a weekly event tagged with a TZID anchors ;; each occurrence to a fixed wall-clock in that timezone, even when ;; the recurrence walks across a DST boundary. UTC-second arithmetic ;; would drift the wall-clock by 1h after Spring/Fall transitions. (test "weekly TZID=Europe/Athens master in winter, occurrence in DST" ;; Master: 2026-01-13 (Tuesday, EET=UTC+2) at 17:00 Athens. ;; 17:00 EET = 15:00 UTC = 1768316400. ;; Apr 28 2026 (Tuesday, EEST=UTC+3): occurrence wall-clock should ;; stay at 17:00 Athens, i.e. 14:00 UTC = 1777384800. ;; The buggy UTC-naive walker produces 15:00 UTC = 1777388400 (off ;; by +3600 — wall-clock drifts to 18:00 EEST). (let* ((ics (string-append "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" "UID:[email protected]\r\n" "DTSTART;TZID=Europe/Athens:20260113T170000\r\n" "DTEND;TZID=Europe/Athens:20260113T180000\r\n" "SUMMARY:Cross-DST recurrence\r\n" "RRULE:FREQ=WEEKLY;BYDAY=TU\r\n" "END:VEVENT\r\nEND:VCALENDAR")) (master (car (ical-parse ics))) (range-start (parse-ical-datetime "20260428T000000Z")) (range-end (parse-ical-datetime "20260429T000000Z")) (occurrences (ical-expand-recurrence master start: range-start end: range-end))) (assert-equal 1 (length occurrences)) (assert-equal "20260428T140000Z" (format-ical-datetime (ical-event-dtstart (car occurrences)))))) (test "daily TZID=Europe/Athens spanning DST start" ;; Master: 2026-03-26 (Thursday, EET=UTC+2) at 09:00 Athens. ;; DST starts Sunday 2026-03-29. The Apr 1 (Wednesday) occurrence ;; should be 09:00 EEST = 06:00 UTC, NOT 09:00 EET = 07:00 UTC. (let* ((ics (string-append "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" "UID:[email protected]\r\n" "DTSTART;TZID=Europe/Athens:20260326T090000\r\n" "DTEND;TZID=Europe/Athens:20260326T100000\r\n" "SUMMARY:Daily across DST\r\n" "RRULE:FREQ=DAILY\r\n" "END:VEVENT\r\nEND:VCALENDAR")) (master (car (ical-parse ics))) (range-start (parse-ical-datetime "20260401T000000Z")) (range-end (parse-ical-datetime "20260402T000000Z")) (occurrences (ical-expand-recurrence master start: range-start end: range-end))) (assert-equal 1 (length occurrences)) (assert-equal "20260401T060000Z" (format-ical-datetime (ical-event-dtstart (car occurrences)))))) (test "monthly TZID=Europe/Athens spanning DST start" ;; Master: 2026-01-15 (Thursday, EET=UTC+2) at 14:30 Athens. ;; April 15 (Wednesday, EEST=UTC+3) — wall-clock 14:30 → 11:30 UTC. (let* ((ics (string-append "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" "UID:[email protected]\r\n" "DTSTART;TZID=Europe/Athens:20260115T143000\r\n" "DTEND;TZID=Europe/Athens:20260115T153000\r\n" "SUMMARY:Monthly across DST\r\n" "RRULE:FREQ=MONTHLY;COUNT=4\r\n" "END:VEVENT\r\nEND:VCALENDAR")) (master (car (ical-parse ics))) (occurrences (ical-expand-recurrence master))) (assert-equal 4 (length occurrences)) ;; Months: Jan (EET, 12:30 UTC), Feb (EET), Mar (EET), Apr (EEST, 11:30 UTC) (assert-equal "20260115T123000Z" (format-ical-datetime (ical-event-dtstart (list-ref occurrences 0)))) (assert-equal "20260415T113000Z" (format-ical-datetime (ical-event-dtstart (list-ref occurrences 3)))))) (test "weekly UTC-anchored event keeps UTC across DST" ;; Sanity: an event with no TZID (UTC-anchored DTSTART) preserves ;; its UTC instant across DST transitions. The wall-clock in any ;; observing tz shifts by 1h, which is correct UTC-anchor behavior. (let* ((ics (string-append "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n" "UID:[email protected]\r\n" "DTSTART:20260113T140000Z\r\n" "DTEND:20260113T150000Z\r\n" "SUMMARY:UTC anchor\r\n" "RRULE:FREQ=WEEKLY;BYDAY=TU\r\n" "END:VEVENT\r\nEND:VCALENDAR")) (master (car (ical-parse ics))) (range-start (parse-ical-datetime "20260428T000000Z")) (range-end (parse-ical-datetime "20260429T000000Z")) (occurrences (ical-expand-recurrence master start: range-start end: range-end))) (assert-equal 1 (length occurrences)) (assert-equal "20260428T140000Z" (format-ical-datetime (ical-event-dtstart (car occurrences)))))));; ============================================================