Skip to content

Comments

refactor: leverage Temporal across the codebase - simplify timezone internals#462

Merged
jens-maus merged 8 commits intojens-maus:masterfrom
KristjanESPERANTO:temporal
Feb 21, 2026
Merged

refactor: leverage Temporal across the codebase - simplify timezone internals#462
jens-maus merged 8 commits intojens-maus:masterfrom
KristjanESPERANTO:temporal

Conversation

@KristjanESPERANTO
Copy link
Contributor

@KristjanESPERANTO KristjanESPERANTO commented Feb 20, 2026

With rrule-temporal and the Temporal polyfill already in the dependency tree, this PR revisits the remaining timezone code paths to replace legacy Intl.DateTimeFormat workarounds and manual UTC arithmetic with direct Temporal API calls.

What started as a pure refactoring effort uncovered a subtle, environment-dependent bug that had been invisible in CI for years — fixed in this PR alongside the cleanup.

Net result: 8 files changed, 122 insertions, 346 deletions — ~224 lines removed while adding a regression test and fixing a bug.

Commits

1. refactor(tz-utils): replace convergeLocalInstant with Temporal

parseDateTimeInZone() used a hand-rolled 3-round fixed-point iteration to convert wall-clock time to UTC. Replaced with a single Temporal.PlainDateTime.from().toZonedDateTime() call. ~85 lines removed.

2. refactor(node-ical): replace Intl EXDATE check with getDateKey

EXDATE exclusion matching duplicated timezone-aware date extraction that getDateKey() already provides. Deduplicated to a single shared call.

3. refactor(tz-utils): replace Intl-based formatDateForRrule with Temporal

Two separate code paths (IANA via Intl formatter cache, offset via manual arithmetic) plus a Node < 22 midnight workaround → one Temporal.Instant.toZonedDateTimeISO() call. Dead helper normalizeMidnightParts removed.

4. fix(ical): normalize DATE-only RRULE start to midnight; replace Intl DTSTART with Temporal

Bug: The getTimezoneOffset() compensation for VALUE=DATE events — introduced in cbb76af — shifted the time by the server's UTC offset on machines east of UTC (e.g. 02:00:00 instead of 00:00:00 on UTC+2). On UTC servers the buggy branch was dead code, so CI never caught it.

Fix: Extract local date components directly — no offset involved. Regression test added.

Same commit also replaces the Intl.DateTimeFormat.formatToParts block for DATE-TIME RRULE DTSTART with Temporal.Instant.toZonedDateTimeISO().

Remaining Intl usage (intentionally kept)

  • guessLocalZone() — no Temporal equivalent for system timezone detection
  • getZoneNames() — no Temporal equivalent for listing known zones
  • isValidIana() — more portable than Temporal; accepts fixed-offset strings; result is cached

Summary by CodeRabbit

  • Bug Fixes

    • Date-only recurring events normalize to local midnight; EXDATE/RECURRENCE comparisons use a consistent date-key to avoid missed/duplicate instances.
  • New Features

    • Public date-key helper exposed for consistent date comparisons across timed and all-day events.
  • Chores

    • Migrated timezone/date handling to Temporal (polyfill added) for more consistent parsing/formatting and clearer fallbacks.
  • Tests

    • Added regression test for date-only RRULE normalization; removed an obsolete midnight-edge test.

parseDateTimeInZone() previously used a manual fixed-point iteration
(convergeLocalInstant) plus a helper (ymdhmsToUtcMs) to work around the
fact that JS Date has no "wall-clock → UTC" API. The algorithm ran up to
3 rounds of Intl formatting and subtraction to converge on the correct
UTC instant, with special-case handling for DST gaps/folds.

Replace the entire mechanism with a single Temporal call:
  Temporal.PlainDateTime.from(fields)
    .toZonedDateTime(tz, { disambiguation: 'later' })
    .epochMilliseconds

Temporal handles DST disambiguation natively. disambiguation:'later' is
used to preserve the existing fold behaviour (post-DST / second
occurrence for repeated hours).

