@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.
@@ -178,9 +178,12 @@ export class Calendar {
178
178
 
179
179
  /**
180
180
  * Update an event
181
- * @param {string} eventId - The event ID
181
+ *
182
+ * An occurrence id taken from view data (see {@link Event.occurrenceId})
183
+ * updates the recurring master, i.e. the whole series.
184
+ * @param {string} eventId - Event id or occurrence id
182
185
  * @param {Object} updates - Properties to update
183
- * @returns {Event} The updated event
186
+ * @returns {Event} The updated event (the master for an occurrence id)
184
187
  */
185
188
  updateEvent(eventId, updates) {
186
189
  const oldEvent = this.eventStore.getEvent(eventId);
@@ -193,7 +196,10 @@ export class Calendar {
193
196
 
194
197
  /**
195
198
  * Remove an event
196
- * @param {string} eventId - The event ID
199
+ *
200
+ * An occurrence id taken from view data (see {@link Event.occurrenceId})
201
+ * removes the recurring master, i.e. the whole series.
202
+ * @param {string} eventId - Event id or occurrence id
197
203
  * @returns {boolean} True if removed
198
204
  */
199
205
  removeEvent(eventId) {
@@ -218,15 +224,48 @@ export class Calendar {
218
224
 
219
225
  /**
220
226
  * Get an event by ID
221
- * @param {string} eventId - The event ID
222
- * @returns {Event|null}
227
+ *
228
+ * Occurrence ids taken from view data (`<masterId>_<startMs>`, see
229
+ * {@link Event.occurrenceId}) resolve to the stored recurring master, so
230
+ * every id a renderer hands back can be looked up here.
231
+ * @param {string} eventId - Event id or occurrence id
232
+ * @returns {Event|null} The stored event (the master for an occurrence id) or null
223
233
  */
224
234
  getEvent(eventId) {
225
235
  return this.eventStore.getEvent(eventId);
226
236
  }
227
237
 
228
238
  /**
229
- * Get all events
239
+ * Resolve an event id or occurrence id to the id of the stored event it
240
+ * refers to: the id itself for a stored event, the master's id for an
241
+ * occurrence id taken from view data, `null` when nothing stored matches.
242
+ * See `EventStore.resolveEventId`.
243
+ *
244
+ * @example
245
+ * calendar.resolveEventId('standup_1750028400000'); // 'standup'
246
+ * calendar.resolveEventId('unknown'); // null
247
+ *
248
+ * @param {string} id - Event id or occurrence id
249
+ * @returns {string|null} Id of the stored event, or null
250
+ */
251
+ resolveEventId(id) {
252
+ return this.eventStore.resolveEventId(id);
253
+ }
254
+
255
+ /**
256
+ * Get the occurrence an occurrence id from view data stands for, as an
257
+ * {@link Event} like the ones the views hold, or `null` when the id is
258
+ * not an occurrence of a stored recurring series. See
259
+ * `EventStore.getOccurrence`.
260
+ * @param {string} occurrenceId - Occurrence id (`<masterId>_<startMs>`)
261
+ * @returns {Event|null} The occurrence, or null
262
+ */
263
+ getOccurrence(occurrenceId) {
264
+ return this.eventStore.getOccurrence(occurrenceId, this.config.timeZone);
265
+ }
266
+
267
+ /**
268
+ * Get all stored events (recurring masters, never their occurrences)
230
269
  * @returns {Event[]}
231
270
  */
232
271
  getEvents() {
@@ -259,13 +298,14 @@ export class Calendar {
259
298
  if (options.reconcile) {
260
299
  return this.reconcileEvents(events, options);
261
300
  }
301
+ this._assertIterable(events, 'setEvents');
262
302
 
263
303
  const removed = this.getEvents();
264
304
  this.eventStore.loadEvents(events);
265
305
  const added = this.getEvents();
266
306
 
267
307
  /** @type {import('../types.js').EventsSetPayload} */
268
- const payload = { events: added, added, updated: [], removed, unchanged: [] };
308
+ const payload = { events: this.getEvents(), added, updated: [], removed, unchanged: [] };
269
309
  this._emit('eventsSet', payload);
270
310
  return payload;
271
311
  }
@@ -295,9 +335,10 @@ export class Calendar {
295
335
  * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
296
336
  * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
297
337
  * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
298
- * @throws {Error} If an entry fails validation or two entries share an id
338
+ * @throws {Error} If `events` is not iterable, an entry fails validation or two entries share an id
299
339
  */
300
340
  reconcileEvents(events, options = {}) {
341
+ this._assertIterable(events, 'reconcileEvents');
301
342
  const { removeMissing, isEquivalent } = options;
302
343
  const prepared = [];
303
344
  for (const eventData of events) {
@@ -320,6 +361,18 @@ export class Calendar {
320
361
  return payload;
321
362
  }
322
363
 
364
+ /**
365
+ * Throw a clear error when a snapshot is not iterable
366
+ * @param {*} events - Candidate snapshot
367
+ * @param {string} method - Calling method, for the message
368
+ * @private
369
+ */
370
+ _assertIterable(events, method) {
371
+ if (!events || typeof events[Symbol.iterator] !== 'function') {
372
+ throw new Error(`${method}() expects an iterable of events`);
373
+ }
374
+ }
375
+
323
376
  /**
324
377
  * Get the event store's change counter
325
378
  *
@@ -344,7 +397,7 @@ export class Calendar {
344
397
  }
345
398
 
346
399
  /**
347
- * Get events for a specific date
400
+ * Get events for a specific date, with recurring series expanded into occurrences
348
401
  * @param {Date} date - The date
349
402
  * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
350
403
  * @returns {Event[]}
@@ -353,6 +406,20 @@ export class Calendar {
353
406
  return this.eventStore.getEventsForDate(date, timezone || this.config.timeZone);
354
407
  }
355
408
 
409
+ /**
410
+ * Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
411
+ *
412
+ * Recurring series are expanded once for the whole range; this is what the
413
+ * month and week views use. See `EventStore.getEventsByDate`.
414
+ * @param {Date} start - First day of the range
415
+ * @param {Date} end - Last day of the range
416
+ * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
417
+ * @returns {Map<string, Event[]>} Local date string -> events on that day
418
+ */
419
+ getEventsByDate(start, end, timezone = null) {
420
+ return this.eventStore.getEventsByDate(start, end, timezone || this.config.timeZone);
421
+ }
422
+
356
423
  /**
357
424
  * Get events in a date range
358
425
  * @param {Date} start - Start date
@@ -364,6 +431,64 @@ export class Calendar {
364
431
  return this.eventStore.getEventsInRange(start, end, true, timezone || this.config.timeZone);
365
432
  }
366
433
 
434
+ /**
435
+ * Lazily iterate the occurrences of an event in chronological order.
436
+ *
437
+ * Occurrences are produced one at a time, so taking the next few of an
438
+ * open-ended series does not expand the series. `after` and `before`
439
+ * are exclusive unless `inclusive` is set; see
440
+ * RecurrenceEngineV2.iterateOccurrences for the full semantics.
441
+ *
442
+ * @example
443
+ * for (const occurrence of calendar.iterateOccurrences('standup', { after: new Date() })) {
444
+ * if (occurrence.start > deadline) break;
445
+ * remind(occurrence);
446
+ * }
447
+ *
448
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
449
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
450
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
451
+ * @throws {Error} If no event with the ID exists
452
+ */
453
+ iterateOccurrences(eventId, options = {}) {
454
+ return this.eventStore.iterateOccurrences(eventId, options);
455
+ }
456
+
457
+ /**
458
+ * First occurrence of an event after an instant, or null when the
459
+ * series has no occurrence after it. `after` is exclusive unless
460
+ * `options.inclusive` is set.
461
+ *
462
+ * @example
463
+ * const upcoming = calendar.getNextOccurrence('standup', new Date());
464
+ *
465
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
466
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
467
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
468
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
469
+ * @throws {Error} If no event with the ID exists
470
+ */
471
+ getNextOccurrence(eventId, after = null, options = {}) {
472
+ return this.eventStore.getNextOccurrence(eventId, after, options);
473
+ }
474
+
475
+ /**
476
+ * The first `count` occurrences of an event inside a window, generated
477
+ * lazily. `count` is capped at the engine's MAX_OCCURRENCES_HARD_LIMIT.
478
+ *
479
+ * @example
480
+ * const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
481
+ *
482
+ * @param {string} eventId - Event id or occurrence id (resolved to its master)
483
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
484
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
485
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
486
+ * @throws {Error} If no event with the ID exists
487
+ */
488
+ takeOccurrences(eventId, count, options = {}) {
489
+ return this.eventStore.takeOccurrences(eventId, count, options);
490
+ }
491
+
367
492
  /**
368
493
  * Set the calendar's timezone
369
494
  * @param {string} timezone - IANA timezone identifier
@@ -529,6 +654,12 @@ export class Calendar {
529
654
  ? 6
530
655
  : Math.ceil((lastDay.getDate() + DateUtils.getDayOfWeek(firstDay, weekStartsOn)) / 7);
531
656
 
657
+ // Expand recurring series once for the whole grid, not once per cell
658
+ const eventsByDate = this.getEventsByDate(
659
+ startDate,
660
+ DateUtils.addDays(startDate, maxWeeks * 7 - 1)
661
+ );
662
+
532
663
  for (let weekIndex = 0; weekIndex < maxWeeks; weekIndex++) {
533
664
  const week = {
534
665
  weekNumber: DateUtils.getWeekNumber(currentDate),
@@ -547,7 +678,7 @@ export class Calendar {
547
678
  isCurrentMonth,
548
679
  isToday,
549
680
  isWeekend,
550
- events: this.getEventsForDate(dayDate)
681
+ events: eventsByDate.get(DateUtils.getLocalDateString(dayDate)) || []
551
682
  });
552
683
 
553
684
  // Use DateUtils.addDays to handle month boundaries correctly
@@ -580,8 +711,12 @@ export class Calendar {
580
711
  const days = [];
581
712
  const currentDate = new Date(startDate);
582
713
 
714
+ // Expand recurring series once for the whole week, not once per day
715
+ const eventsByDate = this.getEventsByDate(startDate, endDate);
716
+
583
717
  for (let i = 0; i < 7; i++) {
584
718
  const dayDate = new Date(currentDate);
719
+ const events = eventsByDate.get(DateUtils.getLocalDateString(dayDate)) || [];
585
720
  days.push({
586
721
  date: dayDate,
587
722
  dayOfMonth: dayDate.getDate(),
@@ -589,9 +724,9 @@ export class Calendar {
589
724
  dayName: DateUtils.getDayName(dayDate, this.state.get('locale')),
590
725
  isToday: DateUtils.isToday(dayDate),
591
726
  isWeekend: dayDate.getDay() === 0 || dayDate.getDay() === 6,
592
- events: this.getEventsForDate(dayDate),
727
+ events,
593
728
  // Add overlap groups for positioning overlapping events
594
- overlapGroups: this.eventStore.getOverlapGroups(dayDate, true),
729
+ overlapGroups: this.eventStore.groupOverlappingEvents(events, true),
595
730
  getEventPositions: events => this.eventStore.calculateEventPositions(events)
596
731
  });
597
732
  // Move to next day
@@ -612,11 +747,19 @@ export class Calendar {
612
747
  * @private
613
748
  */
614
749
  _getDayViewData(date) {
750
+ const timezone = this.config.timeZone;
615
751
  const events = this.getEventsForDate(date);
616
752
 
617
- // Separate all-day and timed events
753
+ // Separate all-day and timed events. Hour slots are placed on the
754
+ // calendar's wall clock, so timed events are compared in that timezone.
618
755
  const allDayEvents = events.filter(e => e.allDay);
619
- const timedEvents = events.filter(e => !e.allDay);
756
+ const timedEvents = events
757
+ .filter(e => !e.allDay)
758
+ .map(event => ({
759
+ event,
760
+ start: event.getStartInTimezone(timezone),
761
+ end: event.getEndInTimezone(timezone)
762
+ }));
620
763
 
621
764
  // Create hourly slots for timed events
622
765
  const hours = [];
@@ -629,11 +772,13 @@ export class Calendar {
629
772
  hours.push({
630
773
  hour,
631
774
  time: DateUtils.formatTime(hourDate, this.state.get('locale')),
632
- events: timedEvents.filter(event => {
633
- // Check if event occurs during this hour (not just starts)
634
- // Event occurs in this hour if it overlaps with the hour slot
635
- return event.start < hourEnd && event.end > hourDate;
636
- })
775
+ events: timedEvents
776
+ .filter(({ start, end }) => {
777
+ // Check if event occurs during this hour (not just starts)
778
+ // Event occurs in this hour if it overlaps with the hour slot
779
+ return start < hourEnd && end > hourDate;
780
+ })
781
+ .map(({ event }) => event)
637
782
  });
638
783
  }
639
784
 
@@ -695,14 +840,37 @@ export class Calendar {
695
840
 
696
841
  /**
697
842
  * Select an event
698
- * @param {string} eventId - Event ID to select
843
+ *
844
+ * Accepts a stored event's id or an occurrence id taken from view data
845
+ * (see {@link Event.occurrenceId}). Either way the stored event is what
846
+ * gets selected: `selectedEventId` in the state holds its id, so it can
847
+ * always be looked up with {@link Calendar#getEvent}. The `eventSelect`
848
+ * payload carries the stored `event`, its `eventId`, and for an
849
+ * occurrence id also `occurrenceId` and the `occurrence` itself (an
850
+ * {@link Event} as in view data, or null when the series has no
851
+ * occurrence at that instant). Nothing happens for an unknown id.
852
+ *
853
+ * @example
854
+ * calendar.on('eventSelect', ({ event, occurrence }) => open(occurrence || event));
855
+ * calendar.selectEvent(chip.dataset.eventId);
856
+ *
857
+ * @param {string} eventId - Event id or occurrence id to select
699
858
  */
700
859
  selectEvent(eventId) {
701
860
  const event = this.getEvent(eventId);
702
- if (event) {
703
- this.state.selectEvent(eventId);
704
- this._emit('eventSelect', { event });
861
+ if (!event) {
862
+ return;
705
863
  }
864
+ const isOccurrence = eventId !== event.id;
865
+ this.state.selectEvent(event.id);
866
+ /** @type {import('../types.js').EventSelectPayload} */
867
+ const payload = {
868
+ event,
869
+ eventId: event.id,
870
+ occurrenceId: isOccurrence ? eventId : null,
871
+ occurrence: isOccurrence ? this.getOccurrence(eventId) : null
872
+ };
873
+ this._emit('eventSelect', payload);
706
874
  }
707
875
 
708
876
  /**
@@ -42,6 +42,33 @@ function deepEqual(a, b) {
42
42
  return true;
43
43
  }
44
44
 
45
+ /**
46
+ * Timezone identifiers Intl has already accepted. Every Event construction
47
+ * (each clone, each occurrence of a recurring series) validates its
48
+ * timezone, and constructing an Intl.DateTimeFormat per event dominated
49
+ * the cost of expanding a month grid.
50
+ * @type {Set<string>}
51
+ */
52
+ const validatedTimezones = new Set();
53
+
54
+ /**
55
+ * Throw unless Intl accepts the timezone identifier
56
+ * @param {string} timezone - IANA timezone identifier
57
+ * @param {string} label - Field name for the error message
58
+ * @throws {Error} If the timezone is not valid
59
+ */
60
+ function assertValidTimezone(timezone, label) {
61
+ if (validatedTimezones.has(timezone)) {
62
+ return;
63
+ }
64
+ try {
65
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone });
66
+ } catch (e) {
67
+ throw new Error(`Invalid ${label}: ${timezone}`, { cause: e });
68
+ }
69
+ validatedTimezones.add(timezone);
70
+ }
71
+
45
72
  export class Event {
46
73
  // Field size limits
47
74
  static FIELD_LIMITS = {
@@ -204,22 +231,12 @@ export class Event {
204
231
  });
205
232
  }
206
233
 
207
- // Validate timezone if provided
234
+ // Validate timezones if provided (memoised per identifier)
208
235
  if (data.timeZone) {
209
- try {
210
- new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
211
- } catch (e) {
212
- throw new Error(`Invalid timezone: ${data.timeZone}`, { cause: e });
213
- }
236
+ assertValidTimezone(data.timeZone, 'timezone');
214
237
  }
215
-
216
- // Validate end timezone if provided
217
238
  if (data.endTimeZone) {
218
- try {
219
- new Intl.DateTimeFormat('en-US', { timeZone: data.endTimeZone });
220
- } catch (e) {
221
- throw new Error(`Invalid end timezone: ${data.endTimeZone}`, { cause: e });
222
- }
239
+ assertValidTimezone(data.endTimeZone, 'end timezone');
223
240
  }
224
241
  }
225
242
 
@@ -325,6 +342,16 @@ export class Event {
325
342
  this.recurring = normalized.recurring;
326
343
  this.recurrenceRule = normalized.recurrenceRule;
327
344
 
345
+ // Occurrence identity. Set by EventStore.expandRecurringEvent on the
346
+ // instances it derives from a recurring series; stored events keep the
347
+ // defaults. The id of an occurrence is Event.occurrenceId(master, start).
348
+ /** @type {boolean} True when this instance is one occurrence of a recurring series */
349
+ this.isOccurrence = false;
350
+ /** @type {string|null} Id of the recurring master this occurrence belongs to */
351
+ this.recurringEventId = null;
352
+ /** @type {Date|null} Start of this occurrence as generated by the recurrence rule */
353
+ this.occurrenceStart = null;
354
+
328
355
  // Store original timezone from system if not provided
329
356
  this._originalTimeZone = normalized.timeZone || null;
330
357
 
@@ -650,6 +677,8 @@ export class Event {
650
677
  * Scalars are compared with strict equality, dates by timestamp and
651
678
  * structured fields (recurrence rule, organizer, attendees, reminders,
652
679
  * categories, attachments, conference data, metadata) structurally.
680
+ * The `color` shorthand is not listed: normalization copies it into
681
+ * `backgroundColor` and `borderColor`, which are compared instead.
653
682
  * @type {ReadonlyArray<string>}
654
683
  */
655
684
  static EQUIVALENCE_FIELDS = Object.freeze([
@@ -662,7 +691,6 @@ export class Event {
662
691
  'endTimeZone',
663
692
  'description',
664
693
  'location',
665
- 'color',
666
694
  'backgroundColor',
667
695
  'borderColor',
668
696
  'textColor',
@@ -694,6 +722,14 @@ export class Event {
694
722
  * describe the same event. Two events with different ids are never
695
723
  * equivalent.
696
724
  *
725
+ * Two things to know when building snapshots for `reconcile()`:
726
+ * - `attendees`, `reminders`, `categories` and `attachments` are compared
727
+ * in order, so the same attendees listed in a different order count as
728
+ * a change.
729
+ * - Only the top-level dates are normalized. Values inside `metadata` are
730
+ * compared as given, so a `Date` and its ISO string are not equivalent
731
+ * there; keep metadata in one representation.
732
+ *
697
733
  * This is the default comparator used by `EventStore.reconcile()` to decide
698
734
  * whether an incoming snapshot entry replaces the stored event.
699
735
  *
@@ -721,6 +757,62 @@ export class Event {
721
757
  return true;
722
758
  }
723
759
 
760
+ /**
761
+ * Build the id of one occurrence of a recurring series.
762
+ *
763
+ * The id is `<recurringEventId>_<startMs>` where `startMs` is the
764
+ * occurrence start as returned by `Date.prototype.getTime()`. It is
765
+ * deterministic, so the same occurrence gets the same id no matter which
766
+ * range it was expanded for, and it matches the ids generated by
767
+ * `RecurrenceEngineV2`. Use {@link Event.parseOccurrenceId} to get the
768
+ * master id back.
769
+ *
770
+ * @example
771
+ * Event.occurrenceId('standup', new Date(2025, 5, 16, 9)); // 'standup_1750028400000'
772
+ *
773
+ * @param {string} recurringEventId - Id of the recurring master event
774
+ * @param {Date|number|string} occurrenceStart - Start of the occurrence
775
+ * @returns {string} Occurrence id
776
+ * @throws {TypeError} If occurrenceStart is not a valid date
777
+ */
778
+ static occurrenceId(recurringEventId, occurrenceStart) {
779
+ const start = occurrenceStart instanceof Date ? occurrenceStart : new Date(occurrenceStart);
780
+ const startMs = start.getTime();
781
+ if (Number.isNaN(startMs)) {
782
+ throw new TypeError('Event.occurrenceId: occurrenceStart must be a valid Date or timestamp');
783
+ }
784
+ return `${recurringEventId}_${startMs}`;
785
+ }
786
+
787
+ /**
788
+ * Split an occurrence id built by {@link Event.occurrenceId} into the master
789
+ * id and the occurrence start.
790
+ *
791
+ * Returns `null` for ids that do not have the `<id>_<startMs>` shape. A
792
+ * positive result only means the id is well-formed; whether the master
793
+ * exists is for the caller (see `EventStore.getEvent`) to check.
794
+ *
795
+ * @param {string} id - Candidate occurrence id
796
+ * @returns {{recurringEventId: string, occurrenceStart: Date}|null} Parsed parts or null
797
+ */
798
+ static parseOccurrenceId(id) {
799
+ if (typeof id !== 'string') {
800
+ return null;
801
+ }
802
+ const separator = id.lastIndexOf('_');
803
+ if (separator <= 0 || separator === id.length - 1) {
804
+ return null;
805
+ }
806
+ const time = id.slice(separator + 1);
807
+ if (!/^-?\d+$/.test(time)) {
808
+ return null;
809
+ }
810
+ return {
811
+ recurringEventId: id.slice(0, separator),
812
+ occurrenceStart: new Date(Number(time))
813
+ };
814
+ }
815
+
724
816
  // ============ Attendee Management Methods ============
725
817
 
726
818
  /**