Commit878f1638Recorded27 Apr 2026Repositorysigil-caldav

sigil-caldav v0.10.1: fix DST-naive recurrence expansion

Message

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.

Changed
 package.sgl               |   2 +-
 src/sigil/caldav/ical.sgl | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
 test/test-caldav.sgl      |  99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 219 insertions(+), 20 deletions(-)
Diff
package.sglmodified
@@ -5,7 +5,7 @@
5
6
(package
7
name: "sigil-caldav"
8
version: "0.10.0"
+8
version: "0.10.1"
9
sigil: "^0.14"
10
description: "CalDAV calendar client library for Sigil"
11
url: "https://codeberg.org/sigil/sigil-caldav"
src/sigil/caldav/ical.sglmodified
@@ -651,12 +651,15 @@
651
((equal? abbrev "SA") 6)
652
(else #f)))
653
654
;; Get the day of week (0=Sunday) for a Unix timestamp
+654
;; Get the day of week (0=Sunday) for a Unix timestamp.
+655
;; Operates on the UTC value directly; for TZID-tagged events, callers
+656
;; should pass a wall-as-UTC instant via wall->wall-as-utc so the local
+657
;; calendar day is what gets classified.
658
(define (timestamp-weekday ts)
659
;; 1970-01-01 was a Thursday (4)
660
(modulo (+ (quotient ts 86400) 4) 7))
661
659
;; Add N days to a timestamp
+662
;; Add N days to a timestamp (UTC arithmetic).
663
(define (add-days ts n)
664
(+ ts (* n 86400)))
665
@@ -676,6 +679,99 @@
679
(new-d (min d max-d)))
680
(utc->timestamp new-y new-m new-d h mi s)))
681
+682
;; ----------------------------------------------------------------
+683
;; TZID-aware date arithmetic
+684
;;
+685
;; Recurring events tagged with a TZID anchor each occurrence to a
+686
;; fixed wall-clock in that tz. Walking the recurrence by adding
+687
;; 86400-second multiples in UTC drifts the wall-clock by the DST
+688
;; shift across spring/fall transitions. The helpers below walk by
+689
;; wall-clock components in the tz and re-resolve to UTC at each
+690
;; step via list->time-in-tz so the OS zoneinfo provides the
+691
;; correct offset for the occurrence's actual fire date.
+692
;;
+693
;; The conversion utc-instant->wall uses a single round-trip
+694
;; through list->time-in-tz to derive wall components without
+695
;; needing a `time->list-in-tz` native. The math:
+696
;; sys-wall = decompose(ts + sys-offset(ts))
+697
;; try-ts = compose(sys-wall, tz) ; treats sys-wall as wall-in-tz
+698
;; delta = try-ts - ts ; = sys-offset - tz-offset
+699
;; wall-in-tz = decompose((ts - delta) + sys-offset(ts - delta))
+700
;; = decompose(ts + tz-offset(ts)) QED
+701
;; ----------------------------------------------------------------
+702
+703
;; Take the first 6 components of (time->list ts) — (s mi h d mo y).
+704
(define (time-wall-components ts)
+705
(let ((parts (time->list ts)))
+706
(list (list-ref parts 0) (list-ref parts 1) (list-ref parts 2)
+707
(list-ref parts 3) (list-ref parts 4) (list-ref parts 5))))
+708
+709
;; Decompose a UTC instant into (s mi h d mo y) as observed in tz.
+710
(define (utc-instant->wall ts tz)
+711
(if (not tz)
+712
(time-wall-components ts)
+713
(let* ((sys-wall (time-wall-components ts))
+714
(try-ts (list->time-in-tz sys-wall tz))
+715
(delta (- try-ts ts)))
+716
(time-wall-components (- ts delta)))))
+717
+718
;; Wall-clock (s mi h d mo y) -> Unix instant whose decomposition
+719
;; in 'utc->timestamp' would give those components. Used as a
+720
;; canonical key for date arithmetic — the "wall instant" is
+721
;; not a real UTC instant, just a positional encoding of the
+722
;; wall-clock that lets us add days/weeks/months with the
+723
;; existing arithmetic.
+724
(define (wall-as-utc-instant wall)
+725
(utc->timestamp (list-ref wall 5) (list-ref wall 4) (list-ref wall 3)
+726
(list-ref wall 2) (list-ref wall 1) (list-ref wall 0)))
+727
+728
;; Inverse of wall-as-utc-instant: decompose to (s mi h d mo y).
+729
(define (utc-instant->wall-components t)
+730
(let ((parts (timestamp->utc t)))
+731
(list (list-ref parts 5) (list-ref parts 4) (list-ref parts 3)
+732
(list-ref parts 2) (list-ref parts 1) (list-ref parts 0))))
+733
+734
;; Resolve wall components in tz back to a real UTC instant.
+735
(define (wall->utc wall tz)
+736
(if tz
+737
(list->time-in-tz wall tz)
+738
(list->time wall)))
+739
+740
;; TZID-aware add-days. Returns a real UTC instant whose wall-clock
+741
;; in tz is the master's wall-clock advanced by n calendar days.
+742
(define (add-days-in-tz ts n tz)
+743
(if (not tz)
+744
(add-days ts n)
+745
(let* ((wall (utc-instant->wall ts tz))
+746
(anchor (wall-as-utc-instant wall))
+747
(advanced (+ anchor (* n 86400)))
+748
(new-wall (utc-instant->wall-components advanced)))
+749
(wall->utc new-wall tz))))
+750
+751
;; TZID-aware add-months. Same idea, calendar-month math on the wall.
+752
(define (add-months-in-tz ts n tz)
+753
(if (not tz)
+754
(add-months ts n)
+755
(let* ((wall (utc-instant->wall ts tz))
+756
(s (list-ref wall 0))
+757
(mi (list-ref wall 1))
+758
(h (list-ref wall 2))
+759
(d (list-ref wall 3))
+760
(mo (list-ref wall 4))
+761
(y (list-ref wall 5))
+762
(total-months (+ (* y 12) (- mo 1) n))
+763
(new-y (quotient total-months 12))
+764
(new-mo (+ (modulo total-months 12) 1))
+765
(max-d (days-in-month new-y new-mo))
+766
(new-d (min d max-d)))
+767
(wall->utc (list s mi h new-d new-mo new-y) tz))))
+768
+769
;; Day-of-week for a real UTC instant as observed in tz.
+770
(define (timestamp-weekday-in-tz ts tz)
+771
(if (not tz)
+772
(timestamp-weekday ts)
+773
(timestamp-weekday (wall-as-utc-instant (utc-instant->wall ts tz)))))
+774
775
;;; Expand a recurring event into individual occurrences within a date range.
776
;;;
777
;;; Supports FREQ=DAILY, FREQ=WEEKLY (with BYDAY), and FREQ=MONTHLY
@@ -708,17 +804,23 @@
804
(ev-start (ical-event-dtstart event))
805
(duration (if (ical-event-dtend event)
806
(- (ical-event-dtend event) ev-start)
711
3600)))
+807
3600))
+808
;; All-day events have date-only DTSTART (no TZID
+809
;; semantics); UTC-anchored events ignore TZID by spec.
+810
;; Only honor the tz for timed events whose DTSTART
+811
;; carried a TZID parameter.
+812
(tz (and (not (ical-event-all-day? event))
+813
(ical-event-timezone event))))
814
(cond
815
((equal? freq "DAILY")
714
(expand-daily event ev-start duration interval count until start end))
+816
(expand-daily event ev-start duration interval count until start end tz))
817
((equal? freq "WEEKLY")
818
(let ((byday (rrule-ref rrule "BYDAY")))
819
(expand-weekly event ev-start duration interval count until
820
(if byday (string-split byday ",") '())
719
start end)))
+821
start end tz)))
822
((equal? freq "MONTHLY")
721
(expand-monthly event ev-start duration interval count until start end))
+823
(expand-monthly event ev-start duration interval count until start end tz))
824
(else (list event)))))))
825
826
;; Clone an event with a new dtstart/dtend
@@ -749,7 +851,7 @@
851
;; Maximum instances to generate (safety limit)
852
(define MAX-INSTANCES 1000)
853
752
(define (expand-daily event ev-start duration interval count until range-start range-end)
+854
(define (expand-daily event ev-start duration interval count until range-start range-end tz)
855
(let loop ((ts ev-start) (n 0) (results '()))
856
(cond
857
((and count (>= n count)) (reverse results))
@@ -757,14 +859,14 @@
859
((and range-end (> ts range-end)) (reverse results))
860
((>= n MAX-INSTANCES) (reverse results))
861
(else
760
(let ((next (add-days ts interval)))
+862
(let ((next (add-days-in-tz ts interval tz)))
863
(if (in-range? ts range-start range-end)
864
(loop next (+ n 1) (cons (clone-event event ts duration) results))
865
(loop next (+ n 1) results)))))))
866
765
(define (expand-weekly event ev-start duration interval count until byday range-start range-end)
+867
(define (expand-weekly event ev-start duration interval count until byday range-start range-end tz)
868
(let ((target-days (if (null? byday)
767
(list (timestamp-weekday ev-start))
+869
(list (timestamp-weekday-in-tz ev-start tz))
870
(let loop ((rest byday) (acc '()))
871
(if (null? rest)
872
(reverse acc)
@@ -783,19 +885,19 @@
885
(cond
886
((>= day-offset 7)
887
;; Move to next week (skip by interval)
786
(loop (add-days ts (* interval 7)) n results))
+888
(loop (add-days-in-tz ts (* interval 7) tz) n results))
889
((and count (>= n count)) (reverse results))
890
((and until (> day-ts until)) (reverse results))
891
((and range-end (> day-ts range-end)) (reverse results))
892
(else
791
(if (memv (timestamp-weekday day-ts) target-days)
+893
(if (memv (timestamp-weekday-in-tz day-ts tz) target-days)
894
(if (in-range? day-ts range-start range-end)
793
(week-loop (add-days day-ts 1) (+ day-offset 1) (+ n 1)
+895
(week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) (+ n 1)
896
(cons (clone-event event day-ts duration) results))
795
(week-loop (add-days day-ts 1) (+ day-offset 1) (+ n 1) results))
796
(week-loop (add-days day-ts 1) (+ day-offset 1) n results))))))))))
+897
(week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) (+ n 1) results))
+898
(week-loop (add-days-in-tz day-ts 1 tz) (+ day-offset 1) n results))))))))))
899
798
(define (expand-monthly event ev-start duration interval count until range-start range-end)
+900
(define (expand-monthly event ev-start duration interval count until range-start range-end tz)
901
(let loop ((ts ev-start) (month-offset 0) (n 0) (results '()))
902
(cond
903
((and count (>= n count)) (reverse results))
@@ -804,10 +906,10 @@
906
((>= n MAX-INSTANCES) (reverse results))
907
(else
908
(if (in-range? ts range-start range-end)
807
(loop (add-months ev-start (+ month-offset interval))
+909
(loop (add-months-in-tz ev-start (+ month-offset interval) tz)
910
(+ month-offset interval) (+ n 1)
911
(cons (clone-event event ts duration) results))
810
(loop (add-months ev-start (+ month-offset interval))
+912
(loop (add-months-in-tz ev-start (+ month-offset interval) tz)
913
(+ month-offset interval) (+ n 1)
914
results))))))
915
test/test-caldav.sglmodified
@@ -342,7 +342,104 @@
342
end: (+ start (* 7 86400)))))
343
;; Should only include events within the 7-day range
344
(assert-true (<= (length expanded) 7))
345
(assert-true (> (length expanded) 0))))))
+345
(assert-true (> (length expanded) 0)))))
+346
+347
;; TZID-aware recurrence: a weekly event tagged with a TZID anchors
+348
;; each occurrence to a fixed wall-clock in that timezone, even when
+349
;; the recurrence walks across a DST boundary. UTC-second arithmetic
+350
;; would drift the wall-clock by 1h after Spring/Fall transitions.
+351
+352
(test "weekly TZID=Europe/Athens master in winter, occurrence in DST"
+353
;; Master: 2026-01-13 (Tuesday, EET=UTC+2) at 17:00 Athens.
+354
;; 17:00 EET = 15:00 UTC = 1768316400.
+355
;; Apr 28 2026 (Tuesday, EEST=UTC+3): occurrence wall-clock should
+356
;; stay at 17:00 Athens, i.e. 14:00 UTC = 1777384800.
+357
;; The buggy UTC-naive walker produces 15:00 UTC = 1777388400 (off
+358
;; by +3600 — wall-clock drifts to 18:00 EEST).
+359
(let* ((ics (string-append
+360
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
+361
"UID:[email protected]\r\n"
+362
"DTSTART;TZID=Europe/Athens:20260113T170000\r\n"
+363
"DTEND;TZID=Europe/Athens:20260113T180000\r\n"
+364
"SUMMARY:Cross-DST recurrence\r\n"
+365
"RRULE:FREQ=WEEKLY;BYDAY=TU\r\n"
+366
"END:VEVENT\r\nEND:VCALENDAR"))
+367
(master (car (ical-parse ics)))
+368
(range-start (parse-ical-datetime "20260428T000000Z"))
+369
(range-end (parse-ical-datetime "20260429T000000Z"))
+370
(occurrences (ical-expand-recurrence master
+371
start: range-start
+372
end: range-end)))
+373
(assert-equal 1 (length occurrences))
+374
(assert-equal "20260428T140000Z"
+375
(format-ical-datetime
+376
(ical-event-dtstart (car occurrences))))))
+377
+378
(test "daily TZID=Europe/Athens spanning DST start"
+379
;; Master: 2026-03-26 (Thursday, EET=UTC+2) at 09:00 Athens.
+380
;; DST starts Sunday 2026-03-29. The Apr 1 (Wednesday) occurrence
+381
;; should be 09:00 EEST = 06:00 UTC, NOT 09:00 EET = 07:00 UTC.
+382
(let* ((ics (string-append
+383
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
+384
"UID:[email protected]\r\n"
+385
"DTSTART;TZID=Europe/Athens:20260326T090000\r\n"
+386
"DTEND;TZID=Europe/Athens:20260326T100000\r\n"
+387
"SUMMARY:Daily across DST\r\n"
+388
"RRULE:FREQ=DAILY\r\n"
+389
"END:VEVENT\r\nEND:VCALENDAR"))
+390
(master (car (ical-parse ics)))
+391
(range-start (parse-ical-datetime "20260401T000000Z"))
+392
(range-end (parse-ical-datetime "20260402T000000Z"))
+393
(occurrences (ical-expand-recurrence master
+394
start: range-start
+395
end: range-end)))
+396
(assert-equal 1 (length occurrences))
+397
(assert-equal "20260401T060000Z"
+398
(format-ical-datetime
+399
(ical-event-dtstart (car occurrences))))))
+400
+401
(test "monthly TZID=Europe/Athens spanning DST start"
+402
;; Master: 2026-01-15 (Thursday, EET=UTC+2) at 14:30 Athens.
+403
;; April 15 (Wednesday, EEST=UTC+3) — wall-clock 14:30 → 11:30 UTC.
+404
(let* ((ics (string-append
+405
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
+406
"UID:[email protected]\r\n"
+407
"DTSTART;TZID=Europe/Athens:20260115T143000\r\n"
+408
"DTEND;TZID=Europe/Athens:20260115T153000\r\n"
+409
"SUMMARY:Monthly across DST\r\n"
+410
"RRULE:FREQ=MONTHLY;COUNT=4\r\n"
+411
"END:VEVENT\r\nEND:VCALENDAR"))
+412
(master (car (ical-parse ics)))
+413
(occurrences (ical-expand-recurrence master)))
+414
(assert-equal 4 (length occurrences))
+415
;; Months: Jan (EET, 12:30 UTC), Feb (EET), Mar (EET), Apr (EEST, 11:30 UTC)
+416
(assert-equal "20260115T123000Z"
+417
(format-ical-datetime (ical-event-dtstart (list-ref occurrences 0))))
+418
(assert-equal "20260415T113000Z"
+419
(format-ical-datetime (ical-event-dtstart (list-ref occurrences 3))))))
+420
+421
(test "weekly UTC-anchored event keeps UTC across DST"
+422
;; Sanity: an event with no TZID (UTC-anchored DTSTART) preserves
+423
;; its UTC instant across DST transitions. The wall-clock in any
+424
;; observing tz shifts by 1h, which is correct UTC-anchor behavior.
+425
(let* ((ics (string-append
+426
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n"
+427
"UID:[email protected]\r\n"
+428
"DTSTART:20260113T140000Z\r\n"
+429
"DTEND:20260113T150000Z\r\n"
+430
"SUMMARY:UTC anchor\r\n"
+431
"RRULE:FREQ=WEEKLY;BYDAY=TU\r\n"
+432
"END:VEVENT\r\nEND:VCALENDAR"))
+433
(master (car (ical-parse ics)))
+434
(range-start (parse-ical-datetime "20260428T000000Z"))
+435
(range-end (parse-ical-datetime "20260429T000000Z"))
+436
(occurrences (ical-expand-recurrence master
+437
start: range-start
+438
end: range-end)))
+439
(assert-equal 1 (length occurrences))
+440
(assert-equal "20260428T140000Z"
+441
(format-ical-datetime
+442
(ical-event-dtstart (car occurrences)))))))
443
444
445
;; ============================================================