Skip to content

Convert non-UTC UNTIL to UTC for CalDAV compatibility#428

Merged
jens-maus merged 4 commits intojens-maus:masterfrom
KristjanESPERANTO:until
Jan 11, 2026
Merged

Convert non-UTC UNTIL to UTC for CalDAV compatibility#428
jens-maus merged 4 commits intojens-maus:masterfrom
KristjanESPERANTO:until

Conversation

@KristjanESPERANTO
Copy link
Contributor

@KristjanESPERANTO KristjanESPERANTO commented Jan 11, 2026

Problem

Some CalDAV servers send RRULE with UNTIL values that aren't in UTC format, violating RFC 5545. After updating to node-ical 0.23.0 (which switched from rrule.js to rrule-temporal), these calendars started failing:

DTSTART;TZID=Europe/Berlin:20240101T100000
RRULE:FREQ=WEEKLY;UNTIL=20241231T100000  (should end with Z)
Error: UNTIL rule part MUST always be specified as a date with UTC time

This worked before because rrule.js was more lenient, but rrule-temporal correctly enforces RFC 5545.

Solution

While rrule-temporal is right to enforce the spec, we should handle this in the parser to maintain backwards compatibility with buggy CalDAV servers. Parse UNTIL in the same timezone as DTSTART and convert to UTC before passing to rrule-temporal.

The fix belongs in node-ical because:

  • rrule-temporal should stay strict - it's enforcing the spec correctly
  • Parsers are responsible for normalizing malformed input
  • node-ical has the timezone context needed for conversion
  • Single fix benefits all library users

Changes

  • ical.js: Added UNTIL normalization in RRULE processing
  • Added test fixtures and 8 comprehensive test cases
  • Graceful error handling with warning logs

Summary by CodeRabbit

  • New Features
    • Timezone-aware RRULE UNTIL normalization: when an event has a timezone, non-UTC UNTIL values are converted to UTC for correct recurrence calculation; conversion failures emit a warning and parsing continues unchanged.
  • Tests
    • Added comprehensive tests and sample calendars covering multiple timezones, DST edges, date-only UNTIL, UTC UNTIL, and invalid timezone cases.

✏️ Tip: You can customize this high-level summary in your review settings.

RFC 5545 requires UNTIL to be UTC when DTSTART has a timezone, but
some CalDAV servers send invalid local time values. This causes
rrule-temporal to throw "UNTIL must be specified as UTC" errors.

Solution:
- Detect non-UTC UNTIL (missing 'Z' suffix)
- Parse in DTSTART timezone and convert to UTC
- Graceful fallback with warning on conversion failure

Fixes compatibility with buggy CalDAV servers while maintaining
RFC 5545 compliance. No breaking changes.
@coderabbitai
Copy link

coderabbitai bot commented Jan 11, 2026

📝 Walkthrough

Walkthrough

Detects RRULE UNTIL values that are specified in local (non-UTC) time when DTSTART has a TZID, converts those UNTIL timestamps to UTC using DTSTART's timezone, updates the RRULE string in-place, and protects the conversion with try/catch to avoid parse failures.

Changes

Cohort / File(s) Summary
Core Implementation
ical.js
Detects non-UTC UNTIL in RRULE when DTSTART has a TZID; resolves the TZID via tzUtil, converts the UNTIL to UTC (formatted as YYYYMMDDTHHMMSSZ), replaces the RRULE UNTIL value, wraps conversion in try/catch, and changes rruleOnly from const to let to allow reassignment.
Test Fixtures
test/non-utc-until.ics, test/non-utc-until-ny.ics
Adds ICS fixtures with VEVENTs using Europe/Berlin and America/New_York DTSTART/DTEND and RRULEs containing local-time (non-UTC) UNTIL values.
Tests
test/non-utc-until.test.js
New test suite covering conversion of non-UTC UNTIL to UTC, DST edge cases, floating times (no TZID), DATE-only UNTIL, already-UTC UNTIL, invalid TZID resilience, and recurrence generation respecting converted UNTIL.

Sequence Diagram(s)

sequenceDiagram
  participant Parser as ICS Parser
  participant RRULE as RRULE Parser
  participant TZ as tzUtil
  Parser->>RRULE: parse RRULE string (contains UNTIL)
  RRULE->>Parser: report UNTIL value and DTSTART TZID
  alt DTSTART has TZID and UNTIL is non-UTC
    Parser->>TZ: resolve TZID and convert local UNTIL -> UTC
    TZ-->>Parser: UTC timestamp (YYYYMMDDTHHMMSSZ)
    Parser->>RRULE: replace UNTIL in RRULE string with UTC value
  else no conversion needed
    Parser->>RRULE: continue parsing as-is
  end
  RRULE->>Parser: produce rrule object (options.until in UTC if converted)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through clocks and timezone grins,

Turned local UNTILs to UTC spins.
Berlin or New York, now synced just right,
Recurrences marching through day and night.
✨📅

