@forcecalendar/core 2.4.0 → 2.5.1

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.
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.1';
36
36
 
37
37
  // Default export
38
38
  export { Calendar as default } from './calendar/Calendar.js';
@@ -24,6 +24,40 @@ export class EnhancedCalendar extends Calendar {
24
24
 
25
25
  // Setup event listeners for real-time indexing
26
26
  this.setupRealtimeIndexing();
27
+
28
+ // The enhanced engine keeps its own expansion cache, so drop the entries
29
+ // of every series the store changes
30
+ this._unsubscribeCacheInvalidation = this.eventStore.subscribe(change =>
31
+ this._invalidateOccurrenceCache(change)
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Invalidate the enhanced engine's cached expansions for a store change
37
+ * @param {import('../types.js').EventStoreChange} change - Store change
38
+ * @private
39
+ */
40
+ _invalidateOccurrenceCache(change) {
41
+ const engine = this.recurrenceEngine;
42
+ if (!engine || !change) {
43
+ return;
44
+ }
45
+ switch (change.type) {
46
+ case 'add':
47
+ case 'update':
48
+ case 'remove':
49
+ if (change.event) {
50
+ engine.clearEventCache(change.event.id);
51
+ }
52
+ break;
53
+ case 'batch':
54
+ for (const entry of change.changes || []) {
55
+ this._invalidateOccurrenceCache(entry);
56
+ }
57
+ break;
58
+ default:
59
+ engine.occurrenceCache.clear();
60
+ }
27
61
  }
28
62
 
29
63
  /**
@@ -56,29 +90,38 @@ export class EnhancedCalendar extends Calendar {
56
90
 
57
91
  /**
58
92
  * Get events with enhanced recurrence expansion
93
+ *
94
+ * Regular events overlapping the range are returned as stored. Every
95
+ * recurring series in the store is expanded with this calendar's
96
+ * RecurrenceEngineV2 (so instance modifications and cancellations apply),
97
+ * including series that started before the range. Occurrences are the
98
+ * engine's plain occurrence objects, with the id `<masterId>_<startMs>`
99
+ * (see `Event.occurrenceId`), `recurringEventId`, `isOccurrence: true` and
100
+ * `occurrenceStart`.
59
101
  */
60
102
  getEventsInRange(startDate, endDate, options = {}) {
61
103
  const startTime = performance.now();
104
+ const rangeStart = new Date(startDate);
105
+ const rangeEnd = new Date(endDate);
62
106
 
63
- const regularEvents = [];
64
- const recurringEvents = [];
107
+ // Recurring masters are represented by their occurrences below
108
+ const regularEvents = this.eventStore
109
+ .getEventsInRange(rangeStart, rangeEnd, false)
110
+ .filter(event => !event.recurring);
65
111
 
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
- }
112
+ // A series that started before the range can still occur inside it, so
113
+ // every recurring series is a candidate; the engine selects by range.
114
+ const recurringEvents = this.eventStore.queryEvents({ recurring: true });
76
115
 
77
116
  // Expand recurring events with enhanced engine
78
117
  const expandedOccurrences = [];
79
118
 
80
119
  for (const event of recurringEvents) {
81
- const occurrences = this.recurrenceEngine.expandEvent(event, startDate, endDate, {
120
+ // Look back one event duration so occurrences that began before the
121
+ // range but overlap it are found
122
+ const duration = Math.max(0, event.end - event.start);
123
+ const expandStart = new Date(rangeStart.getTime() - duration);
124
+ const occurrences = this.recurrenceEngine.expandEvent(event, expandStart, rangeEnd, {
82
125
  maxOccurrences: options.maxOccurrences || 365,
83
126
  includeModified: options.includeModified !== false,
84
127
  includeCancelled: options.includeCancelled || false,
@@ -86,7 +129,16 @@ export class EnhancedCalendar extends Calendar {
86
129
  handleDST: options.handleDST !== false
87
130
  });
88
131
 
89
- expandedOccurrences.push(...occurrences);
132
+ for (const occurrence of occurrences) {
133
+ if (occurrence.end < rangeStart || occurrence.start > rangeEnd) {
134
+ continue;
135
+ }
136
+ expandedOccurrences.push({
137
+ ...occurrence,
138
+ isOccurrence: true,
139
+ occurrenceStart: new Date(occurrence.start)
140
+ });
141
+ }
90
142
  }
91
143
 
92
144
  const endTime = performance.now();
@@ -129,6 +181,62 @@ export class EnhancedCalendar extends Calendar {
129
181
  });
130
182
  }
131
183
 
184
+ /**
185
+ * Lazily iterate the occurrences of an event through the enhanced
186
+ * engine, so occurrences changed with modifyOccurrence or cancelled with
187
+ * cancelOccurrence are reflected. Same semantics as
188
+ * Calendar#iterateOccurrences.
189
+ * @param {string} eventId - The event ID
190
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
191
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
192
+ * @throws {Error} If no event with the ID exists
193
+ */
194
+ iterateOccurrences(eventId, options = {}) {
195
+ const query = this._occurrenceQuery(eventId, options);
196
+ return this.recurrenceEngine.iterateOccurrences(query.event, query.options);
197
+ }
198
+
199
+ /**
200
+ * First occurrence of an event after an instant through the enhanced
201
+ * engine, or null. Same semantics as Calendar#getNextOccurrence.
202
+ * @param {string} eventId - The event ID
203
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
204
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
205
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
206
+ * @throws {Error} If no event with the ID exists
207
+ */
208
+ getNextOccurrence(eventId, after = null, options = {}) {
209
+ const query = this._occurrenceQuery(eventId, options);
210
+ return this.recurrenceEngine.nextOccurrence(query.event, after, query.options);
211
+ }
212
+
213
+ /**
214
+ * The first `count` occurrences of an event through the enhanced
215
+ * engine. Same semantics as Calendar#takeOccurrences.
216
+ * @param {string} eventId - The event ID
217
+ * @param {number} count - Maximum number of occurrences to return
218
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
219
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
220
+ * @throws {Error} If no event with the ID exists
221
+ */
222
+ takeOccurrences(eventId, count, options = {}) {
223
+ const query = this._occurrenceQuery(eventId, options);
224
+ return this.recurrenceEngine.takeOccurrences(query.event, count, query.options);
225
+ }
226
+
227
+ /**
228
+ * Resolve an occurrence query to the stored event and its options, with
229
+ * the timezone defaulted as getEventsInRange does
230
+ * @private
231
+ */
232
+ _occurrenceQuery(eventId, options) {
233
+ const event = this.eventStore.getEvent(eventId);
234
+ if (!event) {
235
+ throw new Error(`Event with id ${eventId} not found`);
236
+ }
237
+ return { event, options: { ...options, timezone: options.timezone || event.timeZone } };
238
+ }
239
+
132
240
  /**
133
241
  * Bulk operations for recurring events
134
242
  */
@@ -366,6 +474,10 @@ export class EnhancedCalendar extends Calendar {
366
474
  this._clearReindexTimeout();
367
475
  this._clearReindexTimeout = null;
368
476
  }
477
+ if (typeof this._unsubscribeCacheInvalidation === 'function') {
478
+ this._unsubscribeCacheInvalidation();
479
+ this._unsubscribeCacheInvalidation = null;
480
+ }
369
481
 
370
482
  // Clean up worker
371
483
  if (this.searchManager) {
@@ -10,6 +10,11 @@ import { TimezoneDatabase } from './TimezoneDatabase.js';
10
10
  // Singleton instance for shared use across the application
11
11
  let sharedInstance = null;
12
12
 
13
+ // Timezone databases know only local mean time before the 19th century, so
14
+ // transition scans check a span that ends before this instant at its ends
15
+ // rather than probing it week by week
16
+ const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
17
+
13
18
  export class TimezoneManager {
14
19
  /**
15
20
  * Get the shared singleton instance of TimezoneManager
@@ -215,19 +220,43 @@ export class TimezoneManager {
215
220
  }
216
221
  timezone = this.database.resolveAlias(timezone);
217
222
  let cached = this.transitionCache.get(timezone);
218
- if (!cached || fromMs < cached.from || toMs > cached.to) {
219
- // Extend coverage generously so repeated expansions over the same
220
- // span hit the cache
221
- const from = Math.min(fromMs, cached ? cached.from : fromMs);
222
- const to = Math.max(toMs, cached ? cached.to : toMs);
223
- cached = { from, to, transitions: this._scanTransitions(timezone, from, to) };
223
+ if (!cached) {
224
+ cached = {
225
+ from: fromMs,
226
+ to: toMs,
227
+ transitions: this._scanTransitions(timezone, fromMs, toMs)
228
+ };
224
229
  this.transitionCache.set(timezone, cached);
230
+ } else {
231
+ // Extend coverage incrementally so only the uncovered span is scanned
232
+ if (fromMs < cached.from) {
233
+ cached.transitions = this._scanTransitions(timezone, fromMs, cached.from).concat(
234
+ cached.transitions
235
+ );
236
+ cached.from = fromMs;
237
+ }
238
+ if (toMs > cached.to) {
239
+ cached.transitions = cached.transitions.concat(
240
+ this._scanTransitions(timezone, cached.to, toMs)
241
+ );
242
+ cached.to = toMs;
243
+ }
225
244
  }
226
- for (const t of cached.transitions) {
227
- if (t > fromMs) {
228
- return t <= toMs ? t : Infinity;
245
+ // First transition after fromMs; the list is sorted
246
+ const transitions = cached.transitions;
247
+ let lo = 0;
248
+ let hi = transitions.length;
249
+ while (lo < hi) {
250
+ const mid = (lo + hi) >>> 1;
251
+ if (transitions[mid] > fromMs) {
252
+ hi = mid;
253
+ } else {
254
+ lo = mid + 1;
229
255
  }
230
256
  }
257
+ if (lo < transitions.length && transitions[lo] <= toMs) {
258
+ return transitions[lo];
259
+ }
231
260
  return Infinity;
232
261
  }
233
262
 
@@ -247,6 +276,13 @@ export class TimezoneManager {
247
276
  const offsetAt = ms => this.getTimezoneOffset(new Date(ms), timezone);
248
277
  let lo = fromMs;
249
278
  let loOffset = offsetAt(lo);
279
+ // Timezone databases know only local mean time before the 19th century:
280
+ // that era has a single offset and is skipped in one step (probing it
281
+ // week by week could take minutes for a very old DTSTART)
282
+ if (lo < PRE_TZDATA_FLOOR_MS) {
283
+ lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
284
+ loOffset = offsetAt(lo);
285
+ }
250
286
  while (lo < toMs) {
251
287
  const hi = Math.min(lo + WEEK, toMs);
252
288
  const hiOffset = offsetAt(hi);
package/core/types.js CHANGED
@@ -324,6 +324,15 @@
324
324
  * @property {import('./events/Event.js').Event[]} unchanged - Events left untouched
325
325
  */
326
326
 
327
+ /**
328
+ * Payload of the Calendar `eventSelect` event
329
+ * @typedef {Object} EventSelectPayload
330
+ * @property {import('./events/Event.js').Event} event - The stored event (the master for an occurrence id)
331
+ * @property {string} eventId - Id of the stored event, as kept in the state's selectedEventId
332
+ * @property {string|null} occurrenceId - The occurrence id that was selected, or null for a stored event's id
333
+ * @property {import('./events/Event.js').Event|null} occurrence - The selected occurrence as in view data, or null
334
+ */
335
+
327
336
  /**
328
337
  * @typedef {Object} QueryFilters
329
338
  * @property {Date} [start] - Start date for range query
@@ -346,6 +355,58 @@
346
355
  * @property {Date} start - Occurrence start date
347
356
  * @property {Date} end - Occurrence end date
348
357
  * @property {string} recurringEventId - ID of the parent recurring event
358
+ * @property {string} [timezone] - Timezone the occurrence was expanded in
359
+ * @property {Date} [originalStart] - Start of the series (DTSTART)
360
+ */
361
+
362
+ /**
363
+ * Window for lazy occurrence iteration. Both bounds are exclusive unless
364
+ * `inclusive` is set: an occurrence starting exactly at `after` or `before`
365
+ * is skipped by default, so iterating from a known occurrence's start
366
+ * continues the series without repeating it. Omit a bound to leave that
367
+ * end of the window open.
368
+ * @typedef {Object} OccurrenceIteratorOptions
369
+ * @property {Date|number} [after] - Only occurrences starting after this instant (Date or timestamp)
370
+ * @property {Date|number} [before] - Only occurrences starting before this instant (Date or timestamp)
371
+ * @property {boolean} [inclusive=false] - Treat `after` and `before` as closed bounds
372
+ * @property {string} [timezone] - Timezone for expansion (defaults to the event's)
373
+ */
374
+
375
+ /**
376
+ * Options for lazy occurrence iteration through RecurrenceEngineV2,
377
+ * EventStore and Calendar: the OccurrenceIteratorOptions window plus the
378
+ * expansion switches RecurrenceEngineV2.expandEvent accepts.
379
+ * @typedef {Object} ExpandedOccurrenceIteratorOptions
380
+ * @property {Date|number} [after] - Only occurrences starting after this instant (Date or timestamp)
381
+ * @property {Date|number} [before] - Only occurrences starting before this instant (Date or timestamp)
382
+ * @property {boolean} [inclusive=false] - Treat `after` and `before` as closed bounds
383
+ * @property {string} [timezone] - Timezone for expansion (defaults to the event's)
384
+ * @property {boolean} [includeModified=true] - Apply stored instance modifications
385
+ * @property {boolean} [includeCancelled=false] - Yield exception dates as cancelled occurrences
386
+ * @property {boolean} [handleDST=true] - Adjust occurrences across DST transitions
387
+ */
388
+
389
+ /**
390
+ * Occurrence produced by RecurrenceEngineV2 (and therefore by EventStore
391
+ * and Calendar occurrence queries)
392
+ * @typedef {Object} ExpandedOccurrence
393
+ * @property {string} id - Occurrence ID (`<eventId>_<startTimestamp>` for recurring events)
394
+ * @property {string} [recurringEventId] - ID of the parent recurring event
395
+ * @property {string} title - Event title
396
+ * @property {Date} start - Occurrence start date
397
+ * @property {Date} end - Occurrence end date
398
+ * @property {Date} [startUTC] - Occurrence start in UTC
399
+ * @property {Date} [endUTC] - Occurrence end in UTC
400
+ * @property {string} timezone - Timezone the occurrence was expanded in
401
+ * @property {Date} [originalStart] - Start of the series (DTSTART)
402
+ * @property {boolean} allDay - Whether the event is all-day
403
+ * @property {string} [description] - Event description
404
+ * @property {string} [location] - Event location
405
+ * @property {string[]} [categories] - Event categories
406
+ * @property {EventStatus} [status] - 'confirmed', or 'cancelled' for exception dates yielded with includeCancelled
407
+ * @property {string} [cancellationReason] - Reason recorded for a cancelled occurrence
408
+ * @property {boolean} isRecurring - Whether the occurrence belongs to a recurring series
409
+ * @property {boolean} [isModified] - Whether a stored instance modification was applied
349
410
  */
350
411
 
351
412
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
@@ -80,14 +80,20 @@ export declare class Calendar {
80
80
  addEvent(eventData: import('../events/Event.js').Event | import('../types.js').EventData): import('../events/Event.js').Event;
81
81
  /**
82
82
  * Update an event
83
- * @param {string} eventId - The event ID
83
+ *
84
+ * An occurrence id taken from view data (see {@link Event.occurrenceId})
85
+ * updates the recurring master, i.e. the whole series.
86
+ * @param {string} eventId - Event id or occurrence id
84
87
  * @param {Object} updates - Properties to update
85
- * @returns {Event} The updated event
88
+ * @returns {Event} The updated event (the master for an occurrence id)
86
89
  */
87
90
  updateEvent(eventId: string, updates: Object): Event;
88
91
  /**
89
92
  * Remove an event
90
- * @param {string} eventId - The event ID
93
+ *
94
+ * An occurrence id taken from view data (see {@link Event.occurrenceId})
95
+ * removes the recurring master, i.e. the whole series.
96
+ * @param {string} eventId - Event id or occurrence id
91
97
  * @returns {boolean} True if removed
92
98
  */
93
99
  removeEvent(eventId: string): boolean;
@@ -99,12 +105,39 @@ export declare class Calendar {
99
105
  deleteEvent(eventId: string): boolean;
100
106
  /**
101
107
  * Get an event by ID
102
- * @param {string} eventId - The event ID
103
- * @returns {Event|null}
108
+ *
109
+ * Occurrence ids taken from view data (`<masterId>_<startMs>`, see
110
+ * {@link Event.occurrenceId}) resolve to the stored recurring master, so
111
+ * every id a renderer hands back can be looked up here.
112
+ * @param {string} eventId - Event id or occurrence id
113
+ * @returns {Event|null} The stored event (the master for an occurrence id) or null
104
114
  */
105
115
  getEvent(eventId: string): Event | null;
106
116
  /**
107
- * Get all events
117
+ * Resolve an event id or occurrence id to the id of the stored event it
118
+ * refers to: the id itself for a stored event, the master's id for an
119
+ * occurrence id taken from view data, `null` when nothing stored matches.
120
+ * See `EventStore.resolveEventId`.
121
+ *
122
+ * @example
123
+ * calendar.resolveEventId('standup_1750028400000'); // 'standup'
124
+ * calendar.resolveEventId('unknown'); // null
125
+ *
126
+ * @param {string} id - Event id or occurrence id
127
+ * @returns {string|null} Id of the stored event, or null
128
+ */
129
+ resolveEventId(id: string): string | null;
130
+ /**
131
+ * Get the occurrence an occurrence id from view data stands for, as an
132
+ * {@link Event} like the ones the views hold, or `null` when the id is
133
+ * not an occurrence of a stored recurring series. See
134
+ * `EventStore.getOccurrence`.
135
+ * @param {string} occurrenceId - Occurrence id (`<masterId>_<startMs>`)
136
+ * @returns {Event|null} The occurrence, or null
137
+ */
138
+ getOccurrence(occurrenceId: string): Event | null;
139
+ /**
140
+ * Get all stored events (recurring masters, never their occurrences)
108
141
  * @returns {Event[]}
109
142
  */
110
143
  getEvents(): Event[];
@@ -156,9 +189,16 @@ export declare class Calendar {
156
189
  * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
157
190
  * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
158
191
  * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
159
- * @throws {Error} If an entry fails validation or two entries share an id
192
+ * @throws {Error} If `events` is not iterable, an entry fails validation or two entries share an id
160
193
  */
161
194
  reconcileEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').ReconcileOptions): import('../types.js').EventsSetPayload;
195
+ /**
196
+ * Throw a clear error when a snapshot is not iterable
197
+ * @param {*} events - Candidate snapshot
198
+ * @param {string} method - Calling method, for the message
199
+ * @private
200
+ */
201
+ private _assertIterable;
162
202
  /**
163
203
  * Get the event store's change counter
164
204
  *
@@ -177,12 +217,23 @@ export declare class Calendar {
177
217
  */
178
218
  queryEvents(filters: Object): Event[];
179
219
  /**
180
- * Get events for a specific date
220
+ * Get events for a specific date, with recurring series expanded into occurrences
181
221
  * @param {Date} date - The date
182
222
  * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
183
223
  * @returns {Event[]}
184
224
  */
185
225
  getEventsForDate(date: Date, timezone?: string): Event[];
226
+ /**
227
+ * Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
228
+ *
229
+ * Recurring series are expanded once for the whole range; this is what the
230
+ * month and week views use. See `EventStore.getEventsByDate`.
231
+ * @param {Date} start - First day of the range
232
+ * @param {Date} end - Last day of the range
233
+ * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
234
+ * @returns {Map<string, Event[]>} Local date string -> events on that day
235
+ */
236
+ getEventsByDate(start: Date, end: Date, timezone?: string): Map<string, Event[]>;
186
237
  /**
187
238
  * Get events in a date range
188
239
  * @param {Date} start - Start date
@@ -191,6 +242,55 @@ export declare class Calendar {
191
242
  * @returns {Event[]}
192
243
  */
193
244
  getEventsInRange(start: Date, end: Date, timezone?: string): Event[];
245
+ /**
246
+ * Lazily iterate the occurrences of an event in chronological order.
247
+ *
248
+ * Occurrences are produced one at a time, so taking the next few of an
249
+ * open-ended series does not expand the series. `after` and `before`
250
+ * are exclusive unless `inclusive` is set; see
251
+ * RecurrenceEngineV2.iterateOccurrences for the full semantics.
252
+ *
253
+ * @example
254
+ * for (const occurrence of calendar.iterateOccurrences('standup', { after: new Date() })) {
255
+ * if (occurrence.start > deadline) break;
256
+ * remind(occurrence);
257
+ * }
258
+ *
259
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
260
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
261
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
262
+ * @throws {Error} If no event with the ID exists
263
+ */
264
+ iterateOccurrences(eventId: string, options?: import('../types.js').ExpandedOccurrenceIteratorOptions): Generator<import('../types.js').ExpandedOccurrence, void, undefined>;
265
+ /**
266
+ * First occurrence of an event after an instant, or null when the
267
+ * series has no occurrence after it. `after` is exclusive unless
268
+ * `options.inclusive` is set.
269
+ *
270
+ * @example
271
+ * const upcoming = calendar.getNextOccurrence('standup', new Date());
272
+ *
273
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
274
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
275
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
276
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
277
+ * @throws {Error} If no event with the ID exists
278
+ */
279
+ getNextOccurrence(eventId: string, after?: Date | number, options?: import('../types.js').ExpandedOccurrenceIteratorOptions): import('../types.js').ExpandedOccurrence | null;
280
+ /**
281
+ * The first `count` occurrences of an event inside a window, generated
282
+ * lazily. `count` is capped at the engine's MAX_OCCURRENCES_HARD_LIMIT.
283
+ *
284
+ * @example
285
+ * const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
286
+ *
287
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
288
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
289
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
290
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
291
+ * @throws {Error} If no event with the ID exists
292
+ */
293
+ takeOccurrences(eventId: string, count: number, options?: import('../types.js').ExpandedOccurrenceIteratorOptions): import('../types.js').ExpandedOccurrence[];
194
294
  /**
195
295
  * Set the calendar's timezone
196
296
  * @param {string} timezone - IANA timezone identifier
@@ -293,7 +393,21 @@ export declare class Calendar {
293
393
  private _getListViewData;
294
394
  /**
295
395
  * Select an event
296
- * @param {string} eventId - Event ID to select
396
+ *
397
+ * Accepts a stored event's id or an occurrence id taken from view data
398
+ * (see {@link Event.occurrenceId}). Either way the stored event is what
399
+ * gets selected: `selectedEventId` in the state holds its id, so it can
400
+ * always be looked up with {@link Calendar#getEvent}. The `eventSelect`
401
+ * payload carries the stored `event`, its `eventId`, and for an
402
+ * occurrence id also `occurrenceId` and the `occurrence` itself (an
403
+ * {@link Event} as in view data, or null when the series has no
404
+ * occurrence at that instant). Nothing happens for an unknown id.
405
+ *
406
+ * @example
407
+ * calendar.on('eventSelect', ({ event, occurrence }) => open(occurrence || event));
408
+ * calendar.selectEvent(chip.dataset.eventId);
409
+ *
410
+ * @param {string} eventId - Event id or occurrence id to select
297
411
  */
298
412
  selectEvent(eventId: string): void;
299
413
  /**
@@ -23,6 +23,12 @@ export declare class Event {
23
23
  textColor: string | undefined;
24
24
  recurring: boolean | undefined;
25
25
  recurrenceRule: string | import("../types.js").RecurrenceRule | undefined;
26
+ /** @type {boolean} True when this instance is one occurrence of a recurring series */
27
+ isOccurrence: boolean;
28
+ /** @type {string|null} Id of the recurring master this occurrence belongs to */
29
+ recurringEventId: string | null;
30
+ /** @type {Date|null} Start of this occurrence as generated by the recurrence rule */
31
+ occurrenceStart: Date | null;
26
32
  _originalTimeZone: string | null;
27
33
  status: import("../types.js").EventStatus | undefined;
28
34
  visibility: import("../types.js").EventVisibility | undefined;
@@ -161,6 +167,8 @@ export declare class Event {
161
167
  * Scalars are compared with strict equality, dates by timestamp and
162
168
  * structured fields (recurrence rule, organizer, attendees, reminders,
163
169
  * categories, attachments, conference data, metadata) structurally.
170
+ * The `color` shorthand is not listed: normalization copies it into
171
+ * `backgroundColor` and `borderColor`, which are compared instead.
164
172
  * @type {ReadonlyArray<string>}
165
173
  */
166
174
  static EQUIVALENCE_FIELDS: ReadonlyArray<string>;
@@ -179,6 +187,14 @@ export declare class Event {
179
187
  * describe the same event. Two events with different ids are never
180
188
  * equivalent.
181
189
  *
190
+ * Two things to know when building snapshots for `reconcile()`:
191
+ * - `attendees`, `reminders`, `categories` and `attachments` are compared
192
+ * in order, so the same attendees listed in a different order count as
193
+ * a change.
194
+ * - Only the top-level dates are normalized. Values inside `metadata` are
195
+ * compared as given, so a `Date` and its ISO string are not equivalent
196
+ * there; keep metadata in one representation.
197
+ *
182
198
  * This is the default comparator used by `EventStore.reconcile()` to decide
183
199
  * whether an incoming snapshot entry replaces the stored event.
184
200
  *
@@ -192,6 +208,40 @@ export declare class Event {
192
208
  * @throws {Error} If raw event data fails {@link Event.validate}
193
209
  */
194
210
  static isEquivalent(a: Event | import('../types.js').EventData, b: Event | import('../types.js').EventData): boolean;
211
+ /**
212
+ * Build the id of one occurrence of a recurring series.
213
+ *
214
+ * The id is `<recurringEventId>_<startMs>` where `startMs` is the
215
+ * occurrence start as returned by `Date.prototype.getTime()`. It is
216
+ * deterministic, so the same occurrence gets the same id no matter which
217
+ * range it was expanded for, and it matches the ids generated by
218
+ * `RecurrenceEngineV2`. Use {@link Event.parseOccurrenceId} to get the
219
+ * master id back.
220
+ *
221
+ * @example
222
+ * Event.occurrenceId('standup', new Date(2025, 5, 16, 9)); // 'standup_1750028400000'
223
+ *
224
+ * @param {string} recurringEventId - Id of the recurring master event
225
+ * @param {Date|number|string} occurrenceStart - Start of the occurrence
226
+ * @returns {string} Occurrence id
227
+ * @throws {TypeError} If occurrenceStart is not a valid date
228
+ */
229
+ static occurrenceId(recurringEventId: string, occurrenceStart: Date | number | string): string;
230
+ /**
231
+ * Split an occurrence id built by {@link Event.occurrenceId} into the master
232
+ * id and the occurrence start.
233
+ *
234
+ * Returns `null` for ids that do not have the `<id>_<startMs>` shape. A
235
+ * positive result only means the id is well-formed; whether the master
236
+ * exists is for the caller (see `EventStore.getEvent`) to check.
237
+ *
238
+ * @param {string} id - Candidate occurrence id
239
+ * @returns {{recurringEventId: string, occurrenceStart: Date}|null} Parsed parts or null
240
+ */
241
+ static parseOccurrenceId(id: string): {
242
+ recurringEventId: string;
243
+ occurrenceStart: Date;
244
+ } | null;
195
245
  /**
196
246
  * Add an attendee to the event
197
247
  * @param {import('../types.js').Attendee} attendee - Attendee to add