refactor: replace moment-timezone with Intl-based timezone utilities#404
refactor: replace moment-timezone with Intl-based timezone utilities#404jens-maus merged 50 commits intojens-maus:masterfrom
moment-timezone with Intl-based timezone utilities#404Conversation
…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`.
…using various date libraries
WalkthroughReplaces 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches🧪 Generate unit tests
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (1)
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. Comment |
|
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 🙂 |
There was a problem hiding this comment.
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 toLocaleStringWhen TZID is an offset like "(UTC+02:00 ...)",
getTimeZonecan 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
getTimeZonereturn 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 fromgetFullYear()/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 RRULEOnly use
timeZoneoption 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 arithmeticConsider 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, andutcAddbehave 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 debuggingPropagate
stderrto 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 snapshotsAvoids 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 wellSome 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 toLocaleDateStringCurrent 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 recurrencesSame 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 formattingIf 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'intotoLocaleStringoptionsical.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
⛔ Files ignored due to path filters (1)
package-lock.jsonis 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 correctGood positive/negative coverage for
isValidIana.test/snapshots/example-rrule-moment.txt (1)
1-39: Snapshot content looks consistent and deduplicatedMatches expected one-instance-per-occurrence behavior.
examples/example-rrule-luxon.js (1)
51-67: Good: override merge and dedup logicUsing
between(..., true, () => true)and dedup by getTime works well for most cases.test/basic.test.js (2)
7-8: LGTM: alias mapping via tz-utilsReplacing moment.tz.link with
tz.linkAliasaligns with the new utils.
92-97: Solid replacement for moment.tz.zone check
tz.isValidIanacheck plus ISO normalization is a good substitute.test/from-url.test.js (1)
93-93: Clearer suite naming pays offRenaming the suite to
integration: fromURLmakes the intent obvious and keeps the test catalogue tidy. Nice touch.examples/example-rrule-moment.js (1)
2-2: Helpful doc tweakCalling 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 consistentThe 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 outOutputs 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 verifiedValues match the expected recurrence instances, giving confidence the date-fns example behaves identically.
test/snapshots/example.txt (1)
1-128: Legacy snapshot retainedGlad 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 wellThe 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
|
@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. |
There was a problem hiding this comment.
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 rangeLine 30 sets
rangeEndto 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: PreserveTZIDmetadata when normalizing all-day DTSTARTWhen you rebuild
curr.startwithnew Date(...)(both the offset correction and the midnight rebuild), you drop the.tz/.dateOnlymetadata thatdateParameterpreviously attached. A few lines later we branch oncurr.start.tzto decide whether to emitDTSTART;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
dateOnlyflag) 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
📒 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 semanticsThe 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 TZIDsReally 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 checkThe extra guard that normalizes via
sv-SEand 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 assertionsSwitching to
isValidIanaalongside the ISO comparison keeps this test meaningful without bringing moment-timezone back in. Clean and effective.
|
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
There was a problem hiding this comment.
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
📒 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
There was a problem hiding this comment.
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
📒 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.npmignoreor restricted bypackage.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
|
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. |
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (6)
tz-utils.js (6)
314-321: Trim TZID early to avoid stray whitespace mismatchesMinor 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 resolveZoneCurrent 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:00You 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 rolloversDate.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 processesUseful 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
📒 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 solidNice 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‑proofGood 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 rightPrefers IANA via Intl, falls back to fixed‑offset arithmetic; handles 24:00 via normalizeMidnightParts. Clean and predictable.
|
@jens-maus To reach a closure, I would stop here for now and wait for your feedback 🙂 |
|
@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. |
|
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! |
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.
|
Thanks for checking and accepting! 🚀 Because I was interested in the numbers, I did some research:
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. |
moment-timezonedependency with a lightweightIntl-poweredtz-utilslayer that keeps TZID metadata, Windows alias handling, and offset parsing intactI tried again to make the commits as comprehensible as possible. It's still a big load, though.
Summary by CodeRabbit
New Features
Documentation
Tests
Chores