🚥 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 'Convert non-UTC UNTIL to UTC for CalDAV compatibility' directly and accurately summarizes the main change: normalizing non-UTC UNTIL values in RRULE to UTC format for compatibility with rrule-temporal and CalDAV servers.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between fe4d9b5 and 7ae9f58.

📒 Files selected for processing (1)
  • ical.js
🧰 Additional context used
🧬 Code graph analysis (1)
ical.js (1)
test/non-utc-until.test.js (1)
  • untilUtc (52-52)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build (24.x, macos-latest)
🔇 Additional comments (1)
ical.js (1)

704-742: UNTIL normalization logic is well-structured and handles edge cases correctly.

The implementation correctly:

  • Uses negative lookahead (?!Z) to precisely match non-UTC UNTIL values
  • Falls back gracefully when timezone resolution fails (line 728 null check)
  • Preserves parsing on error with a warning log
  • Skips floating times (no curr.start.tz) and already-UTC values

One observation: the regex \d{8}T\d{6} only matches datetime UNTIL values, not date-only (UNTIL=20241231). This appears intentional since date-only UNTIL is interpreted as local midnight per RFC 5545 and doesn't have the UTC requirement issue.

Please confirm that date-only UNTIL values (e.g., UNTIL=20241231 without time component) are intentionally excluded from normalization, as they follow different semantics per RFC 5545 §3.3.10.


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.

Actionable comments posted: 0

🧹 Nitpick comments (2)
ical.js (2)

708-708: Consider extracting regex patterns as constants to improve maintainability.

The UNTIL matching regex /UNTIL=(\d{8}T\d{6}Z?)/ (line 708) and replacement regex /UNTIL=\d{8}T\d{6}/ (line 731) are similar but serve different purposes. While this works correctly, extracting them as named constants would improve maintainability and clarify intent:

const UNTIL_DATETIME_PATTERN = /UNTIL=(\d{8}T\d{6}Z?)/;
const UNTIL_DATETIME_NO_UTC_PATTERN = /UNTIL=\d{8}T\d{6}/;
♻️ Optional refactor to use named constants

At the top of the file (near other constants):

+// Regex patterns for RRULE UNTIL normalization
+const UNTIL_DATETIME_PATTERN = /UNTIL=(\d{8}T\d{6}Z?)/;
+const UNTIL_DATETIME_NO_UTC_PATTERN = /UNTIL=\d{8}T\d{6}/;

Then update the usage:

-          const untilMatch = rruleOnly.match(/UNTIL=(\d{8}T\d{6}Z?)/);
+          const untilMatch = rruleOnly.match(UNTIL_DATETIME_PATTERN);
           if (untilMatch && !untilMatch[1].endsWith('Z')) {
             // E.g., "20241231T100000"
             const untilLocal = untilMatch[1];

             try {
               const tzInfo = tzUtil.resolveTZID(curr.start.tz);
               let untilDate;

               // Parse UNTIL in the same timezone as DTSTART
               if (tzInfo.iana && tzUtil.isValidIana(tzInfo.iana)) {
                 untilDate = tzUtil.parseDateTimeInZone(untilLocal, tzInfo.iana);
               } else if (Number.isFinite(tzInfo.offsetMinutes)) {
                 untilDate = tzUtil.parseWithOffset(untilLocal, tzInfo.offset);
               }

               if (untilDate && typeof untilDate.toISOString === 'function') {
                 // Convert to UTC format: 20241231T090000Z
                 const untilUtc = untilDate.toISOString()
                   .replaceAll(/[-:]/g, '')
                   .replace(/\.\d{3}/, '');

                 // Replace in RRULE string
-                rruleOnly = rruleOnly.replace(/UNTIL=\d{8}T\d{6}/, `UNTIL=${untilUtc}`);
+                rruleOnly = rruleOnly.replace(UNTIL_DATETIME_NO_UTC_PATTERN, `UNTIL=${untilUtc}`);
               }
             } catch (error) {
               // If conversion fails, log warning but don't break parsing
               console.warn(`[node-ical] Failed to convert UNTIL to UTC: ${error.message}`);
             }
           }

Also applies to: 731-731


704-738: tzUtil.isValidIana exists and is properly exported—remove the verification concern. Consider enhancing error context as an optional improvement.

The UNTIL normalization logic is well-structured with proper guards, timezone resolution, and error handling. One item to address:

The error message on line 735 could optionally include more context for debugging. Rather than:

console.warn(`[node-ical] Failed to convert UNTIL to UTC: ${error.message}`);

Consider:

console.warn(`[node-ical] Failed to convert UNTIL to UTC for TZID="${curr.start.tz}", UNTIL="${untilLocal}": ${error.message}`);

(Note: tzUtil.isValidIana is defined in tz-utils.js:473 and properly exported; no verification needed.)

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 28fe58b and 51951ec.

