@forcecalendar/core 2.3.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 +198 -14
- package/core/events/Event.js +173 -0
- package/core/events/EventStore.js +484 -92
- package/core/events/RecurrenceEngine.js +586 -20
- package/core/events/RecurrenceEngineV2.js +384 -51
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +88 -14
- package/core/types.js +95 -1
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +137 -10
- package/types/events/Event.d.ts +75 -0
- package/types/events/EventStore.d.ts +213 -6
- package/types/events/RecurrenceEngine.d.ts +216 -1
- package/types/events/RecurrenceEngineV2.d.ts +156 -9
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +66 -1
- package/types/types.d.ts +296 -2
|
@@ -4,12 +4,23 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
7
|
+
import { RecurrenceEngine } from './RecurrenceEngine.js';
|
|
7
8
|
import { RRuleParser } from './RRuleParser.js';
|
|
8
9
|
|
|
10
|
+
const DAY = 86400000;
|
|
11
|
+
|
|
12
|
+
// How far ahead of the iteration cursor DST transitions are scanned at a time
|
|
13
|
+
const DST_SCAN_CHUNK = 100 * DAY;
|
|
14
|
+
|
|
9
15
|
export class RecurrenceEngineV2 {
|
|
10
16
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
11
17
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
12
18
|
|
|
19
|
+
// Hard limit on expansion loop iterations (every occurrence stepped
|
|
20
|
+
// through, inside the range or not) so a rule that cannot be seeked
|
|
21
|
+
// arithmetically still terminates in bounded time
|
|
22
|
+
static MAX_ITERATIONS_HARD_LIMIT = 100000;
|
|
23
|
+
|
|
13
24
|
constructor() {
|
|
14
25
|
// Use singleton to share cache across all components
|
|
15
26
|
this.tzManager = TimezoneManager.getInstance();
|
|
@@ -27,11 +38,23 @@ export class RecurrenceEngineV2 {
|
|
|
27
38
|
|
|
28
39
|
/**
|
|
29
40
|
* Expand recurring event with advanced handling
|
|
30
|
-
*
|
|
41
|
+
*
|
|
42
|
+
* Occurrences before rangeStart are skipped without being generated:
|
|
43
|
+
* daily, weekly, hourly and minutely rules seek straight to the range, so
|
|
44
|
+
* a series that started years before the queried window is expanded at
|
|
45
|
+
* the same cost as one that started yesterday.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('./Event.js').Event} event - Recurring event
|
|
31
48
|
* @param {Date} rangeStart - Start of expansion range
|
|
32
49
|
* @param {Date} rangeEnd - End of expansion range
|
|
33
50
|
* @param {Object} options - Expansion options
|
|
34
|
-
* @
|
|
51
|
+
* @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
|
|
52
|
+
* return. Only occurrences inside the range count towards this limit.
|
|
53
|
+
* @param {boolean} [options.includeModified=true] - Apply stored instance modifications
|
|
54
|
+
* @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
|
|
55
|
+
* @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
|
|
56
|
+
* @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
|
|
57
|
+
* @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences
|
|
35
58
|
*/
|
|
36
59
|
expandEvent(event, rangeStart, rangeEnd, options = {}) {
|
|
37
60
|
const {
|
|
@@ -59,7 +82,8 @@ export class RecurrenceEngineV2 {
|
|
|
59
82
|
const occurrences = [];
|
|
60
83
|
const duration = event.end - event.start;
|
|
61
84
|
|
|
62
|
-
// Initialize expansion state
|
|
85
|
+
// Initialize expansion state. `count` is the number of steps taken from
|
|
86
|
+
// DTSTART, which is what RFC 5545 COUNT measures.
|
|
63
87
|
const state = {
|
|
64
88
|
currentDate: new Date(event.start),
|
|
65
89
|
count: 0,
|
|
@@ -73,43 +97,26 @@ export class RecurrenceEngineV2 {
|
|
|
73
97
|
state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
|
|
74
98
|
}
|
|
75
99
|
|
|
100
|
+
this.seekToRange(state, rule, rangeStart, rangeEnd, timezone);
|
|
101
|
+
|
|
76
102
|
// Expand occurrences
|
|
77
|
-
|
|
103
|
+
let iterations = 0;
|
|
104
|
+
while (
|
|
105
|
+
state.currentDate <= rangeEnd &&
|
|
106
|
+
occurrences.length < maxOccurrences &&
|
|
107
|
+
iterations < RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT
|
|
108
|
+
) {
|
|
109
|
+
iterations++;
|
|
78
110
|
if (state.currentDate >= rangeStart) {
|
|
79
|
-
const occurrence = this.
|
|
111
|
+
const occurrence = this._applyOverrides(
|
|
80
112
|
event,
|
|
81
|
-
state.currentDate,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
113
|
+
this.generateOccurrence(event, state.currentDate, duration, timezone, state),
|
|
114
|
+
rule,
|
|
115
|
+
includeCancelled,
|
|
116
|
+
includeModified
|
|
85
117
|
);
|
|
86
|
-
|
|
87
|
-
// Check exceptions and modifications
|
|
88
118
|
if (occurrence) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// Skip if exception
|
|
92
|
-
if (this.isException(event.id, occurrence.start, rule)) {
|
|
93
|
-
if (!includeCancelled) {
|
|
94
|
-
shouldInclude = false;
|
|
95
|
-
} else {
|
|
96
|
-
occurrence.status = 'cancelled';
|
|
97
|
-
occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Apply modifications if any
|
|
102
|
-
if (shouldInclude && includeModified) {
|
|
103
|
-
const modified = this.getModifiedInstance(event.id, occurrence.start);
|
|
104
|
-
if (modified) {
|
|
105
|
-
Object.assign(occurrence, modified);
|
|
106
|
-
occurrence.isModified = true;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (shouldInclude) {
|
|
111
|
-
occurrences.push(occurrence);
|
|
112
|
-
}
|
|
119
|
+
occurrences.push(occurrence);
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
|
|
@@ -144,6 +151,313 @@ export class RecurrenceEngineV2 {
|
|
|
144
151
|
return this.cloneOccurrences(occurrences);
|
|
145
152
|
}
|
|
146
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
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Move the expansion cursor to the last occurrence before the range
|
|
392
|
+
* without stepping through every occurrence in between.
|
|
393
|
+
*
|
|
394
|
+
* Applies to rules whose step is a fixed duration between system-timezone
|
|
395
|
+
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
|
|
396
|
+
* crosses a transition is taken with getNextDate so the result is exactly
|
|
397
|
+
* what stepping from DTSTART would produce. Never seeks past UNTIL, and
|
|
398
|
+
* counts skipped steps against COUNT.
|
|
399
|
+
*
|
|
400
|
+
* @param {Object} state - Expansion state (currentDate and count are updated)
|
|
401
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
402
|
+
* @param {Date} rangeStart - Start of expansion range
|
|
403
|
+
* @param {Date} rangeEnd - End of expansion range
|
|
404
|
+
* @param {string} timezone - Expansion timezone
|
|
405
|
+
*/
|
|
406
|
+
seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
|
|
407
|
+
const stepMs = this.getFixedStepMs(rule);
|
|
408
|
+
if (stepMs <= 0) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
let targetMs = rangeStart.getTime();
|
|
412
|
+
if (rule.until) {
|
|
413
|
+
// Rule objects may carry UNTIL as a string; an unparseable value
|
|
414
|
+
// compares false and leaves the target alone
|
|
415
|
+
const untilMs = new Date(rule.until).getTime();
|
|
416
|
+
if (untilMs < targetMs) {
|
|
417
|
+
targetMs = untilMs;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const fromMs = state.currentDate.getTime();
|
|
421
|
+
if (!(fromMs < targetMs)) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const seek = RecurrenceEngine._seekFixedStep(
|
|
425
|
+
fromMs,
|
|
426
|
+
targetMs,
|
|
427
|
+
rangeEnd.getTime(),
|
|
428
|
+
stepMs,
|
|
429
|
+
rule.count ? rule.count - 1 : Infinity,
|
|
430
|
+
cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime())
|
|
431
|
+
);
|
|
432
|
+
state.currentDate = new Date(seek.ms);
|
|
433
|
+
state.count = seek.steps;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Milliseconds per step for rules getNextDate advances by a fixed
|
|
438
|
+
* duration while the system UTC offset is constant
|
|
439
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
440
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
441
|
+
*/
|
|
442
|
+
getFixedStepMs(rule) {
|
|
443
|
+
const interval = rule.interval;
|
|
444
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
switch (rule.freq) {
|
|
448
|
+
case 'DAILY':
|
|
449
|
+
return rule.byHour && rule.byHour.length > 0 ? 0 : interval * DAY;
|
|
450
|
+
case 'WEEKLY':
|
|
451
|
+
return rule.byDay && rule.byDay.length > 0 ? 0 : 7 * interval * DAY;
|
|
452
|
+
case 'HOURLY':
|
|
453
|
+
return interval * 3600000;
|
|
454
|
+
case 'MINUTELY':
|
|
455
|
+
return interval * 60000;
|
|
456
|
+
default:
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
147
461
|
/**
|
|
148
462
|
* Generate a single occurrence with timezone handling
|
|
149
463
|
*/
|
|
@@ -473,28 +787,38 @@ export class RecurrenceEngineV2 {
|
|
|
473
787
|
*/
|
|
474
788
|
findDSTTransitions(start, end, timezone) {
|
|
475
789
|
const transitions = [];
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
+
}
|
|
480
795
|
|
|
481
|
-
|
|
482
|
-
|
|
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);
|
|
483
809
|
|
|
484
|
-
if (offset !== lastOffset) {
|
|
810
|
+
if (offset !== scan.lastOffset) {
|
|
485
811
|
transitions.push({
|
|
486
|
-
date: new Date(
|
|
487
|
-
oldOffset: lastOffset,
|
|
812
|
+
date: new Date(scan.cursor),
|
|
813
|
+
oldOffset: scan.lastOffset,
|
|
488
814
|
newOffset: offset,
|
|
489
|
-
type: offset < lastOffset ? 'spring-forward' : 'fall-back'
|
|
815
|
+
type: offset < scan.lastOffset ? 'spring-forward' : 'fall-back'
|
|
490
816
|
});
|
|
491
817
|
}
|
|
492
818
|
|
|
493
|
-
lastOffset = offset;
|
|
494
|
-
|
|
819
|
+
scan.lastOffset = offset;
|
|
820
|
+
scan.cursor.setDate(scan.cursor.getDate() + 1);
|
|
495
821
|
}
|
|
496
|
-
|
|
497
|
-
return transitions;
|
|
498
822
|
}
|
|
499
823
|
|
|
500
824
|
/**
|
|
@@ -642,7 +966,16 @@ export class RecurrenceEngineV2 {
|
|
|
642
966
|
* Clone occurrence results before returning or caching.
|
|
643
967
|
*/
|
|
644
968
|
cloneOccurrences(occurrences) {
|
|
645
|
-
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 {
|
|
646
979
|
...occurrence,
|
|
647
980
|
start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
|
|
648
981
|
end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
|
|
@@ -654,7 +987,7 @@ export class RecurrenceEngineV2 {
|
|
|
654
987
|
categories: Array.isArray(occurrence.categories)
|
|
655
988
|
? [...occurrence.categories]
|
|
656
989
|
: occurrence.categories
|
|
657
|
-
}
|
|
990
|
+
};
|
|
658
991
|
}
|
|
659
992
|
|
|
660
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
|
*/
|