@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.
@@ -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
- * @param {string} eventId - The event ID
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
- * @param {string} eventId - The event ID to remove
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
- * @param {string} eventId - The event ID
197
- * @returns {Event|null} The event or null if not found
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 event;
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
- // Collect candidate event IDs from indices
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 = -1; offset <= 1; 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
- // Filter candidates to events that actually overlap with the requested date
378
- const allEvents = [];
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
- for (const id of candidateIds) {
385
- const event = this.events.get(id);
386
- if (event) {
387
- // Check if event actually occurs on the requested date in the given timezone
388
- const eventStartLocal = event.getStartInTimezone(timezone);
389
- const eventEndLocal = event.getEndInTimezone(timezone);
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
- // Event overlaps with this day if it starts before end of day and ends after start of day
392
- if (eventStartLocal <= endOfDay && eventEndLocal >= startOfDay) {
393
- allEvents.push(event);
394
- }
395
- }
396
- }
599
+ return onDay.sort(this._compareByStart(timezone));
600
+ }
397
601
 
398
- return allEvents.sort((a, b) => {
399
- // Sort by start time in the specified timezone
400
- const aStart = a.getStartInTimezone(timezone);
401
- const bStart = b.getStartInTimezone(timezone);
402
- const timeCompare = aStart - bStart;
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
- let events = this.getEventsForDate(date);
689
+ return this.groupOverlappingEvents(this.getEventsForDate(date), timedOnly);
690
+ }
482
691
 
483
- if (timedOnly) {
484
- events = events.filter(e => !e.allDay);
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
- const occurrences = this.recurrenceEngine.expandEvent(event, rangeStart, rangeEnd, {
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
- return occurrences.map((occurrence, index) => {
667
- // Create a new event instance for each occurrence
668
- const occurrenceEvent = event.clone({
669
- id: `${event.id}_occurrence_${index}`,
670
- start: occurrence.start,
671
- end: occurrence.end,
672
- timeZone: occurrence.timezone || eventTimezone,
673
- metadata: {
674
- ...event.metadata,
675
- recurringEventId: event.id,
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
- return occurrenceEvent;
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 or duplicate ids throw
735
- * before the store is modified. Any error raised while applying the diff
736
- * rolls the store back to its previous state.
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
  /**