diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-20 23:37:31 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-20 23:37:31 -0500 |
| commit | f131fa4fb16949ba66d4153fde8b9c95fc407f84 (patch) | |
| tree | be639a223d4be6d0c99cad6c0787eeed21e54c38 | |
| parent | c3afc208b538c0a1de0890514e9d362c840c2930 (diff) | |
| download | dotemacs-f131fa4fb16949ba66d4153fde8b9c95fc407f84.tar.gz dotemacs-f131fa4fb16949ba66d4153fde8b9c95fc407f84.zip | |
fix(calendar): skip short months for plain monthly-on-the-31st rules
The stepper kept day-of-month verbatim, so Jan 31 stepped to Feb 31 and encode-time normalized it into a phantom Mar 3 occurrence. Plain monthly rules now advance to the next month that has the day, per RFC 5545.
| -rw-r--r-- | modules/calendar-sync-recurrence.el | 20 | ||||
| -rw-r--r-- | tests/test-calendar-sync--expand-monthly.el | 18 |
2 files changed, 36 insertions, 2 deletions
diff --git a/modules/calendar-sync-recurrence.el b/modules/calendar-sync-recurrence.el index a3bd4dc4..9ef12ce5 100644 --- a/modules/calendar-sync-recurrence.el +++ b/modules/calendar-sync-recurrence.el @@ -498,11 +498,27 @@ DTSTART's month, landing each occurrence on the day its BYDAY rule selects BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range. A rule with BYDAY (nth weekday, e.g. 2WE, -1TU, or SU with BYSETPOS) expands via `calendar-sync--expand-monthly-byday'; a plain rule steps -DTSTART's day-of-month." +DTSTART's day-of-month, skipping months without that day." (if (plist-get rrule :byday) (calendar-sync--expand-monthly-byday base-event rrule range) (calendar-sync--expand-simple-recurrence - base-event rrule range #'calendar-sync--add-months))) + base-event rrule range #'calendar-sync--next-monthly-date))) + +(defun calendar-sync--next-monthly-date (date interval) + "Step DATE forward INTERVAL months, skipping months without DATE's day. +A plain FREQ=MONTHLY on the 31st must skip short months (RFC 5545): +`calendar-sync--add-months' keeps day-of-month verbatim, so Jan 31 would +step to Feb 31, which encode-time normalizes into a phantom Mar 3 +occurrence. Bounded so a pathological input can't loop forever." + (require 'time-date) + (let ((next (calendar-sync--add-months date interval)) + (day (nth 2 date)) + (guard 0)) + (while (and (< guard 100) + (> day (date-days-in-month (nth 0 next) (nth 1 next)))) + (setq guard (1+ guard)) + (setq next (calendar-sync--add-months next interval))) + next)) (defun calendar-sync--expand-yearly-byday (base-event rrule range) "Expand a yearly nth-weekday event (e.g. BYMONTH=3;BYDAY=2SU). diff --git a/tests/test-calendar-sync--expand-monthly.el b/tests/test-calendar-sync--expand-monthly.el index d2d25a70..75404937 100644 --- a/tests/test-calendar-sync--expand-monthly.el +++ b/tests/test-calendar-sync--expand-monthly.el @@ -269,5 +269,23 @@ Upper bound alone can't catch a dropped final occurrence -- assert both." ;; 2026 months (Apr on) with a 5th Wednesday: Apr 29, Jul 29, Sep 30, Dec 30. (should (equal days '((4 29) (7 29) (9 30) (12 30)))))) +(ert-deftest test-calendar-sync--expand-monthly-on-31st-skips-short-months () + "Boundary: a plain monthly rule on the 31st skips months without a 31st. +The stepper kept day-of-month verbatim, so Jan 31 stepped to Feb 31, +which encode-time normalizes to Mar 3 -- phantom mis-dated occurrences +instead of the RFC 5545 skip." + (let* ((base-event (list :summary "Monthly on the 31st" + :start '(2030 1 31 10 0) + :end '(2030 1 31 11 0))) + (rrule (list :freq 'monthly :interval 1)) + (range (list (calendar-sync--date-to-time '(2030 1 1)) + (calendar-sync--date-to-time '(2030 12 31)))) + (occurrences (calendar-sync--expand-monthly base-event rrule range)) + (months (mapcar (lambda (o) (nth 1 (plist-get o :start))) occurrences)) + (days (mapcar (lambda (o) (nth 2 (plist-get o :start))) occurrences))) + ;; Only the seven 31-day months of 2030, each on the 31st. + (should (equal months '(1 3 5 7 8 10 12))) + (should (equal days '(31 31 31 31 31 31 31))))) + (provide 'test-calendar-sync--expand-monthly) ;;; test-calendar-sync--expand-monthly.el ends here |
