@forcecalendar/core 2.4.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 +191 -23
- package/core/events/Event.js +106 -14
- package/core/events/EventStore.js +482 -63
- package/core/events/RRuleParser.js +19 -3
- package/core/events/RecurrenceEngine.js +427 -31
- package/core/events/RecurrenceEngineV2.js +497 -88
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +126 -14
- package/core/timezone/TimezoneManager.js +45 -9
- package/core/types.js +61 -0
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +123 -9
- package/types/events/Event.d.ts +50 -0
- package/types/events/EventStore.d.ts +235 -13
- package/types/events/RRuleParser.d.ts +10 -1
- package/types/events/RecurrenceEngine.d.ts +159 -1
- package/types/events/RecurrenceEngineV2.d.ts +178 -15
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +73 -1
- package/types/types.d.ts +201 -0
|
@@ -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
|
|
@@ -98,13 +104,16 @@ export class EventStore {
|
|
|
98
104
|
|
|
99
105
|
/**
|
|
100
106
|
* Update an existing event
|
|
101
|
-
*
|
|
107
|
+
*
|
|
108
|
+
* An occurrence id (see {@link Event.occurrenceId}) updates the recurring
|
|
109
|
+
* master the occurrence belongs to, i.e. the whole series.
|
|
110
|
+
* @param {string} eventId - Event id or occurrence id
|
|
102
111
|
* @param {Partial<import('../types.js').EventData>} updates - Properties to update
|
|
103
|
-
* @returns {Event} The updated event
|
|
112
|
+
* @returns {Event} The updated event (the master for an occurrence id)
|
|
104
113
|
* @throws {Error} If event not found
|
|
105
114
|
*/
|
|
106
115
|
updateEvent(eventId, updates) {
|
|
107
|
-
const existingEvent = this.events.get(eventId);
|
|
116
|
+
const existingEvent = this.events.get(eventId) || this._resolveOccurrenceMaster(eventId);
|
|
108
117
|
if (!existingEvent) {
|
|
109
118
|
throw new Error(`Event with id ${eventId} not found`);
|
|
110
119
|
}
|
|
@@ -145,6 +154,7 @@ export class EventStore {
|
|
|
145
154
|
// Clear query and date range caches since results may have changed
|
|
146
155
|
this.optimizer.queryCache.clear();
|
|
147
156
|
this.optimizer.dateRangeCache.clear();
|
|
157
|
+
this._invalidateOccurrenceCache(replacement.id);
|
|
148
158
|
|
|
149
159
|
// Re-index
|
|
150
160
|
this._indexEvent(replacement);
|
|
@@ -152,11 +162,14 @@ export class EventStore {
|
|
|
152
162
|
|
|
153
163
|
/**
|
|
154
164
|
* Remove an event from the store
|
|
155
|
-
*
|
|
165
|
+
*
|
|
166
|
+
* An occurrence id (see {@link Event.occurrenceId}) removes the recurring
|
|
167
|
+
* master the occurrence belongs to, i.e. the whole series.
|
|
168
|
+
* @param {string} eventId - Event id or occurrence id
|
|
156
169
|
* @returns {boolean} True if removed, false if not found
|
|
157
170
|
*/
|
|
158
171
|
removeEvent(eventId) {
|
|
159
|
-
const event = this.events.get(eventId);
|
|
172
|
+
const event = this.events.get(eventId) || this._resolveOccurrenceMaster(eventId);
|
|
160
173
|
if (!event) {
|
|
161
174
|
return false;
|
|
162
175
|
}
|
|
@@ -186,15 +199,40 @@ export class EventStore {
|
|
|
186
199
|
this.optimizer.eventCache.delete(event.id);
|
|
187
200
|
this.optimizer.queryCache.clear();
|
|
188
201
|
this.optimizer.dateRangeCache.clear();
|
|
202
|
+
this._invalidateOccurrenceCache(event.id);
|
|
189
203
|
|
|
190
204
|
// Remove from indices
|
|
191
205
|
this._unindexEvent(event);
|
|
192
206
|
}
|
|
193
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
|
+
|
|
194
227
|
/**
|
|
195
228
|
* Get an event by ID
|
|
196
|
-
*
|
|
197
|
-
*
|
|
229
|
+
*
|
|
230
|
+
* Occurrence ids produced by {@link EventStore#expandRecurringEvent}
|
|
231
|
+
* (`<masterId>_<startMs>`, see {@link Event.occurrenceId}) resolve to the
|
|
232
|
+
* stored recurring master they were derived from, so an id taken from view
|
|
233
|
+
* data can always be looked up. Occurrences themselves are never stored.
|
|
234
|
+
* @param {string} eventId - Event id or occurrence id
|
|
235
|
+
* @returns {Event|null} The stored event (the master for an occurrence id) or null
|
|
198
236
|
*/
|
|
199
237
|
getEvent(eventId) {
|
|
200
238
|
// Check cache first
|
|
@@ -209,9 +247,80 @@ export class EventStore {
|
|
|
209
247
|
// Cache if found
|
|
210
248
|
if (event) {
|
|
211
249
|
this.optimizer.cache(eventId, event, 'event');
|
|
250
|
+
return event;
|
|
212
251
|
}
|
|
213
252
|
|
|
214
|
-
return
|
|
253
|
+
return this._resolveOccurrenceMaster(eventId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resolve an occurrence id to the stored recurring master it belongs to.
|
|
258
|
+
* Not cached: the master's own cache entry is the one kept in sync.
|
|
259
|
+
* @param {string} eventId - Candidate occurrence id
|
|
260
|
+
* @returns {Event|null} The master event or null
|
|
261
|
+
* @private
|
|
262
|
+
*/
|
|
263
|
+
_resolveOccurrenceMaster(eventId) {
|
|
264
|
+
const parsed = Event.parseOccurrenceId(eventId);
|
|
265
|
+
if (!parsed) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const master = this.events.get(parsed.recurringEventId);
|
|
269
|
+
return master && master.recurring ? master : null;
|
|
270
|
+
}
|
|
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;
|
|
215
324
|
}
|
|
216
325
|
|
|
217
326
|
/**
|
|
@@ -343,19 +452,112 @@ export class EventStore {
|
|
|
343
452
|
|
|
344
453
|
/**
|
|
345
454
|
* Get events for a specific date
|
|
455
|
+
*
|
|
456
|
+
* Recurring series are expanded for the day, so the result holds their
|
|
457
|
+
* occurrences (see {@link EventStore#expandRecurringEvent}) rather than the
|
|
458
|
+
* master events. When building a grid of days use
|
|
459
|
+
* {@link EventStore#getEventsByDate}, which expands once for the whole range.
|
|
346
460
|
* @param {Date} date - The date to query
|
|
347
461
|
* @param {string} [timezone] - Timezone for the query (defaults to store timezone)
|
|
348
462
|
* @returns {Event[]} Events occurring on the date, sorted by start time
|
|
349
463
|
*/
|
|
350
464
|
getEventsForDate(date, timezone = null) {
|
|
351
465
|
timezone = timezone || this.defaultTimezone;
|
|
466
|
+
const dayStart = DateUtils.startOfDay(date);
|
|
467
|
+
const dayEnd = DateUtils.endOfDay(date);
|
|
468
|
+
|
|
469
|
+
const candidates = [];
|
|
470
|
+
for (const id of this._collectDateCandidateIds(date)) {
|
|
471
|
+
const event = this.events.get(id);
|
|
472
|
+
// Recurring masters are represented by their occurrences below
|
|
473
|
+
if (event && !event.recurring) {
|
|
474
|
+
candidates.push(event);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
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);
|
|
482
|
+
for (const id of this.indices.recurring) {
|
|
483
|
+
const event = this.events.get(id);
|
|
484
|
+
if (event) {
|
|
485
|
+
candidates.push(...this.expandRecurringEvent(event, expandStart, expandEnd, timezone));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return this._selectEventsForDay(candidates, dayStart, dayEnd, timezone);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
|
|
494
|
+
*
|
|
495
|
+
* Recurring series are expanded once for the whole range rather than once
|
|
496
|
+
* per day, which is what a month or week grid needs. Every day in the range
|
|
497
|
+
* has an entry (an empty array when nothing occurs) and multi-day events
|
|
498
|
+
* appear under each day they span. Each array is sorted like
|
|
499
|
+
* {@link EventStore#getEventsForDate}.
|
|
500
|
+
*
|
|
501
|
+
* @example
|
|
502
|
+
* const byDate = store.getEventsByDate(gridStart, gridEnd);
|
|
503
|
+
* const events = byDate.get(DateUtils.getLocalDateString(cellDate)) || [];
|
|
504
|
+
*
|
|
505
|
+
* @param {Date} start - First day of the range
|
|
506
|
+
* @param {Date} end - Last day of the range
|
|
507
|
+
* @param {string} [timezone] - Timezone deciding which day an event falls on (defaults to store timezone)
|
|
508
|
+
* @returns {Map<string, Event[]>} Local date string -> events on that day
|
|
509
|
+
*/
|
|
510
|
+
getEventsByDate(start, end, timezone = null) {
|
|
511
|
+
timezone = timezone || this.defaultTimezone;
|
|
512
|
+
const rangeStart = DateUtils.startOfDay(start);
|
|
513
|
+
const rangeEnd = DateUtils.endOfDay(end);
|
|
352
514
|
|
|
353
|
-
|
|
515
|
+
/** @type {Map<string, Event[]>} */
|
|
516
|
+
const byDate = new Map();
|
|
517
|
+
for (const day of DateUtils.getDateRange(rangeStart, rangeEnd)) {
|
|
518
|
+
byDate.set(DateUtils.getLocalDateString(day), []);
|
|
519
|
+
}
|
|
520
|
+
|
|
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.
|
|
523
|
+
const events = this.getEventsInRange(
|
|
524
|
+
DateUtils.addDays(rangeStart, -TIMEZONE_PAD_DAYS),
|
|
525
|
+
DateUtils.addDays(rangeEnd, TIMEZONE_PAD_DAYS),
|
|
526
|
+
true,
|
|
527
|
+
timezone
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
for (const event of events) {
|
|
531
|
+
const eventStart = event.getStartInTimezone(timezone);
|
|
532
|
+
const eventEnd = event.getEndInTimezone(timezone);
|
|
533
|
+
const lastDay = eventEnd < rangeEnd ? eventEnd : rangeEnd;
|
|
534
|
+
let day = DateUtils.startOfDay(eventStart > rangeStart ? eventStart : rangeStart);
|
|
535
|
+
while (day <= lastDay) {
|
|
536
|
+
byDate.get(DateUtils.getLocalDateString(day))?.push(event);
|
|
537
|
+
day = DateUtils.addDays(day, 1);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const compare = this._compareByStart(timezone);
|
|
542
|
+
for (const dayEvents of byDate.values()) {
|
|
543
|
+
dayEvents.sort(compare);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return byDate;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Collect the ids of stored events that may occur on a date.
|
|
551
|
+
* @param {Date} date - The date to query
|
|
552
|
+
* @returns {Set<string>} Candidate event ids
|
|
553
|
+
* @private
|
|
554
|
+
*/
|
|
555
|
+
_collectDateCandidateIds(date) {
|
|
354
556
|
const candidateIds = new Set();
|
|
355
557
|
|
|
356
558
|
// Check byDate index for nearby dates (handles most events)
|
|
357
559
|
const checkDate = new Date(date);
|
|
358
|
-
for (let offset = -
|
|
560
|
+
for (let offset = -TIMEZONE_PAD_DAYS; offset <= TIMEZONE_PAD_DAYS; offset++) {
|
|
359
561
|
const tempDate = new Date(checkDate);
|
|
360
562
|
tempDate.setDate(tempDate.getDate() + offset);
|
|
361
563
|
const tempDateStr = DateUtils.getLocalDateString(tempDate);
|
|
@@ -374,35 +576,41 @@ export class EventStore {
|
|
|
374
576
|
monthEventIds.forEach(id => candidateIds.add(id));
|
|
375
577
|
}
|
|
376
578
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const startOfDay = new Date(date);
|
|
380
|
-
startOfDay.setHours(0, 0, 0, 0);
|
|
381
|
-
const endOfDay = new Date(date);
|
|
382
|
-
endOfDay.setHours(23, 59, 59, 999);
|
|
579
|
+
return candidateIds;
|
|
580
|
+
}
|
|
383
581
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Keep the events that overlap a day in the given timezone, sorted by start.
|
|
584
|
+
* @param {Event[]} events - Candidate events
|
|
585
|
+
* @param {Date} dayStart - Start of the day
|
|
586
|
+
* @param {Date} dayEnd - End of the day
|
|
587
|
+
* @param {string} timezone - Timezone deciding whether an event falls on the day
|
|
588
|
+
* @returns {Event[]} Events on the day, sorted
|
|
589
|
+
* @private
|
|
590
|
+
*/
|
|
591
|
+
_selectEventsForDay(events, dayStart, dayEnd, timezone) {
|
|
592
|
+
const onDay = events.filter(event => {
|
|
593
|
+
// Event overlaps with this day if it starts before end of day and ends after start of day
|
|
594
|
+
const eventStartLocal = event.getStartInTimezone(timezone);
|
|
595
|
+
const eventEndLocal = event.getEndInTimezone(timezone);
|
|
596
|
+
return eventStartLocal <= dayEnd && eventEndLocal >= dayStart;
|
|
597
|
+
});
|
|
390
598
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
allEvents.push(event);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
599
|
+
return onDay.sort(this._compareByStart(timezone));
|
|
600
|
+
}
|
|
397
601
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
602
|
+
/**
|
|
603
|
+
* Comparator ordering events by start time in a timezone, longer events first.
|
|
604
|
+
* @param {string} timezone - Timezone used for the start comparison
|
|
605
|
+
* @returns {(a: Event, b: Event) => number} Comparator
|
|
606
|
+
* @private
|
|
607
|
+
*/
|
|
608
|
+
_compareByStart(timezone) {
|
|
609
|
+
return (a, b) => {
|
|
610
|
+
const timeCompare = a.getStartInTimezone(timezone) - b.getStartInTimezone(timezone);
|
|
403
611
|
if (timeCompare !== 0) return timeCompare;
|
|
404
612
|
return b.duration - a.duration; // Longer events first
|
|
405
|
-
}
|
|
613
|
+
};
|
|
406
614
|
}
|
|
407
615
|
|
|
408
616
|
/**
|
|
@@ -478,11 +686,19 @@ export class EventStore {
|
|
|
478
686
|
* @returns {Array<Event[]>} Array of event groups that overlap
|
|
479
687
|
*/
|
|
480
688
|
getOverlapGroups(date, timedOnly = true) {
|
|
481
|
-
|
|
689
|
+
return this.groupOverlappingEvents(this.getEventsForDate(date), timedOnly);
|
|
690
|
+
}
|
|
482
691
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
692
|
+
/**
|
|
693
|
+
* Group a list of events into clusters of overlapping time slots
|
|
694
|
+
* Same result as {@link EventStore#getOverlapGroups} for events already fetched
|
|
695
|
+
* (for example one day of {@link EventStore#getEventsByDate}).
|
|
696
|
+
* @param {Event[]} events - Events to group; the array is not modified
|
|
697
|
+
* @param {boolean} [timedOnly=true] - Only include timed events (not all-day)
|
|
698
|
+
* @returns {Array<Event[]>} Array of event groups that overlap
|
|
699
|
+
*/
|
|
700
|
+
groupOverlappingEvents(events, timedOnly = true) {
|
|
701
|
+
events = timedOnly ? events.filter(e => !e.allDay) : [...events];
|
|
486
702
|
|
|
487
703
|
if (events.length === 0) return [];
|
|
488
704
|
|
|
@@ -644,6 +860,16 @@ export class EventStore {
|
|
|
644
860
|
|
|
645
861
|
/**
|
|
646
862
|
* Expand a recurring event into individual occurrences
|
|
863
|
+
*
|
|
864
|
+
* Returns every occurrence that overlaps the range, including ones that
|
|
865
|
+
* start before it but run into it (multi-day series). Each occurrence is an
|
|
866
|
+
* {@link Event} cloned from the master with:
|
|
867
|
+
* - `id` from {@link Event.occurrenceId} (`<masterId>_<startMs>`), stable
|
|
868
|
+
* across ranges and resolvable with {@link EventStore#getEvent},
|
|
869
|
+
* - `isOccurrence: true`, `recurringEventId` and `occurrenceStart`,
|
|
870
|
+
* - `metadata.recurringEventId`, `metadata.occurrenceId` (same as `id`) and
|
|
871
|
+
* `metadata.occurrenceIndex` (position within this expansion).
|
|
872
|
+
* Non-recurring events are returned as-is in a one-element array.
|
|
647
873
|
* @param {Event} event - The recurring event
|
|
648
874
|
* @param {Date} rangeStart - Start of the expansion range
|
|
649
875
|
* @param {Date} rangeEnd - End of the expansion range
|
|
@@ -659,27 +885,142 @@ export class EventStore {
|
|
|
659
885
|
|
|
660
886
|
// Expand in the event's timezone for accurate recurrence calculation
|
|
661
887
|
const eventTimezone = event.timeZone || timezone;
|
|
662
|
-
|
|
888
|
+
|
|
889
|
+
// The engine selects occurrences by start, so look back one event
|
|
890
|
+
// duration to catch occurrences that began before the range but overlap it
|
|
891
|
+
const duration = Math.max(0, event.end - event.start);
|
|
892
|
+
const expandStart = new Date(rangeStart.getTime() - duration);
|
|
893
|
+
const occurrences = this.recurrenceEngine.expandEvent(event, expandStart, rangeEnd, {
|
|
663
894
|
timezone: eventTimezone
|
|
664
895
|
});
|
|
665
896
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
occurrenceId: occurrence.id,
|
|
677
|
-
occurrenceIndex: index
|
|
678
|
-
}
|
|
679
|
-
});
|
|
897
|
+
const expanded = [];
|
|
898
|
+
for (const occurrence of occurrences) {
|
|
899
|
+
if (occurrence.end < rangeStart || occurrence.start > rangeEnd) {
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
expanded.push(this._createOccurrence(event, occurrence, eventTimezone, expanded.length));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return expanded;
|
|
906
|
+
}
|
|
680
907
|
|
|
681
|
-
|
|
908
|
+
/**
|
|
909
|
+
* Build the Event instance for one occurrence of a recurring master.
|
|
910
|
+
* @param {Event} event - The recurring master
|
|
911
|
+
* @param {{start: Date, end: Date, timezone?: string}} occurrence - Engine occurrence
|
|
912
|
+
* @param {string} eventTimezone - Timezone the series was expanded in
|
|
913
|
+
* @param {number} index - Position within the current expansion
|
|
914
|
+
* @returns {Event} Occurrence event
|
|
915
|
+
* @private
|
|
916
|
+
*/
|
|
917
|
+
_createOccurrence(event, occurrence, eventTimezone, index) {
|
|
918
|
+
const occurrenceStart = new Date(occurrence.start);
|
|
919
|
+
const id = Event.occurrenceId(event.id, occurrenceStart);
|
|
920
|
+
|
|
921
|
+
const occurrenceEvent = event.clone({
|
|
922
|
+
id,
|
|
923
|
+
start: occurrenceStart,
|
|
924
|
+
end: new Date(occurrence.end),
|
|
925
|
+
timeZone: occurrence.timezone || eventTimezone,
|
|
926
|
+
metadata: {
|
|
927
|
+
...event.metadata,
|
|
928
|
+
recurringEventId: event.id,
|
|
929
|
+
occurrenceId: id,
|
|
930
|
+
occurrenceIndex: index
|
|
931
|
+
}
|
|
682
932
|
});
|
|
933
|
+
|
|
934
|
+
occurrenceEvent.isOccurrence = true;
|
|
935
|
+
occurrenceEvent.recurringEventId = event.id;
|
|
936
|
+
occurrenceEvent.occurrenceStart = new Date(occurrenceStart);
|
|
937
|
+
|
|
938
|
+
return occurrenceEvent;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Lazily iterate the occurrences of a stored event in chronological order.
|
|
943
|
+
*
|
|
944
|
+
* Occurrences come one at a time from the store's recurrence engine
|
|
945
|
+
* (RecurrenceEngineV2 by default), so taking the next few occurrences of
|
|
946
|
+
* an open-ended series does not expand the series. `after` and `before`
|
|
947
|
+
* are exclusive unless `inclusive` is set; see
|
|
948
|
+
* RecurrenceEngineV2.iterateOccurrences for the full semantics. The
|
|
949
|
+
* expansion timezone defaults to the event's, then the store's.
|
|
950
|
+
*
|
|
951
|
+
* @example
|
|
952
|
+
* for (const occurrence of store.iterateOccurrences('standup', { after: new Date() })) {
|
|
953
|
+
* if (occurrence.start > deadline) break;
|
|
954
|
+
* remind(occurrence);
|
|
955
|
+
* }
|
|
956
|
+
*
|
|
957
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
958
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
959
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
960
|
+
* @throws {Error} If no event with the ID exists
|
|
961
|
+
*/
|
|
962
|
+
iterateOccurrences(eventId, options = {}) {
|
|
963
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
964
|
+
return this.recurrenceEngine.iterateOccurrences(query.event, query.options);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* First occurrence of a stored event after an instant, or null when the
|
|
969
|
+
* series has no occurrence after it. `after` is exclusive unless
|
|
970
|
+
* `options.inclusive` is set.
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* const upcoming = store.getNextOccurrence('standup', new Date());
|
|
974
|
+
*
|
|
975
|
+
* @param {string} eventId - Event id or occurrence id (resolved to its master)
|
|
976
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
977
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
978
|
+
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
979
|
+
* @throws {Error} If no event with the ID exists
|
|
980
|
+
*/
|
|
981
|
+
getNextOccurrence(eventId, after = null, options = {}) {
|
|
982
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
983
|
+
return this.recurrenceEngine.nextOccurrence(query.event, after, query.options);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* The first `count` occurrences of a stored event inside a window,
|
|
988
|
+
* generated lazily. `count` is capped at the engine's
|
|
989
|
+
* MAX_OCCURRENCES_HARD_LIMIT.
|
|
990
|
+
*
|
|
991
|
+
* @example
|
|
992
|
+
* const nextFive = store.takeOccurrences('standup', 5, { after: new Date() });
|
|
993
|
+
*
|
|
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)
|
|
996
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
997
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
998
|
+
* @throws {Error} If no event with the ID exists
|
|
999
|
+
*/
|
|
1000
|
+
takeOccurrences(eventId, count, options = {}) {
|
|
1001
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
1002
|
+
return this.recurrenceEngine.takeOccurrences(query.event, count, query.options);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Resolve an occurrence query to the stored event and its options, with
|
|
1007
|
+
* the timezone defaulted as expandRecurringEvent does
|
|
1008
|
+
* @param {string} eventId - The event ID
|
|
1009
|
+
* @param {Object} options - Caller options
|
|
1010
|
+
* @returns {{ event: Event, options: Object }}
|
|
1011
|
+
* @throws {Error} If no event with the ID exists
|
|
1012
|
+
* @private
|
|
1013
|
+
*/
|
|
1014
|
+
_occurrenceQuery(eventId, options) {
|
|
1015
|
+
// Occurrence ids resolve to their master, as in getEvent
|
|
1016
|
+
const event = this.getEvent(eventId);
|
|
1017
|
+
if (!event) {
|
|
1018
|
+
throw new Error(`Event with id ${eventId} not found`);
|
|
1019
|
+
}
|
|
1020
|
+
return {
|
|
1021
|
+
event,
|
|
1022
|
+
options: { ...options, timezone: options.timezone || event.timeZone || this.defaultTimezone }
|
|
1023
|
+
};
|
|
683
1024
|
}
|
|
684
1025
|
|
|
685
1026
|
/**
|
|
@@ -695,6 +1036,7 @@ export class EventStore {
|
|
|
695
1036
|
this.indices.byCategory.clear();
|
|
696
1037
|
this.indices.byStatus.clear();
|
|
697
1038
|
this.eventIndexRefs.clear();
|
|
1039
|
+
this._invalidateOccurrenceCache();
|
|
698
1040
|
|
|
699
1041
|
this._notifyChange({
|
|
700
1042
|
type: 'clear',
|
|
@@ -727,13 +1069,23 @@ export class EventStore {
|
|
|
727
1069
|
* - adds events whose id is not in the store (`add` change),
|
|
728
1070
|
* - removes stored events missing from the snapshot (`remove` change),
|
|
729
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,
|
|
730
1078
|
* - emits a single `batch` notification listing those changes, or nothing at
|
|
731
1079
|
* all when the snapshot matches the store. When called while a batch is
|
|
732
1080
|
* already open the changes are queued on that batch instead.
|
|
733
1081
|
*
|
|
734
|
-
* Input is validated up front: invalid event data
|
|
735
|
-
* before the store is modified.
|
|
736
|
-
*
|
|
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).
|
|
737
1089
|
*
|
|
738
1090
|
* @example
|
|
739
1091
|
* // periodic server snapshot
|
|
@@ -759,7 +1111,13 @@ export class EventStore {
|
|
|
759
1111
|
// Normalize and validate everything before touching the store
|
|
760
1112
|
/** @type {Map<string, Event>} */
|
|
761
1113
|
const incoming = new Map();
|
|
1114
|
+
const occurrenceRefs = [];
|
|
762
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
|
+
}
|
|
763
1121
|
const event = eventData instanceof Event ? eventData : new Event(eventData);
|
|
764
1122
|
if (incoming.has(event.id)) {
|
|
765
1123
|
throw new Error(`Duplicate event id in reconcile input: ${event.id}`);
|
|
@@ -767,6 +1125,22 @@ export class EventStore {
|
|
|
767
1125
|
incoming.set(event.id, event);
|
|
768
1126
|
}
|
|
769
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
|
+
|
|
770
1144
|
/** @type {import('../types.js').ReconcileResult} */
|
|
771
1145
|
const result = { added: [], updated: [], removed: [], unchanged: [] };
|
|
772
1146
|
|
|
@@ -779,7 +1153,7 @@ export class EventStore {
|
|
|
779
1153
|
try {
|
|
780
1154
|
if (removeMissing) {
|
|
781
1155
|
for (const existing of Array.from(this.events.values())) {
|
|
782
|
-
if (!incoming.has(existing.id)) {
|
|
1156
|
+
if (!incoming.has(existing.id) && !retained.has(existing.id)) {
|
|
783
1157
|
this._detachEvent(existing);
|
|
784
1158
|
this._queueChange({ type: 'remove', event: existing, version: ++this.version });
|
|
785
1159
|
result.removed.push(existing);
|
|
@@ -787,6 +1161,10 @@ export class EventStore {
|
|
|
787
1161
|
}
|
|
788
1162
|
}
|
|
789
1163
|
|
|
1164
|
+
for (const masterId of retained) {
|
|
1165
|
+
result.unchanged.push(this.events.get(masterId));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
790
1168
|
for (const event of incoming.values()) {
|
|
791
1169
|
const existing = this.events.get(event.id);
|
|
792
1170
|
if (!existing) {
|
|
@@ -829,6 +1207,44 @@ export class EventStore {
|
|
|
829
1207
|
});
|
|
830
1208
|
}
|
|
831
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
|
+
|
|
832
1248
|
/**
|
|
833
1249
|
* Subscribe to store changes
|
|
834
1250
|
* @param {Function} callback - Callback function
|
|
@@ -1069,10 +1485,6 @@ export class EventStore {
|
|
|
1069
1485
|
}
|
|
1070
1486
|
}
|
|
1071
1487
|
|
|
1072
|
-
/**
|
|
1073
|
-
* Notify listeners of changes
|
|
1074
|
-
* @private
|
|
1075
|
-
*/
|
|
1076
1488
|
/**
|
|
1077
1489
|
* Deliver a change now, or queue it when a batch is open
|
|
1078
1490
|
* @param {import('../types.js').EventStoreChange} change - Change to deliver
|
|
@@ -1086,6 +1498,12 @@ export class EventStore {
|
|
|
1086
1498
|
}
|
|
1087
1499
|
}
|
|
1088
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
|
+
*/
|
|
1089
1507
|
_notifyChange(change) {
|
|
1090
1508
|
for (const listener of this.listeners) {
|
|
1091
1509
|
try {
|
|
@@ -1354,12 +1772,13 @@ export class EventStore {
|
|
|
1354
1772
|
}
|
|
1355
1773
|
|
|
1356
1774
|
/**
|
|
1357
|
-
* Clear all caches
|
|
1775
|
+
* Clear all caches, including the recurrence engine's cached expansions
|
|
1358
1776
|
*/
|
|
1359
1777
|
clearCaches() {
|
|
1360
1778
|
this.optimizer.eventCache.clear();
|
|
1361
1779
|
this.optimizer.queryCache.clear();
|
|
1362
1780
|
this.optimizer.dateRangeCache.clear();
|
|
1781
|
+
this._invalidateOccurrenceCache();
|
|
1363
1782
|
}
|
|
1364
1783
|
|
|
1365
1784
|
/**
|