From a2794edcb957f3bb4d3e982accc63f5fef51d93b Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:04:30 -0700 Subject: [PATCH 1/2] fix: resolve V2 recurrence steps in the event timezone --- core/events/RecurrenceDate.js | 97 +++++++ core/events/RecurrenceEngine.js | 15 +- core/events/RecurrenceEngineV2.js | 236 ++++++++++-------- core/timezone/TimezoneManager.js | 42 +++- core/types.js | 4 +- docs/recurrence-timezones.md | 50 ++++ tests/integration/test-byday-normalization.js | 4 +- tests/integration/test-occurrence-iterator.js | 3 +- .../integration/test-recurrence-cross-host.js | 187 ++++++++++++++ tests/integration/test-recurrence.js | 3 +- tests/integration/test-secondly-recurrence.js | 11 +- tests/integration/test-weekly-seek.js | 7 +- 12 files changed, 526 insertions(+), 133 deletions(-) create mode 100644 core/events/RecurrenceDate.js create mode 100644 docs/recurrence-timezones.md create mode 100644 tests/integration/test-recurrence-cross-host.js 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..3fb4525 --- /dev/null +++ b/docs/recurrence-timezones.md @@ -0,0 +1,50 @@ +# 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. +Returned start and end values are occurrence instants. Identical timestamps, +rules and event zones produce identical occurrence instants on different hosts. + +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. Each occurrence's duration remains the +original end minus start in milliseconds. + +Seeking uses transitions in the recurrence zone. Range bounds and iterator +bounds compare the resulting instants, without a second DST correction. 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. + +## 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'); From e56bc9758756ef289448f534be627ffcfb1bcdc0 Mon Sep 17 00:00:00 2001 From: thedhanawada <13751641+thedhanawada@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:10:23 -0700 Subject: [PATCH 2/2] docs: clarify recurrence override and historical-date limits --- docs/recurrence-timezones.md | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/recurrence-timezones.md b/docs/recurrence-timezones.md index 3fb4525..877967b 100644 --- a/docs/recurrence-timezones.md +++ b/docs/recurrence-timezones.md @@ -2,8 +2,9 @@ RecurrenceEngineV2 uses options.timezone, then event.timeZone, then UTC to interpret its calendar steps. The timestamp in event.start anchors the series. -Returned start and end values are occurrence instants. Identical timestamps, -rules and event zones produce identical occurrence instants on different hosts. +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 @@ -11,12 +12,17 @@ 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. Each occurrence's duration remains the -original end minus start in milliseconds. +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 the resulting instants, without a second DST correction. Floating -UNTIL and EXDATE values in RRULE strings use the recurrence zone; values ending +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. @@ -39,6 +45,16 @@ 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