Skip to content

fix: monthly recurring events on multiple days per month could skip the start month#477

Merged
jens-maus merged 4 commits intojens-maus:masterfrom
KristjanESPERANTO:multiple
Mar 6, 2026
Merged

fix: monthly recurring events on multiple days per month could skip the start month#477
jens-maus merged 4 commits intojens-maus:masterfrom
KristjanESPERANTO:multiple

Conversation

@KristjanESPERANTO
Copy link
Contributor

@KristjanESPERANTO KristjanESPERANTO commented Mar 5, 2026

Started as a one-line dependency bump for rrule-temporal v1.4.7. Writing regression tests led to a refactor of expandRecurringEvent (complexity lint violation), which exposed two pre-existing bugs. A CodeRabbit review caught a third.

All three bugs are latent: they only trigger when a timed recurring event produces more than one instance on the same calendar date (e.g. via BYHOUR). Uncommon enough to go unnoticed, but the consequences — silently excluding or overriding the wrong instance — are real correctness issues.


Commits

1. chore: update rrule-temporal ^1.4.6 → ^1.4.7

Fixes a bug where FREQ=MONTHLY with multiple BYMONTHDAY values skipped the entire start month when an earlier BYMONTHDAY value existed in the same month.

DTSTART=2025-01-15, RRULE:FREQ=MONTHLY;BYMONTHDAY=1,15
— old: first occurrence February 1 · new: January 15

Bumped explicitly (not just relying on ^) because npm install with an existing lock file uses the locked version. Upstream: ggaabe/rrule-temporal#110


2. test: add regression for FREQ=MONTHLY with multiple BYMONTHDAY values

Three tests in test/monthly-bymonthday-multiple.test.js:

  • start month not skipped when an earlier BYMONTHDAY exists
  • no occurrences generated before DTSTART
  • end-to-end via ical.expandRecurringEvent()

3. refactor: decompose expandRecurringEvent into focused helper functions

processRecurringInstance had cyclomatic complexity 22 (lint max: 20). Extracted isExcludedByExdate, validateDateRange, adjustSearchRange, buildRecurringInstance.

Two pre-existing bugs fixed in the process:

  • EXDATE (timed events): old code used date-only key → excluded all instances on that date. Fix: exact ISO timestamp first; date-only key only as fallback when the EXDATE is itself DATE-only.
  • RECURRENCE-ID (timed events): old code used date-only key only. This commit improves to ISO-key-first with fallback; the fallback is eliminated in commit 4.

4. fix: RECURRENCE-ID override must not bleed into other same-day timed instances

