Skip to content

refactor: replace moment-timezone with Intl-based timezone utilities#404

Merged
jens-maus merged 50 commits intojens-maus:masterfrom
KristjanESPERANTO:tz
Oct 19, 2025
Merged

refactor: replace moment-timezone with Intl-based timezone utilities#404
jens-maus merged 50 commits intojens-maus:masterfrom
KristjanESPERANTO:tz

Conversation

@KristjanESPERANTO
Copy link
Contributor

@KristjanESPERANTO KristjanESPERANTO commented Sep 25, 2025

  • replace the parser’s moment-timezone dependency with a lightweight Intl-powered tz-utils layer that keeps TZID metadata, Windows alias handling, and offset parsing intact
    • With that, we now have correct all-day event timezone handling. All-day events retain their original timezone instead of being normalized to UTC midnight. This prevents wrong calendar days for non-UTC events and DST transitions. Consumers should use the attached timezone for local day calculations. This makes node-ical more reliable and standards-compliant.
  • add unit tests for the new helper plus snapshot coverage that runs all recurrence examples under a fixed zone to guard against regressions
  • refresh docs and examples to demonstrate RRULE expansion with Moment, Luxon, Day.js, date-fns, and vanilla Intl while keeping moment-timezone as an examples-only dev dependency

I tried again to make the commits as comprehensible as possible. It's still a big load, though.

Summary by CodeRabbit

  • New Features

    • Added multiple RRULE example scripts demonstrating recurrence expansion, overrides, exceptions, and formatted instance output across several date libraries and a vanilla JS example.
  • Documentation

    • Overhauled README with explicit RRULE and timezone guidance, “Working with the parsed dates”, Intl and Temporal examples, migration notes, and links to synchronous/RRULE examples.
  • Tests

    • Added snapshot tests for examples, comprehensive tz-utils unit tests, and a metadata-preservation test for all-day DTSTART.
  • Chores

    • Replaced runtime Moment usage with an Intl-backed tz-utils, updated dev dependencies and example runner.

