@forcecalendar/core 2.1.69 → 2.2.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 = [];
@@ -2,6 +2,8 @@ import { DateUtils } from '../calendar/DateUtils.js';
2
2
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
3
3
  import { RRuleParser } from './RRuleParser.js';
4
4
 
5
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
6
+
5
7
  /**
6
8
  * RecurrenceEngine - Handles expansion of recurring events
7
9
  * Full support for RFC 5545 (iCalendar) RRULE specification
@@ -10,6 +12,11 @@ export class RecurrenceEngine {
10
12
  // Hard limit to prevent resource exhaustion regardless of caller input
11
13
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
14
 
15
+ // expandEvent is typically called many times with the same RRULE string
16
+ // (every view render), so parsed rules are cached by their source string
17
+ static _ruleCache = new Map();
18
+ static _RULE_CACHE_MAX = 500;
19
+
13
20
  /**
14
21
  * Expand a recurring event into individual occurrences
15
22
  * @param {import('./Event.js').Event} event - The recurring event
@@ -17,7 +24,7 @@ export class RecurrenceEngine {
17
24
  * @param {Date} rangeEnd - End of the expansion range
18
25
  * @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
19
26
  * @param {string} [timezone] - Timezone for expansion (important for DST)
20
- * @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
21
28
  */
22
29
  static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
23
30
  // Enforce hard limit regardless of caller-provided value
@@ -26,14 +33,14 @@ export class RecurrenceEngine {
26
33
  return [{ start: event.start, end: event.end, timezone: event.timeZone }];
27
34
  }
28
35
 
29
- const rule = this.parseRule(event.recurrenceRule);
36
+ const rule = this._getParsedRule(event.recurrenceRule);
30
37
  const occurrences = [];
31
38
  const duration = event.end - event.start;
32
39
  const eventTimezone = timezone || event.timeZone || 'UTC';
33
40
  const tzManager = TimezoneManager.getInstance();
34
41
 
35
42
  // Work in event's timezone for accurate recurrence calculation
36
- let currentDate = new Date(event.start);
43
+ const currentDate = new Date(event.start);
37
44
  let count = 0;
38
45
 
39
46
  // If UNTIL is specified, use it as the range end
