@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
|
@@ -178,9 +178,12 @@ export class Calendar {
|
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
180
|
* Update an event
|
|
181
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
222
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
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:
|
|
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
|
|
727
|
+
events,
|
|
593
728
|
// Add overlap groups for positioning overlapping events
|
|
594
|
-
overlapGroups: this.eventStore.
|
|
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
|
|
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
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
/**
|
package/core/events/Event.js
CHANGED
|
@@ -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
|
|
234
|
+
// Validate timezones if provided (memoised per identifier)
|
|
208
235
|
if (data.timeZone) {
|
|
209
|
-
|
|
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
|
-
|
|
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
|
/**
|