Also require temporal-polyfill directly in both ical.js and tz-utils.js
instead of going through the @js-temporal/polyfill alias. Add
temporal-polyfill as an explicit dependency in package.json (the alias
stays for rrule-temporal's transitional require).

Removed: ymdhmsToUtcMs (~20 lines), convergeLocalInstant (~35 lines),
getLocalParts closure in parseDateTimeInZone (~30 lines).
…tDateKey

The full-day EXDATE exclusion loop in processRecurringInstance used a
local Intl.DateTimeFormat('en-CA') call to extract the calendar date in
the event's timezone. This duplicated the logic already present in
getDateKey() (ical.js), which was introduced to fix issue jens-maus#459.

Replace the inline Intl formatter with a call to getDateKey(exdateValue),
exported from ical.js. The two functions now share a single source of
truth for timezone-aware date key extraction.

generateDateKey() is kept separate: it serves RRULE-generated Date objects
that carry no .tz/.dateOnly metadata, requiring an explicit isFullDay flag.
Temporal.Instant.toZonedDateTimeISO() handles both IANA zones and
fixed-offset strings natively, eliminating two separate code paths
and the Intl formatter cache (dtfCache / getFormatter).

The normalizeMidnightParts workaround for the Node <22 Intl bug
("24:00" at midnight) is no longer needed and is removed together
with its __test__ export and unit test.
…DTSTART construction with Temporal

Bug: getTimezoneOffset()-based compensation shifted VALUE=DATE events
by the server's UTC offset on machines east of UTC (e.g. 02:00:00 on
UTC+2). Silent on UTC servers, so never caught in CI.
Fix: extract local date components directly, consistent with how
dateParameter creates DATE-only Dates.

Refactor (no behavior change): replace Intl.DateTimeFormat formatToParts
block for DATE-TIME RRULE DTSTART with Temporal.Instant.toZonedDateTimeISO(),
matching the existing pattern in getDateKey().

Update example.txt snapshot: "02:00:00" for "foobarTV broadcast starts"
was the bug manifesting on a UTC+2 machine.

Add regression test: fails with old code on UTC+x, was silent on UTC servers.
@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

📝 Walkthrough

Walkthrough

Replace Intl-based timezone/date logic with Temporal (via temporal-polyfill); extract getDateKey into lib/date-utils.js; normalize DATE-only DTSTART to local midnight; adopt Temporal-based DATE-TIME handling and use getDateKey for EXDATE/recurrence comparisons; update tests and snapshots.

Changes

Cohort / File(s) Summary
Temporal & TZ core
tz-utils.js, package.json
Add temporal-polyfill and initialize global Temporal; remove Intl-based formatter caching and helpers; reimplement parsing/formatting using Temporal and windowsZones mapping.
Date utilities
lib/date-utils.js
New getDateKey(dateValue) utility that produces YYYY-MM-DD using dateOnly/local-time, tz metadata via tz-utils, or UTC fallback; exported from this module.
ICal parsing
ical.js
Remove in-file getDateKey implementation and import getDateKey from ./lib/date-utils.js; reconstruct DATE starts as local midnight; simplify DATE-TIME handling via Temporal → zoned Date conversions; adjust exports to no longer expose getDateKey.
Node integration / recurrence handling
node-ical.js
Import and use getDateKey for EXDATE/RECURRENCE-ID comparisons; generateDateKey updated to accept isFullDay and distinguish local-date vs UTC-timed keys.
Tests & snapshots
test/date-only-rrule-until.test.js, test/tz-utils.test.js, test/snapshots/example.txt
Add regression test ensuring DATE;VALUE=DATE normalized to local midnight (duplicate insertions present); remove midnight-roll test; update snapshot timestamp from 02:00:00 → 00:00:00.

Sequence Diagram(s)