…ment-timezone`

- add `tz-utils.js` with helpers:
  `guessLocalZone`, `getZoneNames`, `findExactZoneMatch`, `isValidIana`,
  `parseDateTimeInZone`, `parseWithOffset`, `utcAdd`, `formatMMMMDoYYYY`, `linkAlias`
- refactor `ical.js` to use `tz-utils` (no direct `moment-timezone` imports)
- tests:
  - remove `moment-timezone` from test suite
  - add unit tests `test/tz-utils.test.js`
  - replace moment-specific checks in `test/basic.test.js` and `test/advanced.test.js` with `tz-utils` and stable `Date`-based assertions
  - rename suite titles for clarity (“unit: tz-utils”, “integration: fromURL”)

Validation:
- all tests green (51)

Notes:
- no functional changes; prepares for later TZ library swap
…ed implementation

`moment-timezone` is not completely removed from `package.json` so that the example js files continue to work -but it moves from `dependencies` to `devDependencies`.
@coderabbitai
Copy link

coderabbitai bot commented Sep 25, 2025

Walkthrough

Replaces runtime moment-timezone with an Intl-based tz-utils module; centralizes TZ parsing/formatting/UTC arithmetic; updates ical.js to use tz-utils; adds multiple RRULE expansion example scripts and snapshots; introduces example snapshot runner and tz-utils unit tests; updates README guidance and devDependencies.

Changes

Cohort / File(s) Summary
Docs
README.md
Clarified RRULE/timezone handling and local-day derivation; added Temporal/Intl examples and links to synchronous example scripts.
Core TZ abstraction (new/changed)
tz-utils.js, windowsZones.json
New Intl-based timezone utilities: Windows→IANA mapping, offset parsing/formatting, parseDateTimeInZone, parseWithOffset, utcAdd, formatDateForRrule, resolveTZID, attachTz, alias linking, zone discovery/validation, and testing helpers.
Core parser updates
ical.js
Removed Moment.js usage; delegated TZID resolution, date parsing/formatting and UTC arithmetic to tz-utils; updated DTSTART/DTEND and RRULE emission/formatting paths.
Examples (RRULE expansion)
Added <br> examples/example-rrule-basic.js, examples/example-rrule-basic.ics, examples/example-rrule-moment.js, examples/example-rrule-luxon.js, examples/example-rrule-dayjs.js, examples/example-rrule-datefns.js, examples/example-rrule-vanilla.js
New example scripts that expand VEVENTs (including RRULE/RECURRENCE‑ID/EXDATE), compute per-instance start/end/duration, apply overrides, deduplicate, and print chronological instances using various date libraries or vanilla Date.
Example snapshots & runner
test/examples-snapshot.test.js, test/snapshots/*
New test runner that executes example scripts, normalizes output, and asserts or bootstraps snapshots; added snapshots for each example.
Tests: TZ handling & utils
test/tz-utils.test.js, test/basic.test.js, test/advanced.test.js, test/meta-preservation.test.js, test/from-url.test.js
Replaced moment-timezone usage in tests with tz-utils; added tz-utils unit tests (parsing, utcAdd, alias linking, isValidIana, zone guessing), updated assertions and DST/boundary logic, and added meta-preservation test.
Package configuration
package.json
Moved moment-timezone to devDependencies and added date-fns, dayjs, luxon as devDependencies; updated examples script to run all example variants.
Snapshots / test data
test/snapshots/*.txt
Added snapshot files for example renderings (datefns, dayjs, luxon, moment, vanilla, basic, generic example).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant ICS as ical.js
  participant TZ as tz-utils
  participant Env as Intl/Runtime

  Note over ICS,TZ: Centralize timezone parsing/formatting and UTC math
  ICS->>TZ: resolveTZID(TZID) / parseDateTimeInZone(...)
  TZ-->>ICS: tzInfo + Date (UTC)
  ICS->>TZ: utcAdd(date, amount, unit)
  TZ-->>ICS: Date (UTC)
  ICS->>TZ: formatDateForRrule(date, tzInfo)
  TZ-->>ICS: formatted DTSTART string
Loading
sequenceDiagram
  autonumber
  participant EX as Example script
  participant NC as node-ical
  participant RR as rrule
  participant OUT as Console

  Note over EX: Expand VEVENTs into concrete instances
  EX->>NC: parseFile(example.ics)
  NC-->>EX: events
  loop per VEVENT
    alt Non-recurring
      EX->>OUT: print single instance
    else Recurring
      EX->>RR: rrule.between(rangeStart, rangeEnd)
      RR-->>EX: dates[]
      EX->>EX: merge recurrences, remove EXDATEs, apply overrides
      EX->>EX: compute start/end (duration adjustments)
      EX->>OUT: print each instance chronologically
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • jens-maus
  • jvdheijden92

Poem

Hoppity-hop I chase each zone with glee,
I stitch offsets neat and set dates free.
RRULEs bloom bright, exceptions tucked in line,
Snapshots snug, examples sip sunrise-time. 🐇

Pre-merge checks and finishing touches

✅ 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 succinctly communicates the central refactoring of replacing moment-timezone with an Intl-based timezone utilities layer, aligning directly with the PR’s main objective and using clear, concise phrasing without extraneous details.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 482d7ca and c83371d.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • package.json (2 hunks)

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.

@KristjanESPERANTO KristjanESPERANTO marked this pull request as draft September 25, 2025 22:28
@KristjanESPERANTO
Copy link
Contributor Author

KristjanESPERANTO commented Sep 25, 2025

I want to check a few things, so I've set the PR to draft for now.

But maybe you'd still like to take a look and see if the direction I've taken is okay for you 🙂

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
ical.js (3)

149-166: Bug: getTimeZone may return non-strings (Array/null) causing RangeError in toLocaleString

When TZID is an offset like "(UTC+02:00 ...)", getTimeZone can return an Array or null. Later it’s passed to {timeZone: tz} causing runtime errors. Ensure it returns a valid IANA string or falls back.

Apply this diff to make getTimeZone return a string (maps offsets to Etc/GMT±H, with correct sign inversion) and avoid null/array leakage:

   // Watch out for offset timezones
   // If the conversion above didn't find any matching IANA tz
   // And offset is still present
   if (tz && tz.startsWith('(')) {
-    // Extract just the offset
-    const regex = /[+|-]\d*:\d*/;
-    found = tz.match(regex);
-    tz = null;
+    // Extract numeric offset and convert to an IANA Etc/GMT zone (hours only).
+    const m = tz.match(/([+-])(\d{1,2}):(\d{2})/);
+    if (m) {
+      const sign = m[1];
+      const hours = String(Number(m[2])); // drop leading zeros
+      // Etc/GMT zones invert the sign: +02:00 => Etc/GMT-2
+      found = `Etc/GMT${sign === '+' ? '-' : '+'}${hours}`;
+    } else {
+      found = '';
+    }
+    tz = null;
   }
 
   // Timezone not confirmed yet
   if (found === '') {
     // Lookup tz
     found = tzUtil.findExactZoneMatch(tz);
   }
 
-  return found === '' ? tz : found;
+  return found || tz;

601-608: Brittle date stripping via formatted string; use Y/M/D directly

formatMMMMDoYYYY + regex is fragile and mismatched to the function name. Reconstruct local midnight from getFullYear()/getMonth()/getDate() instead.

-            } else {
-              // Get rid of any time (shouldn't be any, but be sure)
-              const x = tzUtil.formatMMMMDoYYYY(curr.start);
-              const comps = /^(\d{2})\/(\d{2})\/(\d{4})/.exec(x);
-              if (comps) {
-                curr.start = new Date(comps[3], comps[1] - 1, comps[2]);
-              }
-            }
+            } else {
+              // Strip time safely by reconstructing local midnight
+              curr.start = new Date(
+                curr.start.getFullYear(),
+                curr.start.getMonth(),
+                curr.start.getDate(),
+                0, 0, 0, 0,
+              );
+            }

615-626: Guard TZID when building DTSTART for RRULE

Only use timeZone option when the resolved zone is a valid IANA string; otherwise, fall back to UTC-based DTSTART.

-              if (curr.start.tz) {
-                const tz = getTimeZone(curr.start.tz);
+              if (curr.start.tz) {
+                const tz = getTimeZone(curr.start.tz);
+                const useZone = typeof tz === 'string' && tzUtil.isValidIana(tz);
                 // If a timezone is provided, rrule requires the time to be local
                 // but without Z suffix (cf. RFC5545, 3.3.5)
-                const adjustedTimeString = curr.start
-                  .toLocaleString('sv-SE', {timeZone: tz}) // 'sv-SE' outputs 'YYYY-MM-DD' date format
-                  .replaceAll(' ', 'T')
-                  .replaceAll(/[-:Z]/g, '');
-                rule += `;DTSTART;TZID=${tz}:${adjustedTimeString}`;
+                if (useZone) {
+                  const adjustedTimeString = curr.start
+                    .toLocaleString('sv-SE', {timeZone: tz}) // 'sv-SE' => 'YYYY-MM-DD HH:mm:ss'
+                    .replaceAll(' ', 'T')
+                    .replaceAll(/[-:Z]/g, '');
+                  rule += `;DTSTART;TZID=${tz}:${adjustedTimeString}`;
+                } else {
+                  rule += `;DTSTART=${curr.start.toISOString().replaceAll(/[-:]/g, '')}`;
+                }
               } else {
                 rule += `;DTSTART=${curr.start.toISOString().replaceAll(/[-:]/g, '')}`;
               }
🧹 Nitpick comments (8)
test/tz-utils.test.js (1)

11-14: Add DST-transition assertions to harden zone parsing and UTC arithmetic

Consider adding cases around DST edges (e.g., 2024-03-31 Europe/Berlin 02:00 missing hour; and 2024-10-27 repeated hour) to verify parseDateTimeInZone, parseWithOffset, and utcAdd behave as expected across transitions.

Also applies to: 16-20, 22-26

test/examples-snapshot.test.js (3)

32-34: Include stderr in failure message for easier debugging

Propagate stderr to the thrown error to make failures actionable.

-  if (result.status !== 0) {
-    throw new Error(`Script ${script} exited with code ${result.status}`);
-  }
+  if (result.status !== 0) {
+    throw new Error(`Script ${script} exited with code ${result.status}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
+  }

17-18: Ensure snapshot directory exists before creating missing snapshots

Avoids EEXIST errors in fresh environments.

 const snapshotDir = path.join(__dirname, 'snapshots');
 
+// Ensure snapshot directory exists
+if (!fs.existsSync(snapshotDir)) {
+  fs.mkdirSync(snapshotDir, {recursive: true});
+}
@@
       } else {
         // If no snapshot exists, create it
         fs.writeFileSync(snapshotFile, output, 'utf8');
         this.skip();
       }

Also applies to: 55-58


39-43: Normalize lone CR as well

Some environments may emit stray '\r'. Consider handling both CRLF and CR.

 function normalizeNewlines(text) {
-  return text.replaceAll('\r\n', '\n');
+  return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
 }
test/advanced.test.js (2)

253-266: Robust local-day span check; minor tweak to prefer toLocaleDateString

Current approach is fine. For clarity and to avoid slicing, prefer toLocaleDateString('sv-SE', {timeZone: zone}).

-const startLocalYMD = event.start.toLocaleString('sv-SE', {timeZone: zone}).slice(0, 10);
-const endLocalYMD = event.end.toLocaleString('sv-SE', {timeZone: zone}).slice(0, 10);
+const startLocalYMD = event.start.toLocaleDateString('sv-SE', {timeZone: zone});
+const endLocalYMD = event.end.toLocaleDateString('sv-SE', {timeZone: zone});

