@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/calendar/Calendar.js +198 -14
- package/core/events/Event.js +173 -0
- package/core/events/EventStore.js +484 -92
- package/core/events/RecurrenceEngine.js +586 -20
- package/core/events/RecurrenceEngineV2.js +384 -51
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +88 -14
- package/core/types.js +95 -1
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +137 -10
- package/types/events/Event.d.ts +75 -0
- package/types/events/EventStore.d.ts +213 -6
- package/types/events/RecurrenceEngine.d.ts +216 -1
- package/types/events/RecurrenceEngineV2.d.ts +156 -9
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +66 -1
- package/types/types.d.ts +296 -2
|
@@ -178,9 +178,12 @@ export class Calendar {
|
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
180
|
* Update an event
|
|
181
|
-
*
|
|
181
|
+
*
|
|
182
|
+
* An occurrence id taken from view data (see {@link Event.occurrenceId})
|
|
183
|
+
* updates the recurring master, i.e. the whole series.
|
|
184
|
+
* @param {string} eventId - Event id or occurrence id
|
|
182
185
|
* @param {Object} updates - Properties to update
|
|
183
|
-
* @returns {Event} The updated event
|
|
186
|
+
* @returns {Event} The updated event (the master for an occurrence id)
|
|
184
187
|
*/
|
|
185
188
|
updateEvent(eventId, updates) {
|
|
186
189
|
const oldEvent = this.eventStore.getEvent(eventId);
|
|
@@ -193,7 +196,10 @@ export class Calendar {
|
|
|
193
196
|
|
|
194
197
|
/**
|
|
195
198
|
* Remove an event
|
|
196
|
-
*
|
|
199
|
+
*
|
|
200
|
+
* An occurrence id taken from view data (see {@link Event.occurrenceId})
|
|
201
|
+
* removes the recurring master, i.e. the whole series.
|
|
202
|
+
* @param {string} eventId - Event id or occurrence id
|
|
197
203
|
* @returns {boolean} True if removed
|
|
198
204
|
*/
|
|
199
205
|
removeEvent(eventId) {
|
|
@@ -218,15 +224,19 @@ export class Calendar {
|
|
|
218
224
|
|
|
219
225
|
/**
|
|
220
226
|
* Get an event by ID
|
|
221
|
-
*
|
|
222
|
-
*
|
|
227
|
+
*
|
|
228
|
+
* Occurrence ids taken from view data (`<masterId>_<startMs>`, see
|
|
229
|
+
* {@link Event.occurrenceId}) resolve to the stored recurring master, so
|
|
230
|
+
* every id a renderer hands back can be looked up here.
|
|
231
|
+
* @param {string} eventId - Event id or occurrence id
|
|
232
|
+
* @returns {Event|null} The stored event (the master for an occurrence id) or null
|
|
223
233
|
*/
|
|
224
234
|
getEvent(eventId) {
|
|
225
235
|
return this.eventStore.getEvent(eventId);
|
|
226
236
|
}
|
|
227
237
|
|
|
228
238
|
/**
|
|
229
|
-
* Get all events
|
|
239
|
+
* Get all stored events (recurring masters, never their occurrences)
|
|
230
240
|
* @returns {Event[]}
|
|
231
241
|
*/
|
|
232
242
|
getEvents() {
|
|
@@ -235,11 +245,103 @@ export class Calendar {
|
|
|
235
245
|
|
|
236
246
|
/**
|
|
237
247
|
* Set all events (replaces existing)
|
|
238
|
-
*
|
|
239
|
-
|
|
240
|
-
|
|
248
|
+
*
|
|
249
|
+
* By default this clears the store and re-adds every entry, so every stored
|
|
250
|
+
* {@link Event} instance is replaced. Pass `{ reconcile: true }` to apply only
|
|
251
|
+
* the differences instead (see {@link Calendar#reconcileEvents}).
|
|
252
|
+
*
|
|
253
|
+
* Emits a single `eventsSet` event whose payload lists the resulting
|
|
254
|
+
* `events` plus the `added`, `updated`, `removed` and `unchanged` sets, so
|
|
255
|
+
* listeners can tell a snapshot load apart from user mutations (no
|
|
256
|
+
* `eventAdd`/`eventUpdate`/`eventRemove` events are emitted).
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* calendar.on('eventsSet', ({ added, updated, removed }) => {
|
|
260
|
+
* if (added.length || updated.length || removed.length) render();
|
|
261
|
+
* });
|
|
262
|
+
* calendar.setEvents(snapshot, { reconcile: true });
|
|
263
|
+
*
|
|
264
|
+
* @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Array of events
|
|
265
|
+
* @param {import('../types.js').SetEventsOptions} [options={}] - Load options
|
|
266
|
+
* @returns {import('../types.js').EventsSetPayload} The applied change set
|
|
267
|
+
*/
|
|
268
|
+
setEvents(events, options = {}) {
|
|
269
|
+
if (options.reconcile) {
|
|
270
|
+
return this.reconcileEvents(events, options);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const removed = this.getEvents();
|
|
241
274
|
this.eventStore.loadEvents(events);
|
|
242
|
-
|
|
275
|
+
const added = this.getEvents();
|
|
276
|
+
|
|
277
|
+
/** @type {import('../types.js').EventsSetPayload} */
|
|
278
|
+
const payload = { events: added, added, updated: [], removed, unchanged: [] };
|
|
279
|
+
this._emit('eventsSet', payload);
|
|
280
|
+
return payload;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Reconcile the calendar with a snapshot of events, applying only the differences.
|
|
285
|
+
*
|
|
286
|
+
* Intended for consumers that receive periodic full snapshots (polling a
|
|
287
|
+
* server, a reactive `events` prop). Unchanged events keep their existing
|
|
288
|
+
* {@link Event} instance, changed ones are replaced, new ones are added and
|
|
289
|
+
* events missing from the snapshot are removed (unless
|
|
290
|
+
* `removeMissing: false`). Equivalence is decided by
|
|
291
|
+
* {@link Event.isEquivalent} unless an `isEquivalent` comparator is supplied.
|
|
292
|
+
* Plain event data without a `timeZone` defaults to the calendar timezone,
|
|
293
|
+
* exactly as with {@link Calendar#addEvent}.
|
|
294
|
+
*
|
|
295
|
+
* The store emits one `eventStoreChange` of type `batch` (or none when
|
|
296
|
+
* nothing differs) and the calendar emits a single `eventsSet` event
|
|
297
|
+
* carrying the change set. Per-event `eventAdd`/`eventUpdate`/`eventRemove`
|
|
298
|
+
* events are not emitted, so listeners that forward those to a backend are
|
|
299
|
+
* not triggered by a snapshot load.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* const { added, updated, removed, unchanged } = calendar.reconcileEvents(rows);
|
|
303
|
+
* updated.forEach(({ event, oldEvent }) => console.log(oldEvent.title, '->', event.title));
|
|
304
|
+
*
|
|
305
|
+
* @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
|
|
306
|
+
* @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
|
|
307
|
+
* @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
|
|
308
|
+
* @throws {Error} If an entry fails validation or two entries share an id
|
|
309
|
+
*/
|
|
310
|
+
reconcileEvents(events, options = {}) {
|
|
311
|
+
const { removeMissing, isEquivalent } = options;
|
|
312
|
+
const prepared = [];
|
|
313
|
+
for (const eventData of events) {
|
|
314
|
+
if (!(eventData instanceof Event) && eventData && !eventData.timeZone) {
|
|
315
|
+
prepared.push({ ...eventData, timeZone: this.config.timeZone });
|
|
316
|
+
} else {
|
|
317
|
+
prepared.push(eventData);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const storeOptions = {};
|
|
322
|
+
if (removeMissing !== undefined) storeOptions.removeMissing = removeMissing;
|
|
323
|
+
if (isEquivalent !== undefined) storeOptions.isEquivalent = isEquivalent;
|
|
324
|
+
|
|
325
|
+
const result = this.eventStore.reconcile(prepared, storeOptions);
|
|
326
|
+
|
|
327
|
+
/** @type {import('../types.js').EventsSetPayload} */
|
|
328
|
+
const payload = { events: this.getEvents(), ...result };
|
|
329
|
+
this._emit('eventsSet', payload);
|
|
330
|
+
return payload;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get the event store's change counter
|
|
335
|
+
*
|
|
336
|
+
* The counter increases with every add/update/remove/clear and every
|
|
337
|
+
* committed batch, so comparing two readings is a cheap way to find out
|
|
338
|
+
* whether {@link Calendar#setEvents} or {@link Calendar#reconcileEvents}
|
|
339
|
+
* changed anything.
|
|
340
|
+
*
|
|
341
|
+
* @returns {number} Current store version
|
|
342
|
+
*/
|
|
343
|
+
getEventsVersion() {
|
|
344
|
+
return this.eventStore.version;
|
|
243
345
|
}
|
|
244
346
|
|
|
245
347
|
/**
|
|
@@ -252,7 +354,7 @@ export class Calendar {
|
|
|
252
354
|
}
|
|
253
355
|
|
|
254
356
|
/**
|
|
255
|
-
* Get events for a specific date
|
|
357
|
+
* Get events for a specific date, with recurring series expanded into occurrences
|
|
256
358
|
* @param {Date} date - The date
|
|
257
359
|
* @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
|
|
258
360
|
* @returns {Event[]}
|
|
@@ -261,6 +363,20 @@ export class Calendar {
|
|
|
261
363
|
return this.eventStore.getEventsForDate(date, timezone || this.config.timeZone);
|
|
262
364
|
}
|
|
263
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
|
|
368
|
+
*
|
|
369
|
+
* Recurring series are expanded once for the whole range; this is what the
|
|
370
|
+
* month and week views use. See `EventStore.getEventsByDate`.
|
|
371
|
+
* @param {Date} start - First day of the range
|
|
372
|
+
* @param {Date} end - Last day of the range
|
|
373
|
+
* @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
|
|
374
|
+
* @returns {Map<string, Event[]>} Local date string -> events on that day
|
|
375
|
+
*/
|
|
376
|
+
getEventsByDate(start, end, timezone = null) {
|
|
377
|
+
return this.eventStore.getEventsByDate(start, end, timezone || this.config.timeZone);
|
|
378
|
+
}
|
|
379
|
+
|
|
264
380
|
/**
|
|
265
381
|
* Get events in a date range
|
|
266
382
|
* @param {Date} start - Start date
|
|
@@ -272,6 +388,64 @@ export class Calendar {
|
|
|
272
388
|
return this.eventStore.getEventsInRange(start, end, true, timezone || this.config.timeZone);
|
|
273
389
|
}
|
|
274
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Lazily iterate the occurrences of an event in chronological order.
|
|
393
|
+
*
|
|
394
|
+
* Occurrences are produced one at a time, so taking the next few of an
|
|
395
|
+
* open-ended series does not expand the series. `after` and `before`
|
|
396
|
+
* are exclusive unless `inclusive` is set; see
|
|
397
|
+
* RecurrenceEngineV2.iterateOccurrences for the full semantics.
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* for (const occurrence of calendar.iterateOccurrences('standup', { after: new Date() })) {
|
|
401
|
+
* if (occurrence.start > deadline) break;
|
|
402
|
+
* remind(occurrence);
|
|
403
|
+
* }
|
|
404
|
+
*
|
|
405
|
+
* @param {string} eventId - The event ID
|
|
406
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
407
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
408
|
+
* @throws {Error} If no event with the ID exists
|
|
409
|
+
*/
|
|
410
|
+
iterateOccurrences(eventId, options = {}) {
|
|
411
|
+
return this.eventStore.iterateOccurrences(eventId, options);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* First occurrence of an event after an instant, or null when the
|
|
416
|
+
* series has no occurrence after it. `after` is exclusive unless
|
|
417
|
+
* `options.inclusive` is set.
|
|
418
|
+
*
|
|
419
|
+
* @example
|
|
420
|
+
* const upcoming = calendar.getNextOccurrence('standup', new Date());
|
|
421
|
+
*
|
|
422
|
+
* @param {string} eventId - The event ID
|
|
423
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
424
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
425
|
+
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
426
|
+
* @throws {Error} If no event with the ID exists
|
|
427
|
+
*/
|
|
428
|
+
getNextOccurrence(eventId, after = null, options = {}) {
|
|
429
|
+
return this.eventStore.getNextOccurrence(eventId, after, options);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* The first `count` occurrences of an event inside a window, generated
|
|
434
|
+
* lazily. `count` is capped at the engine's MAX_OCCURRENCES_HARD_LIMIT.
|
|
435
|
+
*
|
|
436
|
+
* @example
|
|
437
|
+
* const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
|
|
438
|
+
*
|
|
439
|
+
* @param {string} eventId - The event ID
|
|
440
|
+
* @param {number} count - Maximum number of occurrences to return
|
|
441
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
442
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
443
|
+
* @throws {Error} If no event with the ID exists
|
|
444
|
+
*/
|
|
445
|
+
takeOccurrences(eventId, count, options = {}) {
|
|
446
|
+
return this.eventStore.takeOccurrences(eventId, count, options);
|
|
447
|
+
}
|
|
448
|
+
|
|
275
449
|
/**
|
|
276
450
|
* Set the calendar's timezone
|
|
277
451
|
* @param {string} timezone - IANA timezone identifier
|
|
@@ -437,6 +611,12 @@ export class Calendar {
|
|
|
437
611
|
? 6
|
|
438
612
|
: Math.ceil((lastDay.getDate() + DateUtils.getDayOfWeek(firstDay, weekStartsOn)) / 7);
|
|
439
613
|
|
|
614
|
+
// Expand recurring series once for the whole grid, not once per cell
|
|
615
|
+
const eventsByDate = this.getEventsByDate(
|
|
616
|
+
startDate,
|
|
617
|
+
DateUtils.addDays(startDate, maxWeeks * 7 - 1)
|
|
618
|
+
);
|
|
619
|
+
|
|
440
620
|
for (let weekIndex = 0; weekIndex < maxWeeks; weekIndex++) {
|
|
441
621
|
const week = {
|
|
442
622
|
weekNumber: DateUtils.getWeekNumber(currentDate),
|
|
@@ -455,7 +635,7 @@ export class Calendar {
|
|
|
455
635
|
isCurrentMonth,
|
|
456
636
|
isToday,
|
|
457
637
|
isWeekend,
|
|
458
|
-
events:
|
|
638
|
+
events: eventsByDate.get(DateUtils.getLocalDateString(dayDate)) || []
|
|
459
639
|
});
|
|
460
640
|
|
|
461
641
|
// Use DateUtils.addDays to handle month boundaries correctly
|
|
@@ -488,8 +668,12 @@ export class Calendar {
|
|
|
488
668
|
const days = [];
|
|
489
669
|
const currentDate = new Date(startDate);
|
|
490
670
|
|
|
671
|
+
// Expand recurring series once for the whole week, not once per day
|
|
672
|
+
const eventsByDate = this.getEventsByDate(startDate, endDate);
|
|
673
|
+
|
|
491
674
|
for (let i = 0; i < 7; i++) {
|
|
492
675
|
const dayDate = new Date(currentDate);
|
|
676
|
+
const events = eventsByDate.get(DateUtils.getLocalDateString(dayDate)) || [];
|
|
493
677
|
days.push({
|
|
494
678
|
date: dayDate,
|
|
495
679
|
dayOfMonth: dayDate.getDate(),
|
|
@@ -497,9 +681,9 @@ export class Calendar {
|
|
|
497
681
|
dayName: DateUtils.getDayName(dayDate, this.state.get('locale')),
|
|
498
682
|
isToday: DateUtils.isToday(dayDate),
|
|
499
683
|
isWeekend: dayDate.getDay() === 0 || dayDate.getDay() === 6,
|
|
500
|
-
events
|
|
684
|
+
events,
|
|
501
685
|
// Add overlap groups for positioning overlapping events
|
|
502
|
-
overlapGroups: this.eventStore.
|
|
686
|
+
overlapGroups: this.eventStore.groupOverlappingEvents(events, true),
|
|
503
687
|
getEventPositions: events => this.eventStore.calculateEventPositions(events)
|
|
504
688
|
});
|
|
505
689
|
// Move to next day
|
package/core/events/Event.js
CHANGED
|
@@ -6,6 +6,42 @@
|
|
|
6
6
|
|
|
7
7
|
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Structural equality for plain event sub-values (attendees, metadata, rules...).
|
|
11
|
+
* Handles primitives, Dates, arrays (order-sensitive) and plain objects
|
|
12
|
+
* (key-order insensitive). Functions and symbols never compare equal unless
|
|
13
|
+
* they are the same reference.
|
|
14
|
+
* @param {*} a
|
|
15
|
+
* @param {*} b
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function deepEqual(a, b) {
|
|
19
|
+
if (a === b) return true;
|
|
20
|
+
if (a == null || b == null) return false;
|
|
21
|
+
if (typeof a !== 'object' || typeof b !== 'object') {
|
|
22
|
+
// NaN is the only primitive that is not === itself
|
|
23
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
24
|
+
}
|
|
25
|
+
if (a instanceof Date || b instanceof Date) {
|
|
26
|
+
return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
29
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
30
|
+
for (let i = 0; i < a.length; i++) {
|
|
31
|
+
if (!deepEqual(a[i], b[i])) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const keysA = Object.keys(a).filter(k => a[k] !== undefined);
|
|
36
|
+
const keysB = Object.keys(b).filter(k => b[k] !== undefined);
|
|
37
|
+
if (keysA.length !== keysB.length) return false;
|
|
38
|
+
for (const key of keysA) {
|
|
39
|
+
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
40
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
9
45
|
export class Event {
|
|
10
46
|
// Field size limits
|
|
11
47
|
static FIELD_LIMITS = {
|
|
@@ -289,6 +325,16 @@ export class Event {
|
|
|
289
325
|
this.recurring = normalized.recurring;
|
|
290
326
|
this.recurrenceRule = normalized.recurrenceRule;
|
|
291
327
|
|
|
328
|
+
// Occurrence identity. Set by EventStore.expandRecurringEvent on the
|
|
329
|
+
// instances it derives from a recurring series; stored events keep the
|
|
330
|
+
// defaults. The id of an occurrence is Event.occurrenceId(master, start).
|
|
331
|
+
/** @type {boolean} True when this instance is one occurrence of a recurring series */
|
|
332
|
+
this.isOccurrence = false;
|
|
333
|
+
/** @type {string|null} Id of the recurring master this occurrence belongs to */
|
|
334
|
+
this.recurringEventId = null;
|
|
335
|
+
/** @type {Date|null} Start of this occurrence as generated by the recurrence rule */
|
|
336
|
+
this.occurrenceStart = null;
|
|
337
|
+
|
|
292
338
|
// Store original timezone from system if not provided
|
|
293
339
|
this._originalTimeZone = normalized.timeZone || null;
|
|
294
340
|
|
|
@@ -609,6 +655,133 @@ export class Event {
|
|
|
609
655
|
);
|
|
610
656
|
}
|
|
611
657
|
|
|
658
|
+
/**
|
|
659
|
+
* Fields compared by {@link Event.isEquivalent}, in comparison order.
|
|
660
|
+
* Scalars are compared with strict equality, dates by timestamp and
|
|
661
|
+
* structured fields (recurrence rule, organizer, attendees, reminders,
|
|
662
|
+
* categories, attachments, conference data, metadata) structurally.
|
|
663
|
+
* @type {ReadonlyArray<string>}
|
|
664
|
+
*/
|
|
665
|
+
static EQUIVALENCE_FIELDS = Object.freeze([
|
|
666
|
+
'id',
|
|
667
|
+
'title',
|
|
668
|
+
'start',
|
|
669
|
+
'end',
|
|
670
|
+
'allDay',
|
|
671
|
+
'timeZone',
|
|
672
|
+
'endTimeZone',
|
|
673
|
+
'description',
|
|
674
|
+
'location',
|
|
675
|
+
'color',
|
|
676
|
+
'backgroundColor',
|
|
677
|
+
'borderColor',
|
|
678
|
+
'textColor',
|
|
679
|
+
'recurring',
|
|
680
|
+
'recurrenceRule',
|
|
681
|
+
'status',
|
|
682
|
+
'visibility',
|
|
683
|
+
'organizer',
|
|
684
|
+
'attendees',
|
|
685
|
+
'reminders',
|
|
686
|
+
'categories',
|
|
687
|
+
'attachments',
|
|
688
|
+
'conferenceData',
|
|
689
|
+
'metadata'
|
|
690
|
+
]);
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Deep equivalence check over the full event data surface.
|
|
694
|
+
*
|
|
695
|
+
* Unlike {@link Event#equals} (which only looks at identity, title, dates,
|
|
696
|
+
* description, location, recurrence and status) this compares every field
|
|
697
|
+
* that can be supplied through {@link EventData}: timezones, all-day flag,
|
|
698
|
+
* colours, visibility, organizer, attendees, reminders, categories,
|
|
699
|
+
* attachments, conference data and metadata. Dates are compared by
|
|
700
|
+
* timestamp; structured fields are compared structurally (arrays are
|
|
701
|
+
* order-sensitive, object key order is ignored). Plain event data objects
|
|
702
|
+
* are normalized through the {@link Event} constructor before comparison so
|
|
703
|
+
* that `{ color: 'red' }` and `{ backgroundColor: 'red', borderColor: 'red' }`
|
|
704
|
+
* describe the same event. Two events with different ids are never
|
|
705
|
+
* equivalent.
|
|
706
|
+
*
|
|
707
|
+
* This is the default comparator used by `EventStore.reconcile()` to decide
|
|
708
|
+
* whether an incoming snapshot entry replaces the stored event.
|
|
709
|
+
*
|
|
710
|
+
* @example
|
|
711
|
+
* Event.isEquivalent(stored, { ...stored.toObject(), backgroundColor: '#f00' }); // false
|
|
712
|
+
* Event.isEquivalent(stored, stored.clone()); // true
|
|
713
|
+
*
|
|
714
|
+
* @param {Event|import('../types.js').EventData} a - First event or raw event data
|
|
715
|
+
* @param {Event|import('../types.js').EventData} b - Second event or raw event data
|
|
716
|
+
* @returns {boolean} True when both describe the same event data
|
|
717
|
+
* @throws {Error} If raw event data fails {@link Event.validate}
|
|
718
|
+
*/
|
|
719
|
+
static isEquivalent(a, b) {
|
|
720
|
+
if (a === b) return true;
|
|
721
|
+
if (a == null || b == null) return false;
|
|
722
|
+
|
|
723
|
+
const left = a instanceof Event ? a : new Event(a);
|
|
724
|
+
const right = b instanceof Event ? b : new Event(b);
|
|
725
|
+
|
|
726
|
+
for (const field of Event.EQUIVALENCE_FIELDS) {
|
|
727
|
+
if (!deepEqual(left[field], right[field])) {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Build the id of one occurrence of a recurring series.
|
|
736
|
+
*
|
|
737
|
+
* The id is `<recurringEventId>_<startMs>` where `startMs` is the
|
|
738
|
+
* occurrence start as returned by `Date.prototype.getTime()`. It is
|
|
739
|
+
* deterministic, so the same occurrence gets the same id no matter which
|
|
740
|
+
* range it was expanded for, and it matches the ids generated by
|
|
741
|
+
* `RecurrenceEngineV2`. Use {@link Event.parseOccurrenceId} to get the
|
|
742
|
+
* master id back.
|
|
743
|
+
*
|
|
744
|
+
* @example
|
|
745
|
+
* Event.occurrenceId('standup', new Date(2025, 5, 16, 9)); // 'standup_1750028400000'
|
|
746
|
+
*
|
|
747
|
+
* @param {string} recurringEventId - Id of the recurring master event
|
|
748
|
+
* @param {Date|number|string} occurrenceStart - Start of the occurrence
|
|
749
|
+
* @returns {string} Occurrence id
|
|
750
|
+
*/
|
|
751
|
+
static occurrenceId(recurringEventId, occurrenceStart) {
|
|
752
|
+
const start = occurrenceStart instanceof Date ? occurrenceStart : new Date(occurrenceStart);
|
|
753
|
+
return `${recurringEventId}_${start.getTime()}`;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Split an occurrence id built by {@link Event.occurrenceId} into the master
|
|
758
|
+
* id and the occurrence start.
|
|
759
|
+
*
|
|
760
|
+
* Returns `null` for ids that do not have the `<id>_<startMs>` shape. A
|
|
761
|
+
* positive result only means the id is well-formed; whether the master
|
|
762
|
+
* exists is for the caller (see `EventStore.getEvent`) to check.
|
|
763
|
+
*
|
|
764
|
+
* @param {string} id - Candidate occurrence id
|
|
765
|
+
* @returns {{recurringEventId: string, occurrenceStart: Date}|null} Parsed parts or null
|
|
766
|
+
*/
|
|
767
|
+
static parseOccurrenceId(id) {
|
|
768
|
+
if (typeof id !== 'string') {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
const separator = id.lastIndexOf('_');
|
|
772
|
+
if (separator <= 0 || separator === id.length - 1) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const time = id.slice(separator + 1);
|
|
776
|
+
if (!/^-?\d+$/.test(time)) {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
recurringEventId: id.slice(0, separator),
|
|
781
|
+
occurrenceStart: new Date(Number(time))
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
612
785
|
// ============ Attendee Management Methods ============
|
|
613
786
|
|
|
614
787
|
/**
|