sequenceDiagram
    participant Parser as ICal Parser (ical.js)
    participant TZ as TZ Utils (tz-utils.js)
    participant Utils as Date Utils (lib/date-utils.js)
    participant RR as RRULE Engine (node-ical)
    participant Store as EXDATE Store

    Parser->>TZ: resolve TZID / convert DTSTART using Temporal
    TZ-->>Parser: Date (+tz metadata) or local-midnight for DATE
    Parser->>RR: pass normalized DTSTART and RRULE
    RR->>Utils: generateDateKey(instanceDate, isFullDay?)
    Utils-->>RR: return YYYY-MM-DD or full ISO key
    RR->>Store: compare instance key vs EXDATE keys
    alt match
        Store-->>RR: exclude instance
    else
        Store-->>RR: include instance
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I nibbled at midnight, stitched it true,

zoned the ticks with Temporal glue,
keys now whisper YYYY-MM-DD,
EXDATEs hop out, set free,
carrots, tests, and dates — anew.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the primary change: refactoring to use Temporal API instead of legacy Intl.DateTimeFormat workarounds and simplifying timezone handling internals across the codebase.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 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)
ical.js (1)

1153-1154: Consider adding getDateKey to the main module.exports object instead of appending separately.

The main export block is at line 592. Appending module.exports.getDateKey = getDateKey at the bottom works but is inconsistent with how all other exports are declared in the object literal. Moving it into the object improves discoverability.

Suggested change

Add getDateKey inside the module.exports = { ... } block at line 592:

 module.exports = {
   objectHandlers: {
     // ...
   },
   handleObject(name, value, parameters, ctx, stack, line) { ... },
   parseLines(lines, batchSize = 0, ctx, stack, startIndex = 0, cb) { ... },
   parseICS(string, cb) { ... },
+  getDateKey,
 };
-
-module.exports.getDateKey = getDateKey;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ical.js` around lines 1153 - 1154, The export for getDateKey is appended
separately; move it into the main module.exports object so exports are
consistent and discoverable: locate the module.exports = { ... } object and add
getDateKey as a property (e.g., getDateKey: getDateKey) alongside the other
exports, then remove the separate module.exports.getDateKey = getDateKey line at
the bottom; ensure the symbol getDateKey is referenced exactly as the function
name and that module.exports remains a single object literal.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ical.js`:
- Around line 1153-1154: The export for getDateKey is appended separately; move
it into the main module.exports object so exports are consistent and
discoverable: locate the module.exports = { ... } object and add getDateKey as a
property (e.g., getDateKey: getDateKey) alongside the other exports, then remove
the separate module.exports.getDateKey = getDateKey line at the bottom; ensure
the symbol getDateKey is referenced exactly as the function name and that
module.exports remains a single object literal.

@KristjanESPERANTO KristjanESPERANTO marked this pull request as draft February 20, 2026 08:14
@KristjanESPERANTO
Copy link
Contributor Author

The nitpick comment sounds legit. I'll look into it ASAP 🙂

@jens-maus
Copy link
Owner

Thanks again @KristjanESPERANTO for this (once again) great PR! I really appreciate all your continous efforts in getting node-ical more and more complete and stabilized and it improved considerably since you went onboard the maintainers!

Regarding this PR, it sounds pretty reasonable to me and quickly looking over the proposed changes makes me feel that it is ready for review rather than being a draft, isn't it? So feel free to make it complete and let me know when you are ready for the review and potential merge. Afterward I will then probably bump node-ical to 0.26.0 since this qualifies a bit more than just a plain bug fix, I guess...

@KristjanESPERANTO KristjanESPERANTO marked this pull request as ready for review February 20, 2026 15:21
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)
ical.js (1)

934-945: Consider emitting a warning when TZID resolves to neither IANA nor offset.

When tzInfo is non-null but both tzInfo.iana and tzInfo.offset are falsy, timeZone silently falls back to 'UTC'. getDateKey (lines 38–43) emits a console.warn in this identical situation — keeping the same pattern here aids debugging.

