@forcecalendar/core 2.4.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.
@@ -9,6 +9,9 @@ import { RRuleParser } from './RRuleParser.js';
9
9
 
10
10
  const DAY = 86400000;
11
11
 
12
+ // How far ahead of the iteration cursor DST transitions are scanned at a time
13
+ const DST_SCAN_CHUNK = 100 * DAY;
14
+
12
15
  export class RecurrenceEngineV2 {
13
16
  // Hard limit to prevent resource exhaustion regardless of caller input
14
17
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
@@ -51,7 +54,7 @@ export class RecurrenceEngineV2 {
51
54
  * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
52
55
  * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
53
56
  * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
54
- * @returns {Array} Expanded occurrences
57
+ * @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences
55
58
  */
56
59
  expandEvent(event, rangeStart, rangeEnd, options = {}) {
57
60
  const {
@@ -105,40 +108,15 @@ export class RecurrenceEngineV2 {
105
108
  ) {
106
109
  iterations++;
107
110
  if (state.currentDate >= rangeStart) {
108
- const occurrence = this.generateOccurrence(
111
+ const occurrence = this._applyOverrides(
109
112
  event,
110
- state.currentDate,
111
- duration,
112
- timezone,
113
- state
113
+ this.generateOccurrence(event, state.currentDate, duration, timezone, state),
114
+ rule,
115
+ includeCancelled,
116
+ includeModified
114
117
  );
115
-
116
- // Check exceptions and modifications
117
118
  if (occurrence) {
118
- let shouldInclude = true;
119
-
120
- // Skip if exception
121
- if (this.isException(event.id, occurrence.start, rule)) {
122
- if (!includeCancelled) {
123
- shouldInclude = false;
124
- } else {
125
- occurrence.status = 'cancelled';
126
- occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
127
- }
128
- }
129
-
130
- // Apply modifications if any
131
- if (shouldInclude && includeModified) {
132
- const modified = this.getModifiedInstance(event.id, occurrence.start);
133
- if (modified) {
134
- Object.assign(occurrence, modified);
135
- occurrence.isModified = true;
136
- }
137
- }
138
-
139
- if (shouldInclude) {
140
- occurrences.push(occurrence);
141
- }
119
+ occurrences.push(occurrence);
142
120
  }
143
121
  }
144
122
 
@@ -173,6 +151,242 @@ export class RecurrenceEngineV2 {
173
151
  return this.cloneOccurrences(occurrences);
174
152
  }
175
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
+
176
390
  /**
177
391
  * Move the expansion cursor to the last occurrence before the range
178
392
  * without stepping through every occurrence in between.
@@ -573,28 +787,38 @@ export class RecurrenceEngineV2 {
573
787
  */
574
788
  findDSTTransitions(start, end, timezone) {
575
789
  const transitions = [];
576
- const current = new Date(start);
577
-
578
- // Check each day for offset changes
579
- 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
+ }
580
795
 
581
- while (current <= end) {
582
- 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);
583
809
 
584
- if (offset !== lastOffset) {
810
+ if (offset !== scan.lastOffset) {
585
811
  transitions.push({
586
- date: new Date(current),
587
- oldOffset: lastOffset,
812
+ date: new Date(scan.cursor),
813
+ oldOffset: scan.lastOffset,
588
814
  newOffset: offset,
589
- type: offset < lastOffset ? 'spring-forward' : 'fall-back'
815
+ type: offset < scan.lastOffset ? 'spring-forward' : 'fall-back'
590
816
  });
591
817
  }
592
818
 
593
- lastOffset = offset;
594
- current.setDate(current.getDate() + 1);
819
+ scan.lastOffset = offset;
820
+ scan.cursor.setDate(scan.cursor.getDate() + 1);
595
821
  }
596
-
597
- return transitions;
598
822
  }
599
823
 
600
824
  /**
@@ -742,7 +966,16 @@ export class RecurrenceEngineV2 {
742
966
  * Clone occurrence results before returning or caching.
743
967
  */
744
968
  cloneOccurrences(occurrences) {
745
- 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 {
746
979
  ...occurrence,
747
980
  start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
748
981
  end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
@@ -754,7 +987,7 @@ export class RecurrenceEngineV2 {
754
987
  categories: Array.isArray(occurrence.categories)
755
988
  ? [...occurrence.categories]
756
989
  : occurrence.categories
757
- }));
990
+ };
758
991
  }
759
992
 
760
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.4.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
  */
package/core/types.js CHANGED
@@ -346,6 +346,58 @@
346
346
  * @property {Date} start - Occurrence start date
347
347
  * @property {Date} end - Occurrence end date
348
348
  * @property {string} recurringEventId - ID of the parent recurring event
349
+ * @property {string} [timezone] - Timezone the occurrence was expanded in
350
+ * @property {Date} [originalStart] - Start of the series (DTSTART)
351
+ */
352
+
353
+ /**
354
+ * Window for lazy occurrence iteration. Both bounds are exclusive unless
355
+ * `inclusive` is set: an occurrence starting exactly at `after` or `before`
356
+ * is skipped by default, so iterating from a known occurrence's start
357
+ * continues the series without repeating it. Omit a bound to leave that
358
+ * end of the window open.
359
+ * @typedef {Object} OccurrenceIteratorOptions
360
+ * @property {Date|number} [after] - Only occurrences starting after this instant (Date or timestamp)
361
+ * @property {Date|number} [before] - Only occurrences starting before this instant (Date or timestamp)
362
+ * @property {boolean} [inclusive=false] - Treat `after` and `before` as closed bounds
363
+ * @property {string} [timezone] - Timezone for expansion (defaults to the event's)
364
+ */
365
+
366
+ /**
367
+ * Options for lazy occurrence iteration through RecurrenceEngineV2,
368
+ * EventStore and Calendar: the OccurrenceIteratorOptions window plus the
369
+ * expansion switches RecurrenceEngineV2.expandEvent accepts.
370
+ * @typedef {Object} ExpandedOccurrenceIteratorOptions
371
+ * @property {Date|number} [after] - Only occurrences starting after this instant (Date or timestamp)
372
+ * @property {Date|number} [before] - Only occurrences starting before this instant (Date or timestamp)
373
+ * @property {boolean} [inclusive=false] - Treat `after` and `before` as closed bounds
374
+ * @property {string} [timezone] - Timezone for expansion (defaults to the event's)
375
+ * @property {boolean} [includeModified=true] - Apply stored instance modifications
376
+ * @property {boolean} [includeCancelled=false] - Yield exception dates as cancelled occurrences
377
+ * @property {boolean} [handleDST=true] - Adjust occurrences across DST transitions
378
+ */
379
+
380
+ /**
381
+ * Occurrence produced by RecurrenceEngineV2 (and therefore by EventStore
382
+ * and Calendar occurrence queries)
383
+ * @typedef {Object} ExpandedOccurrence
384
+ * @property {string} id - Occurrence ID (`<eventId>_<startTimestamp>` for recurring events)
385
+ * @property {string} [recurringEventId] - ID of the parent recurring event
386
+ * @property {string} title - Event title
387
+ * @property {Date} start - Occurrence start date
388
+ * @property {Date} end - Occurrence end date
389
+ * @property {Date} [startUTC] - Occurrence start in UTC
390
+ * @property {Date} [endUTC] - Occurrence end in UTC
391
+ * @property {string} timezone - Timezone the occurrence was expanded in
392
+ * @property {Date} [originalStart] - Start of the series (DTSTART)
393
+ * @property {boolean} allDay - Whether the event is all-day
394
+ * @property {string} [description] - Event description
395
+ * @property {string} [location] - Event location
396
+ * @property {string[]} [categories] - Event categories
397
+ * @property {EventStatus} [status] - 'confirmed', or 'cancelled' for exception dates yielded with includeCancelled
398
+ * @property {string} [cancellationReason] - Reason recorded for a cancelled occurrence
399
+ * @property {boolean} isRecurring - Whether the occurrence belongs to a recurring series
400
+ * @property {boolean} [isModified] - Whether a stored instance modification was applied
349
401
  */
350
402
 
351
403
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",