@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
|
@@ -86,19 +86,11 @@ export class EventStore {
|
|
|
86
86
|
this._indexEvent(event);
|
|
87
87
|
|
|
88
88
|
// Notify listeners (batch if in batch mode)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
});
|
|
@@ -106,35 +98,24 @@ export class EventStore {
|
|
|
106
98
|
|
|
107
99
|
/**
|
|
108
100
|
* Update an existing event
|
|
109
|
-
*
|
|
101
|
+
*
|
|
102
|
+
* An occurrence id (see {@link Event.occurrenceId}) updates the recurring
|
|
103
|
+
* master the occurrence belongs to, i.e. the whole series.
|
|
104
|
+
* @param {string} eventId - Event id or occurrence id
|
|
110
105
|
* @param {Partial<import('../types.js').EventData>} updates - Properties to update
|
|
111
|
-
* @returns {Event} The updated event
|
|
106
|
+
* @returns {Event} The updated event (the master for an occurrence id)
|
|
112
107
|
* @throws {Error} If event not found
|
|
113
108
|
*/
|
|
114
109
|
updateEvent(eventId, updates) {
|
|
115
|
-
const existingEvent = this.events.get(eventId);
|
|
110
|
+
const existingEvent = this.events.get(eventId) || this._resolveOccurrenceMaster(eventId);
|
|
116
111
|
if (!existingEvent) {
|
|
117
112
|
throw new Error(`Event with id ${eventId} not found`);
|
|
118
113
|
}
|
|
119
114
|
|
|
120
|
-
// Remove old indices
|
|
121
|
-
this._unindexEvent(existingEvent);
|
|
122
|
-
|
|
123
115
|
// Create updated event
|
|
124
116
|
const updatedEvent = existingEvent.clone(updates);
|
|
125
117
|
|
|
126
|
-
|
|
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);
|
|
118
|
+
this._replaceEvent(existingEvent, updatedEvent);
|
|
138
119
|
|
|
139
120
|
// Notify listeners
|
|
140
121
|
this._notifyChange({
|
|
@@ -147,27 +128,46 @@ export class EventStore {
|
|
|
147
128
|
return updatedEvent;
|
|
148
129
|
}
|
|
149
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Swap a stored event for a new instance with the same id, keeping
|
|
133
|
+
* indices and caches in sync. Does not notify listeners.
|
|
134
|
+
* @param {Event} existingEvent - Event currently in the store
|
|
135
|
+
* @param {Event} replacement - Event instance that takes its place
|
|
136
|
+
* @private
|
|
137
|
+
*/
|
|
138
|
+
_replaceEvent(existingEvent, replacement) {
|
|
139
|
+
// Remove old indices
|
|
140
|
+
this._unindexEvent(existingEvent);
|
|
141
|
+
|
|
142
|
+
// Store replacement
|
|
143
|
+
this.events.set(replacement.id, replacement);
|
|
144
|
+
|
|
145
|
+
// Update cache with new event data
|
|
146
|
+
this.optimizer.cache(replacement.id, replacement, 'event');
|
|
147
|
+
|
|
148
|
+
// Clear query and date range caches since results may have changed
|
|
149
|
+
this.optimizer.queryCache.clear();
|
|
150
|
+
this.optimizer.dateRangeCache.clear();
|
|
151
|
+
|
|
152
|
+
// Re-index
|
|
153
|
+
this._indexEvent(replacement);
|
|
154
|
+
}
|
|
155
|
+
|
|
150
156
|
/**
|
|
151
157
|
* Remove an event from the store
|
|
152
|
-
*
|
|
158
|
+
*
|
|
159
|
+
* An occurrence id (see {@link Event.occurrenceId}) removes the recurring
|
|
160
|
+
* master the occurrence belongs to, i.e. the whole series.
|
|
161
|
+
* @param {string} eventId - Event id or occurrence id
|
|
153
162
|
* @returns {boolean} True if removed, false if not found
|
|
154
163
|
*/
|
|
155
164
|
removeEvent(eventId) {
|
|
156
|
-
const event = this.events.get(eventId);
|
|
165
|
+
const event = this.events.get(eventId) || this._resolveOccurrenceMaster(eventId);
|
|
157
166
|
if (!event) {
|
|
158
167
|
return false;
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
|
|
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);
|
|
170
|
+
this._detachEvent(event);
|
|
171
171
|
|
|
172
172
|
// Notify listeners
|
|
173
173
|
this._notifyChange({
|
|
@@ -179,10 +179,33 @@ export class EventStore {
|
|
|
179
179
|
return true;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Remove an event from storage, caches and indices. Does not notify listeners.
|
|
184
|
+
* @param {Event} event - Event currently in the store
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
_detachEvent(event) {
|
|
188
|
+
// Remove from primary storage
|
|
189
|
+
this.events.delete(event.id);
|
|
190
|
+
|
|
191
|
+
// Invalidate caches
|
|
192
|
+
this.optimizer.eventCache.delete(event.id);
|
|
193
|
+
this.optimizer.queryCache.clear();
|
|
194
|
+
this.optimizer.dateRangeCache.clear();
|
|
195
|
+
|
|
196
|
+
// Remove from indices
|
|
197
|
+
this._unindexEvent(event);
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
/**
|
|
183
201
|
* Get an event by ID
|
|
184
|
-
*
|
|
185
|
-
*
|
|
202
|
+
*
|
|
203
|
+
* Occurrence ids produced by {@link EventStore#expandRecurringEvent}
|
|
204
|
+
* (`<masterId>_<startMs>`, see {@link Event.occurrenceId}) resolve to the
|
|
205
|
+
* stored recurring master they were derived from, so an id taken from view
|
|
206
|
+
* data can always be looked up. Occurrences themselves are never stored.
|
|
207
|
+
* @param {string} eventId - Event id or occurrence id
|
|
208
|
+
* @returns {Event|null} The stored event (the master for an occurrence id) or null
|
|
186
209
|
*/
|
|
187
210
|
getEvent(eventId) {
|
|
188
211
|
// Check cache first
|
|
@@ -197,9 +220,26 @@ export class EventStore {
|
|
|
197
220
|
// Cache if found
|
|
198
221
|
if (event) {
|
|
199
222
|
this.optimizer.cache(eventId, event, 'event');
|
|
223
|
+
return event;
|
|
200
224
|
}
|
|
201
225
|
|
|
202
|
-
return
|
|
226
|
+
return this._resolveOccurrenceMaster(eventId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve an occurrence id to the stored recurring master it belongs to.
|
|
231
|
+
* Not cached: the master's own cache entry is the one kept in sync.
|
|
232
|
+
* @param {string} eventId - Candidate occurrence id
|
|
233
|
+
* @returns {Event|null} The master event or null
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
_resolveOccurrenceMaster(eventId) {
|
|
237
|
+
const parsed = Event.parseOccurrenceId(eventId);
|
|
238
|
+
if (!parsed) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const master = this.events.get(parsed.recurringEventId);
|
|
242
|
+
return master && master.recurring ? master : null;
|
|
203
243
|
}
|
|
204
244
|
|
|
205
245
|
/**
|
|
@@ -331,14 +371,103 @@ export class EventStore {
|
|
|
331
371
|
|
|
332
372
|
/**
|
|
333
373
|
* Get events for a specific date
|
|
374
|
+
*
|
|
375
|
+
* Recurring series are expanded for the day, so the result holds their
|
|
376
|
+
* occurrences (see {@link EventStore#expandRecurringEvent}) rather than the
|
|
377
|
+
* master events. When building a grid of days use
|
|
378
|
+
* {@link EventStore#getEventsByDate}, which expands once for the whole range.
|
|
334
379
|
* @param {Date} date - The date to query
|
|
335
380
|
* @param {string} [timezone] - Timezone for the query (defaults to store timezone)
|
|
336
381
|
* @returns {Event[]} Events occurring on the date, sorted by start time
|
|
337
382
|
*/
|
|
338
383
|
getEventsForDate(date, timezone = null) {
|
|
339
384
|
timezone = timezone || this.defaultTimezone;
|
|
385
|
+
const dayStart = DateUtils.startOfDay(date);
|
|
386
|
+
const dayEnd = DateUtils.endOfDay(date);
|
|
387
|
+
|
|
388
|
+
const candidates = [];
|
|
389
|
+
for (const id of this._collectDateCandidateIds(date)) {
|
|
390
|
+
const event = this.events.get(id);
|
|
391
|
+
// Recurring masters are represented by their occurrences below
|
|
392
|
+
if (event && !event.recurring) {
|
|
393
|
+
candidates.push(event);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const id of this.indices.recurring) {
|
|
398
|
+
const event = this.events.get(id);
|
|
399
|
+
if (event) {
|
|
400
|
+
candidates.push(...this.expandRecurringEvent(event, dayStart, dayEnd, timezone));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return this._selectEventsForDay(candidates, dayStart, dayEnd, timezone);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
|
|
409
|
+
*
|
|
410
|
+
* Recurring series are expanded once for the whole range rather than once
|
|
411
|
+
* per day, which is what a month or week grid needs. Every day in the range
|
|
412
|
+
* has an entry (an empty array when nothing occurs) and multi-day events
|
|
413
|
+
* appear under each day they span. Each array is sorted like
|
|
414
|
+
* {@link EventStore#getEventsForDate}.
|
|
415
|
+
*
|
|
416
|
+
* @example
|
|
417
|
+
* const byDate = store.getEventsByDate(gridStart, gridEnd);
|
|
418
|
+
* const events = byDate.get(DateUtils.getLocalDateString(cellDate)) || [];
|
|
419
|
+
*
|
|
420
|
+
* @param {Date} start - First day of the range
|
|
421
|
+
* @param {Date} end - Last day of the range
|
|
422
|
+
* @param {string} [timezone] - Timezone deciding which day an event falls on (defaults to store timezone)
|
|
423
|
+
* @returns {Map<string, Event[]>} Local date string -> events on that day
|
|
424
|
+
*/
|
|
425
|
+
getEventsByDate(start, end, timezone = null) {
|
|
426
|
+
timezone = timezone || this.defaultTimezone;
|
|
427
|
+
const rangeStart = DateUtils.startOfDay(start);
|
|
428
|
+
const rangeEnd = DateUtils.endOfDay(end);
|
|
429
|
+
|
|
430
|
+
/** @type {Map<string, Event[]>} */
|
|
431
|
+
const byDate = new Map();
|
|
432
|
+
for (const day of DateUtils.getDateRange(rangeStart, rangeEnd)) {
|
|
433
|
+
byDate.set(DateUtils.getLocalDateString(day), []);
|
|
434
|
+
}
|
|
340
435
|
|
|
341
|
-
//
|
|
436
|
+
// Query one day beyond each edge so events that fall on an edge day in
|
|
437
|
+
// the requested timezone are not lost to the UTC-based range filter.
|
|
438
|
+
const events = this.getEventsInRange(
|
|
439
|
+
DateUtils.addDays(rangeStart, -1),
|
|
440
|
+
DateUtils.addDays(rangeEnd, 1),
|
|
441
|
+
true,
|
|
442
|
+
timezone
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
for (const event of events) {
|
|
446
|
+
const eventStart = event.getStartInTimezone(timezone);
|
|
447
|
+
const eventEnd = event.getEndInTimezone(timezone);
|
|
448
|
+
const lastDay = eventEnd < rangeEnd ? eventEnd : rangeEnd;
|
|
449
|
+
let day = DateUtils.startOfDay(eventStart > rangeStart ? eventStart : rangeStart);
|
|
450
|
+
while (day <= lastDay) {
|
|
451
|
+
byDate.get(DateUtils.getLocalDateString(day))?.push(event);
|
|
452
|
+
day = DateUtils.addDays(day, 1);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const compare = this._compareByStart(timezone);
|
|
457
|
+
for (const dayEvents of byDate.values()) {
|
|
458
|
+
dayEvents.sort(compare);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return byDate;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Collect the ids of stored events that may occur on a date.
|
|
466
|
+
* @param {Date} date - The date to query
|
|
467
|
+
* @returns {Set<string>} Candidate event ids
|
|
468
|
+
* @private
|
|
469
|
+
*/
|
|
470
|
+
_collectDateCandidateIds(date) {
|
|
342
471
|
const candidateIds = new Set();
|
|
343
472
|
|
|
344
473
|
// Check byDate index for nearby dates (handles most events)
|
|
@@ -362,35 +491,41 @@ export class EventStore {
|
|
|
362
491
|
monthEventIds.forEach(id => candidateIds.add(id));
|
|
363
492
|
}
|
|
364
493
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const startOfDay = new Date(date);
|
|
368
|
-
startOfDay.setHours(0, 0, 0, 0);
|
|
369
|
-
const endOfDay = new Date(date);
|
|
370
|
-
endOfDay.setHours(23, 59, 59, 999);
|
|
494
|
+
return candidateIds;
|
|
495
|
+
}
|
|
371
496
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
497
|
+
/**
|
|
498
|
+
* Keep the events that overlap a day in the given timezone, sorted by start.
|
|
499
|
+
* @param {Event[]} events - Candidate events
|
|
500
|
+
* @param {Date} dayStart - Start of the day
|
|
501
|
+
* @param {Date} dayEnd - End of the day
|
|
502
|
+
* @param {string} timezone - Timezone deciding whether an event falls on the day
|
|
503
|
+
* @returns {Event[]} Events on the day, sorted
|
|
504
|
+
* @private
|
|
505
|
+
*/
|
|
506
|
+
_selectEventsForDay(events, dayStart, dayEnd, timezone) {
|
|
507
|
+
const onDay = events.filter(event => {
|
|
508
|
+
// Event overlaps with this day if it starts before end of day and ends after start of day
|
|
509
|
+
const eventStartLocal = event.getStartInTimezone(timezone);
|
|
510
|
+
const eventEndLocal = event.getEndInTimezone(timezone);
|
|
511
|
+
return eventStartLocal <= dayEnd && eventEndLocal >= dayStart;
|
|
512
|
+
});
|
|
378
513
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
allEvents.push(event);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
514
|
+
return onDay.sort(this._compareByStart(timezone));
|
|
515
|
+
}
|
|
385
516
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
517
|
+
/**
|
|
518
|
+
* Comparator ordering events by start time in a timezone, longer events first.
|
|
519
|
+
* @param {string} timezone - Timezone used for the start comparison
|
|
520
|
+
* @returns {(a: Event, b: Event) => number} Comparator
|
|
521
|
+
* @private
|
|
522
|
+
*/
|
|
523
|
+
_compareByStart(timezone) {
|
|
524
|
+
return (a, b) => {
|
|
525
|
+
const timeCompare = a.getStartInTimezone(timezone) - b.getStartInTimezone(timezone);
|
|
391
526
|
if (timeCompare !== 0) return timeCompare;
|
|
392
527
|
return b.duration - a.duration; // Longer events first
|
|
393
|
-
}
|
|
528
|
+
};
|
|
394
529
|
}
|
|
395
530
|
|
|
396
531
|
/**
|
|
@@ -466,11 +601,19 @@ export class EventStore {
|
|
|
466
601
|
* @returns {Array<Event[]>} Array of event groups that overlap
|
|
467
602
|
*/
|
|
468
603
|
getOverlapGroups(date, timedOnly = true) {
|
|
469
|
-
|
|
604
|
+
return this.groupOverlappingEvents(this.getEventsForDate(date), timedOnly);
|
|
605
|
+
}
|
|
470
606
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
607
|
+
/**
|
|
608
|
+
* Group a list of events into clusters of overlapping time slots
|
|
609
|
+
* Same result as {@link EventStore#getOverlapGroups} for events already fetched
|
|
610
|
+
* (for example one day of {@link EventStore#getEventsByDate}).
|
|
611
|
+
* @param {Event[]} events - Events to group; the array is not modified
|
|
612
|
+
* @param {boolean} [timedOnly=true] - Only include timed events (not all-day)
|
|
613
|
+
* @returns {Array<Event[]>} Array of event groups that overlap
|
|
614
|
+
*/
|
|
615
|
+
groupOverlappingEvents(events, timedOnly = true) {
|
|
616
|
+
events = timedOnly ? events.filter(e => !e.allDay) : [...events];
|
|
474
617
|
|
|
475
618
|
if (events.length === 0) return [];
|
|
476
619
|
|
|
@@ -632,6 +775,16 @@ export class EventStore {
|
|
|
632
775
|
|
|
633
776
|
/**
|
|
634
777
|
* Expand a recurring event into individual occurrences
|
|
778
|
+
*
|
|
779
|
+
* Returns every occurrence that overlaps the range, including ones that
|
|
780
|
+
* start before it but run into it (multi-day series). Each occurrence is an
|
|
781
|
+
* {@link Event} cloned from the master with:
|
|
782
|
+
* - `id` from {@link Event.occurrenceId} (`<masterId>_<startMs>`), stable
|
|
783
|
+
* across ranges and resolvable with {@link EventStore#getEvent},
|
|
784
|
+
* - `isOccurrence: true`, `recurringEventId` and `occurrenceStart`,
|
|
785
|
+
* - `metadata.recurringEventId`, `metadata.occurrenceId` (same as `id`) and
|
|
786
|
+
* `metadata.occurrenceIndex` (position within this expansion).
|
|
787
|
+
* Non-recurring events are returned as-is in a one-element array.
|
|
635
788
|
* @param {Event} event - The recurring event
|
|
636
789
|
* @param {Date} rangeStart - Start of the expansion range
|
|
637
790
|
* @param {Date} rangeEnd - End of the expansion range
|
|
@@ -647,27 +800,141 @@ export class EventStore {
|
|
|
647
800
|
|
|
648
801
|
// Expand in the event's timezone for accurate recurrence calculation
|
|
649
802
|
const eventTimezone = event.timeZone || timezone;
|
|
650
|
-
|
|
803
|
+
|
|
804
|
+
// The engine selects occurrences by start, so look back one event
|
|
805
|
+
// duration to catch occurrences that began before the range but overlap it
|
|
806
|
+
const duration = Math.max(0, event.end - event.start);
|
|
807
|
+
const expandStart = new Date(rangeStart.getTime() - duration);
|
|
808
|
+
const occurrences = this.recurrenceEngine.expandEvent(event, expandStart, rangeEnd, {
|
|
651
809
|
timezone: eventTimezone
|
|
652
810
|
});
|
|
653
811
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
occurrenceId: occurrence.id,
|
|
665
|
-
occurrenceIndex: index
|
|
666
|
-
}
|
|
667
|
-
});
|
|
812
|
+
const expanded = [];
|
|
813
|
+
for (const occurrence of occurrences) {
|
|
814
|
+
if (occurrence.end < rangeStart || occurrence.start > rangeEnd) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
expanded.push(this._createOccurrence(event, occurrence, eventTimezone, expanded.length));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return expanded;
|
|
821
|
+
}
|
|
668
822
|
|
|
669
|
-
|
|
823
|
+
/**
|
|
824
|
+
* Build the Event instance for one occurrence of a recurring master.
|
|
825
|
+
* @param {Event} event - The recurring master
|
|
826
|
+
* @param {{start: Date, end: Date, timezone?: string}} occurrence - Engine occurrence
|
|
827
|
+
* @param {string} eventTimezone - Timezone the series was expanded in
|
|
828
|
+
* @param {number} index - Position within the current expansion
|
|
829
|
+
* @returns {Event} Occurrence event
|
|
830
|
+
* @private
|
|
831
|
+
*/
|
|
832
|
+
_createOccurrence(event, occurrence, eventTimezone, index) {
|
|
833
|
+
const occurrenceStart = new Date(occurrence.start);
|
|
834
|
+
const id = Event.occurrenceId(event.id, occurrenceStart);
|
|
835
|
+
|
|
836
|
+
const occurrenceEvent = event.clone({
|
|
837
|
+
id,
|
|
838
|
+
start: occurrenceStart,
|
|
839
|
+
end: new Date(occurrence.end),
|
|
840
|
+
timeZone: occurrence.timezone || eventTimezone,
|
|
841
|
+
metadata: {
|
|
842
|
+
...event.metadata,
|
|
843
|
+
recurringEventId: event.id,
|
|
844
|
+
occurrenceId: id,
|
|
845
|
+
occurrenceIndex: index
|
|
846
|
+
}
|
|
670
847
|
});
|
|
848
|
+
|
|
849
|
+
occurrenceEvent.isOccurrence = true;
|
|
850
|
+
occurrenceEvent.recurringEventId = event.id;
|
|
851
|
+
occurrenceEvent.occurrenceStart = new Date(occurrenceStart);
|
|
852
|
+
|
|
853
|
+
return occurrenceEvent;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Lazily iterate the occurrences of a stored event in chronological order.
|
|
858
|
+
*
|
|
859
|
+
* Occurrences come one at a time from the store's recurrence engine
|
|
860
|
+
* (RecurrenceEngineV2 by default), so taking the next few occurrences of
|
|
861
|
+
* an open-ended series does not expand the series. `after` and `before`
|
|
862
|
+
* are exclusive unless `inclusive` is set; see
|
|
863
|
+
* RecurrenceEngineV2.iterateOccurrences for the full semantics. The
|
|
864
|
+
* expansion timezone defaults to the event's, then the store's.
|
|
865
|
+
*
|
|
866
|
+
* @example
|
|
867
|
+
* for (const occurrence of store.iterateOccurrences('standup', { after: new Date() })) {
|
|
868
|
+
* if (occurrence.start > deadline) break;
|
|
869
|
+
* remind(occurrence);
|
|
870
|
+
* }
|
|
871
|
+
*
|
|
872
|
+
* @param {string} eventId - The event ID
|
|
873
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
874
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
875
|
+
* @throws {Error} If no event with the ID exists
|
|
876
|
+
*/
|
|
877
|
+
iterateOccurrences(eventId, options = {}) {
|
|
878
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
879
|
+
return this.recurrenceEngine.iterateOccurrences(query.event, query.options);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* First occurrence of a stored event after an instant, or null when the
|
|
884
|
+
* series has no occurrence after it. `after` is exclusive unless
|
|
885
|
+
* `options.inclusive` is set.
|
|
886
|
+
*
|
|
887
|
+
* @example
|
|
888
|
+
* const upcoming = store.getNextOccurrence('standup', new Date());
|
|
889
|
+
*
|
|
890
|
+
* @param {string} eventId - The event ID
|
|
891
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
892
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
893
|
+
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
894
|
+
* @throws {Error} If no event with the ID exists
|
|
895
|
+
*/
|
|
896
|
+
getNextOccurrence(eventId, after = null, options = {}) {
|
|
897
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
898
|
+
return this.recurrenceEngine.nextOccurrence(query.event, after, query.options);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* The first `count` occurrences of a stored event inside a window,
|
|
903
|
+
* generated lazily. `count` is capped at the engine's
|
|
904
|
+
* MAX_OCCURRENCES_HARD_LIMIT.
|
|
905
|
+
*
|
|
906
|
+
* @example
|
|
907
|
+
* const nextFive = store.takeOccurrences('standup', 5, { after: new Date() });
|
|
908
|
+
*
|
|
909
|
+
* @param {string} eventId - The event ID
|
|
910
|
+
* @param {number} count - Maximum number of occurrences to return
|
|
911
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
912
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
913
|
+
* @throws {Error} If no event with the ID exists
|
|
914
|
+
*/
|
|
915
|
+
takeOccurrences(eventId, count, options = {}) {
|
|
916
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
917
|
+
return this.recurrenceEngine.takeOccurrences(query.event, count, query.options);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Resolve an occurrence query to the stored event and its options, with
|
|
922
|
+
* the timezone defaulted as expandRecurringEvent does
|
|
923
|
+
* @param {string} eventId - The event ID
|
|
924
|
+
* @param {Object} options - Caller options
|
|
925
|
+
* @returns {{ event: Event, options: Object }}
|
|
926
|
+
* @throws {Error} If no event with the ID exists
|
|
927
|
+
* @private
|
|
928
|
+
*/
|
|
929
|
+
_occurrenceQuery(eventId, options) {
|
|
930
|
+
const event = this.events.get(eventId);
|
|
931
|
+
if (!event) {
|
|
932
|
+
throw new Error(`Event with id ${eventId} not found`);
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
event,
|
|
936
|
+
options: { ...options, timezone: options.timezone || event.timeZone || this.defaultTimezone }
|
|
937
|
+
};
|
|
671
938
|
}
|
|
672
939
|
|
|
673
940
|
/**
|
|
@@ -705,6 +972,118 @@ export class EventStore {
|
|
|
705
972
|
this.commitBatch();
|
|
706
973
|
}
|
|
707
974
|
|
|
975
|
+
/**
|
|
976
|
+
* Reconcile the store with a snapshot of events, applying only the differences.
|
|
977
|
+
*
|
|
978
|
+
* Compared with {@link EventStore#loadEvents} (clear + re-add everything) this:
|
|
979
|
+
* - keeps the existing {@link Event} instance for every entry that is
|
|
980
|
+
* equivalent to the stored one (identity is preserved, no notification),
|
|
981
|
+
* - replaces stored events whose incoming data differs (`update` change),
|
|
982
|
+
* - adds events whose id is not in the store (`add` change),
|
|
983
|
+
* - removes stored events missing from the snapshot (`remove` change),
|
|
984
|
+
* unless `removeMissing` is `false`,
|
|
985
|
+
* - emits a single `batch` notification listing those changes, or nothing at
|
|
986
|
+
* all when the snapshot matches the store. When called while a batch is
|
|
987
|
+
* already open the changes are queued on that batch instead.
|
|
988
|
+
*
|
|
989
|
+
* Input is validated up front: invalid event data or duplicate ids throw
|
|
990
|
+
* before the store is modified. Any error raised while applying the diff
|
|
991
|
+
* rolls the store back to its previous state.
|
|
992
|
+
*
|
|
993
|
+
* @example
|
|
994
|
+
* // periodic server snapshot
|
|
995
|
+
* const { added, updated, removed } = store.reconcile(rowsFromServer);
|
|
996
|
+
* if (added.length || updated.length || removed.length) rerender();
|
|
997
|
+
*
|
|
998
|
+
* @param {Array<Event|import('../types.js').EventData>} events - Complete snapshot of events
|
|
999
|
+
* @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
|
|
1000
|
+
* @returns {import('../types.js').ReconcileResult} Events that were added, updated, removed and left untouched
|
|
1001
|
+
* @throws {Error} If an entry fails validation or two entries share an id
|
|
1002
|
+
*/
|
|
1003
|
+
reconcile(events, options = {}) {
|
|
1004
|
+
const { removeMissing = true, isEquivalent = Event.isEquivalent } = options;
|
|
1005
|
+
|
|
1006
|
+
if (!events || typeof events[Symbol.iterator] !== 'function') {
|
|
1007
|
+
throw new Error('reconcile() expects an iterable of events');
|
|
1008
|
+
}
|
|
1009
|
+
if (typeof isEquivalent !== 'function') {
|
|
1010
|
+
throw new Error('reconcile() option isEquivalent must be a function');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return this.optimizer.measure('reconcile', () => {
|
|
1014
|
+
// Normalize and validate everything before touching the store
|
|
1015
|
+
/** @type {Map<string, Event>} */
|
|
1016
|
+
const incoming = new Map();
|
|
1017
|
+
for (const eventData of events) {
|
|
1018
|
+
const event = eventData instanceof Event ? eventData : new Event(eventData);
|
|
1019
|
+
if (incoming.has(event.id)) {
|
|
1020
|
+
throw new Error(`Duplicate event id in reconcile input: ${event.id}`);
|
|
1021
|
+
}
|
|
1022
|
+
incoming.set(event.id, event);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** @type {import('../types.js').ReconcileResult} */
|
|
1026
|
+
const result = { added: [], updated: [], removed: [], unchanged: [] };
|
|
1027
|
+
|
|
1028
|
+
// Nest inside an existing batch if one is open, otherwise own one
|
|
1029
|
+
const ownsBatch = !this.isBatchMode;
|
|
1030
|
+
if (ownsBatch) {
|
|
1031
|
+
this.startBatch(true);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
try {
|
|
1035
|
+
if (removeMissing) {
|
|
1036
|
+
for (const existing of Array.from(this.events.values())) {
|
|
1037
|
+
if (!incoming.has(existing.id)) {
|
|
1038
|
+
this._detachEvent(existing);
|
|
1039
|
+
this._queueChange({ type: 'remove', event: existing, version: ++this.version });
|
|
1040
|
+
result.removed.push(existing);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
for (const event of incoming.values()) {
|
|
1046
|
+
const existing = this.events.get(event.id);
|
|
1047
|
+
if (!existing) {
|
|
1048
|
+
this.events.set(event.id, event);
|
|
1049
|
+
this.optimizer.cache(event.id, event, 'event');
|
|
1050
|
+
this._indexEvent(event);
|
|
1051
|
+
this._queueChange({ type: 'add', event, version: ++this.version });
|
|
1052
|
+
result.added.push(event);
|
|
1053
|
+
} else if (existing === event || isEquivalent(existing, event)) {
|
|
1054
|
+
result.unchanged.push(existing);
|
|
1055
|
+
} else {
|
|
1056
|
+
this._replaceEvent(existing, event);
|
|
1057
|
+
this._queueChange({
|
|
1058
|
+
type: 'update',
|
|
1059
|
+
event,
|
|
1060
|
+
oldEvent: existing,
|
|
1061
|
+
version: ++this.version
|
|
1062
|
+
});
|
|
1063
|
+
result.updated.push({ event, oldEvent: existing });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (result.added.length > 0) {
|
|
1068
|
+
// Newly indexed events may change range/query results
|
|
1069
|
+
this.optimizer.queryCache.clear();
|
|
1070
|
+
this.optimizer.dateRangeCache.clear();
|
|
1071
|
+
}
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
if (ownsBatch) {
|
|
1074
|
+
this.rollbackBatch();
|
|
1075
|
+
}
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (ownsBatch) {
|
|
1080
|
+
this.commitBatch();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return result;
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
|
|
708
1087
|
/**
|
|
709
1088
|
* Subscribe to store changes
|
|
710
1089
|
* @param {Function} callback - Callback function
|
|
@@ -949,6 +1328,19 @@ export class EventStore {
|
|
|
949
1328
|
* Notify listeners of changes
|
|
950
1329
|
* @private
|
|
951
1330
|
*/
|
|
1331
|
+
/**
|
|
1332
|
+
* Deliver a change now, or queue it when a batch is open
|
|
1333
|
+
* @param {import('../types.js').EventStoreChange} change - Change to deliver
|
|
1334
|
+
* @private
|
|
1335
|
+
*/
|
|
1336
|
+
_queueChange(change) {
|
|
1337
|
+
if (this.isBatchMode) {
|
|
1338
|
+
this.batchNotifications.push(change);
|
|
1339
|
+
} else {
|
|
1340
|
+
this._notifyChange(change);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
952
1344
|
_notifyChange(change) {
|
|
953
1345
|
for (const listener of this.listeners) {
|
|
954
1346
|
try {
|
|
@@ -1066,7 +1458,7 @@ export class EventStore {
|
|
|
1066
1458
|
this.batchBackup = null;
|
|
1067
1459
|
|
|
1068
1460
|
// Clear cache
|
|
1069
|
-
this.
|
|
1461
|
+
this.clearCaches();
|
|
1070
1462
|
}
|
|
1071
1463
|
|
|
1072
1464
|
this.batchNotifications = [];
|