♻️ Proposed alignment with getDateKey warning pattern
-          const tzInfo = curr.start.tz ? tzUtil.resolveTZID(curr.start.tz) : undefined;
-          const timeZone = tzInfo?.iana || tzInfo?.offset || 'UTC';
+          const tzInfo = curr.start.tz ? tzUtil.resolveTZID(curr.start.tz) : undefined;
+          const tzId = tzInfo?.iana || tzInfo?.offset;
+          if (tzInfo && !tzId) {
+            console.warn(
+              '[node-ical] Could not resolve TZID to IANA name or UTC offset; falling back to UTC for DTSTART.',
+              {tzid: curr.start.tz, resolved: tzInfo},
+            );
+          }
+          const timeZone = tzId || 'UTC';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ical.js` around lines 934 - 945, The code silently falls back to 'UTC' when
tzInfo exists but neither tzInfo.iana nor tzInfo.offset is set; update the block
that computes timeZone (the tzInfo/timeZone logic used before creating
dtstartTemporal) to detect when tzInfo is non-null and both tzInfo.iana and
tzInfo.offset are falsy and emit a console.warn (matching getDateKey's warning
pattern) stating that TZID resolved to neither IANA nor offset and that the code
is falling back to UTC, then proceed to use 'UTC' for dtstartTemporal creation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ical.js`:
- Around line 934-945: The code silently falls back to 'UTC' when tzInfo exists
but neither tzInfo.iana nor tzInfo.offset is set; update the block that computes
timeZone (the tzInfo/timeZone logic used before creating dtstartTemporal) to
detect when tzInfo is non-null and both tzInfo.iana and tzInfo.offset are falsy
and emit a console.warn (matching getDateKey's warning pattern) stating that
TZID resolved to neither IANA nor offset and that the code is falling back to
UTC, then proceed to use 'UTC' for dtstartTemporal creation.

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: 2

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

44-53: Silent catch swallows Temporal failures without any diagnostic signal.

When tzUtil.resolveTZID or Temporal.Instant.toZonedDateTimeISO throws (e.g., unrecognised timezone string), the caller receives a silently wrong UTC-based key with no indication anything went wrong. This makes timezone-related misbehaviour hard to diagnose.

♻️ Suggested improvement
     } catch {
-      // Fall through to UTC-based key if timezone resolution fails
+      console.warn('[node-ical] getDateKey: timezone resolution failed, falling back to UTC-based key.', {tz: dateValue.tz});
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ical.js` around lines 44 - 53, The silent catch in the timezone branch
swallows errors from tzUtil.resolveTZID and Temporal.Instant.toZonedDateTimeISO
so timezone failures produce an undiagnosed UTC fallback; update the catch to
log the error and context (include tzId and dateValue) before falling back —
e.g., in the catch for the block referencing tzUtil.resolveTZID and
Temporal.Instant.toZonedDateTimeISO, call the existing logger (or console.error)
with a clear message and the thrown error, then continue to the UTC-based key.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ical.js`:
- Around line 934-950: The catch block for converting DTSTART uses error.message
which can be undefined if a non-Error is thrown; update the warning in the catch
inside the dtstartTemporal conversion so it logs a reliable string (e.g., use
error?.message ?? String(error) or String(error)) when warning that it is
falling back to UTC, referencing the dtstartTemporal conversion and
toZonedDateTimeISO/timeZone variables so the message always contains meaningful
error text.
- Around line 1157-1158: Add the missing TypeScript declaration for the exported
function getDateKey to the node-ical.d.ts file: declare and export the function
with the signature export function getDateKey(dateValue: DateWithTimeZone):
string; so TypeScript consumers can import getDateKey without type errors
(ensure DateWithTimeZone is the same type used elsewhere in the declarations).

---

Nitpick comments:
In `@ical.js`:
- Around line 44-53: The silent catch in the timezone branch swallows errors
from tzUtil.resolveTZID and Temporal.Instant.toZonedDateTimeISO so timezone
failures produce an undiagnosed UTC fallback; update the catch to log the error
and context (include tzId and dateValue) before falling back — e.g., in the
catch for the block referencing tzUtil.resolveTZID and
Temporal.Instant.toZonedDateTimeISO, call the existing logger (or console.error)
with a clear message and the thrown error, then continue to the UTC-based key.

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.

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

Duplicate comments:
In `@ical.js`:
- Around line 1157-1158: Add a TypeScript declaration for the newly exported
getDateKey function so TypeScript consumers won't error: open the declaration
file node-ical.d.ts and add an exported function signature matching the runtime
API (export function getDateKey(dateValue: DateWithTimeZone): string;), ensuring
DateWithTimeZone is already declared or imported in that file; save and run the
provided verification commands to confirm getDateKey appears among exported
functions.

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: 2

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

421-437: EXDATE full-day logic is correct; Object.values may yield duplicate Date references.

The getDateKey(exdateValue) replacement correctly handles timezone-aware EXDATEs (e.g., Exchange O365 EXDATE;TZID=CET:...). However, exdateParameter in ical.js stores the same Date object under both dateKey and isoString for every DATE-TIME entry, so Object.values(event.exdate) can include the same Date reference twice. Functionally harmless (first match exits, duplicate just causes an extra getDateKey call in the no-match path), but worth deduplicating.

♻️ Deduplicate via `Set`
-      for (const exdateValue of Object.values(event.exdate)) {
+      for (const exdateValue of new Set(Object.values(event.exdate))) {
         if (!(exdateValue instanceof Date)) {
           continue;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@node-ical.js` around lines 421 - 437, The loop handling full-day EXDATEs in
the branch guarded by excludeExdates and isFullDay should deduplicate duplicate
Date references in event.exdate before comparing; when iterating
Object.values(event.exdate) (in the code around event.exdate, getDateKey and
dateKey) create a Set of unique identifiers (e.g., timestamp via getTime() or
ISO string) to skip repeated Date objects, then only call getDateKey on each
unique Date once and return null on match—this avoids redundant getDateKey calls
while preserving the existing timezone-aware comparison logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ical.js`:
- Around line 891-907: When curr.start.tz is provided but
tzUtil.resolveTZID(curr.start.tz) returns null/undefined, the code silently
keeps timeZone='UTC' which can mis-zone DTSTART; detect this case right after
computing tzInfo (i.e., after const tzInfo = ...), and if curr.start.tz is
truthy and tzInfo is falsy emit a console.warn mentioning the unresolved TZID
(curr.start.tz) and that you're falling back to UTC; keep the existing
downstream logic that sets timeZone from tzInfo?.iana or tzInfo?.offset and
leaves dtstartTemporal creation via
Temporal.Instant.fromEpochMilliseconds(...).toZonedDateTimeISO(timeZone)
unchanged so the only change is the explicit warning when resolveTZID returns
null.

In `@lib/date-utils.js`:
- Around line 30-37: The current check only warns when resolveTZID(dateValue.tz)
returns a truthy object but no iana/offset, so completely unresolvable TZIDs
(resolved === null/undefined) silently fall back to UTC; update the guard around
tzUtil.resolveTZID(dateValue.tz) and tzId to emit a diagnostic whenever tzId is
falsy (i.e., change the condition to detect !tzId rather than resolved && !tzId)
so that you log a warning including dateValue.tz and the resolved value (even if
null); ensure you reference tzUtil.resolveTZID, the tzId variable and
dateValue.tz when adding the log call so the message is informative.

---

Nitpick comments:
In `@node-ical.js`:
- Around line 421-437: The loop handling full-day EXDATEs in the branch guarded
by excludeExdates and isFullDay should deduplicate duplicate Date references in
event.exdate before comparing; when iterating Object.values(event.exdate) (in
the code around event.exdate, getDateKey and dateKey) create a Set of unique
identifiers (e.g., timestamp via getTime() or ISO string) to skip repeated Date
objects, then only call getDateKey on each unique Date once and return null on
match—this avoids redundant getDateKey calls while preserving the existing
timezone-aware comparison logic.

@KristjanESPERANTO
Copy link
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

✅ Actions performed

Reviews resumed.

@KristjanESPERANTO
Copy link
Contributor Author

Thanks a lot for the kind words - really appreciated! 🙂 I’ve now addressed all nits and coderabbit seems satisfied.

And yes, 0.26.0 sounds like a good fit given the scope.

@jens-maus jens-maus merged commit 97e647b into jens-maus:master Feb 21, 2026
16 checks passed
@KristjanESPERANTO KristjanESPERANTO deleted the temporal branch February 21, 2026 20:01
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