Pre-existing bug (same root cause as commit 3's EXDATE fix):

storeRecurrenceOverride stores every DATE-TIME RECURRENCE-ID under both an ISO key and a date-only key. Commit 3 introduced ISO-key-first with a date-only fallback, but that fallback still applies one instance's override to another when both share a calendar date (e.g. BYHOUR=9,15).

Fix: timed events use the ISO key only — a miss unambiguously means "no override for this instance" because every properly stored DATE-TIME RECURRENCE-ID carries both keys.

Regression tests added for both bugs (EXDATE + RECURRENCE-ID) in test/expand-recurring-event.test.js.

Summary by CodeRabbit

  • Bug Fixes

    • Improved recurring exclusions, RECURRENCE-ID override accuracy, full-day end-of-day handling, and date-range validation for correct recurrence expansion.
  • Refactor

    • Recurrence expansion workflow consolidated for consistent instance construction and timezone metadata preservation.
  • Tests

    • Added regression tests for monthly multi-day recurrences and EXDATE/RECURRENCE-ID edge cases.
  • Chores

    • Dependency update and new lint rule to limit function parameters.

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7590b16b-b8c4-4da9-a251-9f0b3d7fe059

📥 Commits

Reviewing files that changed from the base of the PR and between 19f1740 and 8f8302f.

📒 Files selected for processing (3)
  • node-ical.js
  • package.json
  • test/expand-recurring-event.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • package.json

📝 Walkthrough

Walkthrough

Refactors recurring-event expansion in node-ical.js: extracts EXDATE checks, date-range validation/adjustment, and instance construction into helpers (isExcludedByExdate, validateDateRange, adjustSearchRange, buildRecurringInstance); updates expandRecurringEvent to use them; bumps rrule-temporal; adds regression tests for BYMONTHDAY and EXDATE/RECURRENCE-ID edge cases.

Changes

Cohort / File(s) Summary
Recurring event core
node-ical.js
Adds isExcludedByExdate, validateDateRange, adjustSearchRange, buildRecurringInstance; refactors expandRecurringEvent to centralize EXDATE/RECURRENCE-ID handling, range validation/adjustment, full-day normalization, instance building, end-time calculation, and preserves timezone/dateOnly metadata and final sort.
Config / Lint / Dependency
package.json
Bumps rrule-temporal ^1.4.6^1.4.7; adds XO lint rule max-params: ["warn", 5].
Tests — BYMONTHDAY regression
test/monthly-bymonthday-multiple.test.js
Adds regression tests for FREQ=MONTHLY with multiple BYMONTHDAY values verifying ordering and total occurrences via expandRecurringEvent.
Tests — EXDATE / RECURRENCE-ID edge cases
test/expand-recurring-event.test.js
Adds tests ensuring EXDATE and RECURRENCE-ID matching distinguish ISO-timestamp vs date-only keys and that overrides/exclusions apply only to the exact timed instance when two occurrences share a calendar date.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 I hopped through rules and dates with care,

EXDATEs tucked neat beneath my paw,
Ranges trimmed, instances stitched with flair,
Monthly days now leap and meet the law,
A tiny rabbit cheers — tests pass, hurrah!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main fix: addressing a bug where monthly recurring events with multiple days per month could skip the start month, which is the core issue resolved by the rrule-temporal dependency update and refactoring.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
test/monthly-bymonthday-multiple.test.js (1)

32-38: Tests exercise rrule.all() directly rather than expandRecurringEvent.

These tests validate the rrule-temporal fix but don't exercise the refactored expandRecurringEvent function or its new helpers (isExcludedByExdate, buildRecurringInstance, etc.). Consider adding a test that uses expandRecurringEvent with BYMONTHDAY rules to ensure the integration works end-to-end.

// Example additional test using expandRecurringEvent:
it('should work through expandRecurringEvent', function () {
  const parsed = ical.parseICS(icsData);
  const event = Object.values(parsed).find(e => e.type === 'VEVENT');
  
  const instances = ical.expandRecurringEvent(event, {
    from: new Date('2026-02-01T00:00:00Z'),
    to: new Date('2026-05-31T23:59:59Z'),
  });
  
  assert.strictEqual(instances.length, 9);
  assert.strictEqual(instances[0].start.getUTCDate(), 24);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/monthly-bymonthday-multiple.test.js` around lines 32 - 38, Add a test
that exercises the refactored recurrence expansion end-to-end by calling
expandRecurringEvent (instead of using rrule.all() directly) on the parsed
VEVENT from ical.parseICS, passing a from/to range and asserting the expected
instance count and specific start dates; this will also validate the new helpers
(isExcludedByExdate, buildRecurringInstance) are integrated—replace or add to
the existing assertions around event.rrule and recurrences with a call to
ical.expandRecurringEvent(event, { from: <Date>, to: <Date> }) and assert
instances.length and instances[0].start.getUTCDate() (and any other expected
values).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/monthly-bymonthday-multiple.test.js`:
- Around line 32-38: Add a test that exercises the refactored recurrence
expansion end-to-end by calling expandRecurringEvent (instead of using
rrule.all() directly) on the parsed VEVENT from ical.parseICS, passing a from/to
range and asserting the expected instance count and specific start dates; this
will also validate the new helpers (isExcludedByExdate, buildRecurringInstance)
are integrated—replace or add to the existing assertions around event.rrule and
recurrences with a call to ical.expandRecurringEvent(event, { from: <Date>, to:
<Date> }) and assert instances.length and instances[0].start.getUTCDate() (and
any other expected values).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4018f52a-8a89-40ab-85c9-a1f904c5f4a6

📥 Commits

Reviewing files that changed from the base of the PR and between fcb8337 and 0960789.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (3)
  • node-ical.js
  • package.json
  • test/monthly-bymonthday-multiple.test.js

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
node-ical.js (1)

413-431: Minor inefficiency: dual-key storage causes redundant iterations for full-day events.

Due to the dual-key EXDATE storage strategy (both dateKey and ISO string for DATE-TIME entries), Object.values(event.exdate) may return the same Date object reference multiple times. The comparison works correctly but performs redundant iterations.

Consider using a Set to dedupe or breaking after the first match is found (already done), which makes this acceptable as-is. Alternatively, you could iterate Object.keys() and skip ISO-string keys to avoid duplicate comparisons.

♻️ Optional: Skip ISO-string keys to avoid redundant comparisons
   if (isFullDay) {
-    for (const exdateValue of Object.values(event.exdate)) {
-      if (exdateValue instanceof Date && getDateKey(exdateValue) === dateKey) {
+    for (const [key, exdateValue] of Object.entries(event.exdate)) {
+      // Skip ISO-string keys (contain 'T') to avoid checking same Date twice
+      if (key.includes('T')) {
+        continue;
+      }
+      if (exdateValue instanceof Date && getDateKey(exdateValue) === dateKey) {
         return true;
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@node-ical.js` around lines 413 - 431, The isExcludedByExdate function can
waste CPU comparing the same Date value twice because event.exdate stores both
calendar-key and ISO-string keys; update the full-day branch in
isExcludedByExdate to iterate Object.keys(event.exdate) and for each key skip
keys that look like ISO strings (e.g., contain 'T' or match
/^\d{4}-\d{2}-\d{2}T/), then retrieve the Date via event.exdate[key] and compare
with getDateKey(exdateValue) === dateKey, returning true on first match;
alternatively, dedupe values by pushing them into a Set before
comparing—reference isExcludedByExdate, event.exdate, getDateKey, dateKey, and
date.toISOString() when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@node-ical.js`:
- Around line 413-431: The isExcludedByExdate function can waste CPU comparing
the same Date value twice because event.exdate stores both calendar-key and
ISO-string keys; update the full-day branch in isExcludedByExdate to iterate
Object.keys(event.exdate) and for each key skip keys that look like ISO strings
(e.g., contain 'T' or match /^\d{4}-\d{2}-\d{2}T/), then retrieve the Date via
event.exdate[key] and compare with getDateKey(exdateValue) === dateKey,
returning true on first match; alternatively, dedupe values by pushing them into
a Set before comparing—reference isExcludedByExdate, event.exdate, getDateKey,
dateKey, and date.toISOString() when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 17d9a60d-eb72-4e22-9a07-39ce33035c32

📥 Commits

Reviewing files that changed from the base of the PR and between 0960789 and f7765f8.

📒 Files selected for processing (3)
  • node-ical.js
  • package.json
  • test/monthly-bymonthday-multiple.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/monthly-bymonthday-multiple.test.js
  • package.json

Covers the bug fixed in rrule-temporal v1.4.7: when DTSTART fell on
one of multiple BYMONTHDAY values and an earlier BYMONTHDAY value
existed in the same month, the entire start month was skipped.

Tests:
- start month occurrences after DTSTART are included (Feb 24 + Feb 28)
- no occurrences before DTSTART are generated (Feb 10 is excluded)
@KristjanESPERANTO KristjanESPERANTO force-pushed the multiple branch 2 times, most recently from aab4708 to 70a5d79 Compare March 5, 2026 13:18
@KristjanESPERANTO
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@node-ical.js`:
- Around line 494-495: The override lookup currently only checks
event.recurrences?.[dateKey], which can mis-select for timed recurrences; update
the logic in the isOverride/instanceEvent resolution to try an ISO datetime key
first (e.g. isoKey derived from the instance's full ISO timestamp) and use
event.recurrences?.[isoKey] when present, then fall back to
event.recurrences?.[dateKey] if not found, preserving the includeOverrides
guard; set isOverride based on whether either lookup returns a value and set
instanceEvent to the matched override (isoKey match preferred) or event
otherwise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: afffe672-d357-43a7-8cea-bbe909db577f

📥 Commits

Reviewing files that changed from the base of the PR and between aab4708 and 70a5d79.

📒 Files selected for processing (2)
  • node-ical.js
  • package.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@node-ical.js`:
- Line 433: The current check in the return statement uses event.exdate[dateKey]
which for timed recurrences can incorrectly exclude all same-day instances;
change the logic so that for timed events you only match the exact timestamp
(use event.exdate[date.toISOString()]) and for floating/all-day events use
event.exdate[dateKey]. Update the return in the function that currently does
"return Boolean(event.exdate[dateKey] || event.exdate[date.toISOString()]);" to
first detect whether the event is timed (has a time component/non-floating
start) and then only consult event.exdate[date.toISOString()] for timed events
and event.exdate[dateKey] for floating/all-day events.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 13298693-8ec1-446d-acff-add4dfb48ad1

📥 Commits

Reviewing files that changed from the base of the PR and between 70a5d79 and 19f1740.

📒 Files selected for processing (2)
  • node-ical.js
  • package.json

Replace the monolithic processRecurringInstance with four single-purpose helpers:
- isExcludedByExdate: EXDATE lookup logic
- validateDateRange: input validation for from/to
- adjustSearchRange: full-day end-of-day nudge + expandOngoing lookback
- buildRecurringInstance: construct one instance for an RRULE-generated date

isFullDay and baseDurationMs are now computed once in expandRecurringEvent and
passed down, eliminating the duplicate derivation in the old implementation.

Also raise max-params to 5 in xo config — adjustSearchRange and
buildRecurringInstance legitimately need 5 positional parameters; forcing an
options object would obscure required, ordered arguments without real benefit.

Fixes two pre-existing bugs discovered during the refactor:

- EXDATE precision (timed events): the old code matched EXDATE entries by
  date-only key, accidentally excluding every instance on that calendar day.
  isExcludedByExdate now matches by exact ISO timestamp; it falls back to
  the date-only key only when the EXDATE entry itself is DATE-only
  (dateOnly=true), which per RFC 5545 intentionally covers the whole day.

- RECURRENCE-ID precision (timed events): the old code matched recurrence
  overrides by date-only key only. This commit improves that to ISO-key-first
  with a date-only fallback; a remaining edge case (two timed instances sharing
  a calendar date) is addressed in the following commit.
…instances

Pre-existing bug: storeRecurrenceOverride (ical.js) stores every DATE-TIME
RECURRENCE-ID under both an ISO key and a date-only key. The original code
resolved overrides using only the date-only key; the previous commit improved
this to ISO-key-first with a date-only fallback. The fallback is still wrong
when two instances share a calendar date (e.g. BYHOUR=9,15): a miss on the ISO
key unambiguously means "no override for this instance", because every properly
stored DATE-TIME RECURRENCE-ID carries both keys. Falling back to dateKey
would incorrectly apply one occurrence's override to a different occurrence on
the same day.

Fix: for timed events, use the ISO key only. Full-day events have no ISO key
and continue to use dateKey.

Also add regression tests for both bugs fixed across commits 3–4:
- EXDATE: only the matching timed instance must be excluded when two share a date
- RECURRENCE-ID: only the matching timed instance must receive the override
@KristjanESPERANTO
Copy link
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Reviews resumed.

@KristjanESPERANTO
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Mar 5, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@KristjanESPERANTO KristjanESPERANTO marked this pull request as ready for review March 5, 2026 23:12
@KristjanESPERANTO
Copy link
Contributor Author

KristjanESPERANTO commented Mar 5, 2026

Coderabbit seems to be satisfied now!

Oh boy, that was another interesting ride 😄 But it's a good example of how refactoring and further testing are worthwhile, as they reveal edge-case bugs (again also with the help of coderabbit) that might otherwise remain unfixed forever.

The failing CI test seems to be an GitHub issue and not something about this PR changes.

@jens-maus jens-maus merged commit db93f20 into jens-maus:master Mar 6, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants