@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.
package/core/types.js CHANGED
@@ -275,13 +275,55 @@
275
275
 
276
276
  /**
277
277
  * @typedef {Object} EventStoreChange
278
- * @property {('add'|'update'|'remove'|'clear')} type - Type of change
278
+ * @property {('add'|'update'|'remove'|'clear'|'batch')} type - Type of change
279
279
  * @property {import('./events/Event.js').Event} [event] - Affected event
280
280
  * @property {import('./events/Event.js').Event} [oldEvent] - Previous event state (for updates)
281
281
  * @property {import('./events/Event.js').Event[]} [oldEvents] - Previous events (for clear)
282
+ * @property {EventStoreChange[]} [changes] - Individual changes (for batch)
283
+ * @property {number} [count] - Number of individual changes (for batch)
282
284
  * @property {number} version - Store version number
283
285
  */
284
286
 
287
+ /**
288
+ * @typedef {(a: import('./events/Event.js').Event, b: import('./events/Event.js').Event) => boolean} EventEquivalenceFn
289
+ */
290
+
291
+ /**
292
+ * @typedef {Object} ReconcileOptions
293
+ * @property {boolean} [removeMissing=true] - Remove stored events that are absent from the snapshot
294
+ * @property {EventEquivalenceFn} [isEquivalent] - Comparator deciding whether a stored event is unchanged (defaults to Event.isEquivalent)
295
+ */
296
+
297
+ /**
298
+ * @typedef {Object} ReconciledUpdate
299
+ * @property {import('./events/Event.js').Event} event - Event now in the store
300
+ * @property {import('./events/Event.js').Event} oldEvent - Event it replaced
301
+ */
302
+
303
+ /**
304
+ * @typedef {Object} ReconcileResult
305
+ * @property {import('./events/Event.js').Event[]} added - Events that were not in the store before
306
+ * @property {ReconciledUpdate[]} updated - Events whose data changed
307
+ * @property {import('./events/Event.js').Event[]} removed - Events removed from the store
308
+ * @property {import('./events/Event.js').Event[]} unchanged - Stored events left untouched (same instances)
309
+ */
310
+
311
+ /**
312
+ * @typedef {Object} SetEventsOptions
313
+ * @property {boolean} [reconcile=false] - Apply only the differences instead of clearing and re-adding
314
+ * @property {boolean} [removeMissing=true] - Reconcile only: remove stored events absent from the snapshot
315
+ * @property {EventEquivalenceFn} [isEquivalent] - Reconcile only: custom equivalence comparator
316
+ */
317
+
318
+ /**
319
+ * @typedef {Object} EventsSetPayload
320
+ * @property {import('./events/Event.js').Event[]} events - All events after the operation
321
+ * @property {import('./events/Event.js').Event[]} added - Events added by the operation
322
+ * @property {ReconciledUpdate[]} updated - Events replaced by the operation
323
+ * @property {import('./events/Event.js').Event[]} removed - Events removed by the operation
324
+ * @property {import('./events/Event.js').Event[]} unchanged - Events left untouched
325
+ */
326
+
285
327
  /**
286
328
  * @typedef {Object} QueryFilters
287
329
  * @property {Date} [start] - Start date for range query
@@ -304,6 +346,58 @@
304
346
  * @property {Date} start - Occurrence start date
305
347
  * @property {Date} end - Occurrence end date
306
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
307
401
  */
308
402
 
309
403
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.3.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",
@@ -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,20 +105,81 @@ 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
+ * Get all stored events (recurring masters, never their occurrences)
108
118
  * @returns {Event[]}
109
119
  */
110
120
  getEvents(): Event[];
