@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.
@@ -178,9 +178,12 @@ export class Calendar {
178
178
 
179
179
  /**
180
180
  * Update an event
181
- * @param {string} eventId - The event ID
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
- * @param {string} eventId - The event ID
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
- * @param {string} eventId - The event ID
222
- * @returns {Event|null}
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
- * @param {Event[]} events - Array of events
239
- */
240
- setEvents(events) {
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
- this._emit('eventsSet', { events: this.getEvents() });
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: this.getEventsForDate(dayDate)
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: this.getEventsForDate(dayDate),
684
+ events,
501
685
  // Add overlap groups for positioning overlapping events
502
- overlapGroups: this.eventStore.getOverlapGroups(dayDate, true),
686
+ overlapGroups: this.eventStore.groupOverlappingEvents(events, true),
503
687
  getEventPositions: events => this.eventStore.calculateEventPositions(events)
504
688
  });
505
689
  // Move to next day
@@ -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
  /**