@forcecalendar/core 2.1.70 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@ import { TimezoneManager } from '../timezone/TimezoneManager.js';
12
12
  export class Calendar {
13
13
  /**
14
14
  * Create a new Calendar instance
15
- * @param {import('../../types.js').CalendarConfig} [config={}] - Configuration options
15
+ * @param {import('../types.js').CalendarConfig} [config={}] - Configuration options
16
16
  */
17
17
  constructor(config = {}) {
18
18
  // Initialize timezone manager first (use singleton to share cache)
@@ -69,7 +69,7 @@ export class Calendar {
69
69
 
70
70
  /**
71
71
  * Set the calendar view
72
- * @param {import('../../types.js').ViewType} viewType - The view type ('month', 'week', 'day', 'list')
72
+ * @param {import('../types.js').ViewType} viewType - The view type ('month', 'week', 'day', 'list')
73
73
  * @param {Date} [date=null] - Optional date to navigate to
74
74
  */
75
75
  setView(viewType, date = null) {
@@ -87,7 +87,7 @@ export class Calendar {
87
87
 
88
88
  /**
89
89
  * Get the current view type
90
- * @returns {import('../../types.js').ViewType} The current view type
90
+ * @returns {import('../types.js').ViewType} The current view type
91
91
  */
92
92
  getView() {
93
93
  return this.state.get('view');
@@ -160,7 +160,7 @@ export class Calendar {
160
160
 
161
161
  /**
162
162
  * Add an event
163
- * @param {import('../events/Event.js').Event|import('../../types.js').EventData} eventData - Event data or Event instance
163
+ * @param {import('../events/Event.js').Event|import('../types.js').EventData} eventData - Event data or Event instance
164
164
  * @returns {import('../events/Event.js').Event} The added event
165
165
  */
166
166
  addEvent(eventData) {
@@ -389,7 +389,7 @@ export class Calendar {
389
389
 
390
390
  /**
391
391
  * Get the current view's data
392
- * @returns {import('../../types.js').MonthViewData|import('../../types.js').WeekViewData|import('../../types.js').DayViewData|import('../../types.js').ListViewData|null} View-specific data
392
+ * @returns {import('../types.js').MonthViewData|import('../types.js').WeekViewData|import('../types.js').DayViewData|import('../types.js').ListViewData|null} View-specific data
393
393
  */
394
394
  getViewData() {
395
395
  const view = this.state.get('view');
@@ -14,9 +14,9 @@ export class ConflictDetector {
14
14
 
15
15
  /**
16
16
  * Check for conflicts for a specific event
17
- * @param {import('../events/Event.js').Event|import('../../types.js').EventData} event - Event to check
18
- * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
19
- * @returns {import('../../types.js').ConflictSummary} Conflict summary
17
+ * @param {import('../events/Event.js').Event|import('../types.js').EventData} event - Event to check
18
+ * @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
19
+ * @returns {import('../types.js').ConflictSummary} Conflict summary
20
20
  */
21
21
  checkConflicts(event, options = {}) {
22
22
  // Default options
@@ -87,8 +87,8 @@ export class ConflictDetector {
87
87
  * Check for conflicts between two specific events
88
88
  * @param {import('../events/Event.js').Event} event1 - First event
89
89
  * @param {import('../events/Event.js').Event} event2 - Second event
90
- * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
91
- * @returns {import('../../types.js').ConflictDetails[]} Array of conflicts
90
+ * @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
91
+ * @returns {import('../types.js').ConflictDetails[]} Array of conflicts
92
92
  */
93
93
  checkEventPairConflicts(event1, event2, options = {}) {
94
94
  const opts = {
@@ -18,8 +18,8 @@ export class Event {
18
18
 
19
19
  /**
20
20
  * Normalize event data
21
- * @param {import('../../types.js').EventData} data - Raw event data
22
- * @returns {import('../../types.js').EventData} Normalized event data
21
+ * @param {import('../types.js').EventData} data - Raw event data
22
+ * @returns {import('../types.js').EventData} Normalized event data
23
23
  */
24
24
  static normalize(data) {
25
25
  const normalized = { ...data };
@@ -109,7 +109,7 @@ export class Event {
109
109
 
110
110
  /**
111
111
  * Validate event data
112
- * @param {import('../../types.js').EventData} data - Normalized event data
112
+ * @param {import('../types.js').EventData} data - Normalized event data
113
113
  * @throws {Error} If validation fails
114
114
  */
115
115
  static validate(data) {
@@ -173,7 +173,7 @@ export class Event {
173
173
  try {
174
174
  new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
175
175
  } catch (e) {
176
- throw new Error(`Invalid timezone: ${data.timeZone}`);
176
+ throw new Error(`Invalid timezone: ${data.timeZone}`, { cause: e });
177
177
  }
178
178
  }
179
179
 
@@ -182,14 +182,14 @@ export class Event {
182
182
  try {
183
183
  new Intl.DateTimeFormat('en-US', { timeZone: data.endTimeZone });
184
184
  } catch (e) {
185
- throw new Error(`Invalid end timezone: ${data.endTimeZone}`);
185
+ throw new Error(`Invalid end timezone: ${data.endTimeZone}`, { cause: e });
186
186
  }
187
187
  }
188
188
  }
189
189
 
190
190
  /**
191
191
  * Create a new Event instance
192
- * @param {import('../../types.js').EventData} eventData - Event data object
192
+ * @param {import('../types.js').EventData} eventData - Event data object
193
193
  * @throws {Error} If required fields are missing or invalid
194
194
  */
195
195
  constructor({
@@ -426,7 +426,7 @@ export class Event {
426
426
 
427
427
  /**
428
428
  * Backward-compatible alias for recurrenceRule
429
- * @returns {import('../../types.js').RecurrenceRule|string|null}
429
+ * @returns {import('../types.js').RecurrenceRule|string|null}
430
430
  */
431
431
  get recurrence() {
432
432
  return this.recurrenceRule;
@@ -513,7 +513,7 @@ export class Event {
513
513
 
514
514
  /**
515
515
  * Clone the event with optional updates
516
- * @param {Partial<import('../../types.js').EventData>} [updates={}] - Properties to update in the clone
516
+ * @param {Partial<import('../types.js').EventData>} [updates={}] - Properties to update in the clone
517
517
  * @returns {Event} New Event instance with updated properties
518
518
  */
519
519
  clone(updates = {}) {
@@ -548,7 +548,7 @@ export class Event {
548
548
 
549
549
  /**
550
550
  * Convert event to plain object
551
- * @returns {import('../../types.js').EventData} Plain object representation of the event
551
+ * @returns {import('../types.js').EventData} Plain object representation of the event
552
552
  */
553
553
  toObject() {
554
554
  return {
@@ -580,7 +580,7 @@ export class Event {
580
580
 
581
581
  /**
582
582
  * Create Event from plain object
583
- * @param {import('../../types.js').EventData} obj - Plain object with event properties
583
+ * @param {import('../types.js').EventData} obj - Plain object with event properties
584
584
  * @returns {Event} New Event instance
585
585
  */
586
586
  static fromObject(obj) {
@@ -613,7 +613,7 @@ export class Event {
613
613
 
614
614
  /**
615
615
  * Add an attendee to the event
616
- * @param {import('../../types.js').Attendee} attendee - Attendee to add
616
+ * @param {import('../types.js').Attendee} attendee - Attendee to add
617
617
  * @returns {boolean} True if attendee was added, false if already exists
618
618
  */
619
619
  addAttendee(attendee) {
@@ -662,7 +662,7 @@ export class Event {
662
662
  /**
663
663
  * Update an attendee's response status
664
664
  * @param {string} email - Attendee's email
665
- * @param {import('../../types.js').AttendeeResponseStatus} responseStatus - New response status
665
+ * @param {import('../types.js').AttendeeResponseStatus} responseStatus - New response status
666
666
  * @returns {boolean} True if attendee was updated
667
667
  */
668
668
  updateAttendeeResponse(email, responseStatus) {
@@ -678,7 +678,7 @@ export class Event {
678
678
  /**
679
679
  * Get an attendee by email
680
680
  * @param {string} email - Attendee's email
681
- * @returns {import('../../types.js').Attendee|null} The attendee or null
681
+ * @returns {import('../types.js').Attendee|null} The attendee or null
682
682
  */
683
683
  getAttendee(email) {
684
684
  return this.attendees.find(a => a.email === email) || null;
@@ -695,8 +695,8 @@ export class Event {
695
695
 
696
696
  /**
697
697
  * Get attendees by response status
698
- * @param {import('../../types.js').AttendeeResponseStatus} status - Response status to filter by
699
- * @returns {import('../../types.js').Attendee[]} Filtered attendees
698
+ * @param {import('../types.js').AttendeeResponseStatus} status - Response status to filter by
699
+ * @returns {import('../types.js').Attendee[]} Filtered attendees
700
700
  */
701
701
  getAttendeesByStatus(status) {
702
702
  return this.attendees.filter(a => a.responseStatus === status);
@@ -718,7 +718,7 @@ export class Event {
718
718
 
719
719
  /**
720
720
  * Add a reminder to the event
721
- * @param {import('../../types.js').Reminder} reminder - Reminder to add
721
+ * @param {import('../types.js').Reminder} reminder - Reminder to add
722
722
  * @returns {boolean} True if reminder was added
723
723
  */
724
724
  addReminder(reminder) {
@@ -764,7 +764,7 @@ export class Event {
764
764
 
765
765
  /**
766
766
  * Get active reminders
767
- * @returns {import('../../types.js').Reminder[]} Active reminders
767
+ * @returns {import('../types.js').Reminder[]} Active reminders
768
768
  */
769
769
  getActiveReminders() {
770
770
  return this.reminders.filter(r => r.enabled !== false);
@@ -56,13 +56,13 @@ export class EventStore {
56
56
  // Change tracking
57
57
  /** @type {number} */
58
58
  this.version = 0;
59
- /** @type {Set<import('../../types.js').EventListener>} */
59
+ /** @type {Set<import('../types.js').EventListener>} */
60
60
  this.listeners = new Set();
61
61
  }
62
62
 
63
63
  /**
64
64
  * Add an event to the store
65
- * @param {Event|import('../../types.js').EventData} event - The event to add
65
+ * @param {Event|import('../types.js').EventData} event - The event to add
66
66
  * @returns {Event} The added event
67
67
  * @throws {Error} If event with same ID already exists
68
68
  */
@@ -107,7 +107,7 @@ export class EventStore {
107
107
  /**
108
108
  * Update an existing event
109
109
  * @param {string} eventId - The event ID
110
- * @param {Partial<import('../../types.js').EventData>} updates - Properties to update
110
+ * @param {Partial<import('../types.js').EventData>} updates - Properties to update
111
111
  * @returns {Event} The updated event
112
112
  * @throws {Error} If event not found
113
113
  */
@@ -212,7 +212,7 @@ export class EventStore {
212
212
 
213
213
  /**
214
214
  * Query events with filters
215
- * @param {import('../../types.js').QueryFilters} [filters={}] - Query filters
215
+ * @param {import('../types.js').QueryFilters} [filters={}] - Query filters
216
216
  * @returns {Event[]} Filtered events
217
217
  */
218
218
  queryEvents(filters = {}) {
@@ -566,7 +566,7 @@ export class EventStore {
566
566
  * @returns {Event[]}
567
567
  */
568
568
  getEventsInRange(start, end, expandRecurringOrOptions = true, timezone = null) {
569
- let expandRecurring = true;
569
+ let expandRecurring;
570
570
 
571
571
  if (typeof expandRecurringOrOptions === 'object' && expandRecurringOrOptions !== null) {
572
572
  // Options object form: getEventsInRange(start, end, { expandRecurring, timezone })
@@ -1117,7 +1117,7 @@ export class EventStore {
1117
1117
 
1118
1118
  /**
1119
1119
  * Add multiple events in batch
1120
- * @param {Array<Event|import('../../types.js').EventData>} events - Events to add
1120
+ * @param {Array<Event|import('../types.js').EventData>} events - Events to add
1121
1121
  * @returns {Event[]} Added events
1122
1122
  */
1123
1123
  addEvents(events) {
@@ -1277,9 +1277,9 @@ export class EventStore {
1277
1277
 
1278
1278
  /**
1279
1279
  * Check for conflicts for an event
1280
- * @param {Event|import('../../types.js').EventData} event - Event to check
1281
- * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
1282
- * @returns {import('../../types.js').ConflictSummary} Conflict summary
1280
+ * @param {Event|import('../types.js').EventData} event - Event to check
1281
+ * @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
1282
+ * @returns {import('../types.js').ConflictSummary} Conflict summary
1283
1283
  */
1284
1284
  checkConflicts(event, options = {}) {
1285
1285
  return this.conflictDetector.checkConflicts(event, options);
@@ -1289,8 +1289,8 @@ export class EventStore {
1289
1289
  * Check conflicts between two events
1290
1290
  * @param {string} eventId1 - First event ID
1291
1291
  * @param {string} eventId2 - Second event ID
1292
- * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
1293
- * @returns {import('../../types.js').ConflictDetails[]} Conflicts between events
1292
+ * @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
1293
+ * @returns {import('../types.js').ConflictDetails[]} Conflicts between events
1294
1294
  */
1295
1295
  checkEventPairConflicts(eventId1, eventId2, options = {}) {
1296
1296
  const event1 = this.getEvent(eventId1);
@@ -1307,8 +1307,8 @@ export class EventStore {
1307
1307
  * Get all conflicts in a date range
1308
1308
  * @param {Date} start - Start date
1309
1309
  * @param {Date} end - End date
1310
- * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
1311
- * @returns {import('../../types.js').ConflictSummary} All conflicts in range
1310
+ * @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
1311
+ * @returns {import('../types.js').ConflictSummary} All conflicts in range
1312
1312
  */
1313
1313
  getAllConflicts(start, end, options = {}) {
1314
1314
  const events = this.getEventsInRange(start, end, false);
@@ -1363,9 +1363,9 @@ export class EventStore {
1363
1363
 
1364
1364
  /**
1365
1365
  * Add event with conflict checking
1366
- * @param {Event|import('../../types.js').EventData} event - Event to add
1366
+ * @param {Event|import('../types.js').EventData} event - Event to add
1367
1367
  * @param {boolean} [allowConflicts=true] - Whether to allow adding with conflicts
1368
- * @returns {{event: Event, conflicts: import('../../types.js').ConflictSummary}} Result
1368
+ * @returns {{event: Event, conflicts: import('../types.js').ConflictSummary}} Result
1369
1369
  */
1370
1370
  addEventWithConflictCheck(event, allowConflicts = true) {
1371
1371
  // Check conflicts before adding
@@ -1387,7 +1387,7 @@ export class EventStore {
1387
1387
  /**
1388
1388
  * Find events with conflicts
1389
1389
  * @param {Object} [options={}] - Options
1390
- * @returns {Array<{event: Event, conflicts: import('../../types.js').ConflictDetails[]}>} Events with conflicts
1390
+ * @returns {Array<{event: Event, conflicts: import('../types.js').ConflictDetails[]}>} Events with conflicts
1391
1391
  */
1392
1392
  findEventsWithConflicts(options = {}) {
1393
1393
  const eventsWithConflicts = [];
@@ -24,7 +24,7 @@ export class RecurrenceEngine {
24
24
  * @param {Date} rangeEnd - End of the expansion range
25
25
  * @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
26
26
  * @param {string} [timezone] - Timezone for expansion (important for DST)
27
- * @returns {import('../../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
27
+ * @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
28
28
  */
29
29
  static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
30
30
  // Enforce hard limit regardless of caller-provided value
@@ -34,20 +34,72 @@ export class RecurrenceEngine {
34
34
  }
35
35
 
36
36
  const rule = this._getParsedRule(event.recurrenceRule);
37
- const occurrences = [];
38
37
  const duration = event.end - event.start;
39
38
  const eventTimezone = timezone || event.timeZone || 'UTC';
40
39
  const tzManager = TimezoneManager.getInstance();
41
40
 
42
- // Work in event's timezone for accurate recurrence calculation
43
- const currentDate = new Date(event.start);
44
- let count = 0;
45
-
46
41
  // If UNTIL is specified, use it as the range end
47
42
  if (rule.until && rule.until < rangeEnd) {
48
43
  rangeEnd = rule.until;
49
44
  }
50
45
 
46
+ // DAILY and WEEKLY series iterate on numeric timestamps (no Date
47
+ // arithmetic per step); other frequencies use the general loop
48
+ let occurrences = null;
49
+ if (rule.freq === 'DAILY' || rule.freq === 'WEEKLY') {
50
+ occurrences = this._expandFast(
51
+ event,
52
+ rule,
53
+ rangeStart.getTime(),
54
+ rangeEnd.getTime(),
55
+ maxOccurrences,
56
+ eventTimezone,
57
+ tzManager,
58
+ duration
59
+ );
60
+ }
61
+ if (!occurrences) {
62
+ occurrences = this._expandGeneral(
63
+ event,
64
+ rule,
65
+ rangeStart.getTime(),
66
+ rangeEnd.getTime(),
67
+ maxOccurrences,
68
+ eventTimezone,
69
+ tzManager,
70
+ duration
71
+ );
72
+ }
73
+
74
+ // Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
75
+ if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
76
+ return this._applyBySetPos(occurrences, rule);
77
+ }
78
+
79
+ return occurrences;
80
+ }
81
+
82
+ /**
83
+ * General expansion loop: advances a Date cursor per step. Handles every
84
+ * frequency and degenerate rules (non-advancing dates, invalid intervals).
85
+ * @private
86
+ */
87
+ static _expandGeneral(
88
+ event,
89
+ rule,
90
+ rangeStartMs,
91
+ rangeEndMs,
92
+ maxOccurrences,
93
+ eventTimezone,
94
+ tzManager,
95
+ duration
96
+ ) {
97
+ const occurrences = [];
98
+
99
+ // Work in event's timezone for accurate recurrence calculation
100
+ const currentDate = new Date(event.start);
101
+ let count = 0;
102
+
51
103
  // Track DST transitions for proper timezone handling
52
104
  let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
53
105
 
@@ -57,8 +109,6 @@ export class RecurrenceEngine {
57
109
 
58
110
  // Compare on numeric timestamps in the loop — Date-object comparisons
59
111
  // re-coerce through valueOf on every check
60
- const rangeStartMs = rangeStart.getTime();
61
- const rangeEndMs = rangeEnd.getTime();
62
112
  const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
63
113
  let currentMs = currentDate.getTime();
64
114
 
@@ -113,14 +163,179 @@ export class RecurrenceEngine {
113
163
  }
114
164
  }
115
165
 
116
- // Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
117
- if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
118
- return this._applyBySetPos(occurrences, rule);
166
+ return occurrences;
167
+ }
168
+
169
+ /**
170
+ * Numeric expansion loop for DAILY and WEEKLY rules.
171
+ *
172
+ * Between DST transitions a wall-clock-preserving day step is a constant
173
+ * number of milliseconds, so the loop is pure numeric addition. Transition
174
+ * instants — in the system timezone (which defines Date arithmetic) and in
175
+ * the event's timezone (which drives occurrence adjustment) — are
176
+ * discovered by binary search and cached, so only the one step that
177
+ * crosses a transition falls back to Date arithmetic, and only the first
178
+ * occurrence after a transition queries a timezone offset.
179
+ *
180
+ * Produces output identical to _expandGeneral for the rules it accepts;
181
+ * returns null to delegate anything it cannot handle exactly.
182
+ * @private
183
+ */
184
+ static _expandFast(
185
+ event,
186
+ rule,
187
+ rangeStartMs,
188
+ rangeEndMs,
189
+ maxOccurrences,
190
+ eventTimezone,
191
+ tzManager,
192
+ duration
193
+ ) {
194
+ const DAY = 86400000;
195
+ let dayDeltas = null;
196
+ let stepDays = 0;
197
+ let weekday = 0;
198
+
199
+ const startDate = new Date(event.start);
200
+ if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
201
+ const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
202
+ if (daySet.size === 0) {
203
+ return null; // invalid byDay — general loop handles the fallback warning
204
+ }
205
+ dayDeltas = rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
206
+ weekday = startDate.getDay();
207
+ } else {
208
+ stepDays = (rule.freq === 'DAILY' ? 1 : 7) * rule.interval;
209
+ if (!Number.isInteger(stepDays) || stepDays <= 0) {
210
+ return null; // degenerate interval — general loop's stuck detection applies
211
+ }
212
+ }
213
+
214
+ const occurrences = [];
215
+ let currentMs = startDate.getTime();
216
+ if (Number.isNaN(currentMs)) {
217
+ return null;
218
+ }
219
+ let count = 0;
220
+ const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
221
+
222
+ let lastOffset = tzManager.getTimezoneOffset(startDate, eventTimezone);
223
+ let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
224
+ let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
225
+
226
+ while (currentMs <= rangeEndMs && count < maxOccurrences) {
227
+ if (currentMs >= rangeStartMs) {
228
+ const occurrenceStart = new Date(currentMs);
229
+ const occurrenceEnd = new Date(currentMs + duration);
230
+
231
+ // Only the first occurrence past a transition needs an offset check
232
+ if (currentMs >= nextEventTzTransition) {
233
+ const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
234
+ if (currentOffset !== lastOffset) {
235
+ const offsetDiff = lastOffset - currentOffset;
236
+ occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
237
+ occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
238
+ lastOffset = currentOffset;
239
+ }
240
+ nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
241
+ }
242
+
243
+ if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
244
+ occurrences.push({
245
+ start: occurrenceStart,
246
+ end: occurrenceEnd,
247
+ recurringEventId: event.id,
248
+ timezone: eventTimezone,
249
+ originalStart: event.start
250
+ });
251
+ }
252
+ }
253
+
254
+ // Advance: pure addition unless the step crosses a system-timezone
255
+ // transition, where Date arithmetic reproduces wall-clock semantics
256
+ const days = dayDeltas ? dayDeltas[weekday] : stepDays;
257
+ if (dayDeltas) {
258
+ weekday = (weekday + days) % 7;
259
+ }
260
+ const naiveMs = currentMs + days * DAY;
261
+ if (naiveMs >= nextSystemTransition) {
262
+ const cursor = new Date(currentMs);
263
+ cursor.setDate(cursor.getDate() + days);
264
+ currentMs = cursor.getTime();
265
+ nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
266
+ } else {
267
+ currentMs = naiveMs;
268
+ }
269
+ count++;
270
+
271
+ if (rule.count && count >= rule.count) {
272
+ break;
273
+ }
119
274
  }
120
275
 
121
276
  return occurrences;
122
277
  }
123
278
 
279
+ /**
280
+ * Find the next system-timezone offset transition after fromMs.
281
+ * Cached module-wide: the system timezone is fixed for the process.
282
+ * @param {number} fromMs - Search from this timestamp (exclusive)
283
+ * @param {number} toMs - Extend cache coverage at least this far
284
+ * @returns {number} Transition timestamp, or Infinity if none within coverage
285
+ * @private
286
+ */
287
+ static _nextSystemTransition(fromMs, toMs) {
288
+ if (fromMs >= toMs) {
289
+ return Infinity;
290
+ }
291
+ let cache = this._systemTransitions;
292
+ if (!cache || fromMs < cache.from || toMs > cache.to) {
293
+ const from = Math.min(fromMs, cache ? cache.from : fromMs);
294
+ const to = Math.max(toMs, cache ? cache.to : toMs);
295
+ cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
296
+ this._systemTransitions = cache;
297
+ }
298
+ for (const t of cache.transitions) {
299
+ if (t > fromMs) {
300
+ return t;
301
+ }
302
+ }
303
+ return Infinity;
304
+ }
305
+
306
+ /**
307
+ * Scan for system-timezone offset transitions via Date#getTimezoneOffset.
308
+ * Probes weekly (shorter than any real-world gap between transitions)
309
+ * and binary-searches each change to the exact millisecond.
310
+ * @private
311
+ */
312
+ static _scanSystemTransitions(fromMs, toMs) {
313
+ const WEEK = 7 * 86400000;
314
+ const transitions = [];
315
+ let lo = fromMs;
316
+ let loOffset = new Date(lo).getTimezoneOffset();
317
+ while (lo < toMs) {
318
+ const hi = Math.min(lo + WEEK, toMs);
319
+ const hiOffset = new Date(hi).getTimezoneOffset();
320
+ if (hiOffset !== loOffset) {
321
+ let a = lo;
322
+ let b = hi;
323
+ while (b - a > 1) {
324
+ const mid = Math.floor((a + b) / 2);
325
+ if (new Date(mid).getTimezoneOffset() === loOffset) {
326
+ a = mid;
327
+ } else {
328
+ b = mid;
329
+ }
330
+ }
331
+ transitions.push(b);
332
+ loOffset = hiOffset;
333
+ }
334
+ lo = hi;
335
+ }
336
+ return transitions;
337
+ }
338
+
124
339
  /**
125
340
  * Apply BYSETPOS to filter occurrences within each frequency period
126
341
  * @param {Array} occurrences - Generated occurrences
@@ -165,8 +380,8 @@ export class RecurrenceEngine {
165
380
 
166
381
  /**
167
382
  * Parse an RRULE string into a rule object
168
- * @param {string|import('../../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
169
- * @returns {import('../../types.js').RecurrenceRule} Parsed rule object
383
+ * @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
384
+ * @returns {import('../types.js').RecurrenceRule} Parsed rule object
170
385
  */
171
386
  static parseRule(ruleString) {
172
387
  // Use the new comprehensive parser
@@ -177,7 +392,7 @@ export class RecurrenceEngine {
177
392
  * Parse a rule with caching for string rules (internal use by expandEvent).
178
393
  * Cached rule objects are shared across calls and must not be mutated.
179
394
  * @param {string|Object} recurrenceRule - RRULE string or rule object
180
- * @returns {import('../../types.js').RecurrenceRule} Parsed rule object
395
+ * @returns {import('../types.js').RecurrenceRule} Parsed rule object
181
396
  * @private
182
397
  */
183
398
  static _getParsedRule(recurrenceRule) {
@@ -106,7 +106,7 @@ export class ICSHandler {
106
106
 
107
107
  return results;
108
108
  } catch (error) {
109
- throw new Error(`ICS import failed: ${error.message}`);
109
+ throw new Error(`ICS import failed: ${error.message}`, { cause: error });
110
110
  }
111
111
  }
112
112
 
@@ -221,9 +221,11 @@ export class ICSHandler {
221
221
  }
222
222
  } catch (error) {
223
223
  if (error.name === 'AbortError') {
224
- throw new Error(`Failed to import from URL: request timed out after ${requestTimeout}ms`);
224
+ throw new Error(`Failed to import from URL: request timed out after ${requestTimeout}ms`, {
225
+ cause: error
226
+ });
225
227
  }
226
- throw new Error(`Failed to import from URL: ${error.message}`);
228
+ throw new Error(`Failed to import from URL: ${error.message}`, { cause: error });
227
229
  }
228
230
  }
229
231
 
package/core/index.js CHANGED
@@ -24,11 +24,15 @@ export { RecurrenceEngine } from './events/RecurrenceEngine.js';
24
24
  export { RecurrenceEngineV2 } from './events/RecurrenceEngineV2.js';
25
25
  export { RRuleParser } from './events/RRuleParser.js';
26
26
 
27
+ // Timezone and Conflicts
28
+ export { TimezoneManager } from './timezone/TimezoneManager.js';
29
+ export { ConflictDetector } from './conflicts/ConflictDetector.js';
30
+
27
31
  // Enhanced Integration
28
32
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
29
33
 
30
34
  // Version — keep in sync with package.json
31
- export const VERSION = '2.1.70';
35
+ export const VERSION = '2.3.0';
32
36
 
33
37
  // Default export
34
38
  export { Calendar as default } from './calendar/Calendar.js';
@@ -5,7 +5,7 @@
5
5
  export class StateManager {
6
6
  /**
7
7
  * Create a new StateManager instance
8
- * @param {Partial<import('../../types.js').CalendarState>} [initialState={}] - Initial state values
8
+ * @param {Partial<import('../types.js').CalendarState>} [initialState={}] - Initial state values
9
9
  */
10
10
  constructor(initialState = {}) {
11
11
  this.state = {
@@ -75,7 +75,7 @@ export class StateManager {
75
75
 
76
76
  /**
77
77
  * Get the current state
78
- * @returns {import('../../types.js').CalendarState} Current state (frozen)
78
+ * @returns {import('../types.js').CalendarState} Current state (frozen)
79
79
  */
80
80
  getState() {
81
81
  return Object.freeze({ ...this.state });
@@ -83,7 +83,7 @@ export class StateManager {
83
83
 
84
84
  /**
85
85
  * Get a specific state value
86
- * @param {keyof import('../../types.js').CalendarState} key - The state key
86
+ * @param {keyof import('../types.js').CalendarState} key - The state key
87
87
  * @returns {any} The state value
88
88
  */
89
89
  get(key) {