diff --git a/core/events/RecurrenceDate.js b/core/events/RecurrenceDate.js new file mode 100644 index 0000000..2f7e148 --- /dev/null +++ b/core/events/RecurrenceDate.js @@ -0,0 +1,97 @@ +import { TimezoneManager } from '../timezone/TimezoneManager.js'; + +const DAY = 86400000; + +/** + * Internal Date cursor whose calendar fields belong to the recurrence zone. + * Its timestamp is always an instant. Setters use Date's compatible policy: + * choose the earlier instant in a repeated hour, or move forward over a gap. + */ +export class RecurrenceDate extends Date { + constructor(date, timezone) { + super(date); + this.timezone = timezone; + this.manager = TimezoneManager.getInstance(); + } + + _offset(ms) { + return this.manager.getTimezoneOffset(new Date(ms), this.timezone, true) * 60000; + } + + _wall() { + return new Date(this.getTime() - this._offset(this.getTime())); + } + + _set(field, args) { + const wall = this._wall(); + return this._resolve(wall[`setUTC${field}`](...args)); + } + + _resolve(target) { + if (!Number.isFinite(target)) { + return this.setTime(target); + } + + // Offsets on either side cover both a fold and a gap, including zones + // with a half-hour transition and the 24-hour International Date Line jump. + const offsets = new Set([ + this._offset(target - 2 * DAY), + this._offset(target), + this._offset(target + 2 * DAY) + ]); + let earlier = Infinity; + let forward = Infinity; + let forwardWall = Infinity; + for (const offset of offsets) { + const instant = target + offset; + const actualWall = instant - this._offset(instant); + if (actualWall === target) { + earlier = Math.min(earlier, instant); + } else if (actualWall > target && actualWall < forwardWall) { + forward = instant; + forwardWall = actualWall; + } + } + return this.setTime(earlier < Infinity ? earlier : forward); + } + + getFullYear() { + return this._wall().getUTCFullYear(); + } + getMonth() { + return this._wall().getUTCMonth(); + } + getDate() { + return this._wall().getUTCDate(); + } + getDay() { + return this._wall().getUTCDay(); + } + getHours() { + return this._wall().getUTCHours(); + } + getMinutes() { + return this._wall().getUTCMinutes(); + } + getSeconds() { + return this._wall().getUTCSeconds(); + } + setFullYear(...args) { + return this._set('FullYear', args); + } + setMonth(...args) { + return this._set('Month', args); + } + setDate(...args) { + return this._set('Date', args); + } + setHours(...args) { + return this._set('Hours', args); + } + setMinutes(...args) { + return this._set('Minutes', args); + } + setSeconds(...args) { + return this._set('Seconds', args); + } +} diff --git a/core/events/RecurrenceEngine.js b/core/events/RecurrenceEngine.js index b329312..79cb5c9 100644 --- a/core/events/RecurrenceEngine.js +++ b/core/events/RecurrenceEngine.js @@ -725,14 +725,23 @@ export class RecurrenceEngine { * @param {number} stepMs - Step length while the UTC offset is constant * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded) * @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor + * @param {(from: number, to: number) => number} [nextTransition] - Offset-transition lookup; defaults to the host zone * @returns {{ ms: number, steps: number, nextSystemTransition: number }} * Cursor position, steps taken and the next system transition after it * @private */ - static _seekFixedStep(fromMs, rangeStartMs, rangeEndMs, stepMs, maxSteps, advance) { + static _seekFixedStep( + fromMs, + rangeStartMs, + rangeEndMs, + stepMs, + maxSteps, + advance, + nextTransition = (from, to) => this._nextSystemTransition(from, to) + ) { let ms = fromMs; let steps = 0; - let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs); + let nextSystemTransition = nextTransition(ms, rangeEndMs); if (!Number.isFinite(rangeStartMs) || !(stepMs > 0)) { return { ms, steps, nextSystemTransition }; } @@ -758,7 +767,7 @@ export class RecurrenceEngine { advance(cursor); ms = cursor.getTime(); steps++; - nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs); + nextSystemTransition = nextTransition(ms, rangeEndMs); } return { ms, steps, nextSystemTransition }; } diff --git a/core/events/RecurrenceEngineV2.js b/core/events/RecurrenceEngineV2.js index 6b21672..6c5b854 100644 --- a/core/events/RecurrenceEngineV2.js +++ b/core/events/RecurrenceEngineV2.js @@ -6,12 +6,10 @@ import { TimezoneManager } from '../timezone/TimezoneManager.js'; import { RecurrenceEngine } from './RecurrenceEngine.js'; import { RRuleParser } from './RRuleParser.js'; +import { RecurrenceDate } from './RecurrenceDate.js'; const DAY = 86400000; -// How far ahead of the iteration cursor DST transitions are scanned at a time -const DST_SCAN_CHUNK = 100 * DAY; - const WEEKDAYS = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; // An expansion cut short by MAX_ITERATIONS_HARD_LIMIT is reported once per @@ -39,6 +37,7 @@ export class RecurrenceEngineV2 { constructor() { // Use singleton to share cache across all components this.tzManager = TimezoneManager.getInstance(); + this.systemTimezone = this.tzManager.getSystemTimezone(); // Cache for expanded occurrences this.occurrenceCache = new Map(); @@ -73,7 +72,8 @@ export class RecurrenceEngineV2 { * @param {boolean} [options.includeModified=true] - Apply stored instance modifications * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's) - * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions + * @param {boolean} [options.handleDST=true] - Compatibility option. Calendar stepping + * always resolves transitions in the recurrence zone; no extra DST shift is applied. * @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences */ expandEvent(event, rangeStart, rangeEnd, options = {}) { @@ -81,8 +81,7 @@ export class RecurrenceEngineV2 { maxOccurrences: requestedMax = 365, includeModified = true, includeCancelled = false, - timezone = event.timeZone || 'UTC', - handleDST = true + timezone = event.timeZone || 'UTC' } = options; // Enforce hard limit regardless of caller-provided value @@ -98,7 +97,7 @@ export class RecurrenceEngineV2 { return this.cloneOccurrences([this.createOccurrence(event, event.start, event.end)]); } - const rule = RRuleParser.parse(event.recurrenceRule); + const rule = this._parseRule(event.recurrenceRule, timezone); const occurrences = []; const duration = event.end - event.start; @@ -107,16 +106,9 @@ export class RecurrenceEngineV2 { const state = { currentDate: new Date(event.start), count: 0, - tzOffsets: new Map(), - dstTransitions: [], stuckIterations: 0 }; - // Pre-calculate DST transitions in range - if (handleDST) { - state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone); - } - this.seekToRange(state, rule, rangeStart, rangeEnd, timezone); // Expand occurrences @@ -133,7 +125,8 @@ export class RecurrenceEngineV2 { this.generateOccurrence(event, state.currentDate, duration, timezone, state), rule, includeCancelled, - includeModified + includeModified, + timezone ); if (occurrence) { occurrences.push(occurrence); @@ -187,8 +180,8 @@ export class RecurrenceEngineV2 { * and exceptions are applied as each occurrence is produced, so changes * made through addModifiedInstance or addException are visible on the * next pull. Rules seekToRange can seek (daily, weekly, hourly, - * minutely, secondly) jump straight to `after`, and DST transitions are scanned - * lazily ahead of the cursor instead of for the whole window up front. + * minutely, secondly) jump straight to `after` using transitions in the + * recurrence zone. No post-generation DST correction is applied. * * Both bounds are exclusive unless `inclusive` is set: an occurrence that * starts exactly at `after` or `before` is skipped by default, so @@ -220,14 +213,12 @@ export class RecurrenceEngineV2 { const { includeModified = true, includeCancelled = false, - timezone = event.timeZone || 'UTC', - handleDST = true + timezone = event.timeZone || 'UTC' } = options; - return this._iterateRule(event, RRuleParser.parse(event.recurrenceRule), window, { + return this._iterateRule(event, this._parseRule(event.recurrenceRule, timezone), window, { includeModified, includeCancelled, - timezone, - handleDST + timezone }); } @@ -298,36 +289,22 @@ export class RecurrenceEngineV2 { /** * Lazy counterpart of the expandEvent loop: seeks to the window, then - * steps the cursor and yields each in-window occurrence with the same - * DST adjustment, exception handling and instance modifications. + * steps the zoned cursor and yields each in-window occurrence with the + * same exception handling and instance modifications. * @private */ *_iterateRule(event, rule, window, options) { - const { includeModified, includeCancelled, timezone, handleDST } = options; + const { includeModified, includeCancelled, timezone } = options; const duration = event.end - event.start; const state = { currentDate: new Date(event.start), count: 0, - tzOffsets: new Map(), - dstTransitions: [], stuckIterations: 0 }; if (Number.isNaN(state.currentDate.getTime()) || window.startMs > window.endMs) { return; } - // DST transitions are found on the same day grid expandEvent walks, - // starting from the window start (DTSTART for an open window) and - // extended in chunks ahead of the cursor - let dstScan = null; - if (handleDST) { - const scanStart = Number.isFinite(window.startMs) - ? window.startMs - : state.currentDate.getTime(); - dstScan = { cursor: new Date(scanStart), lastOffset: 0 }; - dstScan.lastOffset = this.tzManager.getTimezoneOffset(dstScan.cursor, timezone); - } - if (Number.isFinite(window.startMs)) { const rangeStart = new Date(window.startMs); this.seekToRange(state, rule, rangeStart, rangeStart, timezone); @@ -337,20 +314,13 @@ export class RecurrenceEngineV2 { while (state.currentDate.getTime() <= window.endMs) { const currentMs = state.currentDate.getTime(); if (currentMs >= window.startMs) { - if (dstScan) { - this._scanDSTTransitions( - dstScan, - state.dstTransitions, - Math.min(currentMs + DST_SCAN_CHUNK, window.endMs), - timezone - ); - } const occurrence = this._applyOverrides( event, this.generateOccurrence(event, state.currentDate, duration, timezone, state), rule, includeCancelled, - includeModified + includeModified, + timezone ); if (occurrence) { idleSteps = 0; @@ -395,19 +365,19 @@ export class RecurrenceEngineV2 { * @returns {Object|null} The occurrence, or null when it is excluded * @private */ - _applyOverrides(event, occurrence, rule, includeCancelled, includeModified) { + _applyOverrides(event, occurrence, rule, includeCancelled, includeModified, timezone) { if (!occurrence) { return null; } - if (this.isException(event.id, occurrence.start, rule)) { + if (this.isException(event.id, occurrence.start, rule, timezone)) { if (!includeCancelled) { return null; } occurrence.status = 'cancelled'; - occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start); + occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start, timezone); } if (includeModified) { - const modified = this.getModifiedInstance(event.id, occurrence.start); + const modified = this.getModifiedInstance(event.id, occurrence.start, timezone); if (modified) { Object.assign(occurrence, modified); occurrence.isModified = true; @@ -420,7 +390,7 @@ export class RecurrenceEngineV2 { * Move the expansion cursor to the last occurrence before the range * without stepping through every occurrence in between. * - * Applies to rules whose step is a fixed duration between system-timezone + * Applies to rules whose step is a fixed duration between recurrence-zone * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY, SECONDLY) and to WEEKLY * rules with BYDAY, whose steps repeat in a weekly cycle; the steps that * cross a transition are taken with getNextDate so the result is exactly @@ -461,7 +431,8 @@ export class RecurrenceEngineV2 { rangeEnd.getTime(), stepMs, rule.count ? rule.count - 1 : Infinity, - cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime()) + cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime()), + (from, to) => this.tzManager.getNextTransition(timezone, from, to, true) ); state.currentDate = new Date(seek.ms); state.count = seek.steps; @@ -473,7 +444,7 @@ export class RecurrenceEngineV2 { * fixed and the walk from DTSTART settles into a cycle of weekdays that * repeats every whole number of weeks. The cursor is stepped one * occurrence at a time until it is on that cycle (at most six steps), - * then whole cycles are skipped arithmetically between system-timezone + * then whole cycles are skipped arithmetically between recurrence-zone * transitions, exactly as seekToRange does for fixed steps. * @param {Object} state - Expansion state (currentDate and count are updated) * @param {Object} rule - Parsed recurrence rule @@ -503,7 +474,7 @@ export class RecurrenceEngineV2 { // the steps before the repeat lead in to the cycle const path = []; const seen = new Map(); - let weekday = state.currentDate.getDay(); + let weekday = this._recurrenceDate(state.currentDate, timezone).getDay(); while (!seen.has(weekday)) { seen.set(weekday, path.length); path.push(weekday); @@ -539,7 +510,8 @@ export class RecurrenceEngineV2 { for (let i = 0; i < cycleSteps; i++) { cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime()); } - } + }, + (from, to) => this.tzManager.getNextTransition(timezone, from, to, true) ); state.currentDate = new Date(seek.ms); state.count += seek.steps * cycleSteps; @@ -593,7 +565,7 @@ export class RecurrenceEngineV2 { /** * Milliseconds per step for rules getNextDate advances by a fixed - * duration while the system UTC offset is constant + * duration while the recurrence zone UTC offset is constant * @param {Object} rule - Parsed recurrence rule * @returns {number} Step length in milliseconds, or 0 when not fixed */ @@ -621,16 +593,12 @@ export class RecurrenceEngineV2 { /** * Generate a single occurrence with timezone handling */ - generateOccurrence(event, date, duration, timezone, state) { + generateOccurrence(event, date, duration, timezone, _state) { const start = new Date(date); const end = new Date(date.getTime() + duration); - // Handle DST transitions - if (state.dstTransitions.length > 0) { - const adjusted = this.adjustForDST(start, end, timezone, state.dstTransitions); - start.setTime(adjusted.start.getTime()); - end.setTime(adjusted.end.getTime()); - } + // The cursor is already an instant resolved in the recurrence zone. A + // second DST adjustment here would shift it outside the requested window. return { id: `${event.id}_${start.getTime()}`, @@ -652,15 +620,59 @@ export class RecurrenceEngineV2 { }; } + /** Parse floating UNTIL/EXDATE values in the recurrence zone, not the host. @private */ + _parseRule(input, timezone) { + const rule = RRuleParser.parse(input); + if (typeof input === 'string') { + for (const part of input.toUpperCase().split(';')) { + const [key, value] = part.split('='); + if (key === 'UNTIL') { + rule.until = this._parseRuleDate(value, timezone); + } else if (key === 'EXDATE') { + rule.exceptions = value + .split(',') + .map(date => this._parseRuleDate(date.trim(), timezone)); + } + } + } + return rule; + } + + /** @private */ + _parseRuleDate(value, timezone) { + if (!/^\d{8}(T\d{6})?$/.test(value)) { + return RRuleParser.parseDateTime(value); + } + const wall = new Date(0); + wall.setUTCFullYear(+value.slice(0, 4), +value.slice(4, 6) - 1, +value.slice(6, 8)); + wall.setUTCHours(+value.slice(9, 11), +value.slice(11, 13), +value.slice(13, 15), 0); + const date = new RecurrenceDate(wall, timezone); + date._resolve(wall.getTime()); + return new Date(date); + } + + /** + * Use native setters when the requested zone is the host zone, and an + * equivalent zoned cursor otherwise. Public helpers without a zone retain + * their local Date behavior. + * @private + */ + _recurrenceDate(date, timezone) { + if (!timezone || timezone === this.systemTimezone) { + return new Date(date); + } + return new RecurrenceDate(date, timezone); + } + /** * Get next occurrence date with complex pattern support */ getNextDate(currentDate, rule, timezone, _state = {}) { - const next = new Date(currentDate); + const next = this._recurrenceDate(currentDate, timezone); switch (rule.freq) { case 'DAILY': - return this.getNextDaily(next, rule); + return this.getNextDaily(next, rule, timezone); case 'WEEKLY': return this.getNextWeekly(next, rule, timezone); @@ -673,28 +685,28 @@ export class RecurrenceEngineV2 { case 'HOURLY': next.setHours(next.getHours() + rule.interval); - return next; + return new Date(next); case 'MINUTELY': next.setMinutes(next.getMinutes() + rule.interval); - return next; + return new Date(next); case 'SECONDLY': next.setSeconds(next.getSeconds() + rule.interval); - return next; + return new Date(next); default: // Fallback to daily next.setDate(next.getDate() + rule.interval); - return next; + return new Date(next); } } /** * Get next daily occurrence */ - getNextDaily(date, rule) { - const next = new Date(date); + getNextDaily(date, rule, timezone) { + const next = this._recurrenceDate(date, timezone); next.setDate(next.getDate() + rule.interval); // Apply BYHOUR, BYMINUTE, BYSECOND if specified @@ -710,14 +722,14 @@ export class RecurrenceEngineV2 { } } - return next; + return new Date(next); } /** * Get next weekly occurrence with BYDAY support */ - getNextWeekly(date, rule, _timezone) { - const next = new Date(date); + getNextWeekly(date, rule, timezone) { + const next = this._recurrenceDate(date, timezone); if (rule.byDay && rule.byDay.length > 0) { // BYDAY is a set: the next weekday in it after the current one, or the @@ -743,14 +755,14 @@ export class RecurrenceEngineV2 { next.setDate(next.getDate() + 7 * rule.interval); } - return next; + return new Date(next); } /** * Get next monthly occurrence with complex patterns */ - getNextMonthly(date, rule, _timezone) { - const next = new Date(date); + getNextMonthly(date, rule, timezone) { + const next = this._recurrenceDate(date, timezone); if (rule.byMonthDay && rule.byMonthDay.length > 0) { // Specific day(s) of month @@ -768,7 +780,9 @@ export class RecurrenceEngineV2 { // the first so the month step cannot overflow from a 31st. next.setDate(1); next.setMonth(next.getMonth() + rule.interval); - const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + const lastDay = new Date( + Date.UTC(next.getFullYear(), next.getMonth() + 1, 0) + ).getUTCDate(); next.setDate(Math.max(1, lastDay + targetDay + 1)); } else { // Move to next month @@ -799,10 +813,10 @@ export class RecurrenceEngineV2 { if (rule.byDay && rule.byDay.length > 0) { // Generate all matching weekday occurrences in the month const targetDays = this._weekdayTargets(rule); - const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + const lastDay = new Date(Date.UTC(next.getFullYear(), next.getMonth() + 1, 0)).getUTCDate(); for (let d = 1; d <= lastDay; d++) { - const date = new Date(next.getFullYear(), next.getMonth(), d); - if (targetDays.includes(date.getDay())) { + const date = new Date(Date.UTC(next.getFullYear(), next.getMonth(), d)); + if (targetDays.includes(date.getUTCDay())) { candidates.push(d); } } @@ -826,20 +840,20 @@ export class RecurrenceEngineV2 { next.setMonth(next.getMonth() + rule.interval); // Handle month-end edge cases - const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + const lastDay = new Date(Date.UTC(next.getFullYear(), next.getMonth() + 1, 0)).getUTCDate(); if (currentDay > lastDay) { next.setDate(lastDay); } } - return next; + return new Date(next); } /** * Get next yearly occurrence */ - getNextYearly(date, rule, _timezone) { - const next = new Date(date); + getNextYearly(date, rule, timezone) { + const next = this._recurrenceDate(date, timezone); if (rule.byMonth && rule.byMonth.length > 0) { const currentMonth = next.getMonth(); @@ -877,7 +891,7 @@ export class RecurrenceEngineV2 { next.setFullYear(next.getFullYear() + rule.interval); } - return next; + return new Date(next); } /** @@ -907,10 +921,10 @@ export class RecurrenceEngineV2 { date.setDate(date.getDate() + 7 * (nth - 1)); } else { // Nth occurrence from end - const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + const lastDay = new Date(Date.UTC(date.getFullYear(), date.getMonth() + 1, 0)).getUTCDate(); // Find last occurrence - const temp = new Date(date); + const temp = this._recurrenceDate(date, date.timezone); temp.setDate(lastDay); for (let i = 0; i < 7 && temp.getDay() !== targetDay; i++) { temp.setDate(temp.getDate() - 1); @@ -1010,7 +1024,8 @@ export class RecurrenceEngineV2 { this.modifiedInstances.set(eventId, new Map()); } - const dateKey = this.getDateKey(occurrenceDate); + const dateKey = new Date(occurrenceDate).getTime(); + this.modifiedInstances.get(eventId).delete(dateKey); this.modifiedInstances.get(eventId).set(dateKey, { ...modifications, modifiedAt: new Date() @@ -1023,13 +1038,12 @@ export class RecurrenceEngineV2 { /** * Get modified instance data */ - getModifiedInstance(eventId, occurrenceDate) { + getModifiedInstance(eventId, occurrenceDate, timezone) { if (!this.modifiedInstances.has(eventId)) { return null; } - const dateKey = this.getDateKey(occurrenceDate); - return this.modifiedInstances.get(eventId).get(dateKey); + return this._getDateEntry(this.modifiedInstances.get(eventId), occurrenceDate, timezone); } /** @@ -1040,7 +1054,8 @@ export class RecurrenceEngineV2 { this.exceptionStore.set(eventId, new Map()); } - const dateKey = this.getDateKey(date); + const dateKey = new Date(date).getTime(); + this.exceptionStore.get(eventId).delete(dateKey); this.exceptionStore.get(eventId).set(dateKey, reason); // Clear cache @@ -1050,12 +1065,12 @@ export class RecurrenceEngineV2 { /** * Check if date is an exception */ - isException(eventId, date, rule) { - const dateKey = this.getDateKey(date); + isException(eventId, date, rule, timezone) { + const dateKey = this.getDateKey(date, timezone); // Check enhanced exceptions if (this.exceptionStore.has(eventId)) { - if (this.exceptionStore.get(eventId).has(dateKey)) { + if (this._getDateEntry(this.exceptionStore.get(eventId), date, timezone) !== undefined) { return true; } } @@ -1064,7 +1079,7 @@ export class RecurrenceEngineV2 { if (rule && rule.exceptions) { return rule.exceptions.some(ex => { const exDate = ex instanceof Date ? ex : new Date(ex.date || ex); - return this.getDateKey(exDate) === dateKey; + return this.getDateKey(exDate, timezone) === dateKey; }); } @@ -1074,20 +1089,31 @@ export class RecurrenceEngineV2 { /** * Get exception reason */ - getExceptionReason(eventId, date) { + getExceptionReason(eventId, date, timezone) { if (!this.exceptionStore.has(eventId)) { return 'Cancelled'; } - const dateKey = this.getDateKey(date); - return this.exceptionStore.get(eventId).get(dateKey) || 'Cancelled'; + return this._getDateEntry(this.exceptionStore.get(eventId), date, timezone) || 'Cancelled'; + } + + /** Resolve stored instance dates in the querying event's zone. @private */ + _getDateEntry(entries, date, timezone) { + const day = this.getDateKey(date, timezone); + let result; + for (const [timestamp, value] of entries) { + if (this.getDateKey(new Date(timestamp), timezone) === day) { + result = value; + } + } + return result; } /** * Create date key for indexing */ - getDateKey(date) { - const d = date instanceof Date ? date : new Date(date); + getDateKey(date, timezone) { + const d = this._recurrenceDate(date, timezone); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } @@ -1113,7 +1139,7 @@ export class RecurrenceEngineV2 { } const startMs = new Date(event.start).getTime(); const endMs = new Date(event.end).getTime(); - return `${key}|${startMs}|${endMs}|${this._ruleFingerprint(event.recurrenceRule)}`; + return `${key}|${startMs}|${endMs}|${event.timeZone || 'UTC'}|${this._ruleFingerprint(event.recurrenceRule)}`; } /** diff --git a/core/timezone/TimezoneManager.js b/core/timezone/TimezoneManager.js index bfe015c..e90c856 100644 --- a/core/timezone/TimezoneManager.js +++ b/core/timezone/TimezoneManager.js @@ -121,16 +121,21 @@ export class TimezoneManager { * Get timezone offset in minutes * @param {Date} date - Date to check (for DST calculation) * @param {string} timezone - Timezone identifier + * @param {boolean} [absolute=false] - Read the absolute zone offset instead of the legacy host-relative offset * @returns {number} Offset in minutes from UTC */ - getTimezoneOffset(date, timezone) { + getTimezoneOffset(date, timezone, absolute = false) { // Resolve any aliases timezone = this.database.resolveAlias(timezone); + if (absolute && timezone === 'UTC') { + return 0; + } // Offsets only change at DST transitions, which occur on 15-minute UTC // boundaries worldwide — one cached entry covers each 15-minute bucket const bucket = Math.floor(date.getTime() / 900000); - let zoneCache = this.offsetCache.get(timezone); + const cacheKey = absolute ? `${timezone}|absolute` : timezone; + let zoneCache = this.offsetCache.get(cacheKey); if (zoneCache) { const cached = zoneCache.get(bucket); if (cached !== undefined) { @@ -139,7 +144,7 @@ export class TimezoneManager { } } else { zoneCache = new Map(); - this.offsetCache.set(timezone, zoneCache); + this.offsetCache.set(cacheKey, zoneCache); } this.cacheMisses++; @@ -174,7 +179,15 @@ export class TimezoneManager { break; } } - const tzDate = new Date(year, month - 1, day, hour, minute, second); + const tzDate = absolute + ? new Date(0) + : new Date(year, month - 1, day, hour, minute, second); + if (absolute) { + // Calendar arithmetic needs a true UTC offset, independent of the + // host. Keep the legacy wall-clock conversion mode for existing callers. + tzDate.setUTCFullYear(year, month - 1, day); + tzDate.setUTCHours(hour, minute, second, 0); + } // formatToParts carries no milliseconds, so compare against the // whole-second part of the input or sub-second noise leaks into // the offset (e.g. 660.0042 instead of 660) @@ -212,32 +225,37 @@ export class TimezoneManager { * @param {string} timezone - Timezone identifier * @param {number} fromMs - Search from this timestamp (exclusive) * @param {number} toMs - Search up to this timestamp (inclusive) + * @param {boolean} [absolute=false] - Find transitions of the absolute zone offset * @returns {number} Timestamp of the first offset change after fromMs, or Infinity */ - getNextTransition(timezone, fromMs, toMs) { + getNextTransition(timezone, fromMs, toMs, absolute = false) { if (fromMs >= toMs) { return Infinity; } timezone = this.database.resolveAlias(timezone); - let cached = this.transitionCache.get(timezone); + if (absolute && timezone === 'UTC') { + return Infinity; + } + const cacheKey = absolute ? `${timezone}|absolute` : timezone; + let cached = this.transitionCache.get(cacheKey); if (!cached) { cached = { from: fromMs, to: toMs, - transitions: this._scanTransitions(timezone, fromMs, toMs) + transitions: this._scanTransitions(timezone, fromMs, toMs, absolute) }; - this.transitionCache.set(timezone, cached); + this.transitionCache.set(cacheKey, cached); } else { // Extend coverage incrementally so only the uncovered span is scanned if (fromMs < cached.from) { - cached.transitions = this._scanTransitions(timezone, fromMs, cached.from).concat( + cached.transitions = this._scanTransitions(timezone, fromMs, cached.from, absolute).concat( cached.transitions ); cached.from = fromMs; } if (toMs > cached.to) { cached.transitions = cached.transitions.concat( - this._scanTransitions(timezone, cached.to, toMs) + this._scanTransitions(timezone, cached.to, toMs, absolute) ); cached.to = toMs; } @@ -270,10 +288,10 @@ export class TimezoneManager { * @returns {number[]} Sorted transition timestamps * @private */ - _scanTransitions(timezone, fromMs, toMs) { + _scanTransitions(timezone, fromMs, toMs, absolute = false) { const WEEK = 7 * 86400000; const transitions = []; - const offsetAt = ms => this.getTimezoneOffset(new Date(ms), timezone); + const offsetAt = ms => this.getTimezoneOffset(new Date(ms), timezone, absolute); let lo = fromMs; let loOffset = offsetAt(lo); // Timezone databases know only local mean time before the 19th century: diff --git a/core/types.js b/core/types.js index a7b9633..0203f56 100644 --- a/core/types.js +++ b/core/types.js @@ -383,7 +383,7 @@ * @property {string} [timezone] - Timezone for expansion (defaults to the event's) * @property {boolean} [includeModified=true] - Apply stored instance modifications * @property {boolean} [includeCancelled=false] - Yield exception dates as cancelled occurrences - * @property {boolean} [handleDST=true] - Adjust occurrences across DST transitions + * @property {boolean} [handleDST=true] - Compatibility option; V2 always resolves calendar steps in the recurrence zone without an extra DST shift */ /** @@ -420,7 +420,7 @@ * @property {boolean} [includeModified=true] - Apply stored instance modifications * @property {boolean} [includeCancelled=false] - Include cancelled occurrences * @property {string} [timezone] - Timezone for expansion (defaults to the event's) - * @property {boolean} [handleDST=true] - Adjust occurrences across DST transitions + * @property {boolean} [handleDST=true] - Compatibility option; V2 always resolves calendar steps in the recurrence zone without an extra DST shift */ /** diff --git a/docs/recurrence-timezones.md b/docs/recurrence-timezones.md new file mode 100644 index 0000000..877967b --- /dev/null +++ b/docs/recurrence-timezones.md @@ -0,0 +1,66 @@ +# V2 recurrence timezone contract + +RecurrenceEngineV2 uses options.timezone, then event.timeZone, then UTC to +interpret its calendar steps. The timestamp in event.start anchors the series. +Generated start and end values are occurrence instants. The recurrence zone, +rather than the host zone, controls their calendar steps. Stored instance +overrides are applied afterward. + +Daily, weekly, monthly, yearly and subdaily rules retain their existing calendar +setter behavior in the recurrence zone. Across a forward clock change, a missing +local time advances by the gap; that adjusted time carries into later steps. +Across a backward change, an ambiguous local time uses the earlier instant. +These are native Date setter semantics, including when the event zone is the +host zone. Subdaily rules are calendar increments, not a guarantee of constant +elapsed durations across a transition. Before instance overrides, each +occurrence's duration remains the original end minus start in milliseconds. +An override that changes start or end can also change its duration. + +Seeking uses transitions in the recurrence zone. Range bounds and iterator +bounds compare generated recurrence instants before instance overrides, without +a second DST correction. An occurrence moved outside the requested window by an +override can still be returned; overrides can also change the iterator's output +order. These override semantics are unchanged. + +Floating UNTIL and EXDATE values in RRULE strings use the recurrence zone; values ending +in Z remain UTC. Existing date-based exception and modification matching uses +the occurrence's calendar date in that zone. The default event zone participates +in expansion cache keys. + +The handleDST option remains accepted for compatibility. It previously enabled +an additional correction after calendar stepping, which could move occurrences +outside a queried range. Stepping already resolves transitions, so true and false +now produce the same instants. false was never an elapsed-duration stepping API. + +## Limits of this correction + +This change does not redefine Event's documented wall-clock carrier conventions, +or the legacy TimezoneManager.toUTC/fromUTC conversions. V2's startUTC/endUTC +metadata still uses those conversions and can depend on the host timezone. Use +start/end for the occurrence instants covered by this contract. This is not a +claim that every timezone API is host-independent. + +The supported RRULE fields and their existing selection behavior are retained; +this change does not add RFC 5545 feature completeness or redefine month-end +selection. The legacy RecurrenceEngine remains unchanged apart from an optional +internal transition lookup used by V2's shared arithmetic seek. + +Stepping helpers such as getNextDate now reject an unknown explicit timezone, +consistent with using that timezone for calendar arithmetic. Previously these +helpers ignored the zone; expansion already rejected unknown zones. Calls that +omit the timezone retain local Date behavior. + +Ancient-date support remains limited: year 0000/BCE can produce host-dependent +results because offset extraction does not handle the Intl era field. UTC-suffixed +RRULE dates in years 0000–0099 still inherit the legacy parser's 1900-year remapping. +The cross-host guarantee is not a claim that these ancient-date cases are fixed. + +## Regression coverage + +The cross-host test generates expectations with native Date setters in six event +zones, then checks them on UTC, America/Los_Angeles, Asia/Kolkata and +Australia/Melbourne hosts. It covers all recurrence frequencies, DST gaps and +folds, half-hour transitions, supported monthly/yearly selection, millisecond +precision, bounds, COUNT, UNTIL, exceptions, modifications, iteration, and +seeking independently of a walk from DTSTART. The original 365-day UTC recurrence +queried through 2034 is also checked directly. diff --git a/tests/integration/test-byday-normalization.js b/tests/integration/test-byday-normalization.js index 5e85cd3..9b42f79 100644 --- a/tests/integration/test-byday-normalization.js +++ b/tests/integration/test-byday-normalization.js @@ -32,7 +32,7 @@ if (process.argv[2] === '--expand') { end: new Date(start.getTime() + 3600000), recurring: true, recurrenceRule: rule, - timeZone: 'UTC' + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }; const rangeStart = new Date(2025, 0, 1); const rangeEnd = new Date(2025, 11, 31, 23, 59, 59); @@ -180,7 +180,7 @@ assert( v1Next.getFullYear() === 2025 && v1Next.getMonth() === 1 && v1Next.getDate() === 3 && v1Next.getDay() === 1, 'RecurrenceEngine advances +1MO to the first Monday of the next month' ); -const v2Next = new RecurrenceEngineV2().getNextMonthly(new Date(2025, 0, 6, 9, 0), rawRule, 'UTC'); +const v2Next = new RecurrenceEngineV2().getNextMonthly(new Date(2025, 0, 6, 9, 0), rawRule, Intl.DateTimeFormat().resolvedOptions().timeZone); assert( v2Next.getFullYear() === 2025 && v2Next.getMonth() === 1 && v2Next.getDate() === 3 && v2Next.getDay() === 1, 'RecurrenceEngineV2 advances +1MO to the first Monday of the next month' diff --git a/tests/integration/test-occurrence-iterator.js b/tests/integration/test-occurrence-iterator.js index ea629ff..2d73145 100644 --- a/tests/integration/test-occurrence-iterator.js +++ b/tests/integration/test-occurrence-iterator.js @@ -186,7 +186,8 @@ for (const [name, iterate] of [ console.log('\n=== Test 3: Open-ended iteration stops at COUNT and UNTIL ==='); const counted = makeRecurring('counted', 'FREQ=DAILY;COUNT=10', new Date(2025, 5, 1, 9, 0)); -const untilSeries = makeRecurring('until', 'FREQ=DAILY;UNTIL=20250615T090000', new Date(2025, 5, 1, 9, 0)); +// This test compares local clock fields with V1; use the host as the event zone. +const untilSeries = makeRecurring('until', 'FREQ=DAILY;UNTIL=20250615T090000', new Date(2025, 5, 1, 9, 0), 60, Intl.DateTimeFormat().resolvedOptions().timeZone); const countedAll = Array.from(RecurrenceEngine.iterateOccurrences(counted)); assert(countedAll.length === 10, `RecurrenceEngine: unbounded iteration ends at COUNT (${countedAll.length})`); const countedAllV2 = Array.from(engineV2.iterateOccurrences(counted)); diff --git a/tests/integration/test-recurrence-cross-host.js b/tests/integration/test-recurrence-cross-host.js new file mode 100644 index 0000000..ef0d44e --- /dev/null +++ b/tests/integration/test-recurrence-cross-host.js @@ -0,0 +1,187 @@ +/** Independent native-Date oracles run in each event zone, then replayed on four hosts. */ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { RecurrenceEngineV2 } from '../../core/events/RecurrenceEngineV2.js'; +import { TimezoneManager } from '../../core/timezone/TimezoneManager.js'; + +const hosts = ['UTC', 'America/Los_Angeles', 'Asia/Kolkata', 'Australia/Melbourne']; +const zones = [...hosts, 'America/New_York', 'Australia/Lord_Howe']; +const file = fileURLToPath(import.meta.url); +const starts = rows => rows.map(row => row.start.getTime()); +const localFields = date => [date.getFullYear(), date.getMonth() + 1, date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds()]; +const mode = process.argv[2]; + +function child(mode, zone, input) { + const result = spawnSync(process.execPath, [file, mode], { + env: { ...process.env, TZ: zone }, input, encoding: 'utf8', timeout: 60000, + maxBuffer: 16 * 1024 * 1024 + }); + assert.ifError(result.error); + assert.equal(result.status, 0, `${zone}: ${result.stderr}\n${result.stdout}`); + return result.stdout; +} + +if (!mode) { + const fixtures = zones.flatMap(zone => JSON.parse(child('--reference', zone))); + for (const host of hosts) process.stdout.write(child('--assert', host, JSON.stringify(fixtures))); + console.log('✅ Cross-host recurrence instants, wall times, seek, iterator, bounds and exceptions'); +} else if (mode === '--reference') { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const fixtures = []; + // This oracle does not import the engine's stepping, seeking or timezone helpers. + function add(rule, start, count, advance) { + const date = new Date(start); + const expected = []; + for (let i = 0; i < count; i++) { + expected.push(date.getTime()); + advance(date); + } + fixtures.push({ rule: `${rule};COUNT=${count}`, zone, expected, wall: expected.map(ms => localFields(new Date(ms))) }); + } + const start = new Date(2023, 0, 1, 9, 15, 20, 250); + add('FREQ=DAILY', start, 800, date => date.setDate(date.getDate() + 1)); + add('FREQ=DAILY;INTERVAL=3', start, 280, date => date.setDate(date.getDate() + 3)); + add('FREQ=DAILY;BYHOUR=9,14', start, 50, date => { + date.setDate(date.getDate() + 1); + if (date.getHours() < 14) date.setHours(14); + else { date.setDate(date.getDate() + 1); date.setHours(9); } + }); + add('FREQ=WEEKLY;INTERVAL=2', start, 80, date => date.setDate(date.getDate() + 14)); + add('FREQ=WEEKLY;INTERVAL=2;BYDAY=FR,MO,WE', start, 200, date => { + const current = date.getDay(); + const target = [1, 3, 5].find(day => day > current); + date.setDate(date.getDate() + (target === undefined ? 14 - current + 1 : target - current)); + }); + add('FREQ=MONTHLY', start, 36, date => date.setMonth(date.getMonth() + 1)); + add('FREQ=MONTHLY;BYMONTHDAY=15', new Date(2023, 0, 15, 9), 36, date => { + date.setMonth(date.getMonth() + 1); date.setDate(15); + }); + add('FREQ=MONTHLY;BYMONTHDAY=-1', new Date(2023, 0, 31, 9), 36, date => { + date.setDate(1); date.setMonth(date.getMonth() + 1); + date.setDate(new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()); + }); + for (const ordinal of [2, -1]) { + add(`FREQ=MONTHLY;BYDAY=${ordinal}FR`, start, 36, date => { + date.setDate(1); date.setMonth(date.getMonth() + 1); + if (ordinal > 0) { + while (date.getDay() !== 5) date.setDate(date.getDate() + 1); + date.setDate(date.getDate() + 7); + } else { + date.setDate(new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()); + while (date.getDay() !== 5) date.setDate(date.getDate() - 1); + } + }); + } + add('FREQ=YEARLY', start, 5, date => date.setFullYear(date.getFullYear() + 1)); + add('FREQ=YEARLY;BYMONTH=3,11;BYMONTHDAY=15', start, 8, date => { + if (date.getMonth() < 2) date.setMonth(2); + else if (date.getMonth() < 10) date.setMonth(10); + else { date.setFullYear(date.getFullYear() + 1); date.setMonth(2); } + date.setDate(15); + }); + add('FREQ=YEARLY;BYYEARDAY=-1', start, 5, date => { + date.setFullYear(date.getFullYear() + 1); date.setMonth(11, 31); + }); + for (const [month, day] of [[2, 10], [10, 3], [3, 7], [9, 6]]) { + const transition = new Date(2024, month, day, 1, 59, 58, 250); + add('FREQ=SECONDLY;INTERVAL=7', transition, 20, date => date.setSeconds(date.getSeconds() + 7)); + add('FREQ=MINUTELY;INTERVAL=13', transition, 20, date => date.setMinutes(date.getMinutes() + 13)); + add('FREQ=HOURLY;INTERVAL=2', transition, 20, date => date.setHours(date.getHours() + 2)); + add('FREQ=DAILY', new Date(2024, month, day - 2, 2, 30), 8, date => date.setDate(date.getDate() + 1)); + } + process.stdout.write(JSON.stringify(fixtures)); +} else { + const fixtures = JSON.parse(readFileSync(0, 'utf8')); + for (const { rule, zone, expected, wall } of fixtures) { + const event = { id: 'zone', title: 'Zone recurrence', recurring: true, recurrenceRule: rule, + timeZone: zone, start: new Date(expected[0]), end: new Date(expected[0] + 3600000) }; + const engine = new RecurrenceEngineV2(); + const label = `${process.env.TZ}/${zone}: ${rule} at ${event.start.toISOString()}`; + const before = new Date(expected.at(-1)); + const rows = engine.expandEvent(event, event.start, before, { maxOccurrences: 2000 }); + assert.deepEqual(starts(rows), expected, `${label}: native wall-clock oracle`); + const formatter = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: 'numeric', + day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' }); + const wallFields = rows.map(row => { + const parts = Object.fromEntries(formatter.formatToParts(row.start).map(part => [part.type, part.value])); + return ['year', 'month', 'day', 'hour', 'minute', 'second'].map(field => +parts[field]); + }); + assert.deepEqual(wallFields, wall, `${label}: named-zone calendar fields`); + assert.ok(rows.every(row => row.end - row.start === 3600000), `${label}: duration`); + assert.deepEqual(starts([...engine.iterateOccurrences(event)]), expected, `${label}: independent iterator`); + assert.deepEqual(starts(engine.expandEvent(event, event.start, before, { maxOccurrences: 2000, handleDST: false })), expected, + `${label}: compatible handleDST option does not double-adjust instants`); + for (const index of [1, Math.floor(expected.length / 2), expected.length - 2]) { + const after = new Date(expected[index] + 1); + const endIndex = Math.min(index + 3, expected.length - 1); + const end = new Date(expected[endIndex]); + const filtered = expected.filter(ms => ms >= after && ms <= end); + assert.deepEqual(starts(engine.expandEvent(event, after, end)), filtered, `${label}: seek and bounds`); + assert.deepEqual(starts([...engine.iterateOccurrences(event, { after, before: end, inclusive: true })]), filtered, + `${label}: iterator seek and bounds`); + assert.deepEqual(starts([...engine.iterateOccurrences(event, { + after: new Date(expected[index]), before: end + })]), expected.filter(ms => ms > expected[index] && ms < end), `${label}: exclusive bounds`); + } + assert.equal(engine.nextOccurrence(event, before), null, `${label}: COUNT exhausted`); + const walking = new RecurrenceEngineV2(); + walking.seekToRange = () => {}; + const after = new Date(expected[Math.floor(expected.length / 2)]); + assert.deepEqual(starts(walking.expandEvent(event, after, before, { maxOccurrences: 2000 })), + expected.filter(ms => ms >= after), `${label}: seek-disabled filtering`); + } + + const engine = new RecurrenceEngineV2(); + const start = new Date('2024-01-01T09:00:00Z'); + const event = { id: 'utc-year', start, end: new Date(+start + 3600000), recurring: true, + timeZone: 'UTC', recurrenceRule: 'FREQ=DAILY;COUNT=365' }; + const expected = Array.from({ length: 365 }, (_, day) => +start + day * 86400000); + assert.deepEqual(starts(engine.expandEvent(event, start, new Date('2034-12-31T00:00Z'), { maxOccurrences: 2000 })), expected, + 'ten-year window must not apply future host transitions to a one-year UTC series'); + const old = { ...event, start: new Date('1995-01-01T09:00:00Z'), recurrenceRule: 'FREQ=SECONDLY;INTERVAL=7' }; + old.end = new Date(+old.start + 3600000); + const target = new Date('2026-01-01T09:00:00Z'); + const first = +old.start + Math.ceil((target - old.start) / 7000) * 7000; + let steps = 0; + const advance = engine.getNextDate.bind(engine); + engine.getNextDate = (...args) => { steps++; return advance(...args); }; + assert.deepEqual(starts(engine.takeOccurrences(old, 5, { after: target, inclusive: true })), + Array.from({ length: 5 }, (_, i) => first + i * 7000), 'decades-old explicit UTC seek'); + assert.ok(steps < 20, `UTC seek must skip arithmetically: ${steps}`); + + // UTC, floating UNTIL, date-only EXDATE, and overrides near a zone's midnight. + const tokyo = { ...event, id: 'tokyo', timeZone: 'Asia/Tokyo', start: new Date('2024-01-01T15:30Z'), + end: new Date('2024-01-01T16:30Z'), recurrenceRule: 'FREQ=DAILY;UNTIL=20240105T003000;EXDATE=20240103' }; + const bounds = [new Date('2024-01-01T00:00Z'), new Date('2024-01-10T00:00Z')]; + const tokyoExpected = ['2024-01-01T15:30Z', '2024-01-03T15:30Z', '2024-01-04T15:30Z'].map(Date.parse); + assert.deepEqual(starts(engine.expandEvent(tokyo, ...bounds)), tokyoExpected, 'floating rule dates use event zone'); + engine.addException(tokyo.id, new Date('2024-01-03T16:00Z'), 'Zone holiday'); + engine.addModifiedInstance(tokyo.id, new Date('2024-01-04T17:00Z'), { title: 'Zone edit' }); + const overridden = engine.expandEvent(tokyo, ...bounds, { includeCancelled: true }); + assert.equal(overridden[1].status, 'cancelled'); + assert.equal(overridden[2].status, 'cancelled'); + assert.equal(overridden[2].cancellationReason, 'Zone holiday'); + assert.equal(overridden[3].title, 'Zone edit'); + assert.equal(overridden[3].isModified, true); + engine.addModifiedInstance(tokyo.id, new Date('2024-01-04T16:00Z'), { title: 'Later edit' }); + engine.addModifiedInstance(tokyo.id, new Date('2024-01-04T17:00Z'), { title: 'Latest edit' }); + assert.equal(engine.expandEvent(tokyo, ...bounds).at(-1).title, 'Latest edit', 'last edit on the zoned date wins'); + + const override = { ...event, timeZone: 'America/New_York' }; + assert.deepEqual(starts(engine.expandEvent(override, start, new Date('2024-12-31T00:00Z'), + { maxOccurrences: 2000, timezone: 'UTC' })), expected, 'explicit option overrides event zone'); + const cacheEvent = { ...event, id: 'zone-cache', start: new Date('2024-03-09T14:00Z'), + end: new Date('2024-03-09T15:00Z'), recurrenceRule: 'FREQ=DAILY;COUNT=3' }; + const cacheEnd = new Date('2024-03-12T00:00Z'); + engine.expandEvent(cacheEvent, cacheEvent.start, cacheEnd); + cacheEvent.timeZone = 'America/New_York'; + assert.deepEqual(starts(engine.expandEvent(cacheEvent, cacheEvent.start, cacheEnd)), + ['2024-03-09T14:00Z', '2024-03-10T13:00Z', '2024-03-11T13:00Z'].map(Date.parse), 'cache includes default event zone'); + + const offsets = TimezoneManager.getInstance(); + assert.equal(offsets.getTimezoneOffset(new Date('2024-01-01T00:00Z'), 'Asia/Kolkata', true), -330); + assert.equal(offsets.getTimezoneOffset(new Date('2024-07-01T00:00Z'), 'America/Los_Angeles', true), 420); + console.log(` ✅ ${fixtures.length} independent zone fixtures and cross-host regressions on ${process.env.TZ}`); +} diff --git a/tests/integration/test-recurrence.js b/tests/integration/test-recurrence.js index 39eb643..6df1378 100644 --- a/tests/integration/test-recurrence.js +++ b/tests/integration/test-recurrence.js @@ -131,7 +131,8 @@ function bruteForceV2(event, rangeStart, rangeEnd) { console.log('\n=== Test 3: Series older than the occurrence cap stay visible ==='); const june2025Start = new Date(2025, 5, 1); const june2025End = new Date(2025, 5, 30, 23, 59); -const dailySince2024 = makeRecurring('old-daily', 'FREQ=DAILY', new Date(2024, 0, 1, 9, 0)); +// These assertions inspect host-local fields, so make the event zone explicit. +const dailySince2024 = makeRecurring('old-daily', 'FREQ=DAILY', new Date(2024, 0, 1, 9, 0), 60, Intl.DateTimeFormat().resolvedOptions().timeZone); const v1Default = RecurrenceEngine.expandEvent(dailySince2024, june2025Start, june2025End); assert(v1Default.length === 30, `RecurrenceEngine default cap returns June 2025 for a 2024 daily series (${v1Default.length})`); diff --git a/tests/integration/test-secondly-recurrence.js b/tests/integration/test-secondly-recurrence.js index ed16d76..2335e6c 100644 --- a/tests/integration/test-secondly-recurrence.js +++ b/tests/integration/test-secondly-recurrence.js @@ -125,14 +125,17 @@ if (process.argv[2] !== '--timezone-child') { steps++; return getNextDate.apply(this, args); }; - const oldExpected = RecurrenceEngine.expandEvent(oldEvent, start, at(60), 5); - assert.equal(oldExpected.length, 5, 'V1 old-series oracle returns five occurrences'); + // Explicit UTC recurrence is independent of the host's Date setters. + // V1 still uses host-local stepping, so use integer UTC arithmetic as + // the independent oracle rather than comparing the two engines here. + const firstOld = oldEvent.start.getTime() + Math.ceil((start - oldEvent.start) / 7000) * 7000; + const oldExpected = Array.from({ length: 5 }, (_, i) => new Date(firstOld + i * 7000)); assertStarts(oldEngine.expandEvent(oldEvent, start, at(60), { maxOccurrences: 5 }), - oldExpected.map(occurrence => occurrence.start), 'decades-old expansion'); + oldExpected, 'decades-old expansion'); assert.ok(steps < 200, `old expansion uses bounded seeking (${steps} steps)`); steps = 0; assertStarts(oldEngine.takeOccurrences(oldEvent, 5, { after: start, inclusive: true }), - oldExpected.map(occurrence => occurrence.start), 'decades-old iterator'); + oldExpected, 'decades-old iterator'); assert.ok(steps < 200, `old iterator uses bounded seeking (${steps} steps)`); const exhaustedOldEvent = makeEvent('FREQ=SECONDLY;COUNT=100', oldEvent.start); assertStarts(oldEngine.expandEvent(exhaustedOldEvent, start, at(60)), [], 'old COUNT is exhausted before range'); diff --git a/tests/integration/test-weekly-seek.js b/tests/integration/test-weekly-seek.js index 5ce60c5..9d50955 100644 --- a/tests/integration/test-weekly-seek.js +++ b/tests/integration/test-weekly-seek.js @@ -5,7 +5,6 @@ */ import { Event } from '../../core/events/Event.js'; -import { RecurrenceEngine } from '../../core/events/RecurrenceEngine.js'; import { RecurrenceEngineV2 } from '../../core/events/RecurrenceEngineV2.js'; console.log('Testing WEEKLY BYDAY seeking...\n'); @@ -110,8 +109,10 @@ console.log('\n=== Test 2: far-past WEEKLY BYDAY series ==='); assert(elapsed < 100, `Expanded in ${elapsed.toFixed(1)} ms`); assert(warnings.length === 0, 'No truncation warning'); - const v1 = RecurrenceEngine.expandEvent(event, new Date(2025, 5, 1), new Date(2025, 5, 30, 23, 59, 59, 999), 1000); - assert(signature(v1.map(o => ({ id: 'e', ...o }))) === signature(june.map(o => ({ ...o, id: 'e' }))), 'RecurrenceEngine agrees'); + // The legacy engine has separate historical offset behavior. Use native + // calendar instants as the oracle for the named host zone instead. + const expected = Array.from({ length: 30 }, (_, day) => new Date(2025, 5, day + 1, 9).getTime()); + assert(JSON.stringify(june.map(o => o.start.getTime())) === JSON.stringify(expected), 'Ancient weekly series matches native local dates'); const next = engine.nextOccurrence(makeEvent('FREQ=WEEKLY;BYDAY=MO,WE', new Date(1995, 0, 2, 9)), new Date(2300, 0, 1)); assert(next && next.start.getFullYear() === 2300 && [1, 3].includes(next.start.getDay()), 'nextOccurrence seeks a BYDAY series 300 years ahead');