aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-20 23:37:31 -0500
committerCraig Jennings <c@cjennings.net>2026-07-20 23:37:31 -0500
commitf131fa4fb16949ba66d4153fde8b9c95fc407f84 (patch)
treebe639a223d4be6d0c99cad6c0787eeed21e54c38
parentc3afc208b538c0a1de0890514e9d362c840c2930 (diff)
downloaddotemacs-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.el20
-rw-r--r--tests/test-calendar-sync--expand-monthly.el18
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