@forcecalendar/core 2.4.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.
@@ -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,19 @@ 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
+ * Get all stored events (recurring masters, never their occurrences)
230
240
  * @returns {Event[]}
231
241
  */
232
242
  getEvents() {
@@ -344,7 +354,7 @@ export class Calendar {
344
354
  }
345
355
 
346
356
  /**
347
- * Get events for a specific date
357
+ * Get events for a specific date, with recurring series expanded into occurrences
348
358
  * @param {Date} date - The date
349
359
  * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
350
360
  * @returns {Event[]}
@@ -353,6 +363,20 @@ export class Calendar {
353
363
  return this.eventStore.getEventsForDate(date, timezone || this.config.timeZone);
354
364
  }
355
365
 
366
+ /**
367
+ * Get the events for every day in a range, keyed by local date (YYYY-MM-DD)
368
+ *
369
+ * Recurring series are expanded once for the whole range; this is what the
370
+ * month and week views use. See `EventStore.getEventsByDate`.
371
+ * @param {Date} start - First day of the range
372
+ * @param {Date} end - Last day of the range
373
+ * @param {string} [timezone] - Timezone for the query (defaults to calendar timezone)
374
+ * @returns {Map<string, Event[]>} Local date string -> events on that day
375
+ */
376
+ getEventsByDate(start, end, timezone = null) {
377
+ return this.eventStore.getEventsByDate(start, end, timezone || this.config.timeZone);
378
+ }
379
+
356
380
  /**
357
381
  * Get events in a date range
358
382
  * @param {Date} start - Start date
@@ -364,6 +388,64 @@ export class Calendar {
364
388
  return this.eventStore.getEventsInRange(start, end, true, timezone || this.config.timeZone);
365
389
  }
366
390
 
391
+ /**
392
+ * Lazily iterate the occurrences of an event in chronological order.
393
+ *
394
+ * Occurrences are produced one at a time, so taking the next few of an
395
+ * open-ended series does not expand the series. `after` and `before`
396
+ * are exclusive unless `inclusive` is set; see
397
+ * RecurrenceEngineV2.iterateOccurrences for the full semantics.
398
+ *
399
+ * @example
400
+ * for (const occurrence of calendar.iterateOccurrences('standup', { after: new Date() })) {
401
+ * if (occurrence.start > deadline) break;
402
+ * remind(occurrence);
403
+ * }
404
+ *
405
+ * @param {string} eventId - The event ID
406
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
407
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
408
+ * @throws {Error} If no event with the ID exists
409
+ */
410
+ iterateOccurrences(eventId, options = {}) {
411
+ return this.eventStore.iterateOccurrences(eventId, options);
412
+ }
413
+
414
+ /**
415
+ * First occurrence of an event after an instant, or null when the
416
+ * series has no occurrence after it. `after` is exclusive unless
417
+ * `options.inclusive` is set.
418
+ *
419
+ * @example
420
+ * const upcoming = calendar.getNextOccurrence('standup', new Date());
421
+ *
422
+ * @param {string} eventId - The event ID
423
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
424
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
425
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
426
+ * @throws {Error} If no event with the ID exists
427
+ */
428
+ getNextOccurrence(eventId, after = null, options = {}) {
429
+ return this.eventStore.getNextOccurrence(eventId, after, options);
430
+ }
431
+
432
+ /**
433
+ * The first `count` occurrences of an event inside a window, generated
434
+ * lazily. `count` is capped at the engine's MAX_OCCURRENCES_HARD_LIMIT.
435
+ *
436
+ * @example
437
+ * const nextFive = calendar.takeOccurrences('standup', 5, { after: new Date() });
438
+ *
439
+ * @param {string} eventId - The event ID
440
+ * @param {number} count - Maximum number of occurrences to return
441
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
442
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
443
+ * @throws {Error} If no event with the ID exists
444
+ */
445
+ takeOccurrences(eventId, count, options = {}) {
446
+ return this.eventStore.takeOccurrences(eventId, count, options);
447
+ }
448
+
367
449
  /**
368
450
  * Set the calendar's timezone
369
451
  * @param {string} timezone - IANA timezone identifier
@@ -529,6 +611,12 @@ export class Calendar {
529
611
  ? 6
530
612
  : Math.ceil((lastDay.getDate() + DateUtils.getDayOfWeek(firstDay, weekStartsOn)) / 7);
531
613
 
614
+ // Expand recurring series once for the whole grid, not once per cell
615
+ const eventsByDate = this.getEventsByDate(
616
+ startDate,
617
+ DateUtils.addDays(startDate, maxWeeks * 7 - 1)
618
+ );
619
+
532
620
  for (let weekIndex = 0; weekIndex < maxWeeks; weekIndex++) {
533
621
  const week = {
534
622
  weekNumber: DateUtils.getWeekNumber(currentDate),
@@ -547,7 +635,7 @@ export class Calendar {
547
635
  isCurrentMonth,
548
636
  isToday,
549
637
  isWeekend,
550
- events: this.getEventsForDate(dayDate)
638
+ events: eventsByDate.get(DateUtils.getLocalDateString(dayDate)) || []
551
639
  });
552
640
 
553
641
  // Use DateUtils.addDays to handle month boundaries correctly
@@ -580,8 +668,12 @@ export class Calendar {
580
668
  const days = [];
581
669
  const currentDate = new Date(startDate);
582
670
 
671
+ // Expand recurring series once for the whole week, not once per day
672
+ const eventsByDate = this.getEventsByDate(startDate, endDate);
673
+
583
674
  for (let i = 0; i < 7; i++) {
584
675
  const dayDate = new Date(currentDate);
676
+ const events = eventsByDate.get(DateUtils.getLocalDateString(dayDate)) || [];
585
677
  days.push({
586
678
  date: dayDate,
587
679
  dayOfMonth: dayDate.getDate(),
@@ -589,9 +681,9 @@ export class Calendar {
589
681
  dayName: DateUtils.getDayName(dayDate, this.state.get('locale')),
590
682
  isToday: DateUtils.isToday(dayDate),
591
683
  isWeekend: dayDate.getDay() === 0 || dayDate.getDay() === 6,
592
- events: this.getEventsForDate(dayDate),
684
+ events,
593
685
  // Add overlap groups for positioning overlapping events
594
- overlapGroups: this.eventStore.getOverlapGroups(dayDate, true),
686
+ overlapGroups: this.eventStore.groupOverlappingEvents(events, true),
595
687
  getEventPositions: events => this.eventStore.calculateEventPositions(events)
596
688
  });
597
689
  // Move to next day
@@ -325,6 +325,16 @@ export class Event {
325
325
  this.recurring = normalized.recurring;
326
326
  this.recurrenceRule = normalized.recurrenceRule;
327
327
 
328
+ // Occurrence identity. Set by EventStore.expandRecurringEvent on the
329
+ // instances it derives from a recurring series; stored events keep the
330
+ // defaults. The id of an occurrence is Event.occurrenceId(master, start).
331
+ /** @type {boolean} True when this instance is one occurrence of a recurring series */
332
+ this.isOccurrence = false;
333
+ /** @type {string|null} Id of the recurring master this occurrence belongs to */
334
+ this.recurringEventId = null;
335
+ /** @type {Date|null} Start of this occurrence as generated by the recurrence rule */
336
+ this.occurrenceStart = null;
337
+
328
338
  // Store original timezone from system if not provided
329
339
  this._originalTimeZone = normalized.timeZone || null;
330
340
 
@@ -721,6 +731,57 @@ export class Event {
721
731
  return true;
722
732
  }
723
733
 
734
+ /**
735
+ * Build the id of one occurrence of a recurring series.
736
+ *
737
+ * The id is `<recurringEventId>_<startMs>` where `startMs` is the
738
+ * occurrence start as returned by `Date.prototype.getTime()`. It is
739
+ * deterministic, so the same occurrence gets the same id no matter which
740
+ * range it was expanded for, and it matches the ids generated by
741
+ * `RecurrenceEngineV2`. Use {@link Event.parseOccurrenceId} to get the
742
+ * master id back.
743
+ *
744
+ * @example
745
+ * Event.occurrenceId('standup', new Date(2025, 5, 16, 9)); // 'standup_1750028400000'
746
+ *
747
+ * @param {string} recurringEventId - Id of the recurring master event
748
+ * @param {Date|number|string} occurrenceStart - Start of the occurrence
749
+ * @returns {string} Occurrence id
750
+ */
751
+ static occurrenceId(recurringEventId, occurrenceStart) {
752
+ const start = occurrenceStart instanceof Date ? occurrenceStart : new Date(occurrenceStart);
753
+ return `${recurringEventId}_${start.getTime()}`;
754
+ }
755
+
756
+ /**
757
+ * Split an occurrence id built by {@link Event.occurrenceId} into the master
758
+ * id and the occurrence start.
759
+ *
760
+ * Returns `null` for ids that do not have the `<id>_<startMs>` shape. A
761
+ * positive result only means the id is well-formed; whether the master
762
+ * exists is for the caller (see `EventStore.getEvent`) to check.
763
+ *
764
+ * @param {string} id - Candidate occurrence id
765
+ * @returns {{recurringEventId: string, occurrenceStart: Date}|null} Parsed parts or null
766
+ */
767
+ static parseOccurrenceId(id) {
768
+ if (typeof id !== 'string') {
769
+ return null;
770
+ }
771
+ const separator = id.lastIndexOf('_');
772
+ if (separator <= 0 || separator === id.length - 1) {
773
+ return null;
774
+ }
775
+ const time = id.slice(separator + 1);
776
+ if (!/^-?\d+$/.test(time)) {
777
+ return null;
778
+ }
779
+ return {
780
+ recurringEventId: id.slice(0, separator),
781
+ occurrenceStart: new Date(Number(time))
782
+ };
783
+ }
784
+
724
785
  // ============ Attendee Management Methods ============
725
786
 
726
787
  /**