📒 Files selected for processing (4)
  • ical.js
  • test/non-utc-until-ny.ics
  • test/non-utc-until.ics
  • test/non-utc-until.test.js
🧰 Additional context used
🧬 Code graph analysis (1)
ical.js (1)
test/non-utc-until.test.js (1)
  • untilUtc (52-52)
🔇 Additional comments (11)
ical.js (2)

700-702: LGTM: Variable declaration change enables UNTIL normalization.

Changing rruleOnly from const to let is necessary to allow reassignment when normalizing non-UTC UNTIL values on line 731.


1-13: No action needed. The library versions @js-temporal/polyfill 0.5.1 and rrule-temporal 1.3.0 are at their latest releases, and no security vulnerabilities or compatibility issues exist for either package.

test/non-utc-until.ics (1)

1-12: LGTM: Well-structured test fixture for Europe/Berlin timezone.

The iCalendar test data is correctly formatted and provides a clear test case for non-UTC UNTIL normalization with the Europe/Berlin timezone.

test/non-utc-until-ny.ics (1)

1-12: LGTM: Good test fixture covering America/New_York timezone.

The iCalendar test data correctly represents a daily event with non-UTC UNTIL in America/New_York timezone. The date range (March 1-15, 2024) is particularly valuable as it crosses the DST transition on March 10, 2024.

test/non-utc-until.test.js (7)

12-36: LGTM: Correct UTC conversion test for Europe/Berlin.

The test correctly verifies that 20241231T100000 in Europe/Berlin (CET, UTC+1 in winter) converts to 2024-12-31T09:00:00.000Z. The math is accurate: 10:00 CET - 1 hour offset = 09:00 UTC.


79-101: LGTM: UTC preservation test ensures no double conversion.

This test correctly verifies that UNTIL values already in UTC format (ending with 'Z') are not modified by the normalization logic.


104-124: LGTM: Floating time test ensures normalization is skipped without TZID.

This test correctly verifies that events without a TZID in DTSTART bypass the UNTIL conversion logic, as expected by the conditional guard on line 707 of ical.js.


155-173: LGTM: Graceful error handling for invalid timezones.

This test verifies that parsing doesn't throw even when timezone conversion fails, which aligns with the try-catch error handling in ical.js lines 733-736.


175-199: LGTM: DATE-only UNTIL test confirms correct handling.

This test verifies that DATE-only UNTIL values (format: YYYYMMDD without time) are not modified by the normalization logic. This is correct behavior since the regex /UNTIL=(\d{8}T\d{6}Z?)/ on ical.js line 708 requires a 'T' separator, so it won't match DATE-only values.


59-76: Verify DST status for America/New_York on March 15, 2024.

The test expects 20240315T140000 in America/New_York to convert to 2024-03-15T18:00:00.000Z. Daylight Saving Time in America/New_York started on March 10, 2024, so March 15, 2024 is in EDT (UTC-4). The conversion is correct: 14:00 EDT + 4 hours = 18:00 UTC.


127-153: The DST transition date and UTC conversion in the test are accurate. Europe/Berlin switched from CET to CEST on 31 March 2024, and the expected conversion (April 1, 2024 10:00 CEST = 08:00 UTC) is correct. No changes needed.

Move regex constants from global scope to local scope near their
usage for better locality of reference and code clarity.
Add TZID and UNTIL value to error message for easier debugging
when timezone conversion fails.
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: 0

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

704-742: Well-implemented UNTIL normalization with proper fallback handling.

The logic correctly:

  1. Only processes UNTIL when DTSTART has a timezone
  2. Skips already-UTC values (ending with 'Z')
  3. Uses the DTSTART timezone to interpret the local UNTIL value
  4. Falls back gracefully when timezone conversion fails

One minor observation: UNTIL_DATETIME_NO_UTC_PATTERN on line 710 would technically match the prefix of a UTC timestamp (e.g., matching UNTIL=20241231T100000 within UNTIL=20241231T100000Z). This doesn't cause a bug because line 713 guards against UTC values, but adding a negative lookahead could make the intent clearer:

/UNTIL=\d{8}T\d{6}(?!Z)/

This is optional since the current code works correctly.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 51951ec and fe4d9b5.

📒 Files selected for processing (1)
  • ical.js
🧰 Additional context used
🧬 Code graph analysis (1)
ical.js (1)
test/non-utc-until.test.js (1)
  • untilUtc (52-52)
🔇 Additional comments (2)
ical.js (2)

700-702: LGTM!

Changing from const to let is appropriate here since the RRULE string may be modified during UNTIL normalization.


722-722: No action needed. The isValidIana function is properly defined in tz-utils.js (line 473) and correctly exported (line 658). It is imported into ical.js (line 13) and used correctly at line 722.

matching non-UTC UNTIL values. Makes intent clearer and pattern
more self-documenting.
@KristjanESPERANTO
Copy link
Contributor Author

@jens-maus PR is ready to merge from my side 🙂

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