@@ -48,11 +55,18 @@ export class RecurrenceEngine {
48
55
  let stuckCount = 0;
49
56
  const maxStuckIterations = 3;
50
57
 
51
- while (currentDate <= rangeEnd && count < maxOccurrences) {
58
+ // Compare on numeric timestamps in the loop — Date-object comparisons
59
+ // re-coerce through valueOf on every check
60
+ const rangeStartMs = rangeStart.getTime();
61
+ const rangeEndMs = rangeEnd.getTime();
62
+ const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
63
+ let currentMs = currentDate.getTime();
64
+
65
+ while (currentMs <= rangeEndMs && count < maxOccurrences) {
52
66
  // Check if this occurrence is within the range
53
- if (currentDate >= rangeStart) {
54
- const occurrenceStart = new Date(currentDate);
55
- const occurrenceEnd = new Date(currentDate.getTime() + duration);
67
+ if (currentMs >= rangeStartMs) {
68
+ const occurrenceStart = new Date(currentMs);
69
+ const occurrenceEnd = new Date(currentMs + duration);
56
70
 
57
71
  // Handle DST transitions
58
72
  const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
@@ -65,7 +79,7 @@ export class RecurrenceEngine {
65
79
  lastOffset = currentOffset;
66
80
 
67
81
  // Apply exceptions if any
68
- if (!this.isException(occurrenceStart, rule, event.id)) {
82
+ if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
69
83
  occurrences.push({
70
84
  start: occurrenceStart,
71
85
  end: occurrenceEnd,
@@ -77,12 +91,13 @@ export class RecurrenceEngine {
77
91
  }
78
92
 
79
93
  // Calculate next occurrence
80
- const previousTimestamp = currentDate.getTime();
81
- currentDate = this.getNextOccurrence(currentDate, rule, eventTimezone);
94
+ this._advanceInPlace(currentDate, rule);
95
+ const previousTimestamp = currentMs;
96
+ currentMs = currentDate.getTime();
82
97
  count++;
83
98
 
84
99
  // Safeguard: detect if date is not advancing (infinite loop risk)
85
- if (currentDate.getTime() === previousTimestamp) {
100
+ if (currentMs === previousTimestamp) {
86
101
  stuckCount++;
87
102
  if (stuckCount >= maxStuckIterations) {
88
103
  console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
@@ -150,14 +165,36 @@ export class RecurrenceEngine {
150
165
 
151
166
  /**
152
167
  * Parse an RRULE string into a rule object
153
- * @param {string|import('../../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
154
- * @returns {import('../../types.js').RecurrenceRule} Parsed 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
155
170
  */
156
171
  static parseRule(ruleString) {
157
172
  // Use the new comprehensive parser
158
173
  return RRuleParser.parse(ruleString);
159
174
  }
160
175
 
176
+ /**
177
+ * Parse a rule with caching for string rules (internal use by expandEvent).
178
+ * Cached rule objects are shared across calls and must not be mutated.
179
+ * @param {string|Object} recurrenceRule - RRULE string or rule object
180
+ * @returns {import('../types.js').RecurrenceRule} Parsed rule object
181
+ * @private
182
+ */
183
+ static _getParsedRule(recurrenceRule) {
184
+ if (typeof recurrenceRule !== 'string') {
185
+ return this.parseRule(recurrenceRule);
186
+ }
187
+ let rule = this._ruleCache.get(recurrenceRule);
188
+ if (!rule) {
189
+ rule = this.parseRule(recurrenceRule);
190
+ if (this._ruleCache.size >= this._RULE_CACHE_MAX) {
191
+ this._ruleCache.clear();
192
+ }
193
+ this._ruleCache.set(recurrenceRule, rule);
194
+ }
195
+ return rule;
196
+ }
197
+
161
198
  /**
162
199
  * Calculate the next occurrence based on the rule
163
200
  * @param {Date} currentDate - Current occurrence date
@@ -167,7 +204,18 @@ export class RecurrenceEngine {
167
204
  */
168
205
  static getNextOccurrence(currentDate, rule, _timezone = 'UTC') {
169
206
  const next = new Date(currentDate);
207
+ this._advanceInPlace(next, rule);
208
+ return next;
209
+ }
170
210
 
211
+ /**
212
+ * Advance a date to the next occurrence, mutating it in place.
213
+ * Used by expandEvent to avoid one Date allocation per step.
214
+ * @param {Date} next - Date to advance (mutated)
215
+ * @param {Object} rule - Recurrence rule object
216
+ * @private
217
+ */
218
+ static _advanceInPlace(next, rule) {
171
219
  switch (rule.freq) {
172
220
  case 'SECONDLY':
173
221
  next.setSeconds(next.getSeconds() + rule.interval);
@@ -187,21 +235,17 @@ export class RecurrenceEngine {
187
235
 
188
236
  case 'WEEKLY':
189
237
  if (rule.byDay && rule.byDay.length > 0) {
190
- // Find next day that matches byDay
191
- // Limit iterations to prevent infinite loop with malformed byDay
192
- const maxIterations = 8; // 7 days + 1 for safety
193
- let iterations = 0;
194
- const originalDate = next.getDate();
195
- next.setDate(next.getDate() + 1);
196
- while (!this.matchesByDay(next, rule.byDay) && iterations < maxIterations) {
197
- next.setDate(next.getDate() + 1);
198
- iterations++;
199
- }
200
- // If no match found, fall back to simple weekly interval from original date
201
- if (iterations >= maxIterations) {
238
+ // Jump straight to the next matching weekday using a delta table
239
+ // precompiled once per rule instead of stepping day by day
240
+ const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
241
+ if (daySet.size > 0) {
242
+ const deltas =
243
+ rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
244
+ next.setDate(next.getDate() + deltas[next.getDay()]);
245
+ } else {
246
+ // No valid day codes: fall back to simple weekly interval
202
247
  console.warn('RecurrenceEngine: Invalid byDay rule, falling back to weekly interval');
203
- // Reset to original and add weekly interval
204
- next.setDate(originalDate + 7 * rule.interval);
248
+ next.setDate(next.getDate() + 7 * rule.interval);
205
249
  }
206
250
  } else {
207
251
  // Simple weekly recurrence
@@ -215,7 +259,7 @@ export class RecurrenceEngine {
215
259
  const currentMonth = next.getMonth();
216
260
  next.setMonth(currentMonth + rule.interval);
217
261
  // Clamp to last day of month if day doesn't exist
218
- const daysInMonth = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
262
+ const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
219
263
  next.setDate(Math.min(rule.byMonthDay[0], daysInMonth));
220
264
  } else if (rule.byDay && rule.byDay.length > 0) {
221
265
  // Specific weekday of month (e.g., "2nd Tuesday")
@@ -246,8 +290,58 @@ export class RecurrenceEngine {
246
290
  // Unsupported frequency
247
291
  next.setTime(next.getTime() + 24 * 60 * 60 * 1000); // Daily fallback
248
292
  }
293
+ }
249
294
 
250
- return next;
295
+ /**
296
+ * Days to add from each weekday (index 0-6) to reach the next weekday
297
+ * present in the given set
298
+ * @param {Set<number>} daySet - Non-empty set of weekday numbers
299
+ * @returns {number[]} Delta table indexed by Date#getDay()
300
+ * @private
301
+ */
302
+ static _buildByDayDeltas(daySet) {
303
+ const deltas = new Array(7);
304
+ for (let dow = 0; dow < 7; dow++) {
305
+ for (let d = 1; d <= 7; d++) {
306
+ if (daySet.has((dow + d) % 7)) {
307
+ deltas[dow] = d;
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ return deltas;
313
+ }
314
+
315
+ /**
316
+ * Number of days in a month without allocating a Date
317
+ * @param {number} year - Full year
318
+ * @param {number} month - Month index (0-11)
319
+ * @returns {number}
320
+ * @private
321
+ */
322
+ static _daysInMonth(year, month) {
323
+ if (month === 1) {
324
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
325
+ }
326
+ return DAYS_IN_MONTH[month];
327
+ }
328
+
329
+ /**
330
+ * Build a Set of numeric weekdays (0-6) from BYDAY codes
331
+ * @param {Array<string>} byDay - Array of day codes (e.g., ['MO', '2TU'])
332
+ * @returns {Set<number>} Weekday numbers; invalid codes are skipped
333
+ * @private
334
+ */
335
+ static _buildByDaySet(byDay) {
336
+ const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
337
+ const set = new Set();
338
+ for (const day of byDay) {
339
+ const match = /^(-?\d+)?([A-Z]{2})$/.exec(day);
340
+ if (match && dayMap[match[2]] !== undefined) {
341
+ set.add(dayMap[match[2]]);
342
+ }
343
+ }
344
+ return set;
251
345
  }
252
346
 
253
347
  /**
@@ -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.69';
35
+ export const VERSION = '2.2.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) {