@forcecalendar/core 2.3.0 → 2.5.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.
@@ -4,12 +4,23 @@
4
4
  */
5
5
 
6
6
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
7
+ import { RecurrenceEngine } from './RecurrenceEngine.js';
7
8
  import { RRuleParser } from './RRuleParser.js';
8
9
 
10
+ const DAY = 86400000;
11
+
12
+ // How far ahead of the iteration cursor DST transitions are scanned at a time
13
+ const DST_SCAN_CHUNK = 100 * DAY;
14
+
9
15
  export class RecurrenceEngineV2 {
10
16
  // Hard limit to prevent resource exhaustion regardless of caller input
11
17
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
18
 
19
+ // Hard limit on expansion loop iterations (every occurrence stepped
20
+ // through, inside the range or not) so a rule that cannot be seeked
21
+ // arithmetically still terminates in bounded time
22
+ static MAX_ITERATIONS_HARD_LIMIT = 100000;
23
+
13
24
  constructor() {
14
25
  // Use singleton to share cache across all components
15
26
  this.tzManager = TimezoneManager.getInstance();
@@ -27,11 +38,23 @@ export class RecurrenceEngineV2 {
27
38
 
28
39
  /**
29
40
  * Expand recurring event with advanced handling
30
- * @param {Event} event - Recurring event
41
+ *
42
+ * Occurrences before rangeStart are skipped without being generated:
43
+ * daily, weekly, hourly and minutely rules seek straight to the range, so
44
+ * a series that started years before the queried window is expanded at
45
+ * the same cost as one that started yesterday.
46
+ *
47
+ * @param {import('./Event.js').Event} event - Recurring event
31
48
  * @param {Date} rangeStart - Start of expansion range
32
49
  * @param {Date} rangeEnd - End of expansion range
33
50
  * @param {Object} options - Expansion options
34
- * @returns {Array} Expanded occurrences
51
+ * @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
52
+ * return. Only occurrences inside the range count towards this limit.
53
+ * @param {boolean} [options.includeModified=true] - Apply stored instance modifications
54
+ * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
55
+ * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
56
+ * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
57
+ * @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences
35
58
  */
36
59
  expandEvent(event, rangeStart, rangeEnd, options = {}) {
37
60
  const {
@@ -59,7 +82,8 @@ export class RecurrenceEngineV2 {
59
82
  const occurrences = [];
60
83
  const duration = event.end - event.start;
61
84
 
62
- // Initialize expansion state
85
+ // Initialize expansion state. `count` is the number of steps taken from
86
+ // DTSTART, which is what RFC 5545 COUNT measures.
63
87
  const state = {
64
88
  currentDate: new Date(event.start),
65
89
  count: 0,
@@ -73,43 +97,26 @@ export class RecurrenceEngineV2 {
73
97
  state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
74
98
  }
75
99
 
100
+ this.seekToRange(state, rule, rangeStart, rangeEnd, timezone);
101
+
76
102
  // Expand occurrences
77
- while (state.currentDate <= rangeEnd && state.count < maxOccurrences) {
103
+ let iterations = 0;
104
+ while (
105
+ state.currentDate <= rangeEnd &&
106
+ occurrences.length < maxOccurrences &&
107
+ iterations < RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT
108
+ ) {
109
+ iterations++;
78
110
  if (state.currentDate >= rangeStart) {
79
- const occurrence = this.generateOccurrence(
111
+ const occurrence = this._applyOverrides(
80
112
  event,
81
- state.currentDate,
82
- duration,
83
- timezone,
84
- state
113
+ this.generateOccurrence(event, state.currentDate, duration, timezone, state),
114
+ rule,
115
+ includeCancelled,
116
+ includeModified
85
117
  );
86
-
87
- // Check exceptions and modifications
88
118
  if (occurrence) {
89
- let shouldInclude = true;
90
-
91
- // Skip if exception
92
- if (this.isException(event.id, occurrence.start, rule)) {
93
- if (!includeCancelled) {
94
- shouldInclude = false;
95
- } else {
96
- occurrence.status = 'cancelled';
97
- occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
98
- }
99
- }
100
-
101
- // Apply modifications if any
102
- if (shouldInclude && includeModified) {
103
- const modified = this.getModifiedInstance(event.id, occurrence.start);
104
- if (modified) {
105
- Object.assign(occurrence, modified);
106
- occurrence.isModified = true;
107
- }
108
- }
109
-
110
- if (shouldInclude) {
111
- occurrences.push(occurrence);
112
- }
119
+ occurrences.push(occurrence);
113
120
  }
114
121
  }
115
122
 
@@ -144,6 +151,313 @@ export class RecurrenceEngineV2 {
144
151
  return this.cloneOccurrences(occurrences);
145
152
  }
146
153
 
154
+ /**
155
+ * Lazily iterate the occurrences of an event in chronological order.
156
+ *
157
+ * Yields what expandEvent returns for the window, one occurrence at a
158
+ * time and without the expansion cache: stored instance modifications
159
+ * and exceptions are applied as each occurrence is produced, so changes
160
+ * made through addModifiedInstance or addException are visible on the
161
+ * next pull. Rules seekToRange can seek (plain daily and weekly, hourly,
162
+ * minutely) jump straight to `after`, and DST transitions are scanned
163
+ * lazily ahead of the cursor instead of for the whole window up front.
164
+ *
165
+ * Both bounds are exclusive unless `inclusive` is set: an occurrence that
166
+ * starts exactly at `after` or `before` is skipped by default, so
167
+ * iterating from a known occurrence's start continues the series without
168
+ * repeating it; with `inclusive: true` the window is closed on both ends
169
+ * like expandEvent's range. A non-recurring event yields its single
170
+ * occurrence when it falls inside the window. Iteration ends at COUNT or
171
+ * UNTIL, at `before`, or — as a guard for rules that produce no
172
+ * occurrences — after MAX_ITERATIONS_HARD_LIMIT consecutive steps
173
+ * without one. The generator is single-use; call again for a fresh one.
174
+ *
175
+ * @example
176
+ * const engine = new RecurrenceEngineV2();
177
+ * for (const occurrence of engine.iterateOccurrences(event, { after: new Date() })) {
178
+ * if (occurrence.start > deadline) break;
179
+ * schedule(occurrence);
180
+ * }
181
+ *
182
+ * @param {import('./Event.js').Event} event - The event to iterate
183
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
184
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
185
+ * @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
186
+ */
187
+ iterateOccurrences(event, options = {}) {
188
+ const window = RecurrenceEngine._occurrenceWindow(options);
189
+ if (!event.recurring || !event.recurrenceRule) {
190
+ return this._iterateSingle(event, window);
191
+ }
192
+ const {
193
+ includeModified = true,
194
+ includeCancelled = false,
195
+ timezone = event.timeZone || 'UTC',
196
+ handleDST = true
197
+ } = options;
198
+ return this._iterateRule(event, RRuleParser.parse(event.recurrenceRule), window, {
199
+ includeModified,
200
+ includeCancelled,
201
+ timezone,
202
+ handleDST
203
+ });
204
+ }
205
+
206
+ /**
207
+ * First occurrence of an event after an instant, or null when the series
208
+ * has no occurrence after it. `after` is exclusive unless
209
+ * `options.inclusive` is set, so passing the start of a known occurrence
210
+ * returns the one that follows it.
211
+ *
212
+ * @example
213
+ * const upcoming = engine.nextOccurrence(event, new Date());
214
+ *
215
+ * @param {import('./Event.js').Event} event - The event to query
216
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
217
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
218
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
219
+ */
220
+ nextOccurrence(event, after = null, options = {}) {
221
+ for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
222
+ return occurrence;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * The first `count` occurrences of an event inside a window, generated
229
+ * lazily so an open-ended series costs only the occurrences taken.
230
+ * `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
231
+ * when the series or the window ends first.
232
+ *
233
+ * @example
234
+ * const nextFive = engine.takeOccurrences(event, 5, { after: new Date() });
235
+ *
236
+ * @param {import('./Event.js').Event} event - The event to query
237
+ * @param {number} count - Maximum number of occurrences to return
238
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
239
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
240
+ */
241
+ takeOccurrences(event, count, options = {}) {
242
+ const limit = Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
243
+ const taken = [];
244
+ if (!(limit > 0)) {
245
+ return taken;
246
+ }
247
+ for (const occurrence of this.iterateOccurrences(event, options)) {
248
+ taken.push(occurrence);
249
+ if (taken.length >= limit) {
250
+ break;
251
+ }
252
+ }
253
+ return taken;
254
+ }
255
+
256
+ /**
257
+ * Yield a non-recurring event's single occurrence if it starts inside
258
+ * the window
259
+ * @param {import('./Event.js').Event} event - The event
260
+ * @param {{ startMs: number, endMs: number }} window - Inclusive bounds
261
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>}
262
+ * @private
263
+ */
264
+ *_iterateSingle(event, window) {
265
+ const ms = new Date(event.start).getTime();
266
+ if (ms >= window.startMs && ms <= window.endMs) {
267
+ yield this.cloneOccurrence(this.createOccurrence(event, event.start, event.end));
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Lazy counterpart of the expandEvent loop: seeks to the window, then
273
+ * steps the cursor and yields each in-window occurrence with the same
274
+ * DST adjustment, exception handling and instance modifications.
275
+ * @private
276
+ */
277
+ *_iterateRule(event, rule, window, options) {
278
+ const { includeModified, includeCancelled, timezone, handleDST } = options;
279
+ const duration = event.end - event.start;
280
+ const state = {
281
+ currentDate: new Date(event.start),
282
+ count: 0,
283
+ tzOffsets: new Map(),
284
+ dstTransitions: [],
285
+ stuckIterations: 0
286
+ };
287
+ if (Number.isNaN(state.currentDate.getTime()) || window.startMs > window.endMs) {
288
+ return;
289
+ }
290
+
291
+ // DST transitions are found on the same day grid expandEvent walks,
292
+ // starting from the window start (DTSTART for an open window) and
293
+ // extended in chunks ahead of the cursor
294
+ let dstScan = null;
295
+ if (handleDST) {
296
+ const scanStart = Number.isFinite(window.startMs)
297
+ ? window.startMs
298
+ : state.currentDate.getTime();
299
+ dstScan = { cursor: new Date(scanStart), lastOffset: 0 };
300
+ dstScan.lastOffset = this.tzManager.getTimezoneOffset(dstScan.cursor, timezone);
301
+ }
302
+
303
+ if (Number.isFinite(window.startMs)) {
304
+ const rangeStart = new Date(window.startMs);
305
+ this.seekToRange(state, rule, rangeStart, rangeStart, timezone);
306
+ }
307
+
308
+ let idleSteps = 0;
309
+ while (state.currentDate.getTime() <= window.endMs) {
310
+ const currentMs = state.currentDate.getTime();
311
+ if (currentMs >= window.startMs) {
312
+ if (dstScan) {
313
+ this._scanDSTTransitions(
314
+ dstScan,
315
+ state.dstTransitions,
316
+ Math.min(currentMs + DST_SCAN_CHUNK, window.endMs),
317
+ timezone
318
+ );
319
+ }
320
+ const occurrence = this._applyOverrides(
321
+ event,
322
+ this.generateOccurrence(event, state.currentDate, duration, timezone, state),
323
+ rule,
324
+ includeCancelled,
325
+ includeModified
326
+ );
327
+ if (occurrence) {
328
+ idleSteps = 0;
329
+ yield this.cloneOccurrence(occurrence);
330
+ }
331
+ }
332
+
333
+ state.currentDate = this.getNextDate(state.currentDate, rule, timezone, state);
334
+ state.count++;
335
+
336
+ if (state.currentDate.getTime() <= currentMs) {
337
+ state.stuckIterations++;
338
+ if (state.stuckIterations >= 3) {
339
+ return;
340
+ }
341
+ } else {
342
+ state.stuckIterations = 0;
343
+ }
344
+
345
+ if (rule.count && state.count >= rule.count) {
346
+ return;
347
+ }
348
+ if (rule.until && state.currentDate > rule.until) {
349
+ return;
350
+ }
351
+ idleSteps++;
352
+ if (idleSteps >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT) {
353
+ return;
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Apply exceptions and stored instance modifications to a generated
360
+ * occurrence
361
+ * @param {import('./Event.js').Event} event - The recurring event
362
+ * @param {Object} occurrence - Occurrence from generateOccurrence
363
+ * @param {Object} rule - Parsed recurrence rule
364
+ * @param {boolean} includeCancelled - Return exception dates as cancelled occurrences
365
+ * @param {boolean} includeModified - Apply stored instance modifications
366
+ * @returns {Object|null} The occurrence, or null when it is excluded
367
+ * @private
368
+ */
369
+ _applyOverrides(event, occurrence, rule, includeCancelled, includeModified) {
370
+ if (!occurrence) {
371
+ return null;
372
+ }
373
+ if (this.isException(event.id, occurrence.start, rule)) {
374
+ if (!includeCancelled) {
375
+ return null;
376
+ }
377
+ occurrence.status = 'cancelled';
378
+ occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
379
+ }
380
+ if (includeModified) {
381
+ const modified = this.getModifiedInstance(event.id, occurrence.start);
382
+ if (modified) {
383
+ Object.assign(occurrence, modified);
384
+ occurrence.isModified = true;
385
+ }
386
+ }
387
+ return occurrence;
388
+ }
389
+
390
+ /**
391
+ * Move the expansion cursor to the last occurrence before the range
392
+ * without stepping through every occurrence in between.
393
+ *
394
+ * Applies to rules whose step is a fixed duration between system-timezone
395
+ * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
396
+ * crosses a transition is taken with getNextDate so the result is exactly
397
+ * what stepping from DTSTART would produce. Never seeks past UNTIL, and
398
+ * counts skipped steps against COUNT.
399
+ *
400
+ * @param {Object} state - Expansion state (currentDate and count are updated)
401
+ * @param {Object} rule - Parsed recurrence rule
402
+ * @param {Date} rangeStart - Start of expansion range
403
+ * @param {Date} rangeEnd - End of expansion range
404
+ * @param {string} timezone - Expansion timezone
405
+ */
406
+ seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
407
+ const stepMs = this.getFixedStepMs(rule);
408
+ if (stepMs <= 0) {
409
+ return;
410
+ }
411
+ let targetMs = rangeStart.getTime();
412
+ if (rule.until) {
413
+ // Rule objects may carry UNTIL as a string; an unparseable value
414
+ // compares false and leaves the target alone
415
+ const untilMs = new Date(rule.until).getTime();
416
+ if (untilMs < targetMs) {
417
+ targetMs = untilMs;
418
+ }
419
+ }
420
+ const fromMs = state.currentDate.getTime();
421
+ if (!(fromMs < targetMs)) {
422
+ return;
423
+ }
424
+ const seek = RecurrenceEngine._seekFixedStep(
425
+ fromMs,
426
+ targetMs,
427
+ rangeEnd.getTime(),
428
+ stepMs,
429
+ rule.count ? rule.count - 1 : Infinity,
430
+ cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime())
431
+ );
432
+ state.currentDate = new Date(seek.ms);
433
+ state.count = seek.steps;
434
+ }
435
+
436
+ /**
437
+ * Milliseconds per step for rules getNextDate advances by a fixed
438
+ * duration while the system UTC offset is constant
439
+ * @param {Object} rule - Parsed recurrence rule
440
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
441
+ */
442
+ getFixedStepMs(rule) {
443
+ const interval = rule.interval;
444
+ if (!Number.isInteger(interval) || interval <= 0) {
445
+ return 0;
446
+ }
447
+ switch (rule.freq) {
448
+ case 'DAILY':
449
+ return rule.byHour && rule.byHour.length > 0 ? 0 : interval * DAY;
450
+ case 'WEEKLY':
451
+ return rule.byDay && rule.byDay.length > 0 ? 0 : 7 * interval * DAY;
452
+ case 'HOURLY':
453
+ return interval * 3600000;
454
+ case 'MINUTELY':
455
+ return interval * 60000;
456
+ default:
457
+ return 0;
458
+ }
459
+ }
460
+
147
461
  /**
148
462
  * Generate a single occurrence with timezone handling
149
463
  */
@@ -473,28 +787,38 @@ export class RecurrenceEngineV2 {
473
787
  */
474
788
  findDSTTransitions(start, end, timezone) {
475
789
  const transitions = [];
476
- const current = new Date(start);
477
-
478
- // Check each day for offset changes
479
- let lastOffset = this.tzManager.getTimezoneOffset(current, timezone);
790
+ const scan = { cursor: new Date(start), lastOffset: 0 };
791
+ scan.lastOffset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
792
+ this._scanDSTTransitions(scan, transitions, new Date(end).getTime(), timezone);
793
+ return transitions;
794
+ }
480
795
 
481
- while (current <= end) {
482
- const offset = this.tzManager.getTimezoneOffset(current, timezone);
796
+ /**
797
+ * Walk the scan cursor one day at a time up to untilMs, appending each
798
+ * offset change. The cursor and last offset persist in `scan`, so the
799
+ * walk can be resumed later on the same day grid.
800
+ * @param {{ cursor: Date, lastOffset: number }} scan - Resumable scan position (mutated)
801
+ * @param {Array} transitions - Transition list to append to
802
+ * @param {number} untilMs - Scan through this timestamp (inclusive)
803
+ * @param {string} timezone - Timezone to probe
804
+ * @private
805
+ */
806
+ _scanDSTTransitions(scan, transitions, untilMs, timezone) {
807
+ while (scan.cursor.getTime() <= untilMs) {
808
+ const offset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
483
809
 
484
- if (offset !== lastOffset) {
810
+ if (offset !== scan.lastOffset) {
485
811
  transitions.push({
486
- date: new Date(current),
487
- oldOffset: lastOffset,
812
+ date: new Date(scan.cursor),
813
+ oldOffset: scan.lastOffset,
488
814
  newOffset: offset,
489
- type: offset < lastOffset ? 'spring-forward' : 'fall-back'
815
+ type: offset < scan.lastOffset ? 'spring-forward' : 'fall-back'
490
816
  });
491
817
  }
492
818
 
493
- lastOffset = offset;
494
- current.setDate(current.getDate() + 1);
819
+ scan.lastOffset = offset;
820
+ scan.cursor.setDate(scan.cursor.getDate() + 1);
495
821
  }
496
-
497
- return transitions;
498
822
  }
499
823
 
500
824
  /**
@@ -642,7 +966,16 @@ export class RecurrenceEngineV2 {
642
966
  * Clone occurrence results before returning or caching.
643
967
  */
644
968
  cloneOccurrences(occurrences) {
645
- return occurrences.map(occurrence => ({
969
+ return occurrences.map(occurrence => this.cloneOccurrence(occurrence));
970
+ }
971
+
972
+ /**
973
+ * Clone a single occurrence, copying its Date and array fields.
974
+ * @param {import('../types.js').ExpandedOccurrence} occurrence - Occurrence to clone
975
+ * @returns {import('../types.js').ExpandedOccurrence} Independent copy
976
+ */
977
+ cloneOccurrence(occurrence) {
978
+ return {
646
979
  ...occurrence,
647
980
  start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
648
981
  end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
@@ -654,7 +987,7 @@ export class RecurrenceEngineV2 {
654
987
  categories: Array.isArray(occurrence.categories)
655
988
  ? [...occurrence.categories]
656
989
  : occurrence.categories
657
- }));
990
+ };
658
991
  }
659
992
 
660
993
  /**
package/core/index.js CHANGED
@@ -32,7 +32,7 @@ export { ConflictDetector } from './conflicts/ConflictDetector.js';
32
32
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
33
33
 
34
34
  // Version — keep in sync with package.json
35
- export const VERSION = '2.3.0';
35
+ export const VERSION = '2.5.0';
36
36
 
37
37
  // Default export
38
38
  export { Calendar as default } from './calendar/Calendar.js';
@@ -56,29 +56,38 @@ export class EnhancedCalendar extends Calendar {
56
56
 
57
57
  /**
58
58
  * Get events with enhanced recurrence expansion
59
+ *
60
+ * Regular events overlapping the range are returned as stored. Every
61
+ * recurring series in the store is expanded with this calendar's
62
+ * RecurrenceEngineV2 (so instance modifications and cancellations apply),
63
+ * including series that started before the range. Occurrences are the
64
+ * engine's plain occurrence objects, with the id `<masterId>_<startMs>`
65
+ * (see `Event.occurrenceId`), `recurringEventId`, `isOccurrence: true` and
66
+ * `occurrenceStart`.
59
67
  */
60
68
  getEventsInRange(startDate, endDate, options = {}) {
61
69
  const startTime = performance.now();
70
+ const rangeStart = new Date(startDate);
71
+ const rangeEnd = new Date(endDate);
62
72
 
63
- const regularEvents = [];
64
- const recurringEvents = [];
73
+ // Recurring masters are represented by their occurrences below
74
+ const regularEvents = this.eventStore
75
+ .getEventsInRange(rangeStart, rangeEnd, false)
76
+ .filter(event => !event.recurring);
65
77
 
66
- // Separate regular and recurring events
67
- const allEvents = this.eventStore.getEventsInRange(startDate, endDate, false);
68
-
69
- for (const event of allEvents) {
70
- if (event.recurring) {
71
- recurringEvents.push(event);
72
- } else {
73
- regularEvents.push(event);
74
- }
75
- }
78
+ // A series that started before the range can still occur inside it, so
79
+ // every recurring series is a candidate; the engine selects by range.
80
+ const recurringEvents = this.eventStore.queryEvents({ recurring: true });
76
81
 
77
82
  // Expand recurring events with enhanced engine
78
83
  const expandedOccurrences = [];
79
84
 
80
85
  for (const event of recurringEvents) {
81
- const occurrences = this.recurrenceEngine.expandEvent(event, startDate, endDate, {
86
+ // Look back one event duration so occurrences that began before the
87
+ // range but overlap it are found
88
+ const duration = Math.max(0, event.end - event.start);
89
+ const expandStart = new Date(rangeStart.getTime() - duration);
90
+ const occurrences = this.recurrenceEngine.expandEvent(event, expandStart, rangeEnd, {
82
91
  maxOccurrences: options.maxOccurrences || 365,
83
92
  includeModified: options.includeModified !== false,
84
93
  includeCancelled: options.includeCancelled || false,
@@ -86,7 +95,16 @@ export class EnhancedCalendar extends Calendar {
86
95
  handleDST: options.handleDST !== false
87
96
  });
88
97
 
89
- expandedOccurrences.push(...occurrences);
98
+ for (const occurrence of occurrences) {
99
+ if (occurrence.end < rangeStart || occurrence.start > rangeEnd) {
100
+ continue;
101
+ }
102
+ expandedOccurrences.push({
103
+ ...occurrence,
104
+ isOccurrence: true,
105
+ occurrenceStart: new Date(occurrence.start)
106
+ });
107
+ }
90
108
  }
91
109
 
92
110
  const endTime = performance.now();
@@ -129,6 +147,62 @@ export class EnhancedCalendar extends Calendar {
129
147
  });
130
148
  }
131
149
 
150
+ /**
151
+ * Lazily iterate the occurrences of an event through the enhanced
152
+ * engine, so occurrences changed with modifyOccurrence or cancelled with
153
+ * cancelOccurrence are reflected. Same semantics as
154
+ * Calendar#iterateOccurrences.
155
+ * @param {string} eventId - The event ID
156
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
157
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
158
+ * @throws {Error} If no event with the ID exists
159
+ */
160
+ iterateOccurrences(eventId, options = {}) {
161
+ const query = this._occurrenceQuery(eventId, options);
162
+ return this.recurrenceEngine.iterateOccurrences(query.event, query.options);
163
+ }
164
+
165
+ /**
166
+ * First occurrence of an event after an instant through the enhanced
167
+ * engine, or null. Same semantics as Calendar#getNextOccurrence.
168
+ * @param {string} eventId - The event ID
169
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
170
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
171
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
172
+ * @throws {Error} If no event with the ID exists
173
+ */
174
+ getNextOccurrence(eventId, after = null, options = {}) {
175
+ const query = this._occurrenceQuery(eventId, options);
176
+ return this.recurrenceEngine.nextOccurrence(query.event, after, query.options);
177
+ }
178
+
179
+ /**
180
+ * The first `count` occurrences of an event through the enhanced
181
+ * engine. Same semantics as Calendar#takeOccurrences.
182
+ * @param {string} eventId - The event ID
183
+ * @param {number} count - Maximum number of occurrences to return
184
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
185
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
186
+ * @throws {Error} If no event with the ID exists
187
+ */
188
+ takeOccurrences(eventId, count, options = {}) {
189
+ const query = this._occurrenceQuery(eventId, options);
190
+ return this.recurrenceEngine.takeOccurrences(query.event, count, query.options);
191
+ }
192
+
193
+ /**
194
+ * Resolve an occurrence query to the stored event and its options, with
195
+ * the timezone defaulted as getEventsInRange does
196
+ * @private
197
+ */
198
+ _occurrenceQuery(eventId, options) {
199
+ const event = this.eventStore.getEvent(eventId);
200
+ if (!event) {
201
+ throw new Error(`Event with id ${eventId} not found`);
202
+ }
203
+ return { event, options: { ...options, timezone: options.timezone || event.timeZone } };
204
+ }
205
+
132
206
  /**
133
207
  * Bulk operations for recurring events
134
208
  */