111
121
  /**
112
122
  * Set all events (replaces existing)
113
- * @param {Event[]} events - Array of events
114
- */
115
- setEvents(events: Event[]): void;
123
+ *
124
+ * By default this clears the store and re-adds every entry, so every stored
125
+ * {@link Event} instance is replaced. Pass `{ reconcile: true }` to apply only
126
+ * the differences instead (see {@link Calendar#reconcileEvents}).
127
+ *
128
+ * Emits a single `eventsSet` event whose payload lists the resulting
129
+ * `events` plus the `added`, `updated`, `removed` and `unchanged` sets, so
130
+ * listeners can tell a snapshot load apart from user mutations (no
131
+ * `eventAdd`/`eventUpdate`/`eventRemove` events are emitted).
132
+ *
133
+ * @example
134
+ * calendar.on('eventsSet', ({ added, updated, removed }) => {
135
+ * if (added.length || updated.length || removed.length) render();
136
+ * });
137
+ * calendar.setEvents(snapshot, { reconcile: true });
138
+ *
139
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Array of events
140
+ * @param {import('../types.js').SetEventsOptions} [options={}] - Load options
141
+ * @returns {import('../types.js').EventsSetPayload} The applied change set
142
+ */
143
+ setEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').SetEventsOptions): import('../types.js').EventsSetPayload;
144
+ /**
145
+ * Reconcile the calendar with a snapshot of events, applying only the differences.
146
+ *
147
+ * Intended for consumers that receive periodic full snapshots (polling a
148
+ * server, a reactive `events` prop). Unchanged events keep their existing
149
+ * {@link Event} instance, changed ones are replaced, new ones are added and
150
+ * events missing from the snapshot are removed (unless
151
+ * `removeMissing: false`). Equivalence is decided by
152
+ * {@link Event.isEquivalent} unless an `isEquivalent` comparator is supplied.
153
+ * Plain event data without a `timeZone` defaults to the calendar timezone,
154
+ * exactly as with {@link Calendar#addEvent}.
155
+ *
156
+ * The store emits one `eventStoreChange` of type `batch` (or none when
157
+ * nothing differs) and the calendar emits a single `eventsSet` event
158
+ * carrying the change set. Per-event `eventAdd`/`eventUpdate`/`eventRemove`
159
+ * events are not emitted, so listeners that forward those to a backend are
160
+ * not triggered by a snapshot load.
161
+ *
162
+ * @example
163
+ * const { added, updated, removed, unchanged } = calendar.reconcileEvents(rows);
164
+ * updated.forEach(({ event, oldEvent }) => console.log(oldEvent.title, '->', event.title));
165
+ *
166
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
167
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
168
+ * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
169
+ * @throws {Error} If an entry fails validation or two entries share an id
170
+ */
171
+ reconcileEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').ReconcileOptions): import('../types.js').EventsSetPayload;
172
+ /**
173
+ * Get the event store's change counter
174
+ *
175
+ * The counter increases with every add/update/remove/clear and every
176
+ * committed batch, so comparing two readings is a cheap way to find out
177
+ * whether {@link Calendar#setEvents} or {@link Calendar#reconcileEvents}
178
+ * changed anything.
179
+ *
180
+ * @returns {number} Current store version
181
+ */
182
+ getEventsVersion(): number;
116
183
  /**
117
184
  * Query events with filters
118
185
  * @param {Object} filters - Query filters
@@ -120,12 +187,23 @@ export declare class Calendar {
120
187
  */
121
188
  queryEvents(filters: Object): Event[];
122
189
  /**
123
- * Get events for a specific date
190
+ * Get events for a specific date, with recurring series expanded into occurrences
124
191
  * @param {Date} date - The date
125
192
  * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
126
193
  * @returns {Event[]}
127
194
  */
128
195
  getEventsForDate(date: Date, timezone?: string): Event[];
196
+ /**
197
+ * Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
198
+ *
199
+ * Recurring series are expanded once for the whole range; this is what the
200
+ * month and week views use. See `EventStore.getEventsByDate`.
201
+ * @param {Date} start - First day of the range
202
+ * @param {Date} end - Last day of the range
203
+ * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
204
+ * @returns {Map<string, Event[]>} Local date string -> events on that day
205
+ */
206
+ getEventsByDate(start: Date, end: Date, timezone?: string): Map<string, Event[]>;
129
207
  /**
130
208
  * Get events in a date range
131
209
  * @param {Date} start - Start date
@@ -134,6 +212,55 @@ export declare class Calendar {
134
212
  * @returns {Event[]}
135
213
  */
136
214
  getEventsInRange(start: Date, end: Date, timezone?: string): Event[];
215
+ /**
216
+ * Lazily iterate the occurrences of an event in chronological order.
217
+ *
218
+ * Occurrences are produced one at a time, so taking the next few of an
219
+ * open-ended series does not expand the series. `after` and `before`
220
+ * are exclusive unless `inclusive` is set; see
221
+ * RecurrenceEngineV2.iterateOccurrences for the full semantics.
222
+ *
223
+ * @example
224
+ * for (const occurrence of calendar.iterateOccurrences('standup', { after: new Date() })) {
225
+ * if (occurrence.start > deadline) break;
226
+ * remind(occurrence);
227
+ * }
228
+ *
229
+ * @param {string} eventId - The event ID
230
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
231
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
232
+ * @throws {Error} If no event with the ID exists
233
+ */
234
+ iterateOccurrences(eventId: string, options?: import('../types.js').ExpandedOccurrenceIteratorOptions): Generator<import('../types.js').ExpandedOccurrence, void, undefined>;
235
+ /**
236
+ * First occurrence of an event after an instant, or null when the
237
+ * series has no occurrence after it. `after` is exclusive unless
238
+ * `options.inclusive` is set.
239
+ *
240
+ * @example
241
+ * const upcoming = calendar.getNextOccurrence('standup', new Date());
242
+ *
243
+ * @param {string} eventId - The event ID
244
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
245
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
246
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
247
+ * @throws {Error} If no event with the ID exists
248
+ */
249
+ getNextOccurrence(eventId: string, after?: Date | number, options?: import('../types.js').ExpandedOccurrenceIteratorOptions): import('../types.js').ExpandedOccurrence | null;
250
+ /**
251
+ * The first `count` occurrences of an event inside a window, generated
252
+ * lazily. `count` is capped at the engine's MAX_OCCURRENCES_HARD_LIMIT.
253
+ *
254
+ * @example
255
+ * const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
256
+ *
257
+ * @param {string} eventId - The event ID
258
+ * @param {number} count - Maximum number of occurrences to return
259
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
260
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
261
+ * @throws {Error} If no event with the ID exists
262
+ */
263
+ takeOccurrences(eventId: string, count: number, options?: import('../types.js').ExpandedOccurrenceIteratorOptions): import('../types.js').ExpandedOccurrence[];
137
264
  /**
138
265
  * Set the calendar's timezone
139
266
  * @param {string} timezone - IANA timezone identifier
@@ -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;
@@ -156,6 +162,75 @@ export declare class Event {
156
162
  * @returns {boolean} True if events are equal
157
163
  */
158
164
  equals(other: Event): boolean;
165
+ /**
166
+ * Fields compared by {@link Event.isEquivalent}, in comparison order.
167
+ * Scalars are compared with strict equality, dates by timestamp and
168
+ * structured fields (recurrence rule, organizer, attendees, reminders,
169
+ * categories, attachments, conference data, metadata) structurally.
170
+ * @type {ReadonlyArray<string>}
171
+ */
172
+ static EQUIVALENCE_FIELDS: ReadonlyArray<string>;
173
+ /**
174
+ * Deep equivalence check over the full event data surface.
175
+ *
176
+ * Unlike {@link Event#equals} (which only looks at identity, title, dates,
177
+ * description, location, recurrence and status) this compares every field
178
+ * that can be supplied through {@link EventData}: timezones, all-day flag,
179
+ * colours, visibility, organizer, attendees, reminders, categories,
180
+ * attachments, conference data and metadata. Dates are compared by
181
+ * timestamp; structured fields are compared structurally (arrays are
182
+ * order-sensitive, object key order is ignored). Plain event data objects
183
+ * are normalized through the {@link Event} constructor before comparison so
184
+ * that `{ color: 'red' }` and `{ backgroundColor: 'red', borderColor: 'red' }`
185
+ * describe the same event. Two events with different ids are never
186
+ * equivalent.
187
+ *
188
+ * This is the default comparator used by `EventStore.reconcile()` to decide
189
+ * whether an incoming snapshot entry replaces the stored event.
190
+ *
191
+ * @example
192
+ * Event.isEquivalent(stored, { ...stored.toObject(), backgroundColor: '#f00' }); // false
193
+ * Event.isEquivalent(stored, stored.clone()); // true
194
+ *
195
+ * @param {Event|import('../types.js').EventData} a - First event or raw event data
196
+ * @param {Event|import('../types.js').EventData} b - Second event or raw event data
197
+ * @returns {boolean} True when both describe the same event data
198
+ * @throws {Error} If raw event data fails {@link Event.validate}
199
+ */
200
+ static isEquivalent(a: Event | import('../types.js').EventData, b: Event | import('../types.js').EventData): boolean;
201
+ /**
202
+ * Build the id of one occurrence of a recurring series.
203
+ *
204
+ * The id is `<recurringEventId>_<startMs>` where `startMs` is the
205
+ * occurrence start as returned by `Date.prototype.getTime()`. It is
206
+ * deterministic, so the same occurrence gets the same id no matter which
207
+ * range it was expanded for, and it matches the ids generated by
208
+ * `RecurrenceEngineV2`. Use {@link Event.parseOccurrenceId} to get the
209
+ * master id back.
210
+ *
211
+ * @example
212
+ * Event.occurrenceId('standup', new Date(2025, 5, 16, 9)); // 'standup_1750028400000'
213
+ *
214
+ * @param {string} recurringEventId - Id of the recurring master event
215
+ * @param {Date|number|string} occurrenceStart - Start of the occurrence
216
+ * @returns {string} Occurrence id
217
+ */
218
+ static occurrenceId(recurringEventId: string, occurrenceStart: Date | number | string): string;
219
+ /**
220
+ * Split an occurrence id built by {@link Event.occurrenceId} into the master
221
+ * id and the occurrence start.
222
+ *
223
+ * Returns `null` for ids that do not have the `<id>_<startMs>` shape. A
224
+ * positive result only means the id is well-formed; whether the master
225
+ * exists is for the caller (see `EventStore.getEvent`) to check.
226
+ *
227
+ * @param {string} id - Candidate occurrence id
228
+ * @returns {{recurringEventId: string, occurrenceStart: Date}|null} Parsed parts or null
229
+ */
230
+ static parseOccurrenceId(id: string): {
231
+ recurringEventId: string;
232
+ occurrenceStart: Date;
233
+ } | null;
159
234
  /**
160
235
  * Add an attendee to the event
161
236
  * @param {import('../types.js').Attendee} attendee - Attendee to add