From d029e6d1f01263a609bb12490c5226f32344a275 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:36:14 +0100 Subject: [PATCH 01/13] fix: use VTIMEZONE STANDARD/DAYLIGHT offsets to resolve Outlook custom timezone IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outlook sometimes emits meaningless TZID values like "Customized Time Zone" instead of a real IANA identifier. node-ical previously fell back to guessLocalZone(), which breaks on systems where the host timezone isn't the calendar's timezone (e.g. Homey hubs always run in UTC). The accompanying VTIMEZONE section contains the actual STANDARD/DAYLIGHT offsets. We now use those as a fingerprint to find a matching IANA zone (e.g. -05:00/-04:00 → America/Detroit), so recurring events spanning DST transitions are also handled correctly. Results are cached; the initial scan costs ~14 ms, subsequent lookups < 0.01 ms. Falls back to a fixed offset when no IANA zone matches, and to guessLocalZone() when no VTIMEZONE is present. Fixes #478 --- ical.js | 20 ++++++++++- test/advanced.test.js | 18 ++++++++++ test/tz-utils.test.js | 68 +++++++++++++++++++++++++++++++++++ tz-utils.js | 84 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 1 deletion(-) diff --git a/ical.js b/ical.js index ca72369..17bdc26 100644 --- a/ical.js +++ b/ical.js @@ -391,7 +391,25 @@ const dateParameter = function (name) { tz = tz.toString().replace(/^"(.*)"$/, '$1'); if (tz === 'tzone://Microsoft/Custom' || tz === '(no TZ description)' || tz.startsWith('Customized Time Zone') || tz.startsWith('tzone://Microsoft/')) { - tz = tzUtil.guessLocalZone(); + // Outlook and Exchange often emit custom TZID values (e.g. "Customized Time Zone") + // together with a VTIMEZONE section that contains the real STANDARD/DAYLIGHT rules. + // Try to match those rules to a known IANA zone so that recurring events that span + // DST boundaries are handled correctly. Falls back to guessLocalZone() when no + // VTIMEZONE is present or its offsets cannot be resolved. + const originalTz = tz; + const stackVTimezone = (stack || []) + .flatMap(item => Object.values(item)) + .find(v => v && v.type === 'VTIMEZONE' + && (Array.isArray(v.tzid) ? v.tzid : [String(v.tzid)]) + .map(id => String(id).replace(/^"(.*)"$/, '$1')) + .includes(originalTz)); + + if (stackVTimezone) { + const resolved = tzUtil.resolveVTimezoneToIana(stackVTimezone, year); + tz = resolved.iana || resolved.offset || tzUtil.guessLocalZone(); + } else { + tz = tzUtil.guessLocalZone(); + } } const tzInfo = tzUtil.resolveTZID(tz); diff --git a/test/advanced.test.js b/test/advanced.test.js index 60f3d55..e5b3e86 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -443,6 +443,24 @@ END:VCALENDAR`; assert.notEqual(event.start.tz, 'Customized Time Zone'); }); + it('resolves VTIMEZONE to IANA zone for "Customized Time Zone" (bad_ms_tz.ics)', () => { + // Event DTSTART;TZID=Customized Time Zone:20200825T103500 + // The VTIMEZONE defines STANDARD=-0500 / DAYLIGHT=-0400 (EST/EDT). + // node-ical should match this to a valid EST/EDT IANA zone so that + // recurring events spanning DST transitions are handled correctly. + // August is in EDT (-04:00), so correct UTC = 10:35 + 4h = 14:35 UTC. + const data = ical.parseFile('./test/fixtures/bad_ms_tz.ics'); + const event = Object.values(data).find(x => x.summary === '[private]'); + assert.strictEqual(event.start.toISOString(), '2020-08-25T14:35:00.000Z'); + assert.strictEqual(event.end.toISOString(), '2020-08-25T15:50:00.000Z'); + // The tz should be a real IANA zone, not 'Customized Time Zone' or a fixed offset + assert.notEqual(event.start.tz, 'Customized Time Zone'); + assert.ok( + !event.start.tz.startsWith('+') && !event.start.tz.startsWith('-'), + `expected IANA zone, got offset: ${event.start.tz}`, + ); + }); + it('rejects invalid custom tz (bad_custom_ms_tz2.ics)', () => { const data = ical.parseFile('./test/fixtures/bad_custom_ms_tz2.ics'); const event = Object.values(data).find(x => x.summary === '[private]'); diff --git a/test/tz-utils.test.js b/test/tz-utils.test.js index 2e378c0..11115f3 100644 --- a/test/tz-utils.test.js +++ b/test/tz-utils.test.js @@ -106,4 +106,72 @@ describe('unit: tz-utils', () => { assert.equal(tz.__test__.isUtcTimezone('Etc/GMT+1'), false); }); }); + + describe('resolveVTimezoneToIana', () => { + // Simulates the parsed VTIMEZONE from bad_ms_tz.ics (EST/EDT equivalent) + const estVTimezone = { + type: 'VTIMEZONE', + tzid: 'Customized Time Zone', + 'abc-standard': { + type: 'STANDARD', + tzoffsetfrom: '-0400', + tzoffsetto: '-0500', + rrule: 'RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11', + }, + 'abc-daylight': { + type: 'DAYLIGHT', + tzoffsetfrom: '-0500', + tzoffsetto: '-0400', + rrule: 'RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3', + }, + }; + + it('resolves EST/EDT VTIMEZONE to a valid IANA zone', () => { + const result = tz.resolveVTimezoneToIana(estVTimezone, 2020); + assert.ok(result.iana, 'should resolve to an IANA zone'); + assert.ok(tz.isValidIana(result.iana), `resolved zone ${result.iana} should be a valid IANA zone`); + assert.equal(result.offset, '-05:00'); + }); + + it('returns STANDARD offset as fallback for fixed-offset zones', () => { + const fixedVTimezone = { + type: 'VTIMEZONE', + tzid: 'No DST Zone', + 'abc-standard': { + type: 'STANDARD', + tzoffsetfrom: '+0530', + tzoffsetto: '+0530', + }, + }; + const result = tz.resolveVTimezoneToIana(fixedVTimezone, 2020); + assert.equal(result.offset, '+05:30'); + }); + + it('returns CET/CEST VTIMEZONE as Europe/Berlin (or equivalent)', () => { + const cetVTimezone = { + type: 'VTIMEZONE', + tzid: 'Customized Time Zone', + standard: { + type: 'STANDARD', + tzoffsetfrom: '+0200', + tzoffsetto: '+0100', + }, + daylight: { + type: 'DAYLIGHT', + tzoffsetfrom: '+0100', + tzoffsetto: '+0200', + }, + }; + const result = tz.resolveVTimezoneToIana(cetVTimezone, 2020); + // Must be a valid CET/CEST IANA zone (could be Europe/Berlin, Europe/Paris, etc.) + assert.ok(result.iana, 'should resolve to an IANA zone'); + assert.equal(result.offset, '+01:00'); + }); + + it('returns undefined for empty/missing vtimezone input', () => { + assert.deepEqual(tz.resolveVTimezoneToIana(null, 2020), {iana: undefined, offset: undefined}); + assert.deepEqual(tz.resolveVTimezoneToIana(undefined, 2020), {iana: undefined, offset: undefined}); + assert.deepEqual(tz.resolveVTimezoneToIana({type: 'VTIMEZONE'}, 2020), {iana: undefined, offset: undefined}); + }); + }); }); diff --git a/tz-utils.js b/tz-utils.js index 07822a3..56fff45 100644 --- a/tz-utils.js +++ b/tz-utils.js @@ -473,6 +473,89 @@ function linkAlias(arg1, arg2) { aliasMap.set(String(arg1), String(arg2)); } +// Memoize VTIMEZONE→IANA lookups keyed by "stdOffset|dstOffset|year" +const vtimezoneIanaCache = new Map(); + +/** + * Attempt to match a parsed VTIMEZONE (with STANDARD/DAYLIGHT sub-components) to a + * known IANA timezone by comparing UTC offsets at two probe dates (January and July). + * + * This resolves Outlook's "Customized Time Zone" and similar Microsoft-generated + * identifiers to a real IANA zone so that recurring events that span DST boundaries + * are handled correctly by rrule-temporal. + * + * Falls back to a fixed UTC-offset string (e.g. "-05:00") when no IANA zone matches, + * or `undefined` when the VTIMEZONE contains no usable offset data. + * + * @param {Object} vTimezone - Parsed VTIMEZONE object (from the node-ical parser stack) + * @param {number} year - Reference year for probe dates (e.g. 2020) + * @returns {{iana: string|undefined, offset: string|undefined}} Best-effort resolution result + */ +function resolveVTimezoneToIana(vTimezone, year) { + if (!vTimezone || typeof vTimezone !== 'object') { + return {iana: undefined, offset: undefined}; + } + + // Collect STANDARD and DAYLIGHT sub-components + const components = Object.values(vTimezone).filter(v => v && typeof v === 'object' && typeof v.type === 'string' && (v.type === 'STANDARD' || v.type === 'DAYLIGHT')); + + if (components.length === 0) { + return {iana: undefined, offset: undefined}; + } + + const standard = components.find(c => c.type === 'STANDARD'); + const daylight = components.find(c => c.type === 'DAYLIGHT'); + + const stdMins = standard ? offsetLabelToMinutes(standard.tzoffsetto) : undefined; + const dstMins = daylight ? offsetLabelToMinutes(daylight.tzoffsetto) : undefined; + + // Need at least a STANDARD offset to do anything useful + if (!Number.isFinite(stdMins)) { + return {iana: undefined, offset: undefined}; + } + + const stdOffset = minutesToOffset(stdMins); + + // No DST component → fixed-offset zone; try Etc/GMT mapping or return raw offset + if (!Number.isFinite(dstMins)) { + const etc = minutesToEtcZone(stdMins); + return {iana: etc || undefined, offset: stdOffset}; + } + + // Cache key: unique per offset pair and year (DST boundaries can change historically) + const cacheKey = `${stdMins}|${dstMins}|${year}`; + if (vtimezoneIanaCache.has(cacheKey)) { + return vtimezoneIanaCache.get(cacheKey); + } + + // Probe two dates: mid-January (winter in NH / summer in SH) and mid-July (inverse) + const probeJan = Temporal.Instant.from(`${year}-01-15T12:00:00Z`); + const probeJul = Temporal.Instant.from(`${year}-07-15T12:00:00Z`); + + for (const zone of getZoneNames()) { + try { + const janOffset = probeJan.toZonedDateTimeISO(zone).offsetNanoseconds / 60_000_000_000; + const julOffset = probeJul.toZonedDateTimeISO(zone).offsetNanoseconds / 60_000_000_000; + + // Match: both probe offsets must equal one of {stdMins, dstMins} (in either order, + // to handle both northern and southern hemisphere DST conventions) + const offsets = new Set([stdMins, dstMins]); + if (offsets.has(janOffset) && offsets.has(julOffset) && janOffset !== julOffset) { + const result = {iana: zone, offset: stdOffset}; + vtimezoneIanaCache.set(cacheKey, result); + return result; + } + } catch { + // Skip zones that Temporal/Intl cannot resolve + } + } + + // No IANA match found; return the STANDARD offset as fallback + const fallback = {iana: undefined, offset: stdOffset}; + vtimezoneIanaCache.set(cacheKey, fallback); + return fallback; +} + // Public API module.exports = { guessLocalZone, @@ -484,6 +567,7 @@ module.exports = { utcAdd, linkAlias, resolveTZID, + resolveVTimezoneToIana, formatDateForRrule, attachTz, isUtcTimezone, From e2ddabeb30c8ee7270dbd1c04072ac9932107aba Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:51:44 +0100 Subject: [PATCH 02/13] fix: make VALUE=DATE RRULE expansion timezone-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DATE-only (VALUE=DATE) recurring events were broken for hosts west of UTC because two independent assumptions about UTC midnight did not hold in those timezones: 1. RRuleCompatWrapper converted every rrule-temporal ZonedDateTime result via `new Date(zdt.epochMilliseconds)`. For VALUE=DATE events rrule-temporal anchors occurrences to UTC midnight, so e.g. 2025-01-01T00:00:00Z displays as 2024-12-31 in America/New_York — the wrong calendar date. Fix: add a `dateOnly` flag to RRuleCompatWrapper and a `#zdtToDate()` helper that, for date-only events, builds the Date from the ZDT's calendar components (`new Date(zdt.year, zdt.month-1, zdt.day)`) and sets `dateOnly=true` on the result. 2. `adjustSearchRange` passed local-midnight boundaries to `rrule.between()`. In UTC-5 "2025-01-01 00:00 local" is 2025-01-01T05:00:00Z, which is *after* the UTC-midnight anchor of the first occurrence — so the first day was silently excluded. Fix: for full-day events, normalise both bounds to UTC midnight with `Date.UTC(y, m, d)` so the comparison is host-TZ-independent. Update two test assertions that relied on UTC getters (`getUTCFullYear` etc.) for DATE-only dates, and fix the `handles negative duration` assertion to compare local calendar dates (`toDateString()`) rather than UTC ISO strings. Fixes 7 test failures under TZ=America/New_York. --- ical.js | 36 +++++++++++++++++++++++------- node-ical.js | 29 +++++++++++++++++++----- test/advanced.test.js | 5 ++++- test/date-only-rrule-until.test.js | 16 +++++++------ 4 files changed, 65 insertions(+), 21 deletions(-) diff --git a/ical.js b/ical.js index 17bdc26..5f4e3a0 100644 --- a/ical.js +++ b/ical.js @@ -95,8 +95,12 @@ function storeRecurrenceOverride(recurrences, recurrenceId, recurrenceObject) { * This maintains backward compatibility while using rrule-temporal internally */ class RRuleCompatWrapper { - constructor(rruleTemporal) { + constructor(rruleTemporal, dateOnly = false) { this._rrule = rruleTemporal; + // VALUE=DATE events are anchored to UTC midnight in rrule-temporal. + // Converting via epochMilliseconds shifts the date backwards in timezones + // west of UTC; instead we use the ZonedDateTime calendar components directly. + this._dateOnly = dateOnly; } static #temporalToDate(value) { @@ -132,25 +136,41 @@ class RRuleCompatWrapper { return converted; } + // Convert a ZonedDateTime to a JS Date. + // For VALUE=DATE events the ZDT calendar components (year/month/day in UTC) + // represent the intended calendar date; create a local-midnight Date so that + // .toDateString() returns the correct day regardless of the host timezone. + // Mark the result with dateOnly=true so that downstream helpers that + // distinguish date-only from timed dates (e.g. createLocalDateFromUTC) also + // use local getters rather than UTC getters. + #zdtToDate(zdt) { + if (this._dateOnly) { + const d = new Date(zdt.year, zdt.month - 1, zdt.day, 0, 0, 0, 0); + d.dateOnly = true; + return d; + } + + return new Date(zdt.epochMilliseconds); + } + between(after, before, inclusive = false) { const results = this._rrule.between(after, before, inclusive); - // Convert Temporal.ZonedDateTime → Date - return results.map(zdt => new Date(zdt.epochMilliseconds)); + return results.map(zdt => this.#zdtToDate(zdt)); } all(iterator) { const results = this._rrule.all(iterator); - return results.map(zdt => new Date(zdt.epochMilliseconds)); + return results.map(zdt => this.#zdtToDate(zdt)); } before(date, inclusive = false) { const result = this._rrule.before(date, inclusive); - return result ? new Date(result.epochMilliseconds) : null; + return result ? this.#zdtToDate(result) : null; } after(date, inclusive = false) { const result = this._rrule.after(date, inclusive); - return result ? new Date(result.epochMilliseconds) : null; + return result ? this.#zdtToDate(result) : null; } toText(locale) { @@ -903,7 +923,7 @@ module.exports = { rruleString: fullRruleString, }); - curr.rrule = new RRuleCompatWrapper(rruleTemporal); + curr.rrule = new RRuleCompatWrapper(rruleTemporal, true /* dateOnly */); } else { // DATE-TIME events: convert curr.start (Date) to Temporal.ZonedDateTime const tzInfo = curr.start.tz ? tzUtil.resolveTZID(curr.start.tz) : undefined; @@ -929,7 +949,7 @@ module.exports = { dtstart: dtstartTemporal, }); - curr.rrule = new RRuleCompatWrapper(rruleTemporal); + curr.rrule = new RRuleCompatWrapper(rruleTemporal, false /* dateOnly */); } } } diff --git a/node-ical.js b/node-ical.js index f3f23c4..256d4db 100644 --- a/node-ical.js +++ b/node-ical.js @@ -472,11 +472,30 @@ function validateDateRange(from, to) { * @returns {{searchFrom: Date, searchTo: Date}} */ function adjustSearchRange(from, to, isFullDay, expandOngoing, baseDurationMs) { - const isMidnight = to.getHours() === 0 && to.getMinutes() === 0 && to.getSeconds() === 0; - const searchTo = (isFullDay && isMidnight) - ? new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999) - : to; - const searchFrom = expandOngoing ? new Date(from.getTime() - baseDurationMs) : from; + let searchFrom; + let searchTo; + + if (isFullDay) { + // VALUE=DATE occurrences are anchored to UTC midnight (rrule-temporal uses + // tzid='UTC' for all date-only events). Normalise the caller-supplied + // local-midnight boundaries to their UTC-midnight equivalents so that + // rrule.between() comparisons are host-TZ-independent. + searchFrom = new Date(Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())); + searchTo = new Date(Date.UTC(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999)); + } else { + // Timed events: if `to` is exactly local midnight, extend to end of that day + // so events starting at any time that day are included. + const isMidnight = to.getHours() === 0 && to.getMinutes() === 0 && to.getSeconds() === 0; + searchFrom = from; + searchTo = isMidnight + ? new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999) + : to; + } + + if (expandOngoing) { + searchFrom = new Date(searchFrom.getTime() - baseDurationMs); + } + return {searchFrom, searchTo}; } diff --git a/test/advanced.test.js b/test/advanced.test.js index e5b3e86..8bc6454 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -492,7 +492,10 @@ END:VCALENDAR`; it('handles negative duration (bad_custom_ms_tz.ics)', () => { const data = ical.parseFile('./test/fixtures/bad_custom_ms_tz.ics'); const event = Object.values(data).find(x => x.summary === '*masked-away2*'); - assert.equal(event.end.toISOString().slice(0, 10), new Date(Date.UTC(2021, 2, 23, 21, 56, 56)).toISOString().slice(0, 10)); + // DATE-only DTSTART with time-component duration: the computed end falls + // on 2021-03-23 in *local* time in any sane timezone, so compare as a + // local calendar date rather than a UTC ISO string. + assert.equal(event.end.toDateString(), new Date(2021, 2, 23).toDateString()); }); }); }); diff --git a/test/date-only-rrule-until.test.js b/test/date-only-rrule-until.test.js index d5ebf4b..50aff06 100644 --- a/test/date-only-rrule-until.test.js +++ b/test/date-only-rrule-until.test.js @@ -43,17 +43,19 @@ END:VCALENDAR`; // UNTIL=20190312 means up to and including 2018-03-13 (not 2019-03-13) assert.strictEqual(recurrences.length, 3, 'Should have 3 occurrences'); - // First occurrence should be on 2016-03-13 + // First occurrence should be on 2016-03-13. + // DATE-only events are returned as local-midnight Dates; use local getters + // (.getFullYear / .getMonth / .getDate) so the assertion is TZ-independent. const firstDate = new Date(recurrences[0]); - assert.strictEqual(firstDate.getUTCFullYear(), 2016); - assert.strictEqual(firstDate.getUTCMonth(), 2); // March (0-indexed) - assert.strictEqual(firstDate.getUTCDate(), 13); + assert.strictEqual(firstDate.getFullYear(), 2016); + assert.strictEqual(firstDate.getMonth(), 2); // March (0-indexed) + assert.strictEqual(firstDate.getDate(), 13); // Last occurrence should be on 2018-03-13 const lastDate = new Date(recurrences.at(-1)); - assert.strictEqual(lastDate.getUTCFullYear(), 2018); - assert.strictEqual(lastDate.getUTCMonth(), 2); - assert.strictEqual(lastDate.getUTCDate(), 13); + assert.strictEqual(lastDate.getFullYear(), 2018); + assert.strictEqual(lastDate.getMonth(), 2); + assert.strictEqual(lastDate.getDate(), 13); }); it('should preserve VALUE=DATE in DTSTART when creating RRULE string', function () { From 22036bc2f0ab2816ac605deaacc2c121759ee63c Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:07:35 +0100 Subject: [PATCH 03/13] fix: apply VTIMEZONE resolution in fallbackWithStackTimezone for custom MS TZIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fallbackWithStackTimezone() is the code path taken when DTSTART carries no TZID parameter at all. It found the VTIMEZONE on the parser stack and then called resolveTZID(tzid), but resolveTZID replaces custom Microsoft TZID strings ("Customized Time Zone ...", "tzone://Microsoft/...") with guessLocalZone() unconditionally — so the VTIMEZONE's STANDARD/DAYLIGHT offset rules were silently ignored and the host's local timezone was used instead. The fix mirrors the explicit-TZID branch (issue #478): check whether the TZID is a custom Microsoft name before calling resolveTZID; if so, invoke resolveVTimezoneToIana() on the VTIMEZONE to extract an IANA zone (or fixed-offset fallback) from the STANDARD/DAYLIGHT rules first. Add test fixture floating-dtstart-custom-vtimezone.ics and a test that verifies a DTSTART with no TZID parameter is resolved to the correct UTC instant when the calendar carries a matching custom-name VTIMEZONE. --- ical.js | 17 +++++++++++- test/advanced.test.js | 14 ++++++++++ .../floating-dtstart-custom-vtimezone.ics | 26 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/floating-dtstart-custom-vtimezone.ics diff --git a/ical.js b/ical.js index 5f4e3a0..9d30e38 100644 --- a/ical.js +++ b/ical.js @@ -367,7 +367,22 @@ const dateParameter = function (name) { return new Date(year, monthIndex, day, hour, minute, second); } - const tzInfo = tzUtil.resolveTZID(normalizedTzId); + // Custom Microsoft TZIDs (e.g. "Customized Time Zone", "tzone://Microsoft/Custom") + // cannot be resolved by resolveTZID (which would just substitute the host's local + // zone). When a VTIMEZONE with STANDARD/DAYLIGHT rules is available, resolve via + // its offset data first — same logic as the explicit-TZID branch (issue #478). + let resolvedTzId = String(normalizedTzId).replace(/^"(.*)"$/, '$1'); + const isCustomMsTz = resolvedTzId === 'tzone://Microsoft/Custom' + || resolvedTzId === '(no TZ description)' + || resolvedTzId.startsWith('Customized Time Zone') + || resolvedTzId.startsWith('tzone://Microsoft/'); + + if (isCustomMsTz && vTimezone) { + const resolved = tzUtil.resolveVTimezoneToIana(vTimezone, year); + resolvedTzId = resolved.iana || resolved.offset || tzUtil.guessLocalZone(); + } + + const tzInfo = tzUtil.resolveTZID(resolvedTzId); const offsetString = typeof tzInfo.offset === 'string' ? tzInfo.offset : undefined; if (offsetString) { return tzUtil.parseWithOffset(value, offsetString); diff --git a/test/advanced.test.js b/test/advanced.test.js index 8bc6454..baddba4 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -497,6 +497,20 @@ END:VCALENDAR`; // local calendar date rather than a UTC ISO string. assert.equal(event.end.toDateString(), new Date(2021, 2, 23).toDateString()); }); + + // Floating DTSTART (no TZID param) should still resolve via the VTIMEZONE + // on the parser stack when the VTIMEZONE carries a custom TZID that + // resolveTZID alone cannot map. This exercises the resolveVTimezoneToIana + // fallback inside fallbackWithStackTimezone(). + it('resolves floating DTSTART via VTIMEZONE STANDARD/DAYLIGHT rules', () => { + const data = ical.parseFile('./test/fixtures/floating-dtstart-custom-vtimezone.ics'); + const event = Object.values(data).find(x => x.uid === 'floating-with-custom-vtimezone@test'); + assert.ok(event, 'event should exist'); + // The VTIMEZONE defines STANDARD=-0500 / DAYLIGHT=-0400 (US Eastern). + // June 10 is in EDT (-04:00), so 14:00 wall → 18:00 UTC. + assert.strictEqual(event.start.toISOString(), '2025-06-10T18:00:00.000Z'); + assert.strictEqual(event.end.toISOString(), '2025-06-10T19:00:00.000Z'); + }); }); }); diff --git a/test/fixtures/floating-dtstart-custom-vtimezone.ics b/test/fixtures/floating-dtstart-custom-vtimezone.ics new file mode 100644 index 0000000..70ec986 --- /dev/null +++ b/test/fixtures/floating-dtstart-custom-vtimezone.ics @@ -0,0 +1,26 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VTIMEZONE +TZID:Customized Time Zone 1 +BEGIN:STANDARD +DTSTART:16011104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16010311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:floating-with-custom-vtimezone@test +SUMMARY:Floating DTSTART with custom VTIMEZONE +DTSTART:20250610T140000 +DTEND:20250610T150000 +DTSTAMP:20250101T000000Z +END:VEVENT +END:VCALENDAR From aadfcdc4af760c0dc1bdf77fb6c8a393920396dc Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:18:09 +0100 Subject: [PATCH 04/13] fix: pick most recent applicable STANDARD/DAYLIGHT block in resolveVTimezoneToIana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A VTIMEZONE can contain multiple historic observance blocks for the same component type — e.g. Exchange emits both a pre-2007 and a post-2007 STANDARD/DAYLIGHT pair for US Eastern time. The previous code used .find() which returned whichever block happened to appear first in the (UUID-keyed) object. For an event in 2025 this could silently pick the 1967 observance rule and compute the wrong UTC offset. Fix: replace .find() with pickApplicableBlock(), a small helper that selects the block whose DTSTART year is the largest one \u2264 the event year (i.e. the most recent rule already in effect). Falls back to the oldest block when all blocks start after the reference year. Add fixture multi-era-vtimezone.ics and test that verifies both a modern (2025) and a vintage (1985) event are resolved to the correct UTC instant when the VTIMEZONE contains two STANDARD and two DAYLIGHT blocks. --- test/advanced.test.js | 19 +++++++++++ test/fixtures/multi-era-vtimezone.ics | 49 +++++++++++++++++++++++++++ tz-utils.js | 36 ++++++++++++++++++-- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/multi-era-vtimezone.ics diff --git a/test/advanced.test.js b/test/advanced.test.js index baddba4..2e533b4 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -511,6 +511,25 @@ END:VCALENDAR`; assert.strictEqual(event.start.toISOString(), '2025-06-10T18:00:00.000Z'); assert.strictEqual(event.end.toISOString(), '2025-06-10T19:00:00.000Z'); }); + + // Multi-era VTIMEZONE (multiple STANDARD/DAYLIGHT blocks for different historic rules). + // resolveVTimezoneToIana must pick the block whose DTSTART year is closest to + // but not after the event year — not simply the first block encountered. + it('picks the right observance block for multi-era VTIMEZONE (multi-era-vtimezone.ics)', () => { + const data = ical.parseFile('./test/fixtures/multi-era-vtimezone.ics'); + + // 2025 event: modern rule (DTSTART 2007) applies → EDT in June → 14:00 wall = 18:00 UTC + const modern = Object.values(data).find(x => x.uid === 'multi-era-2025@test'); + assert.ok(modern, '2025 event should exist'); + // Modern rule (post-2007): EDT in June → 14:00-04:00 = 18:00 UTC + assert.strictEqual(modern.start.toISOString(), '2025-06-10T18:00:00.000Z'); + + // 1985 event: old rule (DTSTART 1671) applies → EDT in October 15 → 14:00 wall = 18:00 UTC + const vintage = Object.values(data).find(x => x.uid === 'multi-era-1985@test'); + assert.ok(vintage, '1985 event should exist'); + // Old rule (pre-2007): EDT in Oct 15 → 14:00-04:00 = 18:00 UTC + assert.strictEqual(vintage.start.toISOString(), '1985-10-15T18:00:00.000Z'); + }); }); }); diff --git a/test/fixtures/multi-era-vtimezone.ics b/test/fixtures/multi-era-vtimezone.ics new file mode 100644 index 0000000..3b6763e --- /dev/null +++ b/test/fixtures/multi-era-vtimezone.ics @@ -0,0 +1,49 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VTIMEZONE +TZID:Customized Time Zone 1 +BEGIN:STANDARD +DTSTART:16711029T020000 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:16710404T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +END:DAYLIGHT +BEGIN:STANDARD +DTSTART:20071104T020000 +RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11 +TZOFFSETFROM:-0400 +TZOFFSETTO:-0500 +TZNAME:EST +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:20070311T020000 +RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3 +TZOFFSETFROM:-0500 +TZOFFSETTO:-0400 +TZNAME:EDT +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:multi-era-2025@test +SUMMARY:Event in 2025 (modern rule) +DTSTART;TZID="Customized Time Zone 1":20250610T140000 +DTEND;TZID="Customized Time Zone 1":20250610T150000 +DTSTAMP:20250101T000000Z +END:VEVENT +BEGIN:VEVENT +UID:multi-era-1985@test +SUMMARY:Event in 1985 (old rule) +DTSTART;TZID="Customized Time Zone 1":19851015T140000 +DTEND;TZID="Customized Time Zone 1":19851015T150000 +DTSTAMP:20250101T000000Z +END:VEVENT +END:VCALENDAR diff --git a/tz-utils.js b/tz-utils.js index 56fff45..86d4b53 100644 --- a/tz-utils.js +++ b/tz-utils.js @@ -476,6 +476,36 @@ function linkAlias(arg1, arg2) { // Memoize VTIMEZONE→IANA lookups keyed by "stdOffset|dstOffset|year" const vtimezoneIanaCache = new Map(); +/** +/** + * Pick the STANDARD or DAYLIGHT sub-component that applies to a given reference year. + * A VTIMEZONE may carry multiple historic observance blocks (e.g. the US rule changed in 2007). + * We want the block whose DTSTART year is the largest one that is ≤ refYear — i.e. the most + * recent rule that has already come into effect. + * + * @param {Array} blocks - Array of STANDARD or DAYLIGHT components (all same type) + * @param {number} refYear - The event year to look up the rule for + * @returns {Object|undefined} + */ +function pickApplicableBlock(blocks, refYear) { + if (blocks.length === 0) { + return undefined; + } + + if (blocks.length === 1) { + return blocks[0]; + } + + // Sort descending by the DTSTART year of each observance block. + // "start" is the parsed Date for the DTSTART field inside STANDARD/DAYLIGHT. + const getYear = block => (block.start instanceof Date ? block.start.getFullYear() : 0); + const sorted = [...blocks].sort((a, b) => getYear(b) - getYear(a)); + + // Take the first block whose DTSTART year is ≤ refYear (most recent applicable rule). + // Fall back to the oldest block if all blocks start after refYear (future-only rules). + return sorted.find(b => getYear(b) <= refYear) ?? sorted.at(-1); +} + /** * Attempt to match a parsed VTIMEZONE (with STANDARD/DAYLIGHT sub-components) to a * known IANA timezone by comparing UTC offsets at two probe dates (January and July). @@ -503,8 +533,10 @@ function resolveVTimezoneToIana(vTimezone, year) { return {iana: undefined, offset: undefined}; } - const standard = components.find(c => c.type === 'STANDARD'); - const daylight = components.find(c => c.type === 'DAYLIGHT'); + // When multiple observance blocks exist (e.g. US pre-/post-2007 DST rule change), + // pick the one whose DTSTART year is the newest that is still ≤ the event year. + const standard = pickApplicableBlock(components.filter(c => c.type === 'STANDARD'), year); + const daylight = pickApplicableBlock(components.filter(c => c.type === 'DAYLIGHT'), year); const stdMins = standard ? offsetLabelToMinutes(standard.tzoffsetto) : undefined; const dstMins = daylight ? offsetLabelToMinutes(daylight.tzoffsetto) : undefined; From 62c7492095c37e60d25c0d0bdc91e5195314be8f Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:37:07 +0100 Subject: [PATCH 05/13] refactor: move tz-utils.js into lib/ Consistent with lib/date-utils.js; tz-utils is internal-only (not exported through node-ical.js, not mentioned in README or .d.ts) so this is not a breaking change. Update all internal require paths accordingly. --- ical.js | 2 +- lib/date-utils.js | 2 +- tz-utils.js => lib/tz-utils.js | 3 +-- test/advanced.test.js | 2 +- test/basic.test.js | 2 +- test/tz-utils.test.js | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) rename tz-utils.js => lib/tz-utils.js (99%) diff --git a/ical.js b/ical.js index 9d30e38..90778ec 100644 --- a/ical.js +++ b/ical.js @@ -10,7 +10,7 @@ globalThis.Temporal ??= Temporal; const {RRuleTemporal} = require('rrule-temporal'); const {toText: toTextFunction} = require('rrule-temporal/totext'); -const tzUtil = require('./tz-utils.js'); +const tzUtil = require('./lib/tz-utils.js'); const {getDateKey} = require('./lib/date-utils.js'); /** diff --git a/lib/date-utils.js b/lib/date-utils.js index f42cc3b..67d4ba6 100644 --- a/lib/date-utils.js +++ b/lib/date-utils.js @@ -4,7 +4,7 @@ // Load Temporal polyfill if not natively available const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal; -const tzUtil = require('../tz-utils.js'); +const tzUtil = require('./tz-utils.js'); /** * Construct a date-only key (YYYY-MM-DD) from a Date object. diff --git a/tz-utils.js b/lib/tz-utils.js similarity index 99% rename from tz-utils.js rename to lib/tz-utils.js index 86d4b53..03ae047 100644 --- a/tz-utils.js +++ b/lib/tz-utils.js @@ -3,7 +3,7 @@ // Load Temporal polyfill if not natively available (mirrors ical.js) const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal; -const windowsZones = require('./windowsZones.json'); +const windowsZones = require('../windowsZones.json'); // Ensure polyfill is globally available for downstream modules globalThis.Temporal ??= Temporal; @@ -476,7 +476,6 @@ function linkAlias(arg1, arg2) { // Memoize VTIMEZONE→IANA lookups keyed by "stdOffset|dstOffset|year" const vtimezoneIanaCache = new Map(); -/** /** * Pick the STANDARD or DAYLIGHT sub-component that applies to a given reference year. * A VTIMEZONE may carry multiple historic observance blocks (e.g. the US rule changed in 2007). diff --git a/test/advanced.test.js b/test/advanced.test.js index 2e533b4..bc70414 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const {describe, it, before} = require('mocha'); -const tz = require('../tz-utils.js'); +const tz = require('../lib/tz-utils.js'); const ical = require('../node-ical.js'); // Map 'Etc/Unknown' TZID used in fixtures to a concrete zone diff --git a/test/basic.test.js b/test/basic.test.js index 528e981..80f759e 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -1,6 +1,6 @@ const assert_ = require('node:assert/strict'); const {describe, it} = require('mocha'); -const tz = require('../tz-utils.js'); +const tz = require('../lib/tz-utils.js'); const ical = require('../node-ical.js'); // Map 'Etc/Unknown' TZID used in fixtures to a concrete zone diff --git a/test/tz-utils.test.js b/test/tz-utils.test.js index 11115f3..e55872d 100644 --- a/test/tz-utils.test.js +++ b/test/tz-utils.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const process = require('node:process'); const {describe, it} = require('mocha'); -const tz = require('../tz-utils.js'); +const tz = require('../lib/tz-utils.js'); describe('unit: tz-utils', () => { it('validates IANA zone names', () => { From ad84e01c08d712a99195aabef6f567691cb319fc Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:11:15 +0100 Subject: [PATCH 06/13] fix: return undefined offset instead of stdOffset when IANA lookup fails for DST zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When resolveVTimezoneToIana cannot find any IANA zone that matches the VTIMEZONE's STD/DST offset pair, returning stdOffset is silently wrong for roughly half of all timestamps (those that fall in the DST period). Return {iana: undefined, offset: undefined} instead so callers fall back to floating/local time rather than applying a confident wrong offset. Add a console.warn to surface the case immediately should it ever occur in practice (it shouldn't — every real DST zone is in the Temporal/Intl database). --- lib/tz-utils.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/tz-utils.js b/lib/tz-utils.js index 03ae047..9787a5d 100644 --- a/lib/tz-utils.js +++ b/lib/tz-utils.js @@ -581,8 +581,15 @@ function resolveVTimezoneToIana(vTimezone, year) { } } - // No IANA match found; return the STANDARD offset as fallback - const fallback = {iana: undefined, offset: stdOffset}; + // No IANA zone matched both probe offsets. + // Returning stdOffset here would be silently wrong for ~50 % of timestamps + // (those that fall in the DST period). Return undefined instead so callers + // fall back to floating/local time rather than applying a confident wrong offset. + // This path should be unreachable in practice because every real DST zone is + // present in the Temporal/Intl database; the warning is here to surface any + // exception quickly. + console.warn(`[node-ical] resolveVTimezoneToIana: no IANA zone matched STD=${stdMins} DST=${dstMins} for year ${year}; falling back to floating time`); + const fallback = {iana: undefined, offset: undefined}; vtimezoneIanaCache.set(cacheKey, fallback); return fallback; } From 0134d949640f22185853b8a1c96560eb59c44f04 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:21:42 +0100 Subject: [PATCH 07/13] chore: remove unused eslint rule for import order --- ical.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ical.js b/ical.js index 90778ec..6a4dd4f 100644 --- a/ical.js +++ b/ical.js @@ -1,13 +1,11 @@ -/* eslint-disable max-depth, max-params, no-warning-comments, complexity, import-x/order */ +/* eslint-disable max-depth, max-params, no-warning-comments, complexity */ const {randomUUID} = require('node:crypto'); - // Load Temporal polyfill if not natively available // TODO: Drop the polyfill branch once our minimum Node version ships Temporal const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal; // Ensure Temporal exists before loading rrule-temporal globalThis.Temporal ??= Temporal; - const {RRuleTemporal} = require('rrule-temporal'); const {toText: toTextFunction} = require('rrule-temporal/totext'); const tzUtil = require('./lib/tz-utils.js'); From 72255750fc9880ba0e2d381fa3dfbe9c3d471e9c Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:21:40 +0100 Subject: [PATCH 08/13] chore: remove obsolete eslint-env mocha comments from test files The /* eslint-env */ directive is no longer recognised under ESLint flat config and will become an error in v10. xo already provides Mocha globals for *.test.js files automatically, so the comments were redundant. --- test/date-only-rrule-until.test.js | 1 - test/google-calendar-until-bug.test.js | 1 - test/monthly-bymonthday-multiple.test.js | 1 - test/non-utc-until.test.js | 1 - 4 files changed, 4 deletions(-) diff --git a/test/date-only-rrule-until.test.js b/test/date-only-rrule-until.test.js index 50aff06..d69a103 100644 --- a/test/date-only-rrule-until.test.js +++ b/test/date-only-rrule-until.test.js @@ -1,4 +1,3 @@ -/* eslint-env mocha */ /* eslint-disable prefer-arrow-callback */ const assert = require('node:assert/strict'); diff --git a/test/google-calendar-until-bug.test.js b/test/google-calendar-until-bug.test.js index be4b37a..b6afdf6 100644 --- a/test/google-calendar-until-bug.test.js +++ b/test/google-calendar-until-bug.test.js @@ -1,4 +1,3 @@ -/* eslint-env mocha */ /* eslint-disable prefer-arrow-callback */ const assert = require('node:assert/strict'); diff --git a/test/monthly-bymonthday-multiple.test.js b/test/monthly-bymonthday-multiple.test.js index 56454d7..56acfba 100644 --- a/test/monthly-bymonthday-multiple.test.js +++ b/test/monthly-bymonthday-multiple.test.js @@ -1,4 +1,3 @@ -/* eslint-env mocha */ /* eslint-disable prefer-arrow-callback */ // Regression tests for FREQ=MONTHLY with multiple BYMONTHDAY values. diff --git a/test/non-utc-until.test.js b/test/non-utc-until.test.js index 0313654..9749431 100644 --- a/test/non-utc-until.test.js +++ b/test/non-utc-until.test.js @@ -1,4 +1,3 @@ -/* eslint-env mocha */ /* eslint-disable prefer-arrow-callback */ const assert = require('node:assert/strict'); From 20493239e235b5b32e9a4b38cc50519ffdb05725 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Mon, 9 Mar 2026 23:56:23 +0100 Subject: [PATCH 09/13] fix: align RRuleCompatWrapper public API with declared types - before()/after(): return undefined instead of null when no occurrence is found, matching the declared return type (Date | undefined) - all(iterator): wrap the caller's iterator so it receives a converted Date rather than a raw Temporal.ZonedDateTime, consistent with the rest of the wrapper's public surface Also update the JSDoc for resolveVTimezoneToIana to accurately describe its three return cases after the DST-fallback change (ad84e01). --- ical.js | 12 +++++++++--- lib/tz-utils.js | 20 ++++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/ical.js b/ical.js index 6a4dd4f..d5f9e0e 100644 --- a/ical.js +++ b/ical.js @@ -157,18 +157,24 @@ class RRuleCompatWrapper { } all(iterator) { - const results = this._rrule.all(iterator); + // If the caller supplied an iterator, wrap it so it receives a converted Date + // rather than a raw Temporal.ZonedDateTime — keeping the public API consistent + // with between() and matching the declared return type. + const wrappedIterator = iterator + ? (zdt, length) => iterator(this.#zdtToDate(zdt), length) + : undefined; + const results = this._rrule.all(wrappedIterator); return results.map(zdt => this.#zdtToDate(zdt)); } before(date, inclusive = false) { const result = this._rrule.before(date, inclusive); - return result ? this.#zdtToDate(result) : null; + return result ? this.#zdtToDate(result) : undefined; } after(date, inclusive = false) { const result = this._rrule.after(date, inclusive); - return result ? this.#zdtToDate(result) : null; + return result ? this.#zdtToDate(result) : undefined; } toText(locale) { diff --git a/lib/tz-utils.js b/lib/tz-utils.js index 9787a5d..04e20b9 100644 --- a/lib/tz-utils.js +++ b/lib/tz-utils.js @@ -513,12 +513,24 @@ function pickApplicableBlock(blocks, refYear) { * identifiers to a real IANA zone so that recurring events that span DST boundaries * are handled correctly by rrule-temporal. * - * Falls back to a fixed UTC-offset string (e.g. "-05:00") when no IANA zone matches, - * or `undefined` when the VTIMEZONE contains no usable offset data. + * Return value shape — three possible cases: + * + * 1. IANA zone found (DST zone): { iana: string, offset: string } + * Both fields are set; `offset` holds the STANDARD (winter) offset as a + * convenience for callers that prefer a fixed-offset representation. + * + * 2. Non-DST VTIMEZONE (no DAYLIGHT block): { iana: string|undefined, offset: string } + * `offset` is always the raw fixed UTC offset (e.g. "-05:00"). `iana` is + * set to an Etc/GMT-style zone when one maps exactly, otherwise undefined. + * + * 3. DST zone but no IANA match: { iana: undefined, offset: undefined } + * No reliable representation is available; callers should fall back to + * floating/local time rather than returning a confidently wrong offset. * * @param {Object} vTimezone - Parsed VTIMEZONE object (from the node-ical parser stack) - * @param {number} year - Reference year for probe dates (e.g. 2020) - * @returns {{iana: string|undefined, offset: string|undefined}} Best-effort resolution result + * @param {number} year - Reference year used to select the applicable observance block + * and to probe the IANA database (DST boundaries can change historically). + * @returns {{ iana: string|undefined, offset: string|undefined }} */ function resolveVTimezoneToIana(vTimezone, year) { if (!vTimezone || typeof vTimezone !== 'object') { From 9104418455730c2c4222d5b6e97c3f9108f61a32 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:14:15 +0100 Subject: [PATCH 10/13] fix: widen VTIMEZONE resolution and guard against invalid year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ical.js – fallbackWithStackTimezone: - Remove narrow isCustomMsTz guard; instead attempt resolveVTimezoneToIana whenever a VTIMEZONE block is present. Any TZID can benefit from embedded STANDARD/DAYLIGHT offset data, not just the handful of known Microsoft custom strings. - Drop the guessLocalZone() fallback: when VTIMEZONE resolution returns neither iana nor offset, preserve the original resolvedTzId so resolveTZID() can make a best effort or the floating-time path at the end of the function applies. lib/tz-utils.js – resolveVTimezoneToIana: - Add explicit year guard immediately after the vTimezone check. Coerce to Number and reject non-finite / non-integer values with { iana: undefined, offset: undefined } so that an invalid year can never corrupt the cache key or cause Temporal.Instant.from() to throw on a malformed ISO string. - Use the coerced yearNumber throughout (pickApplicableBlock, cacheKey, probeJan/probeJul, console.warn). --- ical.js | 22 ++++++++++++---------- lib/tz-utils.js | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/ical.js b/ical.js index d5f9e0e..d1dd37a 100644 --- a/ical.js +++ b/ical.js @@ -371,19 +371,21 @@ const dateParameter = function (name) { return new Date(year, monthIndex, day, hour, minute, second); } - // Custom Microsoft TZIDs (e.g. "Customized Time Zone", "tzone://Microsoft/Custom") - // cannot be resolved by resolveTZID (which would just substitute the host's local - // zone). When a VTIMEZONE with STANDARD/DAYLIGHT rules is available, resolve via - // its offset data first — same logic as the explicit-TZID branch (issue #478). let resolvedTzId = String(normalizedTzId).replace(/^"(.*)"$/, '$1'); - const isCustomMsTz = resolvedTzId === 'tzone://Microsoft/Custom' - || resolvedTzId === '(no TZ description)' - || resolvedTzId.startsWith('Customized Time Zone') - || resolvedTzId.startsWith('tzone://Microsoft/'); - if (isCustomMsTz && vTimezone) { + // When a VTIMEZONE block is present, prefer its STANDARD/DAYLIGHT offset data over + // a pure string-based TZID lookup. This handles both well-known IANA names (where + // the embedded rules may be more historically precise) and completely custom TZIDs + // (e.g. Microsoft's "Customized Time Zone", "tzone://Microsoft/Custom") that + // resolveTZID cannot look up at all. + // Only replace resolvedTzId when resolution actually succeeds; otherwise keep the + // original value so resolveTZID can make a best effort — never substitute the host + // zone via guessLocalZone(). + if (vTimezone) { const resolved = tzUtil.resolveVTimezoneToIana(vTimezone, year); - resolvedTzId = resolved.iana || resolved.offset || tzUtil.guessLocalZone(); + if (resolved.iana || resolved.offset) { + resolvedTzId = resolved.iana || resolved.offset; + } } const tzInfo = tzUtil.resolveTZID(resolvedTzId); diff --git a/lib/tz-utils.js b/lib/tz-utils.js index 04e20b9..314084c 100644 --- a/lib/tz-utils.js +++ b/lib/tz-utils.js @@ -537,6 +537,13 @@ function resolveVTimezoneToIana(vTimezone, year) { return {iana: undefined, offset: undefined}; } + // Reject unusable year values before they can corrupt the cache key or cause + // Temporal.Instant.from() to throw on a malformed ISO string. + const yearNumber = Number(year); + if (!Number.isFinite(yearNumber) || !Number.isInteger(yearNumber)) { + return {iana: undefined, offset: undefined}; + } + // Collect STANDARD and DAYLIGHT sub-components const components = Object.values(vTimezone).filter(v => v && typeof v === 'object' && typeof v.type === 'string' && (v.type === 'STANDARD' || v.type === 'DAYLIGHT')); @@ -546,8 +553,8 @@ function resolveVTimezoneToIana(vTimezone, year) { // When multiple observance blocks exist (e.g. US pre-/post-2007 DST rule change), // pick the one whose DTSTART year is the newest that is still ≤ the event year. - const standard = pickApplicableBlock(components.filter(c => c.type === 'STANDARD'), year); - const daylight = pickApplicableBlock(components.filter(c => c.type === 'DAYLIGHT'), year); + const standard = pickApplicableBlock(components.filter(c => c.type === 'STANDARD'), yearNumber); + const daylight = pickApplicableBlock(components.filter(c => c.type === 'DAYLIGHT'), yearNumber); const stdMins = standard ? offsetLabelToMinutes(standard.tzoffsetto) : undefined; const dstMins = daylight ? offsetLabelToMinutes(daylight.tzoffsetto) : undefined; @@ -566,14 +573,14 @@ function resolveVTimezoneToIana(vTimezone, year) { } // Cache key: unique per offset pair and year (DST boundaries can change historically) - const cacheKey = `${stdMins}|${dstMins}|${year}`; + const cacheKey = `${stdMins}|${dstMins}|${yearNumber}`; if (vtimezoneIanaCache.has(cacheKey)) { return vtimezoneIanaCache.get(cacheKey); } // Probe two dates: mid-January (winter in NH / summer in SH) and mid-July (inverse) - const probeJan = Temporal.Instant.from(`${year}-01-15T12:00:00Z`); - const probeJul = Temporal.Instant.from(`${year}-07-15T12:00:00Z`); + const probeJan = Temporal.Instant.from(`${yearNumber}-01-15T12:00:00Z`); + const probeJul = Temporal.Instant.from(`${yearNumber}-07-15T12:00:00Z`); for (const zone of getZoneNames()) { try { @@ -600,7 +607,7 @@ function resolveVTimezoneToIana(vTimezone, year) { // This path should be unreachable in practice because every real DST zone is // present in the Temporal/Intl database; the warning is here to surface any // exception quickly. - console.warn(`[node-ical] resolveVTimezoneToIana: no IANA zone matched STD=${stdMins} DST=${dstMins} for year ${year}; falling back to floating time`); + console.warn(`[node-ical] resolveVTimezoneToIana: no IANA zone matched STD=${stdMins} DST=${dstMins} for year ${yearNumber}; falling back to floating time`); const fallback = {iana: undefined, offset: undefined}; vtimezoneIanaCache.set(cacheKey, fallback); return fallback; From 53254f06d30ad40160e4f891d4527f0b56f4230b Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:18:28 +0100 Subject: [PATCH 11/13] fix: drop guessLocalZone() fallback in explicit-TZID branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a matching VTIMEZONE block exists but resolveVTimezoneToIana() returns { iana: undefined, offset: undefined } (no IANA zone matched the Jan/Jul offset probes), tz was silently replaced with the host timezone — recreating the wrong-instant bug on UTC hosts. Mirror the fix already applied in fallbackWithStackTimezone(): only replace tz when resolution actually yields a value; otherwise leave the original tz in place so resolveTZID() can make a best effort. guessLocalZone() is only substituted when no VTIMEZONE is present at all. --- ical.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ical.js b/ical.js index d1dd37a..5dbe042 100644 --- a/ical.js +++ b/ical.js @@ -447,7 +447,11 @@ const dateParameter = function (name) { if (stackVTimezone) { const resolved = tzUtil.resolveVTimezoneToIana(stackVTimezone, year); - tz = resolved.iana || resolved.offset || tzUtil.guessLocalZone(); + // Only override when resolution succeeds; keep the original tz otherwise + // so resolveTZID can make a best effort — never substitute guessLocalZone() + if (resolved.iana || resolved.offset) { + tz = resolved.iana || resolved.offset; + } } else { tz = tzUtil.guessLocalZone(); } From e5c5a5a39b617ee26a0f2486e132048f71f98554 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:41:35 +0100 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20rename=20iterator=20wrapper=20para?= =?UTF-8?q?meter=20length=20=E2=86=92=20index=20in=20all()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ical.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ical.js b/ical.js index 5dbe042..a130091 100644 --- a/ical.js +++ b/ical.js @@ -161,7 +161,7 @@ class RRuleCompatWrapper { // rather than a raw Temporal.ZonedDateTime — keeping the public API consistent // with between() and matching the declared return type. const wrappedIterator = iterator - ? (zdt, length) => iterator(this.#zdtToDate(zdt), length) + ? (zdt, index) => iterator(this.#zdtToDate(zdt), index) : undefined; const results = this._rrule.all(wrappedIterator); return results.map(zdt => this.#zdtToDate(zdt)); From 30b6f962cb911109b1ad627062ce7712e779dbe5 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:28:53 +0100 Subject: [PATCH 13/13] refactor: extract findVtimezoneInStack as module-level helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VTIMEZONE lookup was inlined twice in different forms: - fallbackWithStackTimezone: two-step find → separate object-values call - explicit-TZID branch: dense flatMap/find with inline TZID normalisation Replace both with a shared findVtimezoneInStack(stack, tzid) at module level. With tzid provided, it returns the VTIMEZONE whose (quote-stripped) tzid matches; without tzid it returns the first VTIMEZONE found (semantics needed by the floating-DTSTART branch). The explicit stack parameter makes the helper testable independently of the dateParameter closure. --- ical.js | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/ical.js b/ical.js index a130091..66ed562 100644 --- a/ical.js +++ b/ical.js @@ -311,6 +311,28 @@ const typeParameter = function (name) { }; }; +// Find a VTIMEZONE block in the parser stack. When tzid is given, only +// the block whose (quote-stripped) tzid matches is returned; without tzid +// the first VTIMEZONE found is returned (floating-DTSTART branch). +function findVtimezoneInStack(stack, tzid) { + for (const item of (stack || [])) { + for (const v of Object.values(item)) { + if (!v || v.type !== 'VTIMEZONE') { + continue; + } + + if (!tzid) { + return v; + } + + const ids = Array.isArray(v.tzid) ? v.tzid : [v.tzid]; + if (ids.some(id => String(id).replace(/^"(.*)"$/, '$1') === tzid)) { + return v; + } + } + } +} + const dateParameter = function (name) { return function (value, parameters, curr, stack) { // The regex from main gets confused by extra : @@ -356,11 +378,7 @@ const dateParameter = function (name) { tzUtil.attachTz(newDate, 'Etc/UTC'); } else { const fallbackWithStackTimezone = () => { - // Get the time zone from the stack - const stackItemWithTimeZone - = (stack || []).find(item => Object.values(item).find(subItem => subItem.type === 'VTIMEZONE')) || {}; - const vTimezone - = Object.values(stackItemWithTimeZone).find(({type}) => type === 'VTIMEZONE'); + const vTimezone = findVtimezoneInStack(stack); // If the VTIMEZONE contains multiple TZIDs (against RFC), use last one const normalizedTzId = vTimezone @@ -438,12 +456,7 @@ const dateParameter = function (name) { // DST boundaries are handled correctly. Falls back to guessLocalZone() when no // VTIMEZONE is present or its offsets cannot be resolved. const originalTz = tz; - const stackVTimezone = (stack || []) - .flatMap(item => Object.values(item)) - .find(v => v && v.type === 'VTIMEZONE' - && (Array.isArray(v.tzid) ? v.tzid : [String(v.tzid)]) - .map(id => String(id).replace(/^"(.*)"$/, '$1')) - .includes(originalTz)); + const stackVTimezone = findVtimezoneInStack(stack, originalTz); if (stackVTimezone) { const resolved = tzUtil.resolveVTimezoneToIana(stackVTimezone, year);