334-343: Apply same local-day span computation for recurrences

Same optional tweak as above; keeps formats consistent across tests.

-const startLocalYMD = rec.start.toLocaleString('sv-SE', {timeZone: zone2}).slice(0, 10);
-const endLocalYMD = rec.end.toLocaleString('sv-SE', {timeZone: zone2}).slice(0, 10);
+const startLocalYMD = rec.start.toLocaleDateString('sv-SE', {timeZone: zone2});
+const endLocalYMD = rec.end.toLocaleDateString('sv-SE', {timeZone: zone2});
examples/example-rrule-luxon.js (1)

101-107: Optional: make zone handling explicit for formatting

If you want stable outputs irrespective of process TZ, consider using the event’s tz metadata (if present) or a fixed zone when formatting:

  • Use DateTime.fromJSDate(event.start, {zone: event.start.tz || 'Europe/Berlin'})
  • Or pass zone: 'Europe/Berlin' into toLocaleString options
ical.js (1)

224-251: Scan all parameters for TZID, not only parameters[0]

Some events may place TZID after other params. Consider searching the entire parameters array for TZID=... before parsing.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2503001 and f4f567c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (20)
  • README.md (2 hunks)
  • examples/example-rrule-datefns.js (1 hunks)
  • examples/example-rrule-dayjs.js (1 hunks)
  • examples/example-rrule-luxon.js (1 hunks)
  • examples/example-rrule-moment.js (1 hunks)
  • examples/example-rrule-vanilla.js (1 hunks)
  • ical.js (10 hunks)
  • package.json (2 hunks)
  • test/advanced.test.js (3 hunks)
  • test/basic.test.js (2 hunks)
  • test/examples-snapshot.test.js (1 hunks)
  • test/from-url.test.js (1 hunks)
  • test/snapshots/example-rrule-datefns.txt (1 hunks)
  • test/snapshots/example-rrule-dayjs.txt (1 hunks)
  • test/snapshots/example-rrule-luxon.txt (1 hunks)
  • test/snapshots/example-rrule-moment.txt (1 hunks)
  • test/snapshots/example-rrule-vanilla.txt (1 hunks)
  • test/snapshots/example.txt (1 hunks)
  • test/tz-utils.test.js (1 hunks)
  • tz-utils.js (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (8)
test/tz-utils.test.js (1)
tz-utils.js (3)
  • tz (54-54)
  • tz (99-99)
  • d (217-217)
test/advanced.test.js (2)
tz-utils.js (2)
  • tz (54-54)
  • tz (99-99)
test/basic.test.js (2)
  • tz (3-3)
  • require (2-2)
examples/example-rrule-dayjs.js (2)
examples/example-rrule-datefns.js (18)
  • path (18-18)
  • require (19-26)
  • ical (27-27)
  • data (30-30)
  • events (33-33)
  • rangeStart (36-36)
  • rangeEnd (37-37)
  • title (40-40)
  • startDate (41-41)
  • endDate (42-42)
  • dates (58-58)
  • rDate (64-64)
  • insideRange (66-66)
  • alreadyPresent (67-67)
  • curEvent (76-76)
  • showRecurrence (77-77)
  • dateLookupKey (83-83)
  • recurrenceTitle (98-98)
examples/example-rrule-vanilla.js (19)
  • path (21-21)
  • duration (60-60)
  • ical (22-22)
  • data (45-45)
  • events (48-48)
  • rangeStart (51-51)
  • rangeEnd (52-52)
  • title (55-55)
  • startDate (56-56)
  • endDate (57-57)
  • dates (73-73)
  • rDate (79-79)
  • insideRange (81-81)
  • alreadyPresent (82-82)
  • curEvent (91-91)
  • showRecurrence (92-92)
  • curDuration (93-93)
  • dateLookupKey (98-98)
  • recurrenceTitle (113-113)
examples/example-rrule-luxon.js (1)
examples/example-rrule-vanilla.js (19)
  • path (21-21)
  • ical (22-22)
  • data (45-45)
  • events (48-48)
  • rangeStart (51-51)
  • rangeEnd (52-52)
  • title (55-55)
  • startDate (56-56)
  • endDate (57-57)
  • duration (60-60)
  • dates (73-73)
  • rDate (79-79)
  • insideRange (81-81)
  • alreadyPresent (82-82)
  • curEvent (91-91)
  • showRecurrence (92-92)
  • curDuration (93-93)
  • dateLookupKey (98-98)
  • recurrenceTitle (113-113)
ical.js (4)
test/advanced.test.js (2)
  • require (2-2)
  • tz (3-3)
test/basic.test.js (2)
  • require (2-2)
  • tz (3-3)
test/tz-utils.test.js (2)
  • require (2-2)
  • tz (3-3)
tz-utils.js (2)
  • tz (54-54)
  • tz (99-99)
tz-utils.js (1)
ical.js (8)
  • tz (130-130)
  • tz (226-226)
  • tz (616-616)
  • p (89-89)
  • p (385-385)
  • i (713-713)
  • offset (228-228)
  • offset (594-594)
test/basic.test.js (4)
tz-utils.js (2)
  • tz (54-54)
  • tz (99-99)
ical.js (4)
  • tz (130-130)
  • tz (226-226)
  • tz (616-616)
  • require (3-3)
test/advanced.test.js (3)
  • tz (3-3)
  • require (2-2)
  • ical (4-4)
test/tz-utils.test.js (2)
  • tz (3-3)
  • require (2-2)
examples/example-rrule-vanilla.js (3)
examples/example-rrule-datefns.js (14)
  • path (18-18)
  • ical (27-27)
  • data (30-30)
  • events (33-33)
  • rangeStart (36-36)
  • rangeEnd (37-37)
  • dates (58-58)
  • rDate (64-64)
  • insideRange (66-66)
  • alreadyPresent (67-67)
  • curEvent (76-76)
  • showRecurrence (77-77)
  • dateLookupKey (83-83)
  • recurrenceTitle (98-98)
examples/example-rrule-dayjs.js (16)
  • path (20-20)
  • ical (27-27)
  • data (37-37)
  • events (40-40)
  • rangeStart (43-43)
  • rangeEnd (44-44)
  • duration (24-24)
  • dates (65-65)
  • rDate (71-71)
  • insideRange (74-74)
  • alreadyPresent (75-75)
  • curEvent (84-84)
  • showRecurrence (85-85)
  • curDuration (86-86)
  • dateLookupKey (91-91)
  • recurrenceTitle (106-106)
examples/example-rrule-luxon.js (16)
  • path (19-19)
  • ical (21-21)
  • data (24-24)
  • events (27-27)
  • rangeStart (30-30)
  • rangeEnd (31-31)
  • duration (39-39)
  • dates (52-52)
  • rDate (58-58)
  • insideRange (61-61)
  • alreadyPresent (62-62)
  • curEvent (71-71)
  • showRecurrence (72-72)
  • curDuration (73-73)
  • dateLookupKey (78-78)
  • recurrenceTitle (93-93)
🔇 Additional comments (12)
test/tz-utils.test.js (1)

6-9: LGTM: basic IANA validation tests are correct

Good positive/negative coverage for isValidIana.

test/snapshots/example-rrule-moment.txt (1)

1-39: Snapshot content looks consistent and deduplicated

Matches expected one-instance-per-occurrence behavior.

examples/example-rrule-luxon.js (1)

51-67: Good: override merge and dedup logic

Using between(..., true, () => true) and dedup by getTime works well for most cases.

test/basic.test.js (2)

7-8: LGTM: alias mapping via tz-utils

Replacing moment.tz.link with tz.linkAlias aligns with the new utils.


92-97: Solid replacement for moment.tz.zone check

tz.isValidIana check plus ISO normalization is a good substitute.

test/from-url.test.js (1)

93-93: Clearer suite naming pays off

Renaming the suite to integration: fromURL makes the intent obvious and keeps the test catalogue tidy. Nice touch.

examples/example-rrule-moment.js (1)

2-2: Helpful doc tweak

Calling out that this script uses moment-timezone sets expectations for readers skimming the examples.

test/snapshots/example-rrule-luxon.txt (1)

1-44: Snapshot looks consistent

The Luxon snapshot mirrors the structure and values from the other libraries’ outputs—thanks for keeping the cross-library expectations aligned.

test/snapshots/example-rrule-vanilla.txt (1)

1-44: Vanilla snapshot checks out

Outputs line up with the moment baseline, so we’ve got good coverage for the Intl-only path.

test/snapshots/example-rrule-datefns.txt (1)

1-44: date-fns snapshot verified

Values match the expected recurrence instances, giving confidence the date-fns example behaves identically.

test/snapshots/example.txt (1)

1-128: Legacy snapshot retained

Glad to see the fest schedule snapshot preserved; that keeps us honest on the classic example output.

README.md (1)

201-236: New RRULE/timezone guidance reads well

The condensed section plus explicit pointers to the runnable examples make the docs much easier to follow.

- replace bespoke Windows/offset detection in `ical.js` with `tz-utils.resolveTZID`
- emit RRULE DTSTART strings via `formatDateForRrule`, falling back when metadata is incomplete
- export the new helpers from `tz-utils` so callers can share the timezone logic
- replace string-based midnight fallback with direct Y/M/D reconstruction in `ical.js`
- drop the unused `formatMMMMDoYYYY` helper from `tz-utils.js`
- read TZID from every parameter entry, update the normalized label in-place, and reuse the extracted date parts for conversions
- improves calendar accuracy for end users by preventing missed timezones in unusual ICS files
- share the existing stack-based timezone fallback to keep the parsing logic consistent
- key RRULE expansions by calendar day so each occurrence prints once
- merge RECURRENCE-ID overrides into the map before rendering
- update all example-rrule-* demos to share the same override/dedupe flow
- getZoneNames() now returns the complete catalog (Node 18+)
- remove legacy fallback to local zone
- update tests to assert local zone is present in the full list
- Set TZ explicitly to a valid IANA zone (Europe/Berlin)
- Assert guessLocalZone returns the set zone
- Assert zone is present in IANA zone list
- Remove redundant assertions for clarity
@KristjanESPERANTO KristjanESPERANTO marked this pull request as ready for review September 26, 2025 16:52
@KristjanESPERANTO
Copy link
Contributor Author

@jens-maus I have reviewed the nitpicks and comments from CodeRabbit and made changes. The PR is now ready for me to remove the draft status 😃

There are a lot of changes and a lot to review. I hope it is manageable and that you will find the time for it.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
examples/example-rrule-moment.js (1)

29-30: Cover the full final day in the range

Line 30 sets rangeEnd to midnight at the start of 2017‑12‑31, so any occurrences later on 12/31 are filtered out as “after” the range. Adjust the bounds to span the entire last day.

Updating the bounds keeps the intended date window:

-const rangeStart = moment('2017-01-01');
-const rangeEnd = moment('2017-12-31');
+const rangeStart = moment('2017-01-01').startOf('day');
+const rangeEnd = moment('2017-12-31').endOf('day');
ical.js (1)

519-552: Preserve TZID metadata when normalizing all-day DTSTART

When you rebuild curr.start with new Date(...) (both the offset correction and the midnight rebuild), you drop the .tz / .dateOnly metadata that dateParameter previously attached. A few lines later we branch on curr.start.tz to decide whether to emit DTSTART;TZID=…; after this reassignment the property is gone, so all-day events fall back to UTC/floating stamps — exactly what this refactor is trying to avoid.

Please cache and reapply the metadata around the reassignment (and likewise keep the dateOnly flag) so downstream logic still sees the original timezone.

             if (offset < 0) {
-              curr.start = new Date(curr.start.getTime() + (Math.abs(offset) * 60_000));
+              const originalTz = curr.start.tz;
+              const isDateOnly = curr.start.dateOnly === true;
+              curr.start = new Date(curr.start.getTime() + (Math.abs(offset) * 60_000));
+              if (originalTz) {
+                curr.start.tz = originalTz;
+              }
+              if (isDateOnly) {
+                curr.start.dateOnly = true;
+              }
             } else {
-              curr.start = new Date(
-                curr.start.getFullYear(),
-                curr.start.getMonth(),
-                curr.start.getDate(),
-                0,
-                0,
-                0,
-                0,
-              );
+              const originalTz = curr.start.tz;
+              const isDateOnly = curr.start.dateOnly === true;
+              curr.start = new Date(
+                curr.start.getFullYear(),
+                curr.start.getMonth(),
+                curr.start.getDate(),
+                0,
+                0,
+                0,
+                0,
+              );
+              if (originalTz) {
+                curr.start.tz = originalTz;
+              }
+              if (isDateOnly) {
+                curr.start.dateOnly = true;
+              }
             }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f4f567c and a4be405.

📒 Files selected for processing (16)
  • README.md (2 hunks)
  • examples/example-rrule-datefns.js (1 hunks)
  • examples/example-rrule-dayjs.js (1 hunks)
  • examples/example-rrule-luxon.js (1 hunks)
  • examples/example-rrule-moment.js (4 hunks)
  • examples/example-rrule-vanilla.js (1 hunks)
  • ical.js (7 hunks)
  • test/advanced.test.js (13 hunks)
  • test/basic.test.js (3 hunks)
  • test/examples-snapshot.test.js (1 hunks)
  • test/snapshots/example-rrule-datefns.txt (1 hunks)
  • test/snapshots/example-rrule-dayjs.txt (1 hunks)
  • test/snapshots/example-rrule-luxon.txt (1 hunks)
  • test/snapshots/example-rrule-vanilla.txt (1 hunks)
  • test/tz-utils.test.js (1 hunks)
  • tz-utils.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (6)
  • test/examples-snapshot.test.js
  • test/snapshots/example-rrule-vanilla.txt
  • test/tz-utils.test.js
  • test/snapshots/example-rrule-datefns.txt
  • test/snapshots/example-rrule-dayjs.txt
  • test/snapshots/example-rrule-luxon.txt
🧰 Additional context used
🧬 Code graph analysis (9)
examples/example-rrule-moment.js (3)
examples/example-rrule-dayjs.js (10)
  • instanceDates (67-67)
  • rangeStart (45-45)
  • rangeEnd (46-46)
  • key (69-69)
  • recurStart (78-78)
  • recurId (79-79)
  • recurrenceKey (88-88)
  • dates (96-98)
  • dateLookupKey (108-108)
  • startDate (50-50)
examples/example-rrule-luxon.js (11)
  • instanceDates (54-54)
  • rangeStart (32-32)
  • rangeEnd (33-33)
  • key (56-56)
  • recurStart (65-65)
  • recurId (66-66)
  • insideRange (71-71)
  • recurrenceKey (72-72)
  • dates (80-82)
  • dateLookupKey (92-92)
  • startDate (37-37)
examples/example-rrule-vanilla.js (10)
  • instanceDates (75-75)
  • rangeStart (53-53)
  • rangeEnd (54-54)
  • key (77-77)
  • recurStart (86-86)
  • recurId (87-87)
  • recurrenceKey (96-96)
  • dates (104-106)
  • dateLookupKey (116-116)
  • startDate (58-58)
test/basic.test.js (2)
tz-utils.js (3)
  • tz (113-113)
  • tz (259-259)
  • tz (304-304)
ical.js (1)
  • tz (176-176)
test/advanced.test.js (2)
tz-utils.js (4)
  • tz (113-113)
  • tz (259-259)
  • tz (304-304)
  • data (24-24)
ical.js (2)
  • tz (176-176)
  • data (77-77)
examples/example-rrule-datefns.js (1)
examples/example-rrule-vanilla.js (19)
  • path (21-21)
  • ical (22-22)
  • data (45-45)
  • events (48-50)
  • rangeStart (53-53)
  • rangeEnd (54-54)
  • title (57-57)
  • startDate (58-58)
  • endDate (59-59)
  • instanceDates (75-75)
  • key (77-77)
  • recurStart (86-86)
  • recurId (87-87)
  • recurrenceKey (96-96)
  • dates (104-106)
  • curEvent (109-109)
  • showRecurrence (110-110)
  • dateLookupKey (116-116)
  • recurrenceTitle (131-131)
ical.js (1)
tz-utils.js (8)
  • year (381-381)
  • day (383-383)
  • hour (384-384)
  • minute (385-385)
  • second (386-386)
  • tz (113-113)
  • tz (259-259)
  • tz (304-304)
examples/example-rrule-dayjs.js (3)
examples/example-rrule-datefns.js (18)
  • path (18-18)
  • require (19-26)
  • ical (27-27)
  • data (30-30)
  • events (33-35)
  • rangeStart (38-38)
  • rangeEnd (39-39)
  • startDate (43-43)
  • endDate (44-44)
  • instanceDates (60-60)
  • key (62-62)
  • recurStart (71-71)
  • recurId (72-72)
  • recurrenceKey (81-81)
  • dates (89-91)
  • curEvent (94-94)
  • dateLookupKey (101-101)
  • recurrenceTitle (116-116)
examples/example-rrule-luxon.js (18)
  • path (19-19)
  • require (20-20)
  • ical (21-21)
  • data (24-24)
  • events (27-29)
  • rangeStart (32-32)
  • rangeEnd (33-33)
  • startDate (37-37)
  • endDate (38-38)
  • instanceDates (54-54)
  • key (56-56)
  • recurStart (65-65)
  • recurId (66-66)
  • recurrenceKey (72-72)
  • dates (80-82)
  • curEvent (85-85)
  • dateLookupKey (92-92)
  • recurrenceTitle (107-107)
examples/example-rrule-vanilla.js (17)
  • path (21-21)
  • ical (22-22)
  • data (45-45)
  • events (48-50)
  • rangeStart (53-53)
  • rangeEnd (54-54)
  • startDate (58-58)
  • endDate (59-59)
  • instanceDates (75-75)
  • key (77-77)
  • recurStart (86-86)
  • recurId (87-87)
  • recurrenceKey (96-96)
  • dates (104-106)
  • curEvent (109-109)
  • dateLookupKey (116-116)
  • recurrenceTitle (131-131)
examples/example-rrule-vanilla.js (1)
examples/example-rrule-datefns.js (20)
  • path (18-18)
  • require (19-26)
  • ical (27-27)
  • data (30-30)
  • events (33-35)
  • rangeStart (38-38)
  • rangeEnd (39-39)
  • title (42-42)
  • startDate (43-43)
  • endDate (44-44)
  • instanceDates (60-60)
  • key (62-62)
  • recurStart (71-71)
  • recurId (72-72)
  • recurrenceKey (81-81)
  • dates (89-91)
  • curEvent (94-94)
  • showRecurrence (95-95)
  • dateLookupKey (101-101)
  • recurrenceTitle (116-116)
examples/example-rrule-luxon.js (3)
examples/example-rrule-datefns.js (20)
  • path (18-18)
  • require (19-26)
  • ical (27-27)
  • data (30-30)
  • events (33-35)
  • rangeStart (38-38)
  • rangeEnd (39-39)
  • title (42-42)
  • startDate (43-43)
  • endDate (44-44)
  • instanceDates (60-60)
  • key (62-62)
  • recurStart (71-71)
  • recurId (72-72)
  • recurrenceKey (81-81)
  • dates (89-91)
  • curEvent (94-94)
  • showRecurrence (95-95)
  • dateLookupKey (101-101)
  • recurrenceTitle (116-116)
examples/example-rrule-dayjs.js (21)
  • path (20-20)
  • ical (27-27)
  • data (37-37)
  • events (40-42)
  • rangeStart (45-45)
  • rangeEnd (46-46)
  • title (49-49)
  • startDate (50-50)
  • endDate (51-51)
  • duration (24-24)
  • instanceDates (67-67)
  • key (69-69)
  • recurStart (78-78)
  • recurId (79-79)
  • recurrenceKey (88-88)
  • dates (96-98)
  • curEvent (101-101)
  • showRecurrence (102-102)
  • curDuration (103-103)
  • dateLookupKey (108-108)
  • recurrenceTitle (123-123)
examples/example-rrule-vanilla.js (21)
  • path (21-21)
  • ical (22-22)
  • data (45-45)
  • events (48-50)
  • rangeStart (53-53)
  • rangeEnd (54-54)
  • title (57-57)
  • startDate (58-58)
  • endDate (59-59)
  • duration (62-62)
  • instanceDates (75-75)
  • key (77-77)
  • recurStart (86-86)
  • recurId (87-87)
  • recurrenceKey (96-96)
  • dates (104-106)
  • curEvent (109-109)
  • showRecurrence (110-110)
  • curDuration (111-111)
  • dateLookupKey (116-116)
  • recurrenceTitle (131-131)
tz-utils.js (1)
ical.js (6)
  • tz (176-176)
  • tzInfo (208-208)
  • tzInfo (539-539)
  • p (89-89)
  • p (304-304)
  • i (642-642)
🔇 Additional comments (4)
README.md (1)

221-256: Excellent clarification of RRULE timezone semantics

The updated guidance nails the practical gotchas around preserving TZ metadata and explicitly walking users through deriving local-day representations. This will save downstream consumers a lot of trial-and-error.

test/advanced.test.js (2)

109-131: Great synthetic coverage for Windows-style TZIDs

Really appreciate this fixture: asserting that the quoted Windows display name resolves to a concrete IANA zone (and that RRULE propagation keeps the tzid) gives us strong confidence in the new heuristics.


280-292: Solid local-day regression check

The extra guard that normalizes via sv-SE and enforces a single-day span is a smart way to ensure we never regress all-day handling across DST boundaries.

test/basic.test.js (1)

93-97: Nice swap to tz-utils assertions

Switching to isValidIana alongside the ISO comparison keeps this test meaningful without bringing moment-timezone back in. Clean and effective.

@KristjanESPERANTO
Copy link
Contributor Author

Okay, I'm checking the new nitpicks and comments 🙂

- keep tz/dateOnly flags after rebuilding RRULE DTSTART dates
- document the intent inline to highlight the non-obvious reattach step
- add a parser regression test covering metadata retention for all-day events
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 (5)
tz-utils.js (5)

102-126: Validate offset bounds (minutes < 60, ±14:00 max) in offsetLabelToMinutes.

Prevents accepting impossible offsets like +12:90 or +23:59 while aligning with IANA/ICS limits.

Apply:

   const [, sign, hoursPart, minutesPart] = match;
   const hours = Number(hoursPart);
   const minutes = minutesPart ? Number(minutesPart) : 0;
   if (Number.isNaN(hours) || Number.isNaN(minutes)) {
     return undefined;
   }
+  // Minutes must be < 60; IANA/ICS max absolute offset is 14:00
+  if (minutes >= 60) {
+    return undefined;
+  }
+  if (hours > 14 || (hours === 14 && minutes !== 0)) {
+    return undefined;
+  }
 
   const total = (hours * 60) + minutes;
   return sign === '-' ? -total : total;

297-307: Cache Intl.DateTimeFormat instances to avoid repeated construction in hot paths.

RRULE expansion and parsing can call this many times per zone; caching cuts CPU and allocations.

Apply:

-    const formatter = new Intl.DateTimeFormat('en-CA', {
-      timeZone: tzInfo.iana,
-      hour12: false,
-      year: 'numeric',
-      month: '2-digit',
-      day: '2-digit',
-      hour: '2-digit',
-      minute: '2-digit',
-      second: '2-digit',
-    });
+    const formatter = getFormatter(tzInfo.iana);

Add once (outside this range, e.g., near the top-level helpers):

// Simple per-zone formatter cache to reduce Intl constructor churn
const dtfCache = new Map();
function getFormatter(tz) {
  let f = dtfCache.get(tz);
  if (!f) {
    f = new Intl.DateTimeFormat('en-CA', {
      timeZone: tz,
      hour12: false,
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
      hour: '2-digit',
      minute: '2-digit',
      second: '2-digit',
    });
    dtfCache.set(tz, f);
  }
  return f;
}

Also applies to: 309-327


456-471: Defensive: return undefined for invalid zones and reuse cached formatter.

Avoids throwing if a bad zone leaks in, and reduces formatter churn in getLocalParts.

Apply:

-  const tz = resolveZone(zone);
+  const tz = resolveZone(zone);
+  if (!isValidIana(tz)) {
+    return undefined;
+  }
   // Initial guess: interpret local fields as if they were UTC
   const t = Date.UTC(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second);
 
   const getLocalParts = date => {
-    const df = new Intl.DateTimeFormat('en-CA', {
-      timeZone: tz,
-      hour12: false,
-      year: 'numeric',
-      month: '2-digit',
-      day: '2-digit',
-      hour: '2-digit',
-      minute: '2-digit',
-      second: '2-digit',
-    });
+    const df = getFormatter(tz);

Helper (if not already added in another change):

// See formatter cache helper in formatDateForRrule comment

408-418: Memoize isValidIana results to avoid repeated Intl throws.

This can be hit frequently via resolveTZID and guards; caching avoids costly exceptions.

Apply:

+const validIanaCache = new Map();
+
 function isValidIana(zone) {
   if (!zone) {
     return false;
   }
 
   try {
     // Rely on Intl throwing for invalid timeZone identifiers
     // This is more portable across Node builds than Temporal alone
-    const tz = resolveZone(zone);
-
-    new Intl.DateTimeFormat('en-US', {timeZone: tz}).format(new Date(0));
-    return true;
+    const tz = resolveZone(zone);
+    if (validIanaCache.has(tz)) {
+      return validIanaCache.get(tz);
+    }
+    new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date(0));
+    validIanaCache.set(tz, true);
+    return true;
   } catch {
-    return false;
+    validIanaCache.set(resolveZone(zone), false);
+    return false;
   }
 }

536-538: Preserve offset metadata on returned Date from parseWithOffset.

Attaching tz as a normalized offset string aligns with parseDateTimeInZone behavior and lets downstream code recover the source offset without serializing it.

Apply:

-  const utcMs = Date.UTC(year, month - 1, day, hour, minute, second) - (totalMinutes * 60_000);
-  return new Date(utcMs);
+  const utcMs = Date.UTC(year, month - 1, day, hour, minute, second) - (totalMinutes * 60_000);
+  return attachTz(new Date(utcMs), minutesToOffset(totalMinutes));
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5c5dd7c and 22b8d63.

📒 Files selected for processing (1)
  • tz-utils.js (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tz-utils.js (5)
test/tz-utils.test.js (2)
  • require (3-3)
  • tz (4-4)
ical.js (21)
  • require (3-3)
  • normalized (208-208)
  • value (699-699)
  • offset (533-533)
  • parts (257-257)
  • parts (337-337)
  • year (149-149)
  • day (151-151)
  • hour (152-152)
  • minute (153-153)
  • second (154-154)
  • i (675-675)
  • tz (192-192)
  • tzInfo (177-177)
  • tzInfo (224-224)
  • tzInfo (572-572)
  • out (45-45)
  • tzid (90-90)
  • p (88-88)
  • p (320-320)
  • unit (413-413)
test/advanced.test.js (2)
  • require (2-2)
  • tz (3-3)
test/from-url.test.js (1)
  • require (3-3)
test/basic.test.js (2)
  • require (2-2)
  • tz (3-3)
🔇 Additional comments (3)
tz-utils.js (3)

260-268: Nice: raw offset TZIDs are handled.

Supporting "UTC+02:00", "+0530", "GMT-4" eliminates a common interop pitfall when feeds omit parentheses.


353-370: Non-enumerable tz metadata is correctly applied.

The property remains readable while staying out of JSON/string output; configurable lets consumers rebind if needed.


6-7: Ensure windowsZones.json is included in npm package.

Requiring a JSON asset at runtime means it must be shipped. Verify package.json files/exports or .npmignore includes windowsZones.json so consumers don’t get ENOENT after install.

Run:

- Enforce IANA/ICS limits: minutes must be < 60, max absolute offset is ±14:00
- Prevents accepting impossible offsets like +12:90 or +23:59
- Returns undefined for invalid values
- Add per-zone formatter cache (dtfCache) to reduce repeated Intl constructor churn
- Introduce getFormatter function to retrieve or create cached formatters
- Reuse cached formatters in formatDateForRrule and parseDateTimeInZone
- Cuts CPU and allocations during RRULE expansion and parsing
- Cache resolveZone normalization to avoid repeated Intl constructor throws
- Reuse validIanaCache to skip redundant exceptions during frequent resolveTZID calls
- Keeps the change minimal while improving hot-path performance
- Normalize offset strings with minutesToOffset before attaching tz metadata
- Return Dates via attachTz so downstream consumers can recover the original offset
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

🧹 Nitpick comments (3)
tz-utils.js (3)

96-101: Cap the DateTimeFormat cache to avoid unbounded growth.

In long‑lived processes parsing many zones, dtfCache can grow without bound. A tiny cap with simple eviction is enough.

Apply:

-// Simple per-zone formatter cache to reduce Intl constructor churn
-const dtfCache = new Map();
+// Simple per-zone formatter cache to reduce Intl constructor churn
+const dtfCache = new Map();
+const DTF_CACHE_MAX = 256;
@@
   if (!formatter) {
     formatter = new Intl.DateTimeFormat('en-CA', {
@@
     });
     dtfCache.set(tz, formatter);
+    if (dtfCache.size > DTF_CACHE_MAX) {
+      // Simple eviction: clear when exceeding cap. Good enough given limited zone count.
+      dtfCache.clear();
+    }
   }

Also applies to: 109-126


134-167: Accept zero-offset shorthands and Unicode minus in offset parsing.

Real-world feeds sometimes use “Z”, “UTC” alone, or the Unicode minus (−). Treat these as valid zero/negative offsets.

Apply:

 function offsetLabelToMinutes(offset) {
   if (!offset) {
     return undefined;
   }
 
-  const trimmed = String(offset)
+  const trimmed = String(offset)
     .trim()
     .replace(/^\(?(?:utc|gmt)\)?\s*/i, '')
     .replace(/\)$/, '')
     .trim();
-  const match = trimmed.match(/^([+-])(\d{1,2})(?::?(\d{2}))?$/);
+  // Accept bare "Z" or empty after UTC/GMT removal as zero offset
+  if (trimmed === '' || /^z$/i.test(trimmed)) {
+    return 0;
+  }
+  // Normalize Unicode minus (U+2212) to ASCII "-".
+  const normalized = trimmed.replace(/\u2212/g, '-');
+  const match = normalized.match(/^([+-])(\d{1,2})(?::?(\d{2}))?$/);
   if (!match) {
     return undefined;
   }
 
-  const [, sign, hoursPart, minutesPart] = match;
+  const [, sign, hoursPart, minutesPart] = match;

581-599: Validate amount in utcAdd.

Guard against non-finite amounts to avoid NaN propagation.

Apply:

   const factor = msPer[unit];
   if (!factor) {
     throw new Error('Unsupported unit: ' + unit);
   }
 
-  return new Date(date.getTime() + (amount * factor));
+  if (!Number.isFinite(amount)) {
+    throw new TypeError('Amount must be a finite number');
+  }
+  return new Date(date.getTime() + (amount * factor));
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 22b8d63 and 096b40b.

📒 Files selected for processing (1)
  • tz-utils.js (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
tz-utils.js (1)
ical.js (8)
  • normalized (208-208)
  • tz (192-192)
  • i (675-675)
  • tzInfo (177-177)
  • tzInfo (224-224)
  • tzInfo (572-572)
  • p (88-88)
  • p (320-320)
🔇 Additional comments (3)
tz-utils.js (3)

552-579: Nice: strict invalid-offset handling.

Failing fast with TypeError on malformed offsets prevents silent UTC assumptions and aligns with the PR’s stricter behavior.


419-424: Using Intl.supportedValuesOf is appropriate given Node 18+ baseline.

This matches the stated engines and avoids extra guards.


4-7: windowsZones.json is included in the published package – it exists at the repo root and isn’t ignored by .npmignore or restricted by package.json, so no further action is required.

- document the pre-Node 22 Intl formatter bug and route midnight through normalizeMidnightParts
- expose the helper for tests and reuse it in formatDateForRrule/parseDateTimeInZone
- add a regression test covering the 24:00 formatter output
@KristjanESPERANTO
Copy link
Contributor Author

I have processed the last new comment from CodeRabbit. I am leaving the three new nitpicks as they are; I consider them not as crucial and would like to find an end for this PR. The last commits have not caused any changes to the above summary.

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 (6)
tz-utils.js (6)

314-321: Trim TZID early to avoid stray whitespace mismatches

Minor hardening: trim before quote‑stripping/mapping to avoid subtle exact‑key misses when labels include leading/trailing spaces.

-  tz = tz.replace(/^"(.*)"$/, '$1');
+  tz = String(tz).trim();
+  tz = tz.replace(/^"(.*)"$/, '$1');
   const original = tz;

442-448: Follow alias chains and guard against cycles in resolveZone

Current implementation handles only a single hop. Chained aliases (A→B→C) won’t resolve fully. Resolve until fixed point with a small cycle guard.

-function resolveZone(zone) {
-  if (!zone) {
-    return zone;
-  }
-
-  return aliasMap.get(zone) || zone;
-}
+function resolveZone(zone) {
+  if (!zone) return zone;
+  let z = String(zone);
+  const seen = new Set();
+  while (aliasMap.has(z) && !seen.has(z)) {
+    seen.add(z);
+    z = aliasMap.get(z);
+  }
+  return z;
+}

501-536: Accept input wall time “24:00:00” by normalizing to next-day 00:00:00

You already normalize Intl’s 24:00 output; do the same for input strings so DTSTART/RECURRENCE parsing tolerates valid RFC 5545 edge cases.

   if (!fields) {
     return undefined;
   }
 
+  // Normalize input 24:00:00 → next day 00:00:00 (RFC 5545 allowance)
+  if (fields.hour === 24 && fields.minute === 0 && fields.second === 0) {
+    const next = new Date(Date.UTC(fields.year, fields.month - 1, fields.day) + 24 * 60 * 60 * 1000);
+    fields.year = next.getUTCFullYear();
+    fields.month = next.getUTCMonth() + 1;
+    fields.day = next.getUTCDate();
+    fields.hour = 0;
+    // minute/second already 0
+  }

243-254: Optionally validate Y/M/D ranges to avoid silent rollovers

Date.UTC will normalize invalid dates (e.g., 2025‑02‑30 → 2025‑03‑02). If you want strictness similar to malformed offset handling, consider an opt‑in validation.

 function ymdhmsToUtcMs(parts = {}) {
   const {
     year,
     month,
     day,
     hour = 0,
     minute = 0,
     second = 0,
   } = parts;
 
-  return Date.UTC(year, month - 1, day, hour, minute, second);
+  const ms = Date.UTC(year, month - 1, day, hour, minute, second);
+  // Strict round‑trip check (kept cheap and local)
+  const d = new Date(ms);
+  if (d.getUTCFullYear() !== year || d.getUTCMonth() + 1 !== month || d.getUTCDate() !== day ||
+      d.getUTCHours() !== hour || d.getUTCMinutes() !== minute || d.getUTCSeconds() !== second) {
+    return NaN; // caller decides how to handle invalid parts
+  }
+  return ms;
 }

590-617: Preserving -00:00 vs +00:00 (if semantics matter)

Current normalization emits “+00:00” for both ±0. If the distinction (-00:00 = unknown offset) is important for consumers, preserve the original sign when totalMinutes === 0. Otherwise, ignore.

-  const normalizedOffset = minutesToOffset(totalMinutes);
+  const normalizedOffset =
+    totalMinutes === 0
+      ? (String(offset).includes('-') ? '-00:00' : '+00:00')
+      : minutesToOffset(totalMinutes);

653-671: Expose a lightweight cache reset for tests/long‑running processes

Useful for deterministic tests and bounded memory in daemons; keep it under test to avoid expanding public API.

 module.exports = {
   guessLocalZone,
   getZoneNames,
   findExactZoneMatch,
   isValidIana,
   parseDateTimeInZone,
   parseWithOffset,
   utcAdd,
   linkAlias,
   resolveTZID,
   formatDateForRrule,
   attachTz,
 };
 
 // Expose some internals for testing
 module.exports.__test__ = {
   normalizeMidnightParts,
+  _resetCaches() {
+    dtfCache.clear();
+    validIanaCache.clear();
+    aliasMap.clear();
+  },
 };
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 096b40b and 482d7ca.

📒 Files selected for processing (2)
  • test/tz-utils.test.js (1 hunks)
  • tz-utils.js (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/tz-utils.test.js
🧰 Additional context used
🧬 Code graph analysis (1)
tz-utils.js (1)
ical.js (8)
  • normalized (208-208)
  • tz (192-192)
  • p (88-88)
  • p (320-320)
  • i (675-675)
  • tzInfo (177-177)
  • tzInfo (224-224)
  • tzInfo (572-572)
🔇 Additional comments (3)
tz-utils.js (3)

96-126: Formatter cache is solid

Nice use of a per‑zone DateTimeFormat cache and fixed ‘en‑CA’ skeleton to ensure 24h, zero‑padded fields, minimizing constructor churn.


128-164: Midnight rollover fix is correct and future‑proof

Good call extracting normalizeMidnightParts and using next‑day parts to fix the Node <22 “24” hour bug.

Please keep the unit test that asserts rollover on a zone known to emit 24:00 on Node ≤21 to guard against regressions.


371-413: RRULE DTSTART formatting paths look right

Prefers IANA via Intl, falls back to fixed‑offset arithmetic; handles 24:00 via normalizeMidnightParts. Clean and predictable.

@KristjanESPERANTO
Copy link
Contributor Author

@jens-maus To reach a closure, I would stop here for now and wait for your feedback 🙂

@KristjanESPERANTO
Copy link
Contributor Author

KristjanESPERANTO commented Oct 18, 2025

@jens-maus, is there anything else I can do to help you move forward here? I know it's a huge piece of work, but it wouldn't be easy to split it into smaller PRs.

@jens-maus
Copy link
Owner

Sorry @KristjanESPERANTO for the longer delay in reviewing this PR, but I was on vacation for some time and also have not had the opportunity yet to walk through your numerous changes.

I did now and tested it myself and I have to say they are of course quite comprehensive, but look perfectly fine so far. I indeed think it is time to merge this to the master branch and then publish a new 0.22.0 version which other users can then use and report any further issues. However, as we do have quite extensive test cases in our environment I also do not expect anything serious given that users are using node-ical through its official interface.

So thanks @KristjanESPERANTO for this great changes and all the work that you have put in! This is highly appreciated and I think this is a bit step forwarding in having a more smooth and streamlined node-ical experience and also given that moment-timezone is now removed as a dependency, this should also reduce the package size of node-ical considerably. So thanks again!

@jens-maus jens-maus merged commit fefb696 into jens-maus:master Oct 19, 2025
16 checks passed
khassel pushed a commit to MagicMirrorOrg/MagicMirror that referenced this pull request Oct 19, 2025
Normally, I wouldn't update the dependencies again so soon, but
`node-ical` underwent some major changes (see
jens-maus/node-ical#404) with the last release,
and I'd like to use it here as early as possible to see if there are any
problems with it.
@KristjanESPERANTO
Copy link
Contributor Author

Thanks for checking and accepting! 🚀

Because I was interested in the numbers, I did some research:

Metric (tarball contents, no deps) 0.21.0 (moment-timezone) 0.22.0 (Intl utilities) Net Change
npm “Unpacked Size” (tarball contents) 78 kB 98 kB +20 kB
Tarball size (compressed download) 23.4 kB 29.4 kB +6 kB
node_modules size after install ~9.2 MB ~1.2 MB ≈ 8 MB saved
Heap bump after require('node-ical') ~2.4 MB ~0.85 MB ≈ 1.5 MB less RAM

So the slightly higher npm “Unpacked Size” is the cost of bundling our leaner Intl-based utilities, while users still benefit from much smaller installs and lower runtime memory.

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