@forcecalendar/core 2.5.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/calendar/Calendar.js +93 -17
- package/core/events/Event.js +46 -15
- package/core/events/EventStore.js +184 -20
- package/core/events/RRuleParser.js +19 -3
- package/core/events/RecurrenceEngine.js +63 -18
- package/core/events/RecurrenceEngineV2.js +219 -43
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +38 -0
- package/core/timezone/TimezoneManager.js +45 -9
- package/core/types.js +9 -0
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +50 -6
- package/types/events/Event.d.ts +11 -0
- package/types/events/EventStore.d.ts +76 -12
- package/types/events/RRuleParser.d.ts +10 -1
- package/types/events/RecurrenceEngine.d.ts +4 -2
- package/types/events/RecurrenceEngineV2.d.ts +68 -9
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +7 -0
- package/types/types.d.ts +26 -0
|
@@ -235,6 +235,35 @@ export class Calendar {
|
|
|
235
235
|
return this.eventStore.getEvent(eventId);
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Resolve an event id or occurrence id to the id of the stored event it
|
|
240
|
+
* refers to: the id itself for a stored event, the master's id for an
|
|
241
|
+
* occurrence id taken from view data, `null` when nothing stored matches.
|
|
242
|
+
* See `EventStore.resolveEventId`.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* calendar.resolveEventId('standup_1750028400000'); // 'standup'
|
|
246
|
+
* calendar.resolveEventId('unknown'); // null
|
|
247
|
+
*
|
|
248
|
+
* @param {string} id - Event id or occurrence id
|
|
249
|
+
* @returns {string|null} Id of the stored event, or null
|
|
250
|
+
*/
|
|
251
|
+
resolveEventId(id) {
|
|
252
|
+
return this.eventStore.resolveEventId(id);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get the occurrence an occurrence id from view data stands for, as an
|
|
257
|
+
* {@link Event} like the ones the views hold, or `null` when the id is
|
|
258
|
+
* not an occurrence of a stored recurring series. See
|
|
259
|
+
* `EventStore.getOccurrence`.
|
|
260
|
+
* @param {string} occurrenceId - Occurrence id (`<masterId>_<startMs>`)
|
|
261
|
+
* @returns {Event|null} The occurrence, or null
|
|
262
|
+
*/
|
|
263
|
+
getOccurrence(occurrenceId) {
|
|
264
|
+
return this.eventStore.getOccurrence(occurrenceId, this.config.timeZone);
|
|
265
|
+
}
|
|
266
|
+
|
|
238
267
|
/**
|
|
239
268
|
* Get all stored events (recurring masters, never their occurrences)
|
|
240
269
|
* @returns {Event[]}
|
|
@@ -269,13 +298,14 @@ export class Calendar {
|
|
|
269
298
|
if (options.reconcile) {
|
|
270
299
|
return this.reconcileEvents(events, options);
|
|
271
300
|
}
|
|
301
|
+
this._assertIterable(events, 'setEvents');
|
|
272
302
|
|
|
273
303
|
const removed = this.getEvents();
|
|
274
304
|
this.eventStore.loadEvents(events);
|
|
275
305
|
const added = this.getEvents();
|
|
276
306
|
|
|
277
307
|
/** @type {import('../types.js').EventsSetPayload} */
|
|
278
|
-
const payload = { events:
|
|
308
|
+
const payload = { events: this.getEvents(), added, updated: [], removed, unchanged: [] };
|
|
279
309
|
this._emit('eventsSet', payload);
|
|
280
310
|
return payload;
|
|
281
311
|
}
|
|
@@ -305,9 +335,10 @@ export class Calendar {
|
|
|
305
335
|
* @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
|
|
306
336
|
* @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
|
|
307
337
|
* @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
|
|
338
|
+
* @throws {Error} If `events` is not iterable, an entry fails validation or two entries share an id
|
|
309
339
|
*/
|
|
310
340
|
reconcileEvents(events, options = {}) {
|
|
341
|
+
this._assertIterable(events, 'reconcileEvents');
|
|
311
342
|
const { removeMissing, isEquivalent } = options;
|
|
312
343
|
const prepared = [];
|
|
313
344
|
for (const eventData of events) {
|
|
@@ -330,6 +361,18 @@ export class Calendar {
|
|
|
330
361
|
return payload;
|
|
331
362
|
}
|
|
332
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Throw a clear error when a snapshot is not iterable
|
|
366
|
+
* @param {*} events - Candidate snapshot
|
|
367
|
+
* @param {string} method - Calling method, for the message
|
|
368
|
+
* @private
|
|
369
|
+
*/
|
|
370
|
+
_assertIterable(events, method) {
|
|
371
|
+
if (!events || typeof events[Symbol.iterator] !== 'function') {
|
|
372
|
+
throw new Error(`${method}() expects an iterable of events`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
333
376
|
/**
|
|
334
377
|
* Get the event store's change counter
|
|
335
378
|
*
|
|
@@ -402,7 +445,7 @@ export class Calendar {
|
|
|
402
445
|
* remind(occurrence);
|
|
403
446
|
* }
|
|
404
447
|
*
|
|
405
|
-
* @param {string} eventId -
|
|
448
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
406
449
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
407
450
|
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
408
451
|
* @throws {Error} If no event with the ID exists
|
|
@@ -419,7 +462,7 @@ export class Calendar {
|
|
|
419
462
|
* @example
|
|
420
463
|
* const upcoming = calendar.getNextOccurrence('standup', new Date());
|
|
421
464
|
*
|
|
422
|
-
* @param {string} eventId -
|
|
465
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
423
466
|
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
424
467
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
425
468
|
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
@@ -436,8 +479,8 @@ export class Calendar {
|
|
|
436
479
|
* @example
|
|
437
480
|
* const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
|
|
438
481
|
*
|
|
439
|
-
* @param {string} eventId -
|
|
440
|
-
* @param {number} count - Maximum number of occurrences to return
|
|
482
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
483
|
+
* @param {number} count - Maximum number of occurrences to return (fractions are floored)
|
|
441
484
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
442
485
|
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
443
486
|
* @throws {Error} If no event with the ID exists
|
|
@@ -704,11 +747,19 @@ export class Calendar {
|
|
|
704
747
|
* @private
|
|
705
748
|
*/
|
|
706
749
|
_getDayViewData(date) {
|
|
750
|
+
const timezone = this.config.timeZone;
|
|
707
751
|
const events = this.getEventsForDate(date);
|
|
708
752
|
|
|
709
|
-
// Separate all-day and timed events
|
|
753
|
+
// Separate all-day and timed events. Hour slots are placed on the
|
|
754
|
+
// calendar's wall clock, so timed events are compared in that timezone.
|
|
710
755
|
const allDayEvents = events.filter(e => e.allDay);
|
|
711
|
-
const timedEvents = events
|
|
756
|
+
const timedEvents = events
|
|
757
|
+
.filter(e => !e.allDay)
|
|
758
|
+
.map(event => ({
|
|
759
|
+
event,
|
|
760
|
+
start: event.getStartInTimezone(timezone),
|
|
761
|
+
end: event.getEndInTimezone(timezone)
|
|
762
|
+
}));
|
|
712
763
|
|
|
713
764
|
// Create hourly slots for timed events
|
|
714
765
|
const hours = [];
|
|
@@ -721,11 +772,13 @@ export class Calendar {
|
|
|
721
772
|
hours.push({
|
|
722
773
|
hour,
|
|
723
774
|
time: DateUtils.formatTime(hourDate, this.state.get('locale')),
|
|
724
|
-
events: timedEvents
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
775
|
+
events: timedEvents
|
|
776
|
+
.filter(({ start, end }) => {
|
|
777
|
+
// Check if event occurs during this hour (not just starts)
|
|
778
|
+
// Event occurs in this hour if it overlaps with the hour slot
|
|
779
|
+
return start < hourEnd && end > hourDate;
|
|
780
|
+
})
|
|
781
|
+
.map(({ event }) => event)
|
|
729
782
|
});
|
|
730
783
|
}
|
|
731
784
|
|
|
@@ -787,14 +840,37 @@ export class Calendar {
|
|
|
787
840
|
|
|
788
841
|
/**
|
|
789
842
|
* Select an event
|
|
790
|
-
*
|
|
843
|
+
*
|
|
844
|
+
* Accepts a stored event's id or an occurrence id taken from view data
|
|
845
|
+
* (see {@link Event.occurrenceId}). Either way the stored event is what
|
|
846
|
+
* gets selected: `selectedEventId` in the state holds its id, so it can
|
|
847
|
+
* always be looked up with {@link Calendar#getEvent}. The `eventSelect`
|
|
848
|
+
* payload carries the stored `event`, its `eventId`, and for an
|
|
849
|
+
* occurrence id also `occurrenceId` and the `occurrence` itself (an
|
|
850
|
+
* {@link Event} as in view data, or null when the series has no
|
|
851
|
+
* occurrence at that instant). Nothing happens for an unknown id.
|
|
852
|
+
*
|
|
853
|
+
* @example
|
|
854
|
+
* calendar.on('eventSelect', ({ event, occurrence }) => open(occurrence || event));
|
|
855
|
+
* calendar.selectEvent(chip.dataset.eventId);
|
|
856
|
+
*
|
|
857
|
+
* @param {string} eventId - Event id or occurrence id to select
|
|
791
858
|
*/
|
|
792
859
|
selectEvent(eventId) {
|
|
793
860
|
const event = this.getEvent(eventId);
|
|
794
|
-
if (event) {
|
|
795
|
-
|
|
796
|
-
this._emit('eventSelect', { event });
|
|
861
|
+
if (!event) {
|
|
862
|
+
return;
|
|
797
863
|
}
|
|
864
|
+
const isOccurrence = eventId !== event.id;
|
|
865
|
+
this.state.selectEvent(event.id);
|
|
866
|
+
/** @type {import('../types.js').EventSelectPayload} */
|
|
867
|
+
const payload = {
|
|
868
|
+
event,
|
|
869
|
+
eventId: event.id,
|
|
870
|
+
occurrenceId: isOccurrence ? eventId : null,
|
|
871
|
+
occurrence: isOccurrence ? this.getOccurrence(eventId) : null
|
|
872
|
+
};
|
|
873
|
+
this._emit('eventSelect', payload);
|
|
798
874
|
}
|
|
799
875
|
|
|
800
876
|
/**
|
package/core/events/Event.js
CHANGED
|
@@ -42,6 +42,33 @@ function deepEqual(a, b) {
|
|
|
42
42
|
return true;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Timezone identifiers Intl has already accepted. Every Event construction
|
|
47
|
+
* (each clone, each occurrence of a recurring series) validates its
|
|
48
|
+
* timezone, and constructing an Intl.DateTimeFormat per event dominated
|
|
49
|
+
* the cost of expanding a month grid.
|
|
50
|
+
* @type {Set<string>}
|
|
51
|
+
*/
|
|
52
|
+
const validatedTimezones = new Set();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Throw unless Intl accepts the timezone identifier
|
|
56
|
+
* @param {string} timezone - IANA timezone identifier
|
|
57
|
+
* @param {string} label - Field name for the error message
|
|
58
|
+
* @throws {Error} If the timezone is not valid
|
|
59
|
+
*/
|
|
60
|
+
function assertValidTimezone(timezone, label) {
|
|
61
|
+
if (validatedTimezones.has(timezone)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw new Error(`Invalid ${label}: ${timezone}`, { cause: e });
|
|
68
|
+
}
|
|
69
|
+
validatedTimezones.add(timezone);
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
export class Event {
|
|
46
73
|
// Field size limits
|
|
47
74
|
static FIELD_LIMITS = {
|
|
@@ -204,22 +231,12 @@ export class Event {
|
|
|
204
231
|
});
|
|
205
232
|
}
|
|
206
233
|
|
|
207
|
-
// Validate
|
|
234
|
+
// Validate timezones if provided (memoised per identifier)
|
|
208
235
|
if (data.timeZone) {
|
|
209
|
-
|
|
210
|
-
new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
|
|
211
|
-
} catch (e) {
|
|
212
|
-
throw new Error(`Invalid timezone: ${data.timeZone}`, { cause: e });
|
|
213
|
-
}
|
|
236
|
+
assertValidTimezone(data.timeZone, 'timezone');
|
|
214
237
|
}
|
|
215
|
-
|
|
216
|
-
// Validate end timezone if provided
|
|
217
238
|
if (data.endTimeZone) {
|
|
218
|
-
|
|
219
|
-
new Intl.DateTimeFormat('en-US', { timeZone: data.endTimeZone });
|
|
220
|
-
} catch (e) {
|
|
221
|
-
throw new Error(`Invalid end timezone: ${data.endTimeZone}`, { cause: e });
|
|
222
|
-
}
|
|
239
|
+
assertValidTimezone(data.endTimeZone, 'end timezone');
|
|
223
240
|
}
|
|
224
241
|
}
|
|
225
242
|
|
|
@@ -660,6 +677,8 @@ export class Event {
|
|
|
660
677
|
* Scalars are compared with strict equality, dates by timestamp and
|
|
661
678
|
* structured fields (recurrence rule, organizer, attendees, reminders,
|
|
662
679
|
* categories, attachments, conference data, metadata) structurally.
|
|
680
|
+
* The `color` shorthand is not listed: normalization copies it into
|
|
681
|
+
* `backgroundColor` and `borderColor`, which are compared instead.
|
|
663
682
|
* @type {ReadonlyArray<string>}
|
|
664
683
|
*/
|
|
665
684
|
static EQUIVALENCE_FIELDS = Object.freeze([
|
|
@@ -672,7 +691,6 @@ export class Event {
|
|
|
672
691
|
'endTimeZone',
|
|
673
692
|
'description',
|
|
674
693
|
'location',
|
|
675
|
-
'color',
|
|
676
694
|
'backgroundColor',
|
|
677
695
|
'borderColor',
|
|
678
696
|
'textColor',
|
|
@@ -704,6 +722,14 @@ export class Event {
|
|
|
704
722
|
* describe the same event. Two events with different ids are never
|
|
705
723
|
* equivalent.
|
|
706
724
|
*
|
|
725
|
+
* Two things to know when building snapshots for `reconcile()`:
|
|
726
|
+
* - `attendees`, `reminders`, `categories` and `attachments` are compared
|
|
727
|
+
* in order, so the same attendees listed in a different order count as
|
|
728
|
+
* a change.
|
|
729
|
+
* - Only the top-level dates are normalized. Values inside `metadata` are
|
|
730
|
+
* compared as given, so a `Date` and its ISO string are not equivalent
|
|
731
|
+
* there; keep metadata in one representation.
|
|
732
|
+
*
|
|
707
733
|
* This is the default comparator used by `EventStore.reconcile()` to decide
|
|
708
734
|
* whether an incoming snapshot entry replaces the stored event.
|
|
709
735
|
*
|
|
@@ -747,10 +773,15 @@ export class Event {
|
|
|
747
773
|
* @param {string} recurringEventId - Id of the recurring master event
|
|
748
774
|
* @param {Date|number|string} occurrenceStart - Start of the occurrence
|
|
749
775
|
* @returns {string} Occurrence id
|
|
776
|
+
* @throws {TypeError} If occurrenceStart is not a valid date
|
|
750
777
|
*/
|
|
751
778
|
static occurrenceId(recurringEventId, occurrenceStart) {
|
|
752
779
|
const start = occurrenceStart instanceof Date ? occurrenceStart : new Date(occurrenceStart);
|
|
753
|
-
|
|
780
|
+
const startMs = start.getTime();
|
|
781
|
+
if (Number.isNaN(startMs)) {
|
|
782
|
+
throw new TypeError('Event.occurrenceId: occurrenceStart must be a valid Date or timestamp');
|
|
783
|
+
}
|
|
784
|
+
return `${recurringEventId}_${startMs}`;
|
|
754
785
|
}
|
|
755
786
|
|
|
756
787
|
/**
|
|
@@ -5,6 +5,12 @@ import { PerformanceOptimizer } from '../performance/PerformanceOptimizer.js';
|
|
|
5
5
|
import { ConflictDetector } from '../conflicts/ConflictDetector.js';
|
|
6
6
|
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
7
7
|
|
|
8
|
+
// Events are indexed and expanded on their own wall clock, which can differ
|
|
9
|
+
// from the query timezone's by up to 26 hours (UTC-12 to UTC+14). Day
|
|
10
|
+
// queries therefore look this many days beyond each edge before filtering
|
|
11
|
+
// precisely in the query timezone.
|
|
12
|
+
const TIMEZONE_PAD_DAYS = 2;
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* EventStore - Manages calendar events with efficient querying
|
|
10
16
|
* Uses Map for O(1) lookups and spatial indexing concepts for date queries
|
|
@@ -148,6 +154,7 @@ export class EventStore {
|
|
|
148
154
|
// Clear query and date range caches since results may have changed
|
|
149
155
|
this.optimizer.queryCache.clear();
|
|
150
156
|
this.optimizer.dateRangeCache.clear();
|
|
157
|
+
this._invalidateOccurrenceCache(replacement.id);
|
|
151
158
|
|
|
152
159
|
// Re-index
|
|
153
160
|
this._indexEvent(replacement);
|
|
@@ -192,11 +199,31 @@ export class EventStore {
|
|
|
192
199
|
this.optimizer.eventCache.delete(event.id);
|
|
193
200
|
this.optimizer.queryCache.clear();
|
|
194
201
|
this.optimizer.dateRangeCache.clear();
|
|
202
|
+
this._invalidateOccurrenceCache(event.id);
|
|
195
203
|
|
|
196
204
|
// Remove from indices
|
|
197
205
|
this._unindexEvent(event);
|
|
198
206
|
}
|
|
199
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Drop the recurrence engine's cached expansions of one series, or of
|
|
210
|
+
* every series when no id is given. The engine is pluggable, so both
|
|
211
|
+
* hooks are optional.
|
|
212
|
+
* @param {string} [eventId] - Series to invalidate; omit to clear everything
|
|
213
|
+
* @private
|
|
214
|
+
*/
|
|
215
|
+
_invalidateOccurrenceCache(eventId = null) {
|
|
216
|
+
const engine = this.recurrenceEngine;
|
|
217
|
+
if (!engine) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (eventId !== null && typeof engine.clearEventCache === 'function') {
|
|
221
|
+
engine.clearEventCache(eventId);
|
|
222
|
+
} else if (engine.occurrenceCache instanceof Map) {
|
|
223
|
+
engine.occurrenceCache.clear();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
200
227
|
/**
|
|
201
228
|
* Get an event by ID
|
|
202
229
|
*
|
|
@@ -242,6 +269,60 @@ export class EventStore {
|
|
|
242
269
|
return master && master.recurring ? master : null;
|
|
243
270
|
}
|
|
244
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Resolve an id taken from anywhere (store, view data, a drag or click
|
|
274
|
+
* handler) to the id of the stored event it refers to.
|
|
275
|
+
*
|
|
276
|
+
* Returns the id itself for a stored event, the master's id for an
|
|
277
|
+
* occurrence id (see {@link Event.occurrenceId}) whose master is a stored
|
|
278
|
+
* recurring event, and `null` when nothing stored matches. This is the
|
|
279
|
+
* same resolution {@link EventStore#getEvent}, updateEvent and removeEvent
|
|
280
|
+
* apply, exposed for consumers that only need the id.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* const masterId = store.resolveEventId(chip.dataset.eventId); // 'standup' for 'standup_1750028400000'
|
|
284
|
+
*
|
|
285
|
+
* @param {string} id - Event id or occurrence id
|
|
286
|
+
* @returns {string|null} Id of the stored event, or null
|
|
287
|
+
*/
|
|
288
|
+
resolveEventId(id) {
|
|
289
|
+
const event = this.getEvent(id);
|
|
290
|
+
return event ? event.id : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get the occurrence an occurrence id stands for, as an {@link Event}
|
|
295
|
+
* exactly like the ones {@link EventStore#expandRecurringEvent} returns.
|
|
296
|
+
*
|
|
297
|
+
* Returns `null` when the id is not an occurrence id, its master is not a
|
|
298
|
+
* stored recurring event, or the series has no occurrence starting at the
|
|
299
|
+
* encoded instant. A master id is never an occurrence, so it yields null
|
|
300
|
+
* too; use {@link EventStore#getEvent} for the master.
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* const occurrence = store.getOccurrence('standup_1750028400000');
|
|
304
|
+
*
|
|
305
|
+
* @param {string} occurrenceId - Occurrence id (`<masterId>_<startMs>`)
|
|
306
|
+
* @param {string} [timezone] - Timezone for the expansion (defaults to the store timezone)
|
|
307
|
+
* @returns {Event|null} The occurrence, or null
|
|
308
|
+
*/
|
|
309
|
+
getOccurrence(occurrenceId, timezone = null) {
|
|
310
|
+
const parsed = Event.parseOccurrenceId(occurrenceId);
|
|
311
|
+
const master = parsed ? this._resolveOccurrenceMaster(occurrenceId) : null;
|
|
312
|
+
if (!master || Number.isNaN(parsed.occurrenceStart.getTime())) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
// A DST adjustment can move an occurrence away from the stepped
|
|
316
|
+
// instant, so expand a day either side and match on the id
|
|
317
|
+
const occurrences = this.expandRecurringEvent(
|
|
318
|
+
master,
|
|
319
|
+
DateUtils.addDays(parsed.occurrenceStart, -1),
|
|
320
|
+
DateUtils.addDays(parsed.occurrenceStart, 1),
|
|
321
|
+
timezone
|
|
322
|
+
);
|
|
323
|
+
return occurrences.find(occurrence => occurrence.id === occurrenceId) || null;
|
|
324
|
+
}
|
|
325
|
+
|
|
245
326
|
/**
|
|
246
327
|
* Get all events
|
|
247
328
|
* @returns {Event[]} Array of all events
|
|
@@ -394,10 +475,14 @@ export class EventStore {
|
|
|
394
475
|
}
|
|
395
476
|
}
|
|
396
477
|
|
|
478
|
+
// Series are expanded on their own wall clock, so cover the offset
|
|
479
|
+
// spread and let _selectEventsForDay decide in the query timezone
|
|
480
|
+
const expandStart = DateUtils.addDays(dayStart, -TIMEZONE_PAD_DAYS);
|
|
481
|
+
const expandEnd = DateUtils.addDays(dayEnd, TIMEZONE_PAD_DAYS);
|
|
397
482
|
for (const id of this.indices.recurring) {
|
|
398
483
|
const event = this.events.get(id);
|
|
399
484
|
if (event) {
|
|
400
|
-
candidates.push(...this.expandRecurringEvent(event,
|
|
485
|
+
candidates.push(...this.expandRecurringEvent(event, expandStart, expandEnd, timezone));
|
|
401
486
|
}
|
|
402
487
|
}
|
|
403
488
|
|
|
@@ -433,11 +518,11 @@ export class EventStore {
|
|
|
433
518
|
byDate.set(DateUtils.getLocalDateString(day), []);
|
|
434
519
|
}
|
|
435
520
|
|
|
436
|
-
// Query
|
|
437
|
-
//
|
|
521
|
+
// Query beyond each edge so events that fall on an edge day in the
|
|
522
|
+
// requested timezone are not lost to the range filter on event wall clocks.
|
|
438
523
|
const events = this.getEventsInRange(
|
|
439
|
-
DateUtils.addDays(rangeStart, -
|
|
440
|
-
DateUtils.addDays(rangeEnd,
|
|
524
|
+
DateUtils.addDays(rangeStart, -TIMEZONE_PAD_DAYS),
|
|
525
|
+
DateUtils.addDays(rangeEnd, TIMEZONE_PAD_DAYS),
|
|
441
526
|
true,
|
|
442
527
|
timezone
|
|
443
528
|
);
|
|
@@ -472,7 +557,7 @@ export class EventStore {
|
|
|
472
557
|
|
|
473
558
|
// Check byDate index for nearby dates (handles most events)
|
|
474
559
|
const checkDate = new Date(date);
|
|
475
|
-
for (let offset = -
|
|
560
|
+
for (let offset = -TIMEZONE_PAD_DAYS; offset <= TIMEZONE_PAD_DAYS; offset++) {
|
|
476
561
|
const tempDate = new Date(checkDate);
|
|
477
562
|
tempDate.setDate(tempDate.getDate() + offset);
|
|
478
563
|
const tempDateStr = DateUtils.getLocalDateString(tempDate);
|
|
@@ -869,7 +954,7 @@ export class EventStore {
|
|
|
869
954
|
* remind(occurrence);
|
|
870
955
|
* }
|
|
871
956
|
*
|
|
872
|
-
* @param {string} eventId -
|
|
957
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
873
958
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
874
959
|
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
875
960
|
* @throws {Error} If no event with the ID exists
|
|
@@ -887,7 +972,7 @@ export class EventStore {
|
|
|
887
972
|
* @example
|
|
888
973
|
* const upcoming = store.getNextOccurrence('standup', new Date());
|
|
889
974
|
*
|
|
890
|
-
* @param {string} eventId -
|
|
975
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
891
976
|
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
892
977
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
893
978
|
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
@@ -906,8 +991,8 @@ export class EventStore {
|
|
|
906
991
|
* @example
|
|
907
992
|
* const nextFive = store.takeOccurrences('standup', 5, { after: new Date() });
|
|
908
993
|
*
|
|
909
|
-
* @param {string} eventId -
|
|
910
|
-
* @param {number} count - Maximum number of occurrences to return
|
|
994
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
995
|
+
* @param {number} count - Maximum number of occurrences to return (fractions are floored)
|
|
911
996
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
912
997
|
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
913
998
|
* @throws {Error} If no event with the ID exists
|
|
@@ -927,7 +1012,8 @@ export class EventStore {
|
|
|
927
1012
|
* @private
|
|
928
1013
|
*/
|
|
929
1014
|
_occurrenceQuery(eventId, options) {
|
|
930
|
-
|
|
1015
|
+
// Occurrence ids resolve to their master, as in getEvent
|
|
1016
|
+
const event = this.getEvent(eventId);
|
|
931
1017
|
if (!event) {
|
|
932
1018
|
throw new Error(`Event with id ${eventId} not found`);
|
|
933
1019
|
}
|
|
@@ -950,6 +1036,7 @@ export class EventStore {
|
|
|
950
1036
|
this.indices.byCategory.clear();
|
|
951
1037
|
this.indices.byStatus.clear();
|
|
952
1038
|
this.eventIndexRefs.clear();
|
|
1039
|
+
this._invalidateOccurrenceCache();
|
|
953
1040
|
|
|
954
1041
|
this._notifyChange({
|
|
955
1042
|
type: 'clear',
|
|
@@ -982,13 +1069,23 @@ export class EventStore {
|
|
|
982
1069
|
* - adds events whose id is not in the store (`add` change),
|
|
983
1070
|
* - removes stored events missing from the snapshot (`remove` change),
|
|
984
1071
|
* unless `removeMissing` is `false`,
|
|
1072
|
+
* - treats occurrences of a recurring series (entries with
|
|
1073
|
+
* `isOccurrence: true`, the plain object of such an occurrence, or an id
|
|
1074
|
+
* that {@link EventStore#getEvent} resolves to a stored recurring master)
|
|
1075
|
+
* as a reference to their master: the master is kept as unchanged and is
|
|
1076
|
+
* never replaced by an occurrence. An occurrence whose master is neither
|
|
1077
|
+
* stored nor in the snapshot is an error,
|
|
985
1078
|
* - emits a single `batch` notification listing those changes, or nothing at
|
|
986
1079
|
* all when the snapshot matches the store. When called while a batch is
|
|
987
1080
|
* already open the changes are queued on that batch instead.
|
|
988
1081
|
*
|
|
989
|
-
* Input is validated up front: invalid event data
|
|
990
|
-
* before the store is modified.
|
|
991
|
-
*
|
|
1082
|
+
* Input is validated up front: invalid event data, duplicate ids and
|
|
1083
|
+
* occurrences without a master throw before the store is modified. When
|
|
1084
|
+
* reconcile opens the batch itself, any error raised while applying the
|
|
1085
|
+
* diff rolls the store back to its previous state; inside a batch opened
|
|
1086
|
+
* by the caller the changes applied so far stay queued on that batch, and
|
|
1087
|
+
* it is the caller's rollbackBatch() that undoes them (a custom
|
|
1088
|
+
* `isEquivalent` that throws is the usual way to get there).
|
|
992
1089
|
*
|
|
993
1090
|
* @example
|
|
994
1091
|
* // periodic server snapshot
|
|
@@ -1014,7 +1111,13 @@ export class EventStore {
|
|
|
1014
1111
|
// Normalize and validate everything before touching the store
|
|
1015
1112
|
/** @type {Map<string, Event>} */
|
|
1016
1113
|
const incoming = new Map();
|
|
1114
|
+
const occurrenceRefs = [];
|
|
1017
1115
|
for (const eventData of events) {
|
|
1116
|
+
const masterId = this._occurrenceMasterId(eventData);
|
|
1117
|
+
if (masterId !== null) {
|
|
1118
|
+
occurrenceRefs.push({ id: eventData.id, masterId });
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1018
1121
|
const event = eventData instanceof Event ? eventData : new Event(eventData);
|
|
1019
1122
|
if (incoming.has(event.id)) {
|
|
1020
1123
|
throw new Error(`Duplicate event id in reconcile input: ${event.id}`);
|
|
@@ -1022,6 +1125,22 @@ export class EventStore {
|
|
|
1022
1125
|
incoming.set(event.id, event);
|
|
1023
1126
|
}
|
|
1024
1127
|
|
|
1128
|
+
// Stored masters that occurrence entries stand for: kept, not compared
|
|
1129
|
+
/** @type {Set<string>} */
|
|
1130
|
+
const retained = new Set();
|
|
1131
|
+
for (const { id, masterId } of occurrenceRefs) {
|
|
1132
|
+
if (incoming.has(masterId)) {
|
|
1133
|
+
continue; // the master itself is in the snapshot
|
|
1134
|
+
}
|
|
1135
|
+
const stored = this.events.get(masterId);
|
|
1136
|
+
if (!stored || !stored.recurring) {
|
|
1137
|
+
throw new Error(
|
|
1138
|
+
`Occurrence ${id} in reconcile input refers to recurring event ${masterId}, which is neither stored nor in the snapshot`
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
retained.add(masterId);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1025
1144
|
/** @type {import('../types.js').ReconcileResult} */
|
|
1026
1145
|
const result = { added: [], updated: [], removed: [], unchanged: [] };
|
|
1027
1146
|
|
|
@@ -1034,7 +1153,7 @@ export class EventStore {
|
|
|
1034
1153
|
try {
|
|
1035
1154
|
if (removeMissing) {
|
|
1036
1155
|
for (const existing of Array.from(this.events.values())) {
|
|
1037
|
-
if (!incoming.has(existing.id)) {
|
|
1156
|
+
if (!incoming.has(existing.id) && !retained.has(existing.id)) {
|
|
1038
1157
|
this._detachEvent(existing);
|
|
1039
1158
|
this._queueChange({ type: 'remove', event: existing, version: ++this.version });
|
|
1040
1159
|
result.removed.push(existing);
|
|
@@ -1042,6 +1161,10 @@ export class EventStore {
|
|
|
1042
1161
|
}
|
|
1043
1162
|
}
|
|
1044
1163
|
|
|
1164
|
+
for (const masterId of retained) {
|
|
1165
|
+
result.unchanged.push(this.events.get(masterId));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1045
1168
|
for (const event of incoming.values()) {
|
|
1046
1169
|
const existing = this.events.get(event.id);
|
|
1047
1170
|
if (!existing) {
|
|
@@ -1084,6 +1207,44 @@ export class EventStore {
|
|
|
1084
1207
|
});
|
|
1085
1208
|
}
|
|
1086
1209
|
|
|
1210
|
+
/**
|
|
1211
|
+
* Id of the recurring master a reconcile entry is an occurrence of, or
|
|
1212
|
+
* null when the entry is an event in its own right. Recognises Event
|
|
1213
|
+
* occurrences (isOccurrence), their toObject() form (occurrence markers
|
|
1214
|
+
* in metadata) and ids that resolve to a stored recurring master.
|
|
1215
|
+
* @param {Event|import('../types.js').EventData} eventData - Reconcile entry
|
|
1216
|
+
* @returns {string|null} Master id or null
|
|
1217
|
+
* @private
|
|
1218
|
+
*/
|
|
1219
|
+
_occurrenceMasterId(eventData) {
|
|
1220
|
+
if (!eventData || typeof eventData !== 'object') {
|
|
1221
|
+
return null; // let the Event constructor report it
|
|
1222
|
+
}
|
|
1223
|
+
const parsed = Event.parseOccurrenceId(eventData.id);
|
|
1224
|
+
if (eventData.isOccurrence === true) {
|
|
1225
|
+
if (typeof eventData.recurringEventId === 'string' && eventData.recurringEventId) {
|
|
1226
|
+
return eventData.recurringEventId;
|
|
1227
|
+
}
|
|
1228
|
+
if (parsed) {
|
|
1229
|
+
return parsed.recurringEventId;
|
|
1230
|
+
}
|
|
1231
|
+
throw new Error(
|
|
1232
|
+
`Occurrence ${eventData.id} in reconcile input has no recurringEventId and no occurrence id`
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
const metadata = eventData.metadata;
|
|
1236
|
+
if (
|
|
1237
|
+
metadata &&
|
|
1238
|
+
typeof metadata === 'object' &&
|
|
1239
|
+
typeof metadata.recurringEventId === 'string' &&
|
|
1240
|
+
metadata.occurrenceId === eventData.id
|
|
1241
|
+
) {
|
|
1242
|
+
return metadata.recurringEventId;
|
|
1243
|
+
}
|
|
1244
|
+
const master = parsed ? this._resolveOccurrenceMaster(eventData.id) : null;
|
|
1245
|
+
return master ? master.id : null;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1087
1248
|
/**
|
|
1088
1249
|
* Subscribe to store changes
|
|
1089
1250
|
* @param {Function} callback - Callback function
|
|
@@ -1324,10 +1485,6 @@ export class EventStore {
|
|
|
1324
1485
|
}
|
|
1325
1486
|
}
|
|
1326
1487
|
|
|
1327
|
-
/**
|
|
1328
|
-
* Notify listeners of changes
|
|
1329
|
-
* @private
|
|
1330
|
-
*/
|
|
1331
1488
|
/**
|
|
1332
1489
|
* Deliver a change now, or queue it when a batch is open
|
|
1333
1490
|
* @param {import('../types.js').EventStoreChange} change - Change to deliver
|
|
@@ -1341,6 +1498,12 @@ export class EventStore {
|
|
|
1341
1498
|
}
|
|
1342
1499
|
}
|
|
1343
1500
|
|
|
1501
|
+
/**
|
|
1502
|
+
* Deliver a change to every subscriber now, regardless of batch mode.
|
|
1503
|
+
* A listener that throws is reported and does not stop the others.
|
|
1504
|
+
* @param {import('../types.js').EventStoreChange} change - Change to deliver
|
|
1505
|
+
* @private
|
|
1506
|
+
*/
|
|
1344
1507
|
_notifyChange(change) {
|
|
1345
1508
|
for (const listener of this.listeners) {
|
|
1346
1509
|
try {
|
|
@@ -1609,12 +1772,13 @@ export class EventStore {
|
|
|
1609
1772
|
}
|
|
1610
1773
|
|
|
1611
1774
|
/**
|
|
1612
|
-
* Clear all caches
|
|
1775
|
+
* Clear all caches, including the recurrence engine's cached expansions
|
|
1613
1776
|
*/
|
|
1614
1777
|
clearCaches() {
|
|
1615
1778
|
this.optimizer.eventCache.clear();
|
|
1616
1779
|
this.optimizer.queryCache.clear();
|
|
1617
1780
|
this.optimizer.dateRangeCache.clear();
|
|
1781
|
+
this._invalidateOccurrenceCache();
|
|
1618
1782
|
}
|
|
1619
1783
|
|
|
1620
1784
|
/**
|