From 2443d3da778b8ebccd33ee9a603c217ed069e340 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 16 Jul 2026 15:22:48 -0500 Subject: fix(calendar-sync): keep the final occurrence of an UNTIL-bounded series RFC 5545 3.3.10 bounds a recurrence inclusively: when UNTIL lines up with the recurrence, that date is the last instance. The expansion loops guarded on calendar-sync--before-date-p, a strict comparison, so the instance landing exactly on UNTIL was dropped. Every bounded series silently lost its last meeting from the agenda. A series whose UNTIL equals its start date lost the only instance it had and vanished. I added calendar-sync--date-on-or-before-p beside --before-date-p and swapped the three UNTIL sites: the simple-recurrence loop feeding daily, monthly and yearly, plus both weekly checks. --before-date-p is unchanged, since a date is still not before itself. The two UNTIL property tests asserted the wrong invariant. They required every occurrence to fall strictly before UNTIL, the exclusive reading the RFC contradicts, so they pinned the defect they should have caught. The property is now on-or-before. An upper bound alone can't catch a dropped occurrence, so I added one asserting the series reaches its UNTIL date. Reverting the fix failed three daily tests and one property test, and no weekly ones. The weekly loop was fixed but unguarded, so it has its own test now. The comparison stays date-granular, matching --date-to-time. An UNTIL carrying a time of day earlier than the event's start will include that final instance where the RFC would exclude it. Making it datetime-precise means reworking --date-to-time and the timezone conversions feeding it, a much larger change than this bug warrants. --- modules/calendar-sync-ics.el | 12 ++++++++++++ modules/calendar-sync-recurrence.el | 10 ++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) (limited to 'modules') diff --git a/modules/calendar-sync-ics.el b/modules/calendar-sync-ics.el index 9cb57e96..4888112c 100644 --- a/modules/calendar-sync-ics.el +++ b/modules/calendar-sync-ics.el @@ -218,6 +218,18 @@ Both dates should be lists like (year month day)." (time-less-p (calendar-sync--date-to-time date1) (calendar-sync--date-to-time date2))) +(defun calendar-sync--date-on-or-before-p (date1 date2) + "Return t if DATE1 falls on or before DATE2. +Both dates should be lists like (year month day); like +`calendar-sync--date-to-time', only the first three elements are compared, +so any hour/minute tail is ignored. + +This is the comparison RRULE UNTIL needs. RFC 5545 3.3.10 bounds a +recurrence \"in an inclusive manner\": when UNTIL lines up with the +recurrence, that date is the last instance. A strict +`calendar-sync--before-date-p' drops it." + (not (calendar-sync--before-date-p date2 date1))) + ;;; Datetime Parsing (defun calendar-sync--parse-ics-datetime (value) diff --git a/modules/calendar-sync-recurrence.el b/modules/calendar-sync-recurrence.el index 2689cdd6..1cb25636 100644 --- a/modules/calendar-sync-recurrence.el +++ b/modules/calendar-sync-recurrence.el @@ -322,7 +322,8 @@ ADVANCE-FN takes (current-date interval) and returns the next date." (num-generated 0) (range-end-time (cadr range))) (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) - (or (not until) (calendar-sync--before-date-p current-date until)) + ;; UNTIL is inclusive (RFC 5545 3.3.10) -- on-or-before, not before. + (or (not until) (calendar-sync--date-on-or-before-p current-date until)) (or (not count) (< num-generated count))) (let ((occurrence-datetime (append current-date (nthcdr 3 start)))) (setq num-generated (1+ num-generated)) @@ -364,7 +365,8 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." (while (and (< iterations max-iterations) (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) (or (not count) (< num-generated count)) - (or (not until) (calendar-sync--before-date-p current-date until))) + ;; UNTIL is inclusive (RFC 5545 3.3.10) -- on-or-before, not before. + (or (not until) (calendar-sync--date-on-or-before-p current-date until))) (setq iterations (1+ iterations)) ;; Generate occurrences for each weekday in this week (dolist (weekday weekdays) @@ -372,8 +374,8 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." (days-ahead (mod (- weekday current-weekday) 7)) (occurrence-date (calendar-sync--add-days current-date days-ahead)) (occurrence-datetime (append occurrence-date (nthcdr 3 start)))) - ;; Check UNTIL date first - (when (or (not until) (calendar-sync--before-date-p occurrence-date until)) + ;; Check UNTIL date first -- inclusive per RFC 5545 3.3.10. + (when (or (not until) (calendar-sync--date-on-or-before-p occurrence-date until)) ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start (when (or (not count) (< num-generated count)) (setq num-generated (1+ num-generated)) -- cgit v1.2.3