@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.
- package/core/calendar/Calendar.js +102 -10
- package/core/events/Event.js +61 -0
- package/core/events/EventStore.js +308 -53
- package/core/events/RecurrenceEngine.js +368 -17
- package/core/events/RecurrenceEngineV2.js +281 -48
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +88 -14
- package/core/types.js +52 -0
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +77 -7
- package/types/events/Event.d.ts +39 -0
- package/types/events/EventStore.d.ts +163 -5
- package/types/events/RecurrenceEngine.d.ts +156 -0
- package/types/events/RecurrenceEngineV2.d.ts +112 -8
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +66 -1
- package/types/types.d.ts +175 -0
|
@@ -9,6 +9,9 @@ import { RRuleParser } from './RRuleParser.js';
|
|
|
9
9
|
|
|
10
10
|
const DAY = 86400000;
|
|
11
11
|
|
|
12
|
+
// How far ahead of the iteration cursor DST transitions are scanned at a time
|
|
13
|
+
const DST_SCAN_CHUNK = 100 * DAY;
|
|
14
|
+
|
|
12
15
|
export class RecurrenceEngineV2 {
|
|
13
16
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
14
17
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
@@ -51,7 +54,7 @@ export class RecurrenceEngineV2 {
|
|
|
51
54
|
* @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
|
|
52
55
|
* @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
|
|
53
56
|
* @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
|
|
54
|
-
* @returns {
|
|
57
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences
|
|
55
58
|
*/
|
|
56
59
|
expandEvent(event, rangeStart, rangeEnd, options = {}) {
|
|
57
60
|
const {
|
|
@@ -105,40 +108,15 @@ export class RecurrenceEngineV2 {
|
|
|
105
108
|
) {
|
|
106
109
|
iterations++;
|
|
107
110
|
if (state.currentDate >= rangeStart) {
|
|
108
|
-
const occurrence = this.
|
|
111
|
+
const occurrence = this._applyOverrides(
|
|
109
112
|
event,
|
|
110
|
-
state.currentDate,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
this.generateOccurrence(event, state.currentDate, duration, timezone, state),
|
|
114
|
+
rule,
|
|
115
|
+
includeCancelled,
|
|
116
|
+
includeModified
|
|
114
117
|
);
|
|
115
|
-
|
|
116
|
-
// Check exceptions and modifications
|
|
117
118
|
if (occurrence) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Skip if exception
|
|
121
|
-
if (this.isException(event.id, occurrence.start, rule)) {
|
|
122
|
-
if (!includeCancelled) {
|
|
123
|
-
shouldInclude = false;
|
|
124
|
-
} else {
|
|
125
|
-
occurrence.status = 'cancelled';
|
|
126
|
-
occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Apply modifications if any
|
|
131
|
-
if (shouldInclude && includeModified) {
|
|
132
|
-
const modified = this.getModifiedInstance(event.id, occurrence.start);
|
|
133
|
-
if (modified) {
|
|
134
|
-
Object.assign(occurrence, modified);
|
|
135
|
-
occurrence.isModified = true;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (shouldInclude) {
|
|
140
|
-
occurrences.push(occurrence);
|
|
141
|
-
}
|
|
119
|
+
occurrences.push(occurrence);
|
|
142
120
|
}
|
|
143
121
|
}
|
|
144
122
|
|
|
@@ -173,6 +151,242 @@ export class RecurrenceEngineV2 {
|
|
|
173
151
|
return this.cloneOccurrences(occurrences);
|
|
174
152
|
}
|
|
175
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Lazily iterate the occurrences of an event in chronological order.
|
|
156
|
+
*
|
|
157
|
+
* Yields what expandEvent returns for the window, one occurrence at a
|
|
158
|
+
* time and without the expansion cache: stored instance modifications
|
|
159
|
+
* and exceptions are applied as each occurrence is produced, so changes
|
|
160
|
+
* made through addModifiedInstance or addException are visible on the
|
|
161
|
+
* next pull. Rules seekToRange can seek (plain daily and weekly, hourly,
|
|
162
|
+
* minutely) jump straight to `after`, and DST transitions are scanned
|
|
163
|
+
* lazily ahead of the cursor instead of for the whole window up front.
|
|
164
|
+
*
|
|
165
|
+
* Both bounds are exclusive unless `inclusive` is set: an occurrence that
|
|
166
|
+
* starts exactly at `after` or `before` is skipped by default, so
|
|
167
|
+
* iterating from a known occurrence's start continues the series without
|
|
168
|
+
* repeating it; with `inclusive: true` the window is closed on both ends
|
|
169
|
+
* like expandEvent's range. A non-recurring event yields its single
|
|
170
|
+
* occurrence when it falls inside the window. Iteration ends at COUNT or
|
|
171
|
+
* UNTIL, at `before`, or — as a guard for rules that produce no
|
|
172
|
+
* occurrences — after MAX_ITERATIONS_HARD_LIMIT consecutive steps
|
|
173
|
+
* without one. The generator is single-use; call again for a fresh one.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* const engine = new RecurrenceEngineV2();
|
|
177
|
+
* for (const occurrence of engine.iterateOccurrences(event, { after: new Date() })) {
|
|
178
|
+
* if (occurrence.start > deadline) break;
|
|
179
|
+
* schedule(occurrence);
|
|
180
|
+
* }
|
|
181
|
+
*
|
|
182
|
+
* @param {import('./Event.js').Event} event - The event to iterate
|
|
183
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
184
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
185
|
+
* @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
|
|
186
|
+
*/
|
|
187
|
+
iterateOccurrences(event, options = {}) {
|
|
188
|
+
const window = RecurrenceEngine._occurrenceWindow(options);
|
|
189
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
190
|
+
return this._iterateSingle(event, window);
|
|
191
|
+
}
|
|
192
|
+
const {
|
|
193
|
+
includeModified = true,
|
|
194
|
+
includeCancelled = false,
|
|
195
|
+
timezone = event.timeZone || 'UTC',
|
|
196
|
+
handleDST = true
|
|
197
|
+
} = options;
|
|
198
|
+
return this._iterateRule(event, RRuleParser.parse(event.recurrenceRule), window, {
|
|
199
|
+
includeModified,
|
|
200
|
+
includeCancelled,
|
|
201
|
+
timezone,
|
|
202
|
+
handleDST
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* First occurrence of an event after an instant, or null when the series
|
|
208
|
+
* has no occurrence after it. `after` is exclusive unless
|
|
209
|
+
* `options.inclusive` is set, so passing the start of a known occurrence
|
|
210
|
+
* returns the one that follows it.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* const upcoming = engine.nextOccurrence(event, new Date());
|
|
214
|
+
*
|
|
215
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
216
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
217
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
218
|
+
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
219
|
+
*/
|
|
220
|
+
nextOccurrence(event, after = null, options = {}) {
|
|
221
|
+
for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
|
|
222
|
+
return occurrence;
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The first `count` occurrences of an event inside a window, generated
|
|
229
|
+
* lazily so an open-ended series costs only the occurrences taken.
|
|
230
|
+
* `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
|
|
231
|
+
* when the series or the window ends first.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* const nextFive = engine.takeOccurrences(event, 5, { after: new Date() });
|
|
235
|
+
*
|
|
236
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
237
|
+
* @param {number} count - Maximum number of occurrences to return
|
|
238
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
239
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
240
|
+
*/
|
|
241
|
+
takeOccurrences(event, count, options = {}) {
|
|
242
|
+
const limit = Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
|
|
243
|
+
const taken = [];
|
|
244
|
+
if (!(limit > 0)) {
|
|
245
|
+
return taken;
|
|
246
|
+
}
|
|
247
|
+
for (const occurrence of this.iterateOccurrences(event, options)) {
|
|
248
|
+
taken.push(occurrence);
|
|
249
|
+
if (taken.length >= limit) {
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return taken;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Yield a non-recurring event's single occurrence if it starts inside
|
|
258
|
+
* the window
|
|
259
|
+
* @param {import('./Event.js').Event} event - The event
|
|
260
|
+
* @param {{ startMs: number, endMs: number }} window - Inclusive bounds
|
|
261
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>}
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
*_iterateSingle(event, window) {
|
|
265
|
+
const ms = new Date(event.start).getTime();
|
|
266
|
+
if (ms >= window.startMs && ms <= window.endMs) {
|
|
267
|
+
yield this.cloneOccurrence(this.createOccurrence(event, event.start, event.end));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Lazy counterpart of the expandEvent loop: seeks to the window, then
|
|
273
|
+
* steps the cursor and yields each in-window occurrence with the same
|
|
274
|
+
* DST adjustment, exception handling and instance modifications.
|
|
275
|
+
* @private
|
|
276
|
+
*/
|
|
277
|
+
*_iterateRule(event, rule, window, options) {
|
|
278
|
+
const { includeModified, includeCancelled, timezone, handleDST } = options;
|
|
279
|
+
const duration = event.end - event.start;
|
|
280
|
+
const state = {
|
|
281
|
+
currentDate: new Date(event.start),
|
|
282
|
+
count: 0,
|
|
283
|
+
tzOffsets: new Map(),
|
|
284
|
+
dstTransitions: [],
|
|
285
|
+
stuckIterations: 0
|
|
286
|
+
};
|
|
287
|
+
if (Number.isNaN(state.currentDate.getTime()) || window.startMs > window.endMs) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// DST transitions are found on the same day grid expandEvent walks,
|
|
292
|
+
// starting from the window start (DTSTART for an open window) and
|
|
293
|
+
// extended in chunks ahead of the cursor
|
|
294
|
+
let dstScan = null;
|
|
295
|
+
if (handleDST) {
|
|
296
|
+
const scanStart = Number.isFinite(window.startMs)
|
|
297
|
+
? window.startMs
|
|
298
|
+
: state.currentDate.getTime();
|
|
299
|
+
dstScan = { cursor: new Date(scanStart), lastOffset: 0 };
|
|
300
|
+
dstScan.lastOffset = this.tzManager.getTimezoneOffset(dstScan.cursor, timezone);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (Number.isFinite(window.startMs)) {
|
|
304
|
+
const rangeStart = new Date(window.startMs);
|
|
305
|
+
this.seekToRange(state, rule, rangeStart, rangeStart, timezone);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let idleSteps = 0;
|
|
309
|
+
while (state.currentDate.getTime() <= window.endMs) {
|
|
310
|
+
const currentMs = state.currentDate.getTime();
|
|
311
|
+
if (currentMs >= window.startMs) {
|
|
312
|
+
if (dstScan) {
|
|
313
|
+
this._scanDSTTransitions(
|
|
314
|
+
dstScan,
|
|
315
|
+
state.dstTransitions,
|
|
316
|
+
Math.min(currentMs + DST_SCAN_CHUNK, window.endMs),
|
|
317
|
+
timezone
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const occurrence = this._applyOverrides(
|
|
321
|
+
event,
|
|
322
|
+
this.generateOccurrence(event, state.currentDate, duration, timezone, state),
|
|
323
|
+
rule,
|
|
324
|
+
includeCancelled,
|
|
325
|
+
includeModified
|
|
326
|
+
);
|
|
327
|
+
if (occurrence) {
|
|
328
|
+
idleSteps = 0;
|
|
329
|
+
yield this.cloneOccurrence(occurrence);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
state.currentDate = this.getNextDate(state.currentDate, rule, timezone, state);
|
|
334
|
+
state.count++;
|
|
335
|
+
|
|
336
|
+
if (state.currentDate.getTime() <= currentMs) {
|
|
337
|
+
state.stuckIterations++;
|
|
338
|
+
if (state.stuckIterations >= 3) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
state.stuckIterations = 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (rule.count && state.count >= rule.count) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (rule.until && state.currentDate > rule.until) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
idleSteps++;
|
|
352
|
+
if (idleSteps >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Apply exceptions and stored instance modifications to a generated
|
|
360
|
+
* occurrence
|
|
361
|
+
* @param {import('./Event.js').Event} event - The recurring event
|
|
362
|
+
* @param {Object} occurrence - Occurrence from generateOccurrence
|
|
363
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
364
|
+
* @param {boolean} includeCancelled - Return exception dates as cancelled occurrences
|
|
365
|
+
* @param {boolean} includeModified - Apply stored instance modifications
|
|
366
|
+
* @returns {Object|null} The occurrence, or null when it is excluded
|
|
367
|
+
* @private
|
|
368
|
+
*/
|
|
369
|
+
_applyOverrides(event, occurrence, rule, includeCancelled, includeModified) {
|
|
370
|
+
if (!occurrence) {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
if (this.isException(event.id, occurrence.start, rule)) {
|
|
374
|
+
if (!includeCancelled) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
occurrence.status = 'cancelled';
|
|
378
|
+
occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
|
|
379
|
+
}
|
|
380
|
+
if (includeModified) {
|
|
381
|
+
const modified = this.getModifiedInstance(event.id, occurrence.start);
|
|
382
|
+
if (modified) {
|
|
383
|
+
Object.assign(occurrence, modified);
|
|
384
|
+
occurrence.isModified = true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return occurrence;
|
|
388
|
+
}
|
|
389
|
+
|
|
176
390
|
/**
|
|
177
391
|
* Move the expansion cursor to the last occurrence before the range
|
|
178
392
|
* without stepping through every occurrence in between.
|
|
@@ -573,28 +787,38 @@ export class RecurrenceEngineV2 {
|
|
|
573
787
|
*/
|
|
574
788
|
findDSTTransitions(start, end, timezone) {
|
|
575
789
|
const transitions = [];
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
790
|
+
const scan = { cursor: new Date(start), lastOffset: 0 };
|
|
791
|
+
scan.lastOffset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
|
|
792
|
+
this._scanDSTTransitions(scan, transitions, new Date(end).getTime(), timezone);
|
|
793
|
+
return transitions;
|
|
794
|
+
}
|
|
580
795
|
|
|
581
|
-
|
|
582
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Walk the scan cursor one day at a time up to untilMs, appending each
|
|
798
|
+
* offset change. The cursor and last offset persist in `scan`, so the
|
|
799
|
+
* walk can be resumed later on the same day grid.
|
|
800
|
+
* @param {{ cursor: Date, lastOffset: number }} scan - Resumable scan position (mutated)
|
|
801
|
+
* @param {Array} transitions - Transition list to append to
|
|
802
|
+
* @param {number} untilMs - Scan through this timestamp (inclusive)
|
|
803
|
+
* @param {string} timezone - Timezone to probe
|
|
804
|
+
* @private
|
|
805
|
+
*/
|
|
806
|
+
_scanDSTTransitions(scan, transitions, untilMs, timezone) {
|
|
807
|
+
while (scan.cursor.getTime() <= untilMs) {
|
|
808
|
+
const offset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
|
|
583
809
|
|
|
584
|
-
if (offset !== lastOffset) {
|
|
810
|
+
if (offset !== scan.lastOffset) {
|
|
585
811
|
transitions.push({
|
|
586
|
-
date: new Date(
|
|
587
|
-
oldOffset: lastOffset,
|
|
812
|
+
date: new Date(scan.cursor),
|
|
813
|
+
oldOffset: scan.lastOffset,
|
|
588
814
|
newOffset: offset,
|
|
589
|
-
type: offset < lastOffset ? 'spring-forward' : 'fall-back'
|
|
815
|
+
type: offset < scan.lastOffset ? 'spring-forward' : 'fall-back'
|
|
590
816
|
});
|
|
591
817
|
}
|
|
592
818
|
|
|
593
|
-
lastOffset = offset;
|
|
594
|
-
|
|
819
|
+
scan.lastOffset = offset;
|
|
820
|
+
scan.cursor.setDate(scan.cursor.getDate() + 1);
|
|
595
821
|
}
|
|
596
|
-
|
|
597
|
-
return transitions;
|
|
598
822
|
}
|
|
599
823
|
|
|
600
824
|
/**
|
|
@@ -742,7 +966,16 @@ export class RecurrenceEngineV2 {
|
|
|
742
966
|
* Clone occurrence results before returning or caching.
|
|
743
967
|
*/
|
|
744
968
|
cloneOccurrences(occurrences) {
|
|
745
|
-
return occurrences.map(occurrence => (
|
|
969
|
+
return occurrences.map(occurrence => this.cloneOccurrence(occurrence));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Clone a single occurrence, copying its Date and array fields.
|
|
974
|
+
* @param {import('../types.js').ExpandedOccurrence} occurrence - Occurrence to clone
|
|
975
|
+
* @returns {import('../types.js').ExpandedOccurrence} Independent copy
|
|
976
|
+
*/
|
|
977
|
+
cloneOccurrence(occurrence) {
|
|
978
|
+
return {
|
|
746
979
|
...occurrence,
|
|
747
980
|
start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
|
|
748
981
|
end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
|
|
@@ -754,7 +987,7 @@ export class RecurrenceEngineV2 {
|
|
|
754
987
|
categories: Array.isArray(occurrence.categories)
|
|
755
988
|
? [...occurrence.categories]
|
|
756
989
|
: occurrence.categories
|
|
757
|
-
}
|
|
990
|
+
};
|
|
758
991
|
}
|
|
759
992
|
|
|
760
993
|
/**
|
package/core/index.js
CHANGED
|
@@ -32,7 +32,7 @@ export { ConflictDetector } from './conflicts/ConflictDetector.js';
|
|
|
32
32
|
export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
|
|
33
33
|
|
|
34
34
|
// Version — keep in sync with package.json
|
|
35
|
-
export const VERSION = '2.
|
|
35
|
+
export const VERSION = '2.5.0';
|
|
36
36
|
|
|
37
37
|
// Default export
|
|
38
38
|
export { Calendar as default } from './calendar/Calendar.js';
|
|
@@ -56,29 +56,38 @@ export class EnhancedCalendar extends Calendar {
|
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
58
|
* Get events with enhanced recurrence expansion
|
|
59
|
+
*
|
|
60
|
+
* Regular events overlapping the range are returned as stored. Every
|
|
61
|
+
* recurring series in the store is expanded with this calendar's
|
|
62
|
+
* RecurrenceEngineV2 (so instance modifications and cancellations apply),
|
|
63
|
+
* including series that started before the range. Occurrences are the
|
|
64
|
+
* engine's plain occurrence objects, with the id `<masterId>_<startMs>`
|
|
65
|
+
* (see `Event.occurrenceId`), `recurringEventId`, `isOccurrence: true` and
|
|
66
|
+
* `occurrenceStart`.
|
|
59
67
|
*/
|
|
60
68
|
getEventsInRange(startDate, endDate, options = {}) {
|
|
61
69
|
const startTime = performance.now();
|
|
70
|
+
const rangeStart = new Date(startDate);
|
|
71
|
+
const rangeEnd = new Date(endDate);
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
const
|
|
73
|
+
// Recurring masters are represented by their occurrences below
|
|
74
|
+
const regularEvents = this.eventStore
|
|
75
|
+
.getEventsInRange(rangeStart, rangeEnd, false)
|
|
76
|
+
.filter(event => !event.recurring);
|
|
65
77
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
for (const event of allEvents) {
|
|
70
|
-
if (event.recurring) {
|
|
71
|
-
recurringEvents.push(event);
|
|
72
|
-
} else {
|
|
73
|
-
regularEvents.push(event);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
78
|
+
// A series that started before the range can still occur inside it, so
|
|
79
|
+
// every recurring series is a candidate; the engine selects by range.
|
|
80
|
+
const recurringEvents = this.eventStore.queryEvents({ recurring: true });
|
|
76
81
|
|
|
77
82
|
// Expand recurring events with enhanced engine
|
|
78
83
|
const expandedOccurrences = [];
|
|
79
84
|
|
|
80
85
|
for (const event of recurringEvents) {
|
|
81
|
-
|
|
86
|
+
// Look back one event duration so occurrences that began before the
|
|
87
|
+
// range but overlap it are found
|
|
88
|
+
const duration = Math.max(0, event.end - event.start);
|
|
89
|
+
const expandStart = new Date(rangeStart.getTime() - duration);
|
|
90
|
+
const occurrences = this.recurrenceEngine.expandEvent(event, expandStart, rangeEnd, {
|
|
82
91
|
maxOccurrences: options.maxOccurrences || 365,
|
|
83
92
|
includeModified: options.includeModified !== false,
|
|
84
93
|
includeCancelled: options.includeCancelled || false,
|
|
@@ -86,7 +95,16 @@ export class EnhancedCalendar extends Calendar {
|
|
|
86
95
|
handleDST: options.handleDST !== false
|
|
87
96
|
});
|
|
88
97
|
|
|
89
|
-
|
|
98
|
+
for (const occurrence of occurrences) {
|
|
99
|
+
if (occurrence.end < rangeStart || occurrence.start > rangeEnd) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
expandedOccurrences.push({
|
|
103
|
+
...occurrence,
|
|
104
|
+
isOccurrence: true,
|
|
105
|
+
occurrenceStart: new Date(occurrence.start)
|
|
106
|
+
});
|
|
107
|
+
}
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
const endTime = performance.now();
|
|
@@ -129,6 +147,62 @@ export class EnhancedCalendar extends Calendar {
|
|
|
129
147
|
});
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Lazily iterate the occurrences of an event through the enhanced
|
|
152
|
+
* engine, so occurrences changed with modifyOccurrence or cancelled with
|
|
153
|
+
* cancelOccurrence are reflected. Same semantics as
|
|
154
|
+
* Calendar#iterateOccurrences.
|
|
155
|
+
* @param {string} eventId - The event ID
|
|
156
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
157
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
158
|
+
* @throws {Error} If no event with the ID exists
|
|
159
|
+
*/
|
|
160
|
+
iterateOccurrences(eventId, options = {}) {
|
|
161
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
162
|
+
return this.recurrenceEngine.iterateOccurrences(query.event, query.options);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* First occurrence of an event after an instant through the enhanced
|
|
167
|
+
* engine, or null. Same semantics as Calendar#getNextOccurrence.
|
|
168
|
+
* @param {string} eventId - The event ID
|
|
169
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
170
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
171
|
+
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
172
|
+
* @throws {Error} If no event with the ID exists
|
|
173
|
+
*/
|
|
174
|
+
getNextOccurrence(eventId, after = null, options = {}) {
|
|
175
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
176
|
+
return this.recurrenceEngine.nextOccurrence(query.event, after, query.options);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The first `count` occurrences of an event through the enhanced
|
|
181
|
+
* engine. Same semantics as Calendar#takeOccurrences.
|
|
182
|
+
* @param {string} eventId - The event ID
|
|
183
|
+
* @param {number} count - Maximum number of occurrences to return
|
|
184
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
185
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
186
|
+
* @throws {Error} If no event with the ID exists
|
|
187
|
+
*/
|
|
188
|
+
takeOccurrences(eventId, count, options = {}) {
|
|
189
|
+
const query = this._occurrenceQuery(eventId, options);
|
|
190
|
+
return this.recurrenceEngine.takeOccurrences(query.event, count, query.options);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve an occurrence query to the stored event and its options, with
|
|
195
|
+
* the timezone defaulted as getEventsInRange does
|
|
196
|
+
* @private
|
|
197
|
+
*/
|
|
198
|
+
_occurrenceQuery(eventId, options) {
|
|
199
|
+
const event = this.eventStore.getEvent(eventId);
|
|
200
|
+
if (!event) {
|
|
201
|
+
throw new Error(`Event with id ${eventId} not found`);
|
|
202
|
+
}
|
|
203
|
+
return { event, options: { ...options, timezone: options.timezone || event.timeZone } };
|
|
204
|
+
}
|
|
205
|
+
|
|
132
206
|
/**
|
|
133
207
|
* Bulk operations for recurring events
|
|
134
208
|
*/
|
package/core/types.js
CHANGED
|
@@ -346,6 +346,58 @@
|
|
|
346
346
|
* @property {Date} start - Occurrence start date
|
|
347
347
|
* @property {Date} end - Occurrence end date
|
|
348
348
|
* @property {string} recurringEventId - ID of the parent recurring event
|
|
349
|
+
* @property {string} [timezone] - Timezone the occurrence was expanded in
|
|
350
|
+
* @property {Date} [originalStart] - Start of the series (DTSTART)
|
|
351
|
+
*/
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Window for lazy occurrence iteration. Both bounds are exclusive unless
|
|
355
|
+
* `inclusive` is set: an occurrence starting exactly at `after` or `before`
|
|
356
|
+
* is skipped by default, so iterating from a known occurrence's start
|
|
357
|
+
* continues the series without repeating it. Omit a bound to leave that
|
|
358
|
+
* end of the window open.
|
|
359
|
+
* @typedef {Object} OccurrenceIteratorOptions
|
|
360
|
+
* @property {Date|number} [after] - Only occurrences starting after this instant (Date or timestamp)
|
|
361
|
+
* @property {Date|number} [before] - Only occurrences starting before this instant (Date or timestamp)
|
|
362
|
+
* @property {boolean} [inclusive=false] - Treat `after` and `before` as closed bounds
|
|
363
|
+
* @property {string} [timezone] - Timezone for expansion (defaults to the event's)
|
|
364
|
+
*/
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Options for lazy occurrence iteration through RecurrenceEngineV2,
|
|
368
|
+
* EventStore and Calendar: the OccurrenceIteratorOptions window plus the
|
|
369
|
+
* expansion switches RecurrenceEngineV2.expandEvent accepts.
|
|
370
|
+
* @typedef {Object} ExpandedOccurrenceIteratorOptions
|
|
371
|
+
* @property {Date|number} [after] - Only occurrences starting after this instant (Date or timestamp)
|
|
372
|
+
* @property {Date|number} [before] - Only occurrences starting before this instant (Date or timestamp)
|
|
373
|
+
* @property {boolean} [inclusive=false] - Treat `after` and `before` as closed bounds
|
|
374
|
+
* @property {string} [timezone] - Timezone for expansion (defaults to the event's)
|
|
375
|
+
* @property {boolean} [includeModified=true] - Apply stored instance modifications
|
|
376
|
+
* @property {boolean} [includeCancelled=false] - Yield exception dates as cancelled occurrences
|
|
377
|
+
* @property {boolean} [handleDST=true] - Adjust occurrences across DST transitions
|
|
378
|
+
*/
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Occurrence produced by RecurrenceEngineV2 (and therefore by EventStore
|
|
382
|
+
* and Calendar occurrence queries)
|
|
383
|
+
* @typedef {Object} ExpandedOccurrence
|
|
384
|
+
* @property {string} id - Occurrence ID (`<eventId>_<startTimestamp>` for recurring events)
|
|
385
|
+
* @property {string} [recurringEventId] - ID of the parent recurring event
|
|
386
|
+
* @property {string} title - Event title
|
|
387
|
+
* @property {Date} start - Occurrence start date
|
|
388
|
+
* @property {Date} end - Occurrence end date
|
|
389
|
+
* @property {Date} [startUTC] - Occurrence start in UTC
|
|
390
|
+
* @property {Date} [endUTC] - Occurrence end in UTC
|
|
391
|
+
* @property {string} timezone - Timezone the occurrence was expanded in
|
|
392
|
+
* @property {Date} [originalStart] - Start of the series (DTSTART)
|
|
393
|
+
* @property {boolean} allDay - Whether the event is all-day
|
|
394
|
+
* @property {string} [description] - Event description
|
|
395
|
+
* @property {string} [location] - Event location
|
|
396
|
+
* @property {string[]} [categories] - Event categories
|
|
397
|
+
* @property {EventStatus} [status] - 'confirmed', or 'cancelled' for exception dates yielded with includeCancelled
|
|
398
|
+
* @property {string} [cancellationReason] - Reason recorded for a cancelled occurrence
|
|
399
|
+
* @property {boolean} isRecurring - Whether the occurrence belongs to a recurring series
|
|
400
|
+
* @property {boolean} [isModified] - Whether a stored instance modification was applied
|
|
349
401
|
*/
|
|
350
402
|
|
|
351
403
|
/**
|