diff --git a/ical.js b/ical.js index ca72369..66ed562 100644 --- a/ical.js +++ b/ical.js @@ -1,16 +1,14 @@ -/* 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('./tz-utils.js'); +const tzUtil = require('./lib/tz-utils.js'); const {getDateKey} = require('./lib/date-utils.js'); /** @@ -95,8 +93,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 +134,47 @@ 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)); + // 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, index) => iterator(this.#zdtToDate(zdt), index) + : 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 ? new Date(result.epochMilliseconds) : null; + return result ? this.#zdtToDate(result) : undefined; } after(date, inclusive = false) { const result = this._rrule.after(date, inclusive); - return result ? new Date(result.epochMilliseconds) : null; + return result ? this.#zdtToDate(result) : undefined; } toText(locale) { @@ -287,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 : @@ -332,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 @@ -347,7 +389,24 @@ const dateParameter = function (name) { return new Date(year, monthIndex, day, hour, minute, second); } - const tzInfo = tzUtil.resolveTZID(normalizedTzId); + let resolvedTzId = String(normalizedTzId).replace(/^"(.*)"$/, '$1'); + + // 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); + if (resolved.iana || resolved.offset) { + resolvedTzId = resolved.iana || resolved.offset; + } + } + + const tzInfo = tzUtil.resolveTZID(resolvedTzId); const offsetString = typeof tzInfo.offset === 'string' ? tzInfo.offset : undefined; if (offsetString) { return tzUtil.parseWithOffset(value, offsetString); @@ -391,7 +450,24 @@ 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 = findVtimezoneInStack(stack, originalTz); + + if (stackVTimezone) { + const resolved = tzUtil.resolveVTimezoneToIana(stackVTimezone, year); + // 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(); + } } const tzInfo = tzUtil.resolveTZID(tz); @@ -885,7 +961,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; @@ -911,7 +987,7 @@ module.exports = { dtstart: dtstartTemporal, }); - curr.rrule = new RRuleCompatWrapper(rruleTemporal); + curr.rrule = new RRuleCompatWrapper(rruleTemporal, false /* dateOnly */); } } } 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 67% rename from tz-utils.js rename to lib/tz-utils.js index 07822a3..314084c 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; @@ -473,6 +473,146 @@ function linkAlias(arg1, arg2) { aliasMap.set(String(arg1), String(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). + * + * 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. + * + * 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 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') { + 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')); + + if (components.length === 0) { + return {iana: undefined, offset: undefined}; + } + + // 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'), 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; + + // 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}|${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(`${yearNumber}-01-15T12:00:00Z`); + const probeJul = Temporal.Instant.from(`${yearNumber}-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 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 ${yearNumber}; falling back to floating time`); + const fallback = {iana: undefined, offset: undefined}; + vtimezoneIanaCache.set(cacheKey, fallback); + return fallback; +} + // Public API module.exports = { guessLocalZone, @@ -484,6 +624,7 @@ module.exports = { utcAdd, linkAlias, resolveTZID, + resolveVTimezoneToIana, formatDateForRrule, attachTz, isUtcTimezone, 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 60f3d55..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 @@ -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]'); @@ -474,7 +492,43 @@ 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()); + }); + + // 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'); + }); + + // 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/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/date-only-rrule-until.test.js b/test/date-only-rrule-until.test.js index d5ebf4b..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'); @@ -43,17 +42,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 () { 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 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/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'); diff --git a/test/tz-utils.test.js b/test/tz-utils.test.js index 2e378c0..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', () => { @@ -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}); + }); + }); });