@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
|
@@ -9,6 +9,24 @@ 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
|
+
|
|
15
|
+
const WEEKDAYS = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
|
|
16
|
+
|
|
17
|
+
// An expansion cut short by MAX_ITERATIONS_HARD_LIMIT is reported once per
|
|
18
|
+
// process rather than on every render
|
|
19
|
+
let iterationLimitWarned = false;
|
|
20
|
+
function warnIterationLimit(eventId) {
|
|
21
|
+
if (iterationLimitWarned) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
iterationLimitWarned = true;
|
|
25
|
+
console.warn(
|
|
26
|
+
`RecurrenceEngineV2: expansion of event ${eventId} stopped after ${RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT} steps without reaching the end of the range; results are truncated`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export class RecurrenceEngineV2 {
|
|
13
31
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
14
32
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
@@ -36,10 +54,15 @@ export class RecurrenceEngineV2 {
|
|
|
36
54
|
/**
|
|
37
55
|
* Expand recurring event with advanced handling
|
|
38
56
|
*
|
|
39
|
-
* Occurrences before rangeStart are skipped without being generated
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* the same cost as one that
|
|
57
|
+
* Occurrences before rangeStart are skipped without being generated for
|
|
58
|
+
* the rules seekToRange can seek: DAILY (without BYHOUR), WEEKLY (with or
|
|
59
|
+
* without BYDAY), HOURLY and MINUTELY. Such a series that started years
|
|
60
|
+
* before the queried window is expanded at the same cost as one that
|
|
61
|
+
* started yesterday. MONTHLY and YEARLY rules, and DAILY with BYHOUR,
|
|
62
|
+
* are stepped from DTSTART; they take few enough steps per year that
|
|
63
|
+
* this is cheap, but an expansion that would need more than
|
|
64
|
+
* MAX_ITERATIONS_HARD_LIMIT steps is truncated (with one console.warn
|
|
65
|
+
* per process).
|
|
43
66
|
*
|
|
44
67
|
* @param {import('./Event.js').Event} event - Recurring event
|
|
45
68
|
* @param {Date} rangeStart - Start of expansion range
|
|
@@ -51,7 +74,7 @@ export class RecurrenceEngineV2 {
|
|
|
51
74
|
* @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
|
|
52
75
|
* @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
|
|
53
76
|
* @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
|
|
54
|
-
* @returns {
|
|
77
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences
|
|
55
78
|
*/
|
|
56
79
|
expandEvent(event, rangeStart, rangeEnd, options = {}) {
|
|
57
80
|
const {
|
|
@@ -66,7 +89,7 @@ export class RecurrenceEngineV2 {
|
|
|
66
89
|
const maxOccurrences = Math.min(requestedMax, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
|
|
67
90
|
|
|
68
91
|
// Check cache
|
|
69
|
-
const cacheKey = this.getCacheKey(event
|
|
92
|
+
const cacheKey = this.getCacheKey(event, rangeStart, rangeEnd, options);
|
|
70
93
|
if (this.occurrenceCache.has(cacheKey)) {
|
|
71
94
|
return this.cloneOccurrences(this.occurrenceCache.get(cacheKey));
|
|
72
95
|
}
|
|
@@ -105,40 +128,15 @@ export class RecurrenceEngineV2 {
|
|
|
105
128
|
) {
|
|
106
129
|
iterations++;
|
|
107
130
|
if (state.currentDate >= rangeStart) {
|
|
108
|
-
const occurrence = this.
|
|
131
|
+
const occurrence = this._applyOverrides(
|
|
109
132
|
event,
|
|
110
|
-
state.currentDate,
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
133
|
+
this.generateOccurrence(event, state.currentDate, duration, timezone, state),
|
|
134
|
+
rule,
|
|
135
|
+
includeCancelled,
|
|
136
|
+
includeModified
|
|
114
137
|
);
|
|
115
|
-
|
|
116
|
-
// Check exceptions and modifications
|
|
117
138
|
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
|
-
}
|
|
139
|
+
occurrences.push(occurrence);
|
|
142
140
|
}
|
|
143
141
|
}
|
|
144
142
|
|
|
@@ -167,19 +165,265 @@ export class RecurrenceEngineV2 {
|
|
|
167
165
|
}
|
|
168
166
|
}
|
|
169
167
|
|
|
168
|
+
if (
|
|
169
|
+
iterations >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT &&
|
|
170
|
+
state.currentDate <= rangeEnd &&
|
|
171
|
+
occurrences.length < maxOccurrences
|
|
172
|
+
) {
|
|
173
|
+
warnIterationLimit(event.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
170
176
|
// Cache results
|
|
171
177
|
this.cacheOccurrences(cacheKey, occurrences);
|
|
172
178
|
|
|
173
179
|
return this.cloneOccurrences(occurrences);
|
|
174
180
|
}
|
|
175
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Lazily iterate the occurrences of an event in chronological order.
|
|
184
|
+
*
|
|
185
|
+
* Yields what expandEvent returns for the window, one occurrence at a
|
|
186
|
+
* time and without the expansion cache: stored instance modifications
|
|
187
|
+
* and exceptions are applied as each occurrence is produced, so changes
|
|
188
|
+
* made through addModifiedInstance or addException are visible on the
|
|
189
|
+
* next pull. Rules seekToRange can seek (daily, weekly, hourly,
|
|
190
|
+
* minutely) jump straight to `after`, and DST transitions are scanned
|
|
191
|
+
* lazily ahead of the cursor instead of for the whole window up front.
|
|
192
|
+
*
|
|
193
|
+
* Both bounds are exclusive unless `inclusive` is set: an occurrence that
|
|
194
|
+
* starts exactly at `after` or `before` is skipped by default, so
|
|
195
|
+
* iterating from a known occurrence's start continues the series without
|
|
196
|
+
* repeating it; with `inclusive: true` the window is closed on both ends
|
|
197
|
+
* like expandEvent's range. A non-recurring event yields its single
|
|
198
|
+
* occurrence when it falls inside the window. Iteration ends at COUNT or
|
|
199
|
+
* UNTIL, at `before`, or — as a guard for rules that produce no
|
|
200
|
+
* occurrences — after MAX_ITERATIONS_HARD_LIMIT consecutive steps
|
|
201
|
+
* without one. The generator is single-use; call again for a fresh one.
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* const engine = new RecurrenceEngineV2();
|
|
205
|
+
* for (const occurrence of engine.iterateOccurrences(event, { after: new Date() })) {
|
|
206
|
+
* if (occurrence.start > deadline) break;
|
|
207
|
+
* schedule(occurrence);
|
|
208
|
+
* }
|
|
209
|
+
*
|
|
210
|
+
* @param {import('./Event.js').Event} event - The event to iterate
|
|
211
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
212
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
|
|
213
|
+
* @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
|
|
214
|
+
*/
|
|
215
|
+
iterateOccurrences(event, options = {}) {
|
|
216
|
+
const window = RecurrenceEngine._occurrenceWindow(options);
|
|
217
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
218
|
+
return this._iterateSingle(event, window);
|
|
219
|
+
}
|
|
220
|
+
const {
|
|
221
|
+
includeModified = true,
|
|
222
|
+
includeCancelled = false,
|
|
223
|
+
timezone = event.timeZone || 'UTC',
|
|
224
|
+
handleDST = true
|
|
225
|
+
} = options;
|
|
226
|
+
return this._iterateRule(event, RRuleParser.parse(event.recurrenceRule), window, {
|
|
227
|
+
includeModified,
|
|
228
|
+
includeCancelled,
|
|
229
|
+
timezone,
|
|
230
|
+
handleDST
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* First occurrence of an event after an instant, or null when the series
|
|
236
|
+
* has no occurrence after it. `after` is exclusive unless
|
|
237
|
+
* `options.inclusive` is set, so passing the start of a known occurrence
|
|
238
|
+
* returns the one that follows it.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* const upcoming = engine.nextOccurrence(event, new Date());
|
|
242
|
+
*
|
|
243
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
244
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
245
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
|
|
246
|
+
* @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
|
|
247
|
+
*/
|
|
248
|
+
nextOccurrence(event, after = null, options = {}) {
|
|
249
|
+
for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
|
|
250
|
+
return occurrence;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The first `count` occurrences of an event inside a window, generated
|
|
257
|
+
* lazily so an open-ended series costs only the occurrences taken.
|
|
258
|
+
* `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
|
|
259
|
+
* when the series or the window ends first.
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* const nextFive = engine.takeOccurrences(event, 5, { after: new Date() });
|
|
263
|
+
*
|
|
264
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
265
|
+
* @param {number} count - Maximum number of occurrences to return (fractions are floored)
|
|
266
|
+
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
267
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
268
|
+
*/
|
|
269
|
+
takeOccurrences(event, count, options = {}) {
|
|
270
|
+
const limit = Math.floor(Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT));
|
|
271
|
+
const taken = [];
|
|
272
|
+
if (!(limit > 0)) {
|
|
273
|
+
return taken;
|
|
274
|
+
}
|
|
275
|
+
for (const occurrence of this.iterateOccurrences(event, options)) {
|
|
276
|
+
taken.push(occurrence);
|
|
277
|
+
if (taken.length >= limit) {
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return taken;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Yield a non-recurring event's single occurrence if it starts inside
|
|
286
|
+
* the window
|
|
287
|
+
* @param {import('./Event.js').Event} event - The event
|
|
288
|
+
* @param {{ startMs: number, endMs: number }} window - Inclusive bounds
|
|
289
|
+
* @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>}
|
|
290
|
+
* @private
|
|
291
|
+
*/
|
|
292
|
+
*_iterateSingle(event, window) {
|
|
293
|
+
const ms = new Date(event.start).getTime();
|
|
294
|
+
if (ms >= window.startMs && ms <= window.endMs) {
|
|
295
|
+
yield this.cloneOccurrence(this.createOccurrence(event, event.start, event.end));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Lazy counterpart of the expandEvent loop: seeks to the window, then
|
|
301
|
+
* steps the cursor and yields each in-window occurrence with the same
|
|
302
|
+
* DST adjustment, exception handling and instance modifications.
|
|
303
|
+
* @private
|
|
304
|
+
*/
|
|
305
|
+
*_iterateRule(event, rule, window, options) {
|
|
306
|
+
const { includeModified, includeCancelled, timezone, handleDST } = options;
|
|
307
|
+
const duration = event.end - event.start;
|
|
308
|
+
const state = {
|
|
309
|
+
currentDate: new Date(event.start),
|
|
310
|
+
count: 0,
|
|
311
|
+
tzOffsets: new Map(),
|
|
312
|
+
dstTransitions: [],
|
|
313
|
+
stuckIterations: 0
|
|
314
|
+
};
|
|
315
|
+
if (Number.isNaN(state.currentDate.getTime()) || window.startMs > window.endMs) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// DST transitions are found on the same day grid expandEvent walks,
|
|
320
|
+
// starting from the window start (DTSTART for an open window) and
|
|
321
|
+
// extended in chunks ahead of the cursor
|
|
322
|
+
let dstScan = null;
|
|
323
|
+
if (handleDST) {
|
|
324
|
+
const scanStart = Number.isFinite(window.startMs)
|
|
325
|
+
? window.startMs
|
|
326
|
+
: state.currentDate.getTime();
|
|
327
|
+
dstScan = { cursor: new Date(scanStart), lastOffset: 0 };
|
|
328
|
+
dstScan.lastOffset = this.tzManager.getTimezoneOffset(dstScan.cursor, timezone);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (Number.isFinite(window.startMs)) {
|
|
332
|
+
const rangeStart = new Date(window.startMs);
|
|
333
|
+
this.seekToRange(state, rule, rangeStart, rangeStart, timezone);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let idleSteps = 0;
|
|
337
|
+
while (state.currentDate.getTime() <= window.endMs) {
|
|
338
|
+
const currentMs = state.currentDate.getTime();
|
|
339
|
+
if (currentMs >= window.startMs) {
|
|
340
|
+
if (dstScan) {
|
|
341
|
+
this._scanDSTTransitions(
|
|
342
|
+
dstScan,
|
|
343
|
+
state.dstTransitions,
|
|
344
|
+
Math.min(currentMs + DST_SCAN_CHUNK, window.endMs),
|
|
345
|
+
timezone
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const occurrence = this._applyOverrides(
|
|
349
|
+
event,
|
|
350
|
+
this.generateOccurrence(event, state.currentDate, duration, timezone, state),
|
|
351
|
+
rule,
|
|
352
|
+
includeCancelled,
|
|
353
|
+
includeModified
|
|
354
|
+
);
|
|
355
|
+
if (occurrence) {
|
|
356
|
+
idleSteps = 0;
|
|
357
|
+
yield this.cloneOccurrence(occurrence);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
state.currentDate = this.getNextDate(state.currentDate, rule, timezone, state);
|
|
362
|
+
state.count++;
|
|
363
|
+
|
|
364
|
+
if (state.currentDate.getTime() <= currentMs) {
|
|
365
|
+
state.stuckIterations++;
|
|
366
|
+
if (state.stuckIterations >= 3) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
state.stuckIterations = 0;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (rule.count && state.count >= rule.count) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (rule.until && state.currentDate > rule.until) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
idleSteps++;
|
|
380
|
+
if (idleSteps >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT) {
|
|
381
|
+
warnIterationLimit(event.id);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Apply exceptions and stored instance modifications to a generated
|
|
389
|
+
* occurrence
|
|
390
|
+
* @param {import('./Event.js').Event} event - The recurring event
|
|
391
|
+
* @param {Object} occurrence - Occurrence from generateOccurrence
|
|
392
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
393
|
+
* @param {boolean} includeCancelled - Return exception dates as cancelled occurrences
|
|
394
|
+
* @param {boolean} includeModified - Apply stored instance modifications
|
|
395
|
+
* @returns {Object|null} The occurrence, or null when it is excluded
|
|
396
|
+
* @private
|
|
397
|
+
*/
|
|
398
|
+
_applyOverrides(event, occurrence, rule, includeCancelled, includeModified) {
|
|
399
|
+
if (!occurrence) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
if (this.isException(event.id, occurrence.start, rule)) {
|
|
403
|
+
if (!includeCancelled) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
occurrence.status = 'cancelled';
|
|
407
|
+
occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
|
|
408
|
+
}
|
|
409
|
+
if (includeModified) {
|
|
410
|
+
const modified = this.getModifiedInstance(event.id, occurrence.start);
|
|
411
|
+
if (modified) {
|
|
412
|
+
Object.assign(occurrence, modified);
|
|
413
|
+
occurrence.isModified = true;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return occurrence;
|
|
417
|
+
}
|
|
418
|
+
|
|
176
419
|
/**
|
|
177
420
|
* Move the expansion cursor to the last occurrence before the range
|
|
178
421
|
* without stepping through every occurrence in between.
|
|
179
422
|
*
|
|
180
423
|
* Applies to rules whose step is a fixed duration between system-timezone
|
|
181
|
-
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY)
|
|
182
|
-
*
|
|
424
|
+
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY) and to WEEKLY
|
|
425
|
+
* rules with BYDAY, whose steps repeat in a weekly cycle; the steps that
|
|
426
|
+
* cross a transition are taken with getNextDate so the result is exactly
|
|
183
427
|
* what stepping from DTSTART would produce. Never seeks past UNTIL, and
|
|
184
428
|
* counts skipped steps against COUNT.
|
|
185
429
|
*
|
|
@@ -190,6 +434,10 @@ export class RecurrenceEngineV2 {
|
|
|
190
434
|
* @param {string} timezone - Expansion timezone
|
|
191
435
|
*/
|
|
192
436
|
seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
|
|
437
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
438
|
+
this._seekWeekCycle(state, rule, rangeStart, rangeEnd, timezone);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
193
441
|
const stepMs = this.getFixedStepMs(rule);
|
|
194
442
|
if (stepMs <= 0) {
|
|
195
443
|
return;
|
|
@@ -219,6 +467,127 @@ export class RecurrenceEngineV2 {
|
|
|
219
467
|
state.count = seek.steps;
|
|
220
468
|
}
|
|
221
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Seek for WEEKLY rules with BYDAY. getNextWeekly picks the next weekday
|
|
472
|
+
* from the BYDAY list (in list order), so the step from each weekday is
|
|
473
|
+
* fixed and the walk from DTSTART settles into a cycle of weekdays that
|
|
474
|
+
* repeats every whole number of weeks. The cursor is stepped one
|
|
475
|
+
* occurrence at a time until it is on that cycle (at most six steps),
|
|
476
|
+
* then whole cycles are skipped arithmetically between system-timezone
|
|
477
|
+
* transitions, exactly as seekToRange does for fixed steps.
|
|
478
|
+
* @param {Object} state - Expansion state (currentDate and count are updated)
|
|
479
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
480
|
+
* @param {Date} rangeStart - Start of expansion range
|
|
481
|
+
* @param {Date} rangeEnd - End of expansion range
|
|
482
|
+
* @param {string} timezone - Expansion timezone
|
|
483
|
+
* @private
|
|
484
|
+
*/
|
|
485
|
+
_seekWeekCycle(state, rule, rangeStart, rangeEnd, timezone) {
|
|
486
|
+
const deltas = this._weekdayDeltas(rule);
|
|
487
|
+
if (!deltas) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
let targetMs = rangeStart.getTime();
|
|
491
|
+
if (rule.until) {
|
|
492
|
+
const untilMs = new Date(rule.until).getTime();
|
|
493
|
+
if (untilMs < targetMs) {
|
|
494
|
+
targetMs = untilMs;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (!(state.currentDate.getTime() < targetMs)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
501
|
+
|
|
502
|
+
// Follow the weekday graph from the cursor until a weekday repeats:
|
|
503
|
+
// the steps before the repeat lead in to the cycle
|
|
504
|
+
const path = [];
|
|
505
|
+
const seen = new Map();
|
|
506
|
+
let weekday = state.currentDate.getDay();
|
|
507
|
+
while (!seen.has(weekday)) {
|
|
508
|
+
seen.set(weekday, path.length);
|
|
509
|
+
path.push(weekday);
|
|
510
|
+
weekday = (weekday + deltas[weekday]) % 7;
|
|
511
|
+
}
|
|
512
|
+
const leadIn = seen.get(weekday);
|
|
513
|
+
const cycleSteps = path.length - leadIn;
|
|
514
|
+
let cycleDays = 0;
|
|
515
|
+
for (let i = leadIn; i < path.length; i++) {
|
|
516
|
+
cycleDays += deltas[path[i]];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Lead-in: single steps, identical to the expansion loop's
|
|
520
|
+
for (let i = 0; i < leadIn; i++) {
|
|
521
|
+
if (state.count >= maxSteps) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const next = this.getNextDate(state.currentDate, rule, timezone, state);
|
|
525
|
+
if (!(next.getTime() < targetMs)) {
|
|
526
|
+
return; // the next step lands in the range; the loop takes it
|
|
527
|
+
}
|
|
528
|
+
state.currentDate = next;
|
|
529
|
+
state.count++;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const seek = RecurrenceEngine._seekFixedStep(
|
|
533
|
+
state.currentDate.getTime(),
|
|
534
|
+
targetMs,
|
|
535
|
+
rangeEnd.getTime(),
|
|
536
|
+
cycleDays * DAY,
|
|
537
|
+
Math.floor((maxSteps - state.count) / cycleSteps),
|
|
538
|
+
cursor => {
|
|
539
|
+
for (let i = 0; i < cycleSteps; i++) {
|
|
540
|
+
cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime());
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
state.currentDate = new Date(seek.ms);
|
|
545
|
+
state.count += seek.steps * cycleSteps;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Days getNextWeekly adds from each weekday (index 0-6) for a WEEKLY
|
|
550
|
+
* rule with BYDAY, or null when the rule cannot be seeked (an invalid
|
|
551
|
+
* interval or day code, which the expansion loop handles as before)
|
|
552
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
553
|
+
* @returns {number[]|null} Delta table indexed by Date#getDay()
|
|
554
|
+
* @private
|
|
555
|
+
*/
|
|
556
|
+
_weekdayDeltas(rule) {
|
|
557
|
+
const interval = rule.interval;
|
|
558
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const targets = this._weekdayTargets(rule);
|
|
562
|
+
if (targets.some(target => target === undefined)) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const deltas = [];
|
|
566
|
+
for (let weekday = 0; weekday < 7; weekday++) {
|
|
567
|
+
const next = targets.find(target => target > weekday);
|
|
568
|
+
deltas[weekday] =
|
|
569
|
+
next !== undefined ? next - weekday : 7 - weekday + targets[0] + 7 * (interval - 1);
|
|
570
|
+
}
|
|
571
|
+
return deltas;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Weekday numbers (Date#getDay) of a rule's BYDAY entries in ascending
|
|
576
|
+
* order, computed once per parsed rule. Invalid day codes map to
|
|
577
|
+
* undefined and sort last.
|
|
578
|
+
* @param {Object} rule - Parsed recurrence rule with byDay
|
|
579
|
+
* @returns {number[]} Sorted weekday numbers
|
|
580
|
+
* @private
|
|
581
|
+
*/
|
|
582
|
+
_weekdayTargets(rule) {
|
|
583
|
+
if (!rule._weekdayTargets) {
|
|
584
|
+
rule._weekdayTargets = rule.byDay
|
|
585
|
+
.map(byDay => WEEKDAYS[byDay.weekday || byDay])
|
|
586
|
+
.sort((a, b) => (a === undefined) - (b === undefined) || a - b);
|
|
587
|
+
}
|
|
588
|
+
return rule._weekdayTargets;
|
|
589
|
+
}
|
|
590
|
+
|
|
222
591
|
/**
|
|
223
592
|
* Milliseconds per step for rules getNextDate advances by a fixed
|
|
224
593
|
* duration while the system UTC offset is constant
|
|
@@ -342,33 +711,16 @@ export class RecurrenceEngineV2 {
|
|
|
342
711
|
const next = new Date(date);
|
|
343
712
|
|
|
344
713
|
if (rule.byDay && rule.byDay.length > 0) {
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
MO: 1,
|
|
349
|
-
TU: 2,
|
|
350
|
-
WE: 3,
|
|
351
|
-
TH: 4,
|
|
352
|
-
FR: 5,
|
|
353
|
-
SA: 6
|
|
354
|
-
};
|
|
355
|
-
|
|
714
|
+
// BYDAY is a set: the next weekday in it after the current one, or the
|
|
715
|
+
// earliest one INTERVAL weeks on when the week has none left
|
|
716
|
+
const targets = this._weekdayTargets(rule);
|
|
356
717
|
const currentDay = next.getDay();
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
daysToAdd = targetDay - currentDay;
|
|
364
|
-
break;
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// If no day found in current week, go to next week
|
|
369
|
-
if (daysToAdd === null) {
|
|
370
|
-
const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]];
|
|
371
|
-
daysToAdd = 7 - currentDay + firstDay;
|
|
718
|
+
const nextDay = targets.find(target => target > currentDay);
|
|
719
|
+
let daysToAdd;
|
|
720
|
+
if (nextDay !== undefined) {
|
|
721
|
+
daysToAdd = nextDay - currentDay;
|
|
722
|
+
} else {
|
|
723
|
+
daysToAdd = 7 - currentDay + targets[0];
|
|
372
724
|
|
|
373
725
|
// Apply interval for weekly recurrence
|
|
374
726
|
if (rule.interval > 1) {
|
|
@@ -401,15 +753,17 @@ export class RecurrenceEngineV2 {
|
|
|
401
753
|
// Found a day in current month
|
|
402
754
|
next.setDate(targetDay);
|
|
403
755
|
} else {
|
|
404
|
-
// Move to next month
|
|
405
|
-
next.setMonth(next.getMonth() + rule.interval);
|
|
406
|
-
|
|
407
|
-
// Handle negative days (from end of month)
|
|
408
756
|
targetDay = targetDays[0];
|
|
409
757
|
if (targetDay < 0) {
|
|
758
|
+
// Counted from the end of the month (-1 is the last day). Move to
|
|
759
|
+
// the first so the month step cannot overflow from a 31st.
|
|
760
|
+
next.setDate(1);
|
|
761
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
410
762
|
const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
|
|
411
|
-
next.setDate(lastDay + targetDay + 1);
|
|
763
|
+
next.setDate(Math.max(1, lastDay + targetDay + 1));
|
|
412
764
|
} else {
|
|
765
|
+
// Move to next month
|
|
766
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
413
767
|
next.setDate(targetDay);
|
|
414
768
|
}
|
|
415
769
|
}
|
|
@@ -573,28 +927,38 @@ export class RecurrenceEngineV2 {
|
|
|
573
927
|
*/
|
|
574
928
|
findDSTTransitions(start, end, timezone) {
|
|
575
929
|
const transitions = [];
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
930
|
+
const scan = { cursor: new Date(start), lastOffset: 0 };
|
|
931
|
+
scan.lastOffset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
|
|
932
|
+
this._scanDSTTransitions(scan, transitions, new Date(end).getTime(), timezone);
|
|
933
|
+
return transitions;
|
|
934
|
+
}
|
|
580
935
|
|
|
581
|
-
|
|
582
|
-
|
|
936
|
+
/**
|
|
937
|
+
* Walk the scan cursor one day at a time up to untilMs, appending each
|
|
938
|
+
* offset change. The cursor and last offset persist in `scan`, so the
|
|
939
|
+
* walk can be resumed later on the same day grid.
|
|
940
|
+
* @param {{ cursor: Date, lastOffset: number }} scan - Resumable scan position (mutated)
|
|
941
|
+
* @param {Array} transitions - Transition list to append to
|
|
942
|
+
* @param {number} untilMs - Scan through this timestamp (inclusive)
|
|
943
|
+
* @param {string} timezone - Timezone to probe
|
|
944
|
+
* @private
|
|
945
|
+
*/
|
|
946
|
+
_scanDSTTransitions(scan, transitions, untilMs, timezone) {
|
|
947
|
+
while (scan.cursor.getTime() <= untilMs) {
|
|
948
|
+
const offset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
|
|
583
949
|
|
|
584
|
-
if (offset !== lastOffset) {
|
|
950
|
+
if (offset !== scan.lastOffset) {
|
|
585
951
|
transitions.push({
|
|
586
|
-
date: new Date(
|
|
587
|
-
oldOffset: lastOffset,
|
|
952
|
+
date: new Date(scan.cursor),
|
|
953
|
+
oldOffset: scan.lastOffset,
|
|
588
954
|
newOffset: offset,
|
|
589
|
-
type: offset < lastOffset ? 'spring-forward' : 'fall-back'
|
|
955
|
+
type: offset < scan.lastOffset ? 'spring-forward' : 'fall-back'
|
|
590
956
|
});
|
|
591
957
|
}
|
|
592
958
|
|
|
593
|
-
lastOffset = offset;
|
|
594
|
-
|
|
959
|
+
scan.lastOffset = offset;
|
|
960
|
+
scan.cursor.setDate(scan.cursor.getDate() + 1);
|
|
595
961
|
}
|
|
596
|
-
|
|
597
|
-
return transitions;
|
|
598
962
|
}
|
|
599
963
|
|
|
600
964
|
/**
|
|
@@ -720,9 +1084,45 @@ export class RecurrenceEngineV2 {
|
|
|
720
1084
|
|
|
721
1085
|
/**
|
|
722
1086
|
* Create cache key
|
|
1087
|
+
*
|
|
1088
|
+
* When given the event itself the key also covers everything the
|
|
1089
|
+
* expansion depends on (DTSTART, end, recurrence rule), so a series that
|
|
1090
|
+
* is updated, replaced or re-added under the same id can never be served
|
|
1091
|
+
* a stale expansion. Keys always start with `<eventId>_`, which is what
|
|
1092
|
+
* {@link RecurrenceEngineV2#clearEventCache} matches on.
|
|
1093
|
+
* @param {import('./Event.js').Event|string} event - Recurring event, or just its id
|
|
1094
|
+
* @param {Date} start - Start of expansion range
|
|
1095
|
+
* @param {Date} end - End of expansion range
|
|
1096
|
+
* @param {Object} options - Expansion options
|
|
1097
|
+
* @returns {string} Cache key
|
|
1098
|
+
*/
|
|
1099
|
+
getCacheKey(event, start, end, options) {
|
|
1100
|
+
const eventId = typeof event === 'string' ? event : event.id;
|
|
1101
|
+
const key = `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`;
|
|
1102
|
+
if (typeof event === 'string') {
|
|
1103
|
+
return key;
|
|
1104
|
+
}
|
|
1105
|
+
const startMs = new Date(event.start).getTime();
|
|
1106
|
+
const endMs = new Date(event.end).getTime();
|
|
1107
|
+
return `${key}|${startMs}|${endMs}|${this._ruleFingerprint(event.recurrenceRule)}`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Stable text form of a recurrence rule for cache keys. A rule that
|
|
1112
|
+
* cannot be serialised gets a unique fingerprint, i.e. is never cached.
|
|
1113
|
+
* @param {string|Object} rule - RRULE string or rule object
|
|
1114
|
+
* @returns {string} Fingerprint
|
|
1115
|
+
* @private
|
|
723
1116
|
*/
|
|
724
|
-
|
|
725
|
-
|
|
1117
|
+
_ruleFingerprint(rule) {
|
|
1118
|
+
if (typeof rule === 'string') {
|
|
1119
|
+
return rule;
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
return JSON.stringify(rule);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return `uncacheable:${Date.now()}:${Math.random()}`;
|
|
1125
|
+
}
|
|
726
1126
|
}
|
|
727
1127
|
|
|
728
1128
|
/**
|
|
@@ -742,7 +1142,16 @@ export class RecurrenceEngineV2 {
|
|
|
742
1142
|
* Clone occurrence results before returning or caching.
|
|
743
1143
|
*/
|
|
744
1144
|
cloneOccurrences(occurrences) {
|
|
745
|
-
return occurrences.map(occurrence => (
|
|
1145
|
+
return occurrences.map(occurrence => this.cloneOccurrence(occurrence));
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Clone a single occurrence, copying its Date and array fields.
|
|
1150
|
+
* @param {import('../types.js').ExpandedOccurrence} occurrence - Occurrence to clone
|
|
1151
|
+
* @returns {import('../types.js').ExpandedOccurrence} Independent copy
|
|
1152
|
+
*/
|
|
1153
|
+
cloneOccurrence(occurrence) {
|
|
1154
|
+
return {
|
|
746
1155
|
...occurrence,
|
|
747
1156
|
start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
|
|
748
1157
|
end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
|
|
@@ -754,7 +1163,7 @@ export class RecurrenceEngineV2 {
|
|
|
754
1163
|
categories: Array.isArray(occurrence.categories)
|
|
755
1164
|
? [...occurrence.categories]
|
|
756
1165
|
: occurrence.categories
|
|
757
|
-
}
|
|
1166
|
+
};
|
|
758
1167
|
}
|
|
759
1168
|
|
|
760
1169
|
/**
|