@forcecalendar/core 2.2.0 → 2.4.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.
@@ -235,11 +235,103 @@ export class Calendar {
235
235
 
236
236
  /**
237
237
  * Set all events (replaces existing)
238
- * @param {Event[]} events - Array of events
239
- */
240
- setEvents(events) {
238
+ *
239
+ * By default this clears the store and re-adds every entry, so every stored
240
+ * {@link Event} instance is replaced. Pass `{ reconcile: true }` to apply only
241
+ * the differences instead (see {@link Calendar#reconcileEvents}).
242
+ *
243
+ * Emits a single `eventsSet` event whose payload lists the resulting
244
+ * `events` plus the `added`, `updated`, `removed` and `unchanged` sets, so
245
+ * listeners can tell a snapshot load apart from user mutations (no
246
+ * `eventAdd`/`eventUpdate`/`eventRemove` events are emitted).
247
+ *
248
+ * @example
249
+ * calendar.on('eventsSet', ({ added, updated, removed }) => {
250
+ * if (added.length || updated.length || removed.length) render();
251
+ * });
252
+ * calendar.setEvents(snapshot, { reconcile: true });
253
+ *
254
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Array of events
255
+ * @param {import('../types.js').SetEventsOptions} [options={}] - Load options
256
+ * @returns {import('../types.js').EventsSetPayload} The applied change set
257
+ */
258
+ setEvents(events, options = {}) {
259
+ if (options.reconcile) {
260
+ return this.reconcileEvents(events, options);
261
+ }
262
+
263
+ const removed = this.getEvents();
241
264
  this.eventStore.loadEvents(events);
242
- this._emit('eventsSet', { events: this.getEvents() });
265
+ const added = this.getEvents();
266
+
267
+ /** @type {import('../types.js').EventsSetPayload} */
268
+ const payload = { events: added, added, updated: [], removed, unchanged: [] };
269
+ this._emit('eventsSet', payload);
270
+ return payload;
271
+ }
272
+
273
+ /**
274
+ * Reconcile the calendar with a snapshot of events, applying only the differences.
275
+ *
276
+ * Intended for consumers that receive periodic full snapshots (polling a
277
+ * server, a reactive `events` prop). Unchanged events keep their existing
278
+ * {@link Event} instance, changed ones are replaced, new ones are added and
279
+ * events missing from the snapshot are removed (unless
280
+ * `removeMissing: false`). Equivalence is decided by
281
+ * {@link Event.isEquivalent} unless an `isEquivalent` comparator is supplied.
282
+ * Plain event data without a `timeZone` defaults to the calendar timezone,
283
+ * exactly as with {@link Calendar#addEvent}.
284
+ *
285
+ * The store emits one `eventStoreChange` of type `batch` (or none when
286
+ * nothing differs) and the calendar emits a single `eventsSet` event
287
+ * carrying the change set. Per-event `eventAdd`/`eventUpdate`/`eventRemove`
288
+ * events are not emitted, so listeners that forward those to a backend are
289
+ * not triggered by a snapshot load.
290
+ *
291
+ * @example
292
+ * const { added, updated, removed, unchanged } = calendar.reconcileEvents(rows);
293
+ * updated.forEach(({ event, oldEvent }) => console.log(oldEvent.title, '->', event.title));
294
+ *
295
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
296
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
297
+ * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
298
+ * @throws {Error} If an entry fails validation or two entries share an id
299
+ */
300
+ reconcileEvents(events, options = {}) {
301
+ const { removeMissing, isEquivalent } = options;
302
+ const prepared = [];
303
+ for (const eventData of events) {
304
+ if (!(eventData instanceof Event) && eventData && !eventData.timeZone) {
305
+ prepared.push({ ...eventData, timeZone: this.config.timeZone });
306
+ } else {
307
+ prepared.push(eventData);
308
+ }
309
+ }
310
+
311
+ const storeOptions = {};
312
+ if (removeMissing !== undefined) storeOptions.removeMissing = removeMissing;
313
+ if (isEquivalent !== undefined) storeOptions.isEquivalent = isEquivalent;
314
+
315
+ const result = this.eventStore.reconcile(prepared, storeOptions);
316
+
317
+ /** @type {import('../types.js').EventsSetPayload} */
318
+ const payload = { events: this.getEvents(), ...result };
319
+ this._emit('eventsSet', payload);
320
+ return payload;
321
+ }
322
+
323
+ /**
324
+ * Get the event store's change counter
325
+ *
326
+ * The counter increases with every add/update/remove/clear and every
327
+ * committed batch, so comparing two readings is a cheap way to find out
328
+ * whether {@link Calendar#setEvents} or {@link Calendar#reconcileEvents}
329
+ * changed anything.
330
+ *
331
+ * @returns {number} Current store version
332
+ */
333
+ getEventsVersion() {
334
+ return this.eventStore.version;
243
335
  }
244
336
 
245
337
  /**
@@ -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 = {
@@ -609,6 +645,82 @@ export class Event {
609
645
  );
610
646
  }
611
647
 
648
+ /**
649
+ * Fields compared by {@link Event.isEquivalent}, in comparison order.
650
+ * Scalars are compared with strict equality, dates by timestamp and
651
+ * structured fields (recurrence rule, organizer, attendees, reminders,
652
+ * categories, attachments, conference data, metadata) structurally.
653
+ * @type {ReadonlyArray<string>}
654
+ */
655
+ static EQUIVALENCE_FIELDS = Object.freeze([
656
+ 'id',
657
+ 'title',
658
+ 'start',
659
+ 'end',
660
+ 'allDay',
661
+ 'timeZone',
662
+ 'endTimeZone',
663
+ 'description',
664
+ 'location',
665
+ 'color',
666
+ 'backgroundColor',
667
+ 'borderColor',
668
+ 'textColor',
669
+ 'recurring',
670
+ 'recurrenceRule',
671
+ 'status',
672
+ 'visibility',
673
+ 'organizer',
674
+ 'attendees',
675
+ 'reminders',
676
+ 'categories',
677
+ 'attachments',
678
+ 'conferenceData',
679
+ 'metadata'
680
+ ]);
681
+
682
+ /**
683
+ * Deep equivalence check over the full event data surface.
684
+ *
685
+ * Unlike {@link Event#equals} (which only looks at identity, title, dates,
686
+ * description, location, recurrence and status) this compares every field
687
+ * that can be supplied through {@link EventData}: timezones, all-day flag,
688
+ * colours, visibility, organizer, attendees, reminders, categories,
689
+ * attachments, conference data and metadata. Dates are compared by
690
+ * timestamp; structured fields are compared structurally (arrays are
691
+ * order-sensitive, object key order is ignored). Plain event data objects
692
+ * are normalized through the {@link Event} constructor before comparison so
693
+ * that `{ color: 'red' }` and `{ backgroundColor: 'red', borderColor: 'red' }`
694
+ * describe the same event. Two events with different ids are never
695
+ * equivalent.
696
+ *
697
+ * This is the default comparator used by `EventStore.reconcile()` to decide
698
+ * whether an incoming snapshot entry replaces the stored event.
699
+ *
700
+ * @example
701
+ * Event.isEquivalent(stored, { ...stored.toObject(), backgroundColor: '#f00' }); // false
702
+ * Event.isEquivalent(stored, stored.clone()); // true
703
+ *
704
+ * @param {Event|import('../types.js').EventData} a - First event or raw event data
705
+ * @param {Event|import('../types.js').EventData} b - Second event or raw event data
706
+ * @returns {boolean} True when both describe the same event data
707
+ * @throws {Error} If raw event data fails {@link Event.validate}
708
+ */
709
+ static isEquivalent(a, b) {
710
+ if (a === b) return true;
711
+ if (a == null || b == null) return false;
712
+
713
+ const left = a instanceof Event ? a : new Event(a);
714
+ const right = b instanceof Event ? b : new Event(b);
715
+
716
+ for (const field of Event.EQUIVALENCE_FIELDS) {
717
+ if (!deepEqual(left[field], right[field])) {
718
+ return false;
719
+ }
720
+ }
721
+ return true;
722
+ }
723
+
612
724
  // ============ Attendee Management Methods ============
613
725
 
614
726
  /**
@@ -86,19 +86,11 @@ export class EventStore {
86
86
  this._indexEvent(event);
87
87
 
88
88
  // Notify listeners (batch if in batch mode)
89
- if (this.isBatchMode) {
90
- this.batchNotifications.push({
91
- type: 'add',
92
- event,
93
- version: ++this.version
94
- });
95
- } else {
96
- this._notifyChange({
97
- type: 'add',
98
- event,
99
- version: ++this.version
100
- });
101
- }
89
+ this._queueChange({
90
+ type: 'add',
91
+ event,
92
+ version: ++this.version
93
+ });
102
94
 
103
95
  return event;
104
96
  });
@@ -117,24 +109,10 @@ export class EventStore {
117
109
  throw new Error(`Event with id ${eventId} not found`);
118
110
  }
119
111
 
120
- // Remove old indices
121
- this._unindexEvent(existingEvent);
122
-
123
112
  // Create updated event
124
113
  const updatedEvent = existingEvent.clone(updates);
125
114
 
126
- // Store updated event
127
- this.events.set(eventId, updatedEvent);
128
-
129
- // Update cache with new event data
130
- this.optimizer.cache(eventId, updatedEvent, 'event');
131
-
132
- // Clear query and date range caches since results may have changed
133
- this.optimizer.queryCache.clear();
134
- this.optimizer.dateRangeCache.clear();
135
-
136
- // Re-index
137
- this._indexEvent(updatedEvent);
115
+ this._replaceEvent(existingEvent, updatedEvent);
138
116
 
139
117
  // Notify listeners
140
118
  this._notifyChange({
@@ -147,6 +125,31 @@ export class EventStore {
147
125
  return updatedEvent;
148
126
  }
149
127
 
128
+ /**
129
+ * Swap a stored event for a new instance with the same id, keeping
130
+ * indices and caches in sync. Does not notify listeners.
131
+ * @param {Event} existingEvent - Event currently in the store
132
+ * @param {Event} replacement - Event instance that takes its place
133
+ * @private
134
+ */
135
+ _replaceEvent(existingEvent, replacement) {
136
+ // Remove old indices
137
+ this._unindexEvent(existingEvent);
138
+
139
+ // Store replacement
140
+ this.events.set(replacement.id, replacement);
141
+
142
+ // Update cache with new event data
143
+ this.optimizer.cache(replacement.id, replacement, 'event');
144
+
145
+ // Clear query and date range caches since results may have changed
146
+ this.optimizer.queryCache.clear();
147
+ this.optimizer.dateRangeCache.clear();
148
+
149
+ // Re-index
150
+ this._indexEvent(replacement);
151
+ }
152
+
150
153
  /**
151
154
  * Remove an event from the store
152
155
  * @param {string} eventId - The event ID to remove
@@ -158,16 +161,7 @@ export class EventStore {
158
161
  return false;
159
162
  }
160
163
 
161
- // Remove from primary storage
162
- this.events.delete(eventId);
163
-
164
- // Invalidate caches
165
- this.optimizer.eventCache.delete(eventId);
166
- this.optimizer.queryCache.clear();
167
- this.optimizer.dateRangeCache.clear();
168
-
169
- // Remove from indices
170
- this._unindexEvent(event);
164
+ this._detachEvent(event);
171
165
 
172
166
  // Notify listeners
173
167
  this._notifyChange({
@@ -179,6 +173,24 @@ export class EventStore {
179
173
  return true;
180
174
  }
181
175
 
176
+ /**
177
+ * Remove an event from storage, caches and indices. Does not notify listeners.
178
+ * @param {Event} event - Event currently in the store
179
+ * @private
180
+ */
181
+ _detachEvent(event) {
182
+ // Remove from primary storage
183
+ this.events.delete(event.id);
184
+
185
+ // Invalidate caches
186
+ this.optimizer.eventCache.delete(event.id);
187
+ this.optimizer.queryCache.clear();
188
+ this.optimizer.dateRangeCache.clear();
189
+
190
+ // Remove from indices
191
+ this._unindexEvent(event);
192
+ }
193
+
182
194
  /**
183
195
  * Get an event by ID
184
196
  * @param {string} eventId - The event ID
@@ -705,6 +717,118 @@ export class EventStore {
705
717
  this.commitBatch();
706
718
  }
707
719
 
720
+ /**
721
+ * Reconcile the store with a snapshot of events, applying only the differences.
722
+ *
723
+ * Compared with {@link EventStore#loadEvents} (clear + re-add everything) this:
724
+ * - keeps the existing {@link Event} instance for every entry that is
725
+ * equivalent to the stored one (identity is preserved, no notification),
726
+ * - replaces stored events whose incoming data differs (`update` change),
727
+ * - adds events whose id is not in the store (`add` change),
728
+ * - removes stored events missing from the snapshot (`remove` change),
729
+ * unless `removeMissing` is `false`,
730
+ * - emits a single `batch` notification listing those changes, or nothing at
731
+ * all when the snapshot matches the store. When called while a batch is
732
+ * already open the changes are queued on that batch instead.
733
+ *
734
+ * Input is validated up front: invalid event data or duplicate ids throw
735
+ * before the store is modified. Any error raised while applying the diff
736
+ * rolls the store back to its previous state.
737
+ *
738
+ * @example
739
+ * // periodic server snapshot
740
+ * const { added, updated, removed } = store.reconcile(rowsFromServer);
741
+ * if (added.length || updated.length || removed.length) rerender();
742
+ *
743
+ * @param {Array<Event|import('../types.js').EventData>} events - Complete snapshot of events
744
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
745
+ * @returns {import('../types.js').ReconcileResult} Events that were added, updated, removed and left untouched
746
+ * @throws {Error} If an entry fails validation or two entries share an id
747
+ */
748
+ reconcile(events, options = {}) {
749
+ const { removeMissing = true, isEquivalent = Event.isEquivalent } = options;
750
+
751
+ if (!events || typeof events[Symbol.iterator] !== 'function') {
752
+ throw new Error('reconcile() expects an iterable of events');
753
+ }
754
+ if (typeof isEquivalent !== 'function') {
755
+ throw new Error('reconcile() option isEquivalent must be a function');
756
+ }
757
+
758
+ return this.optimizer.measure('reconcile', () => {
759
+ // Normalize and validate everything before touching the store
760
+ /** @type {Map<string, Event>} */
761
+ const incoming = new Map();
762
+ for (const eventData of events) {
763
+ const event = eventData instanceof Event ? eventData : new Event(eventData);
764
+ if (incoming.has(event.id)) {
765
+ throw new Error(`Duplicate event id in reconcile input: ${event.id}`);
766
+ }
767
+ incoming.set(event.id, event);
768
+ }
769
+
770
+ /** @type {import('../types.js').ReconcileResult} */
771
+ const result = { added: [], updated: [], removed: [], unchanged: [] };
772
+
773
+ // Nest inside an existing batch if one is open, otherwise own one
774
+ const ownsBatch = !this.isBatchMode;
775
+ if (ownsBatch) {
776
+ this.startBatch(true);
777
+ }
778
+
779
+ try {
780
+ if (removeMissing) {
781
+ for (const existing of Array.from(this.events.values())) {
782
+ if (!incoming.has(existing.id)) {
783
+ this._detachEvent(existing);
784
+ this._queueChange({ type: 'remove', event: existing, version: ++this.version });
785
+ result.removed.push(existing);
786
+ }
787
+ }
788
+ }
789
+
790
+ for (const event of incoming.values()) {
791
+ const existing = this.events.get(event.id);
792
+ if (!existing) {
793
+ this.events.set(event.id, event);
794
+ this.optimizer.cache(event.id, event, 'event');
795
+ this._indexEvent(event);
796
+ this._queueChange({ type: 'add', event, version: ++this.version });
797
+ result.added.push(event);
798
+ } else if (existing === event || isEquivalent(existing, event)) {
799
+ result.unchanged.push(existing);
800
+ } else {
801
+ this._replaceEvent(existing, event);
802
+ this._queueChange({
803
+ type: 'update',
804
+ event,
805
+ oldEvent: existing,
806
+ version: ++this.version
807
+ });
808
+ result.updated.push({ event, oldEvent: existing });
809
+ }
810
+ }
811
+
812
+ if (result.added.length > 0) {
813
+ // Newly indexed events may change range/query results
814
+ this.optimizer.queryCache.clear();
815
+ this.optimizer.dateRangeCache.clear();
816
+ }
817
+ } catch (error) {
818
+ if (ownsBatch) {
819
+ this.rollbackBatch();
820
+ }
821
+ throw error;
822
+ }
823
+
824
+ if (ownsBatch) {
825
+ this.commitBatch();
826
+ }
827
+
828
+ return result;
829
+ });
830
+ }
831
+
708
832
  /**
709
833
  * Subscribe to store changes
710
834
  * @param {Function} callback - Callback function
@@ -949,6 +1073,19 @@ export class EventStore {
949
1073
  * Notify listeners of changes
950
1074
  * @private
951
1075
  */
1076
+ /**
1077
+ * Deliver a change now, or queue it when a batch is open
1078
+ * @param {import('../types.js').EventStoreChange} change - Change to deliver
1079
+ * @private
1080
+ */
1081
+ _queueChange(change) {
1082
+ if (this.isBatchMode) {
1083
+ this.batchNotifications.push(change);
1084
+ } else {
1085
+ this._notifyChange(change);
1086
+ }
1087
+ }
1088
+
952
1089
  _notifyChange(change) {
953
1090
  for (const listener of this.listeners) {
954
1091
  try {
@@ -1066,7 +1203,7 @@ export class EventStore {
1066
1203
  this.batchBackup = null;
1067
1204
 
1068
1205
  // Clear cache
1069
- this.optimizer.clearCache();
1206
+ this.clearCaches();
1070
1207
  }
1071
1208
 
1072
1209
  this.batchNotifications = [];