@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
|
@@ -12,6 +12,11 @@ export class RecurrenceEngine {
|
|
|
12
12
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
13
13
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
14
14
|
|
|
15
|
+
// Hard limit on expansion loop iterations (every occurrence stepped
|
|
16
|
+
// through, inside the range or not) so a rule that cannot be seeked
|
|
17
|
+
// arithmetically still terminates in bounded time
|
|
18
|
+
static MAX_ITERATIONS_HARD_LIMIT = 100000;
|
|
19
|
+
|
|
15
20
|
// expandEvent is typically called many times with the same RRULE string
|
|
16
21
|
// (every view render), so parsed rules are cached by their source string
|
|
17
22
|
static _ruleCache = new Map();
|
|
@@ -19,10 +24,18 @@ export class RecurrenceEngine {
|
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Expand a recurring event into individual occurrences
|
|
27
|
+
*
|
|
28
|
+
* Occurrences before rangeStart are skipped without being generated:
|
|
29
|
+
* daily, weekly and sub-daily rules seek straight to the range, so the
|
|
30
|
+
* cost of a query does not grow with the age of the series, and a series
|
|
31
|
+
* that started years before the queried window is still expanded.
|
|
32
|
+
*
|
|
22
33
|
* @param {import('./Event.js').Event} event - The recurring event
|
|
23
34
|
* @param {Date} rangeStart - Start of the expansion range
|
|
24
35
|
* @param {Date} rangeEnd - End of the expansion range
|
|
25
|
-
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to
|
|
36
|
+
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to return.
|
|
37
|
+
* Only occurrences inside the range count towards this limit; occurrences
|
|
38
|
+
* between the series start and rangeStart do not consume it.
|
|
26
39
|
* @param {string} [timezone] - Timezone for expansion (important for DST)
|
|
27
40
|
* @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
|
|
28
41
|
*/
|
|
@@ -79,6 +92,335 @@ export class RecurrenceEngine {
|
|
|
79
92
|
return occurrences;
|
|
80
93
|
}
|
|
81
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Lazily iterate the occurrences of an event in chronological order.
|
|
97
|
+
*
|
|
98
|
+
* Yields the same occurrence objects, in the same order, that expandEvent
|
|
99
|
+
* returns for the window — but one at a time, so a caller can stop after
|
|
100
|
+
* any number of them without the rest of the series being generated.
|
|
101
|
+
* Daily, weekly and sub-daily rules are seeked to `after` arithmetically,
|
|
102
|
+
* so finding the first occurrence after a far-away instant does not step
|
|
103
|
+
* through the series from its start.
|
|
104
|
+
*
|
|
105
|
+
* Both bounds are exclusive unless `inclusive` is set: an occurrence that
|
|
106
|
+
* starts exactly at `after` or `before` is skipped by default, which lets
|
|
107
|
+
* `iterateOccurrences(event, { after: previous.start })` continue a series
|
|
108
|
+
* without repeating `previous`. With `inclusive: true` the window is
|
|
109
|
+
* closed on both ends, exactly like expandEvent's range. An omitted bound
|
|
110
|
+
* leaves that end of the window open.
|
|
111
|
+
*
|
|
112
|
+
* COUNT, UNTIL, INTERVAL, BYDAY/BYMONTHDAY/BYSETPOS and exception dates
|
|
113
|
+
* are honoured as in expandEvent; BYSETPOS rules are yielded one period at
|
|
114
|
+
* a time, since the set positions of a period are only known once it is
|
|
115
|
+
* complete. A non-recurring event yields its single occurrence when it
|
|
116
|
+
* falls inside the window. Iteration ends at COUNT or UNTIL, at `before`,
|
|
117
|
+
* or — as a guard for rules that produce no occurrences — after
|
|
118
|
+
* MAX_ITERATIONS_HARD_LIMIT consecutive steps without one.
|
|
119
|
+
*
|
|
120
|
+
* The generator is single-use; call this method again for a fresh one.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* for (const occurrence of RecurrenceEngine.iterateOccurrences(event, { after: new Date() })) {
|
|
124
|
+
* if (occurrence.start > deadline) break;
|
|
125
|
+
* schedule(occurrence);
|
|
126
|
+
* }
|
|
127
|
+
*
|
|
128
|
+
* @param {import('./Event.js').Event} event - The event to iterate
|
|
129
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
130
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>} Occurrences in chronological order
|
|
131
|
+
* @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
|
|
132
|
+
*/
|
|
133
|
+
static iterateOccurrences(event, options = {}) {
|
|
134
|
+
const window = this._occurrenceWindow(options);
|
|
135
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
136
|
+
return this._iterateSingle(
|
|
137
|
+
{ start: event.start, end: event.end, timezone: event.timeZone },
|
|
138
|
+
window
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rule = this._getParsedRule(event.recurrenceRule);
|
|
143
|
+
let rangeEndMs = window.endMs;
|
|
144
|
+
// Same UNTIL clamp as expandEvent (a non-Date UNTIL compares false)
|
|
145
|
+
if (rule.until && rule.until < rangeEndMs) {
|
|
146
|
+
rangeEndMs = rule.until.valueOf();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let occurrences = this._iterateRule(
|
|
150
|
+
event,
|
|
151
|
+
rule,
|
|
152
|
+
window.startMs,
|
|
153
|
+
rangeEndMs,
|
|
154
|
+
options.timezone || event.timeZone || 'UTC',
|
|
155
|
+
TimezoneManager.getInstance(),
|
|
156
|
+
event.end - event.start
|
|
157
|
+
);
|
|
158
|
+
if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
|
|
159
|
+
occurrences = this._iterateBySetPos(occurrences, rule);
|
|
160
|
+
}
|
|
161
|
+
return occurrences;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* First occurrence of an event after an instant, or null when the series
|
|
166
|
+
* has no occurrence after it (past COUNT or UNTIL, or a non-recurring
|
|
167
|
+
* event that already started).
|
|
168
|
+
*
|
|
169
|
+
* `after` is exclusive unless `options.inclusive` is set, so passing the
|
|
170
|
+
* start of a known occurrence returns the one that follows it. Not to be
|
|
171
|
+
* confused with getNextOccurrence, which steps a parsed rule once
|
|
172
|
+
* without regard to COUNT, UNTIL or exceptions.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* const upcoming = RecurrenceEngine.nextOccurrence(event, new Date());
|
|
176
|
+
*
|
|
177
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
178
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
179
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Further window options
|
|
180
|
+
* @returns {import('../types.js').EventOccurrence|null} The next occurrence, or null
|
|
181
|
+
*/
|
|
182
|
+
static nextOccurrence(event, after = null, options = {}) {
|
|
183
|
+
for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
|
|
184
|
+
return occurrence;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The first `count` occurrences of an event inside a window, generated
|
|
191
|
+
* lazily so an open-ended series costs only the occurrences taken.
|
|
192
|
+
* `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
|
|
193
|
+
* when the series or the window ends first.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* const nextFive = RecurrenceEngine.takeOccurrences(event, 5, { after: new Date() });
|
|
197
|
+
*
|
|
198
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
199
|
+
* @param {number} count - Maximum number of occurrences to return
|
|
200
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
201
|
+
* @returns {import('../types.js').EventOccurrence[]} Up to `count` occurrences in chronological order
|
|
202
|
+
*/
|
|
203
|
+
static takeOccurrences(event, count, options = {}) {
|
|
204
|
+
const limit = Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT);
|
|
205
|
+
const taken = [];
|
|
206
|
+
if (!(limit > 0)) {
|
|
207
|
+
return taken;
|
|
208
|
+
}
|
|
209
|
+
for (const occurrence of this.iterateOccurrences(event, options)) {
|
|
210
|
+
taken.push(occurrence);
|
|
211
|
+
if (taken.length >= limit) {
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return taken;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve iterator options into a closed window on numeric timestamps.
|
|
220
|
+
* Exclusive bounds are shifted by one millisecond, the resolution of
|
|
221
|
+
* Date, so the expansion loops only ever compare inclusively.
|
|
222
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} options - Iterator options
|
|
223
|
+
* @returns {{ startMs: number, endMs: number }} Inclusive bounds (infinite when open)
|
|
224
|
+
* @throws {TypeError} If a bound is not a valid Date or timestamp
|
|
225
|
+
* @private
|
|
226
|
+
*/
|
|
227
|
+
static _occurrenceWindow(options) {
|
|
228
|
+
const { after = null, before = null, inclusive = false } = options;
|
|
229
|
+
const shift = inclusive ? 0 : 1;
|
|
230
|
+
return {
|
|
231
|
+
startMs: after == null ? -Infinity : this._boundMs(after, 'after') + shift,
|
|
232
|
+
endMs: before == null ? Infinity : this._boundMs(before, 'before') - shift
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Timestamp of a window bound given as a Date or a number
|
|
238
|
+
* @param {Date|number} value - Bound to convert
|
|
239
|
+
* @param {string} name - Option name for the error message
|
|
240
|
+
* @returns {number} Timestamp in milliseconds
|
|
241
|
+
* @throws {TypeError} If the value is not a valid Date or timestamp
|
|
242
|
+
* @private
|
|
243
|
+
*/
|
|
244
|
+
static _boundMs(value, name) {
|
|
245
|
+
const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : NaN;
|
|
246
|
+
if (Number.isNaN(ms)) {
|
|
247
|
+
throw new TypeError(`RecurrenceEngine: ${name} must be a valid Date or timestamp`);
|
|
248
|
+
}
|
|
249
|
+
return ms;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Yield a single occurrence if it starts inside the window
|
|
254
|
+
* @param {import('../types.js').EventOccurrence} occurrence - The occurrence
|
|
255
|
+
* @param {{ startMs: number, endMs: number }} window - Inclusive bounds
|
|
256
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
|
|
257
|
+
* @private
|
|
258
|
+
*/
|
|
259
|
+
static *_iterateSingle(occurrence, window) {
|
|
260
|
+
const ms = new Date(occurrence.start).getTime();
|
|
261
|
+
if (ms >= window.startMs && ms <= window.endMs) {
|
|
262
|
+
yield occurrence;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Lazy counterpart of the expansion loops: seeks to the window, then
|
|
268
|
+
* advances a Date cursor per step and yields each in-window occurrence,
|
|
269
|
+
* applying the same DST adjustment and exception filtering.
|
|
270
|
+
* @private
|
|
271
|
+
*/
|
|
272
|
+
static *_iterateRule(event, rule, rangeStartMs, rangeEndMs, eventTimezone, tzManager, duration) {
|
|
273
|
+
const currentDate = new Date(event.start);
|
|
274
|
+
let currentMs = currentDate.getTime();
|
|
275
|
+
if (Number.isNaN(currentMs) || rangeStartMs > rangeEndMs) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Steps taken from DTSTART, which is what COUNT measures
|
|
279
|
+
let count = 0;
|
|
280
|
+
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
281
|
+
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
282
|
+
|
|
283
|
+
if (currentMs < rangeStartMs) {
|
|
284
|
+
const seek = this._seekToWindow(currentMs, currentDate.getDay(), rule, rangeStartMs);
|
|
285
|
+
if (seek) {
|
|
286
|
+
currentMs = seek.ms;
|
|
287
|
+
count = seek.steps;
|
|
288
|
+
currentDate.setTime(currentMs);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let stuckCount = 0;
|
|
293
|
+
let idleSteps = 0;
|
|
294
|
+
while (currentMs <= rangeEndMs) {
|
|
295
|
+
if (currentMs >= rangeStartMs) {
|
|
296
|
+
const occurrenceStart = new Date(currentMs);
|
|
297
|
+
const occurrenceEnd = new Date(currentMs + duration);
|
|
298
|
+
|
|
299
|
+
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
300
|
+
if (currentOffset !== lastOffset) {
|
|
301
|
+
const offsetDiff = lastOffset - currentOffset;
|
|
302
|
+
occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
|
|
303
|
+
occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
|
|
304
|
+
}
|
|
305
|
+
lastOffset = currentOffset;
|
|
306
|
+
|
|
307
|
+
if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
|
|
308
|
+
idleSteps = 0;
|
|
309
|
+
yield {
|
|
310
|
+
start: occurrenceStart,
|
|
311
|
+
end: occurrenceEnd,
|
|
312
|
+
recurringEventId: event.id,
|
|
313
|
+
timezone: eventTimezone,
|
|
314
|
+
originalStart: event.start
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (rule.count && count + 1 >= rule.count) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this._advanceInPlace(currentDate, rule);
|
|
324
|
+
const previousMs = currentMs;
|
|
325
|
+
currentMs = currentDate.getTime();
|
|
326
|
+
count++;
|
|
327
|
+
|
|
328
|
+
if (currentMs === previousMs) {
|
|
329
|
+
stuckCount++;
|
|
330
|
+
if (stuckCount >= 3) {
|
|
331
|
+
console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
stuckCount = 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
idleSteps++;
|
|
339
|
+
if (idleSteps >= RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Seek the iteration cursor to the last occurrence before rangeStartMs
|
|
347
|
+
* using the same arithmetic as expandEvent. The system-transition scan
|
|
348
|
+
* is bounded by the target itself, so an open-ended window costs no
|
|
349
|
+
* more than a closed one.
|
|
350
|
+
* @param {number} fromMs - Cursor position (DTSTART)
|
|
351
|
+
* @param {number} weekday - Weekday of the cursor
|
|
352
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
353
|
+
* @param {number} rangeStartMs - Seek target
|
|
354
|
+
* @returns {{ ms: number, steps: number }|null} New cursor, or null for rules that cannot seek
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
static _seekToWindow(fromMs, weekday, rule, rangeStartMs) {
|
|
358
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
359
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
360
|
+
const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
|
|
361
|
+
if (daySet.size === 0) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
rule._byDayDeltas = rule._byDayDeltas || this._buildByDayDeltas(daySet);
|
|
365
|
+
return this._seekWeekCycle(fromMs, weekday, rangeStartMs, rangeStartMs, rule, maxSteps);
|
|
366
|
+
}
|
|
367
|
+
const stepMs = this._seekStepMs(rule);
|
|
368
|
+
if (stepMs <= 0) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
return this._seekFixedStep(fromMs, rangeStartMs, rangeStartMs, stepMs, maxSteps, cursor =>
|
|
372
|
+
this._advanceInPlace(cursor, rule)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Milliseconds per _advanceInPlace step for every rule whose step is a
|
|
378
|
+
* fixed duration between system-timezone transitions: the sub-daily
|
|
379
|
+
* frequencies, DAILY and plain WEEKLY
|
|
380
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
381
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
382
|
+
* @private
|
|
383
|
+
*/
|
|
384
|
+
static _seekStepMs(rule) {
|
|
385
|
+
const interval = rule.interval;
|
|
386
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
if (rule.freq === 'DAILY') {
|
|
390
|
+
return interval * 86400000;
|
|
391
|
+
}
|
|
392
|
+
if (rule.freq === 'WEEKLY') {
|
|
393
|
+
return 7 * interval * 86400000;
|
|
394
|
+
}
|
|
395
|
+
return this._fixedStepMs(rule);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Streaming BYSETPOS filter: buffers the occurrences of one period and
|
|
400
|
+
* yields its selected positions once the next period begins. Periods
|
|
401
|
+
* arrive contiguously and in order, so the result matches _applyBySetPos.
|
|
402
|
+
* @param {Iterable<import('../types.js').EventOccurrence>} source - Occurrences in order
|
|
403
|
+
* @param {Object} rule - Rule with bySetPos
|
|
404
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
|
|
405
|
+
* @private
|
|
406
|
+
*/
|
|
407
|
+
static *_iterateBySetPos(source, rule) {
|
|
408
|
+
let key = null;
|
|
409
|
+
let group = [];
|
|
410
|
+
for (const occurrence of source) {
|
|
411
|
+
const occurrenceKey = this._bySetPosKey(occurrence, rule);
|
|
412
|
+
if (occurrenceKey !== key && group.length > 0) {
|
|
413
|
+
yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
|
|
414
|
+
group = [];
|
|
415
|
+
}
|
|
416
|
+
key = occurrenceKey;
|
|
417
|
+
group.push(occurrence);
|
|
418
|
+
}
|
|
419
|
+
if (group.length > 0) {
|
|
420
|
+
yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
82
424
|
/**
|
|
83
425
|
* General expansion loop: advances a Date cursor per step. Handles every
|
|
84
426
|
* frequency and degenerate rules (non-advancing dates, invalid intervals).
|
|
@@ -98,6 +440,8 @@ export class RecurrenceEngine {
|
|
|
98
440
|
|
|
99
441
|
// Work in event's timezone for accurate recurrence calculation
|
|
100
442
|
const currentDate = new Date(event.start);
|
|
443
|
+
// Steps taken from DTSTART: RFC 5545 COUNT is measured from there,
|
|
444
|
+
// independent of how many occurrences fall inside the range
|
|
101
445
|
let count = 0;
|
|
102
446
|
|
|
103
447
|
// Track DST transitions for proper timezone handling
|
|
@@ -112,7 +456,31 @@ export class RecurrenceEngine {
|
|
|
112
456
|
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
113
457
|
let currentMs = currentDate.getTime();
|
|
114
458
|
|
|
115
|
-
|
|
459
|
+
// Sub-daily rules step a fixed number of milliseconds, so the span
|
|
460
|
+
// before the range is skipped arithmetically instead of one step at a
|
|
461
|
+
// time; other frequencies take few enough steps per year to just walk
|
|
462
|
+
const stepMs = this._fixedStepMs(rule);
|
|
463
|
+
if (stepMs > 0 && currentMs < rangeStartMs) {
|
|
464
|
+
const seek = this._seekFixedStep(
|
|
465
|
+
currentMs,
|
|
466
|
+
rangeStartMs,
|
|
467
|
+
rangeEndMs,
|
|
468
|
+
stepMs,
|
|
469
|
+
rule.count ? rule.count - 1 : Infinity,
|
|
470
|
+
cursor => this._advanceInPlace(cursor, rule)
|
|
471
|
+
);
|
|
472
|
+
currentMs = seek.ms;
|
|
473
|
+
count = seek.steps;
|
|
474
|
+
currentDate.setTime(currentMs);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let iterations = 0;
|
|
478
|
+
while (
|
|
479
|
+
currentMs <= rangeEndMs &&
|
|
480
|
+
occurrences.length < maxOccurrences &&
|
|
481
|
+
iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
|
|
482
|
+
) {
|
|
483
|
+
iterations++;
|
|
116
484
|
// Check if this occurrence is within the range
|
|
117
485
|
if (currentMs >= rangeStartMs) {
|
|
118
486
|
const occurrenceStart = new Date(currentMs);
|
|
@@ -223,7 +591,37 @@ export class RecurrenceEngine {
|
|
|
223
591
|
let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
|
|
224
592
|
let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
|
|
225
593
|
|
|
226
|
-
|
|
594
|
+
// Seek to the last occurrence before the range. nextEventTzTransition is
|
|
595
|
+
// deliberately left as computed from DTSTART: if the seek passed an
|
|
596
|
+
// event-timezone transition, the first in-range occurrence must still
|
|
597
|
+
// compare its offset with lastOffset, exactly as the general loop does.
|
|
598
|
+
if (currentMs < rangeStartMs) {
|
|
599
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
600
|
+
const seek = dayDeltas
|
|
601
|
+
? this._seekWeekCycle(currentMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps)
|
|
602
|
+
: this._seekFixedStep(
|
|
603
|
+
currentMs,
|
|
604
|
+
rangeStartMs,
|
|
605
|
+
rangeEndMs,
|
|
606
|
+
stepDays * DAY,
|
|
607
|
+
maxSteps,
|
|
608
|
+
cursor => cursor.setDate(cursor.getDate() + stepDays)
|
|
609
|
+
);
|
|
610
|
+
currentMs = seek.ms;
|
|
611
|
+
count = seek.steps;
|
|
612
|
+
nextSystemTransition = seek.nextSystemTransition;
|
|
613
|
+
if (dayDeltas) {
|
|
614
|
+
weekday = seek.weekday;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let iterations = 0;
|
|
619
|
+
while (
|
|
620
|
+
currentMs <= rangeEndMs &&
|
|
621
|
+
occurrences.length < maxOccurrences &&
|
|
622
|
+
iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
|
|
623
|
+
) {
|
|
624
|
+
iterations++;
|
|
227
625
|
if (currentMs >= rangeStartMs) {
|
|
228
626
|
const occurrenceStart = new Date(currentMs);
|
|
229
627
|
const occurrenceEnd = new Date(currentMs + duration);
|
|
@@ -276,6 +674,152 @@ export class RecurrenceEngine {
|
|
|
276
674
|
return occurrences;
|
|
277
675
|
}
|
|
278
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Milliseconds per step for rules whose step is a fixed duration while
|
|
679
|
+
* the system UTC offset is constant. Only the sub-daily frequencies are
|
|
680
|
+
* reported here: DAILY and WEEKLY have their own numeric loop, and the
|
|
681
|
+
* calendar-based frequencies take too few steps per year to need seeking.
|
|
682
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
683
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
684
|
+
* @private
|
|
685
|
+
*/
|
|
686
|
+
static _fixedStepMs(rule) {
|
|
687
|
+
const interval = rule.interval;
|
|
688
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
691
|
+
switch (rule.freq) {
|
|
692
|
+
case 'SECONDLY':
|
|
693
|
+
return interval * 1000;
|
|
694
|
+
case 'MINUTELY':
|
|
695
|
+
return interval * 60000;
|
|
696
|
+
case 'HOURLY':
|
|
697
|
+
return interval * 3600000;
|
|
698
|
+
default:
|
|
699
|
+
return 0;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Skip the occurrences of a fixed-step rule that fall before
|
|
705
|
+
* rangeStartMs without visiting each one.
|
|
706
|
+
*
|
|
707
|
+
* While the system UTC offset is constant, a wall-clock step of the
|
|
708
|
+
* cursor is a constant number of milliseconds, so a whole run of steps
|
|
709
|
+
* collapses into one multiplication. The single step that crosses a
|
|
710
|
+
* system-timezone transition is taken with `advance` instead, so the
|
|
711
|
+
* cursor ends up exactly where stepping every occurrence would have put
|
|
712
|
+
* it. Stops at the last occurrence before rangeStartMs; the caller's loop
|
|
713
|
+
* takes the step into the range.
|
|
714
|
+
*
|
|
715
|
+
* @param {number} fromMs - Cursor position (an occurrence instant)
|
|
716
|
+
* @param {number} rangeStartMs - Seek target
|
|
717
|
+
* @param {number} rangeEndMs - Upper bound for transition lookup
|
|
718
|
+
* @param {number} stepMs - Step length while the UTC offset is constant
|
|
719
|
+
* @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
|
|
720
|
+
* @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor
|
|
721
|
+
* @returns {{ ms: number, steps: number, nextSystemTransition: number }}
|
|
722
|
+
* Cursor position, steps taken and the next system transition after it
|
|
723
|
+
* @private
|
|
724
|
+
*/
|
|
725
|
+
static _seekFixedStep(fromMs, rangeStartMs, rangeEndMs, stepMs, maxSteps, advance) {
|
|
726
|
+
let ms = fromMs;
|
|
727
|
+
let steps = 0;
|
|
728
|
+
let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
729
|
+
if (!Number.isFinite(rangeStartMs) || !(stepMs > 0)) {
|
|
730
|
+
return { ms, steps, nextSystemTransition };
|
|
731
|
+
}
|
|
732
|
+
// Comparisons are written so an invalid (NaN) cursor ends the seek
|
|
733
|
+
while (ms < rangeStartMs && steps < maxSteps) {
|
|
734
|
+
const limit = Math.min(rangeStartMs, nextSystemTransition);
|
|
735
|
+
// Largest k with ms + k * stepMs < limit
|
|
736
|
+
let k = Math.ceil((limit - ms) / stepMs) - 1;
|
|
737
|
+
if (ms + k * stepMs >= limit) {
|
|
738
|
+
k--; // division rounded up
|
|
739
|
+
}
|
|
740
|
+
k = Math.min(k, maxSteps - steps);
|
|
741
|
+
if (k > 0) {
|
|
742
|
+
ms += k * stepMs;
|
|
743
|
+
steps += k;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (ms + stepMs >= rangeStartMs) {
|
|
747
|
+
break; // next step lands in the range
|
|
748
|
+
}
|
|
749
|
+
// Next step crosses a system-timezone transition
|
|
750
|
+
const cursor = new Date(ms);
|
|
751
|
+
advance(cursor);
|
|
752
|
+
ms = cursor.getTime();
|
|
753
|
+
steps++;
|
|
754
|
+
nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
755
|
+
}
|
|
756
|
+
return { ms, steps, nextSystemTransition };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Seek for WEEKLY BYDAY rules, whose step pattern repeats every week:
|
|
761
|
+
* whole weeks are skipped arithmetically from any weekday in the BYDAY
|
|
762
|
+
* set, and single steps (identical to the expansion loop's) are only
|
|
763
|
+
* taken to reach the set, around system-timezone transitions and in the
|
|
764
|
+
* last week before the range.
|
|
765
|
+
*
|
|
766
|
+
* @param {number} fromMs - Cursor position (an occurrence instant)
|
|
767
|
+
* @param {number} weekday - Weekday of the cursor (Date#getDay)
|
|
768
|
+
* @param {number} rangeStartMs - Seek target
|
|
769
|
+
* @param {number} rangeEndMs - Upper bound for transition lookup
|
|
770
|
+
* @param {Object} rule - Parsed rule with compiled _byDaySet/_byDayDeltas
|
|
771
|
+
* @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
|
|
772
|
+
* @returns {{ ms: number, steps: number, weekday: number, nextSystemTransition: number }}
|
|
773
|
+
* @private
|
|
774
|
+
*/
|
|
775
|
+
static _seekWeekCycle(fromMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps) {
|
|
776
|
+
const DAY = 86400000;
|
|
777
|
+
const WEEK = 7 * DAY;
|
|
778
|
+
const daySet = rule._byDaySet;
|
|
779
|
+
const dayDeltas = rule._byDayDeltas;
|
|
780
|
+
const stepsPerWeek = daySet.size;
|
|
781
|
+
let ms = fromMs;
|
|
782
|
+
let steps = 0;
|
|
783
|
+
let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
784
|
+
if (!Number.isFinite(rangeStartMs)) {
|
|
785
|
+
return { ms, steps, weekday, nextSystemTransition };
|
|
786
|
+
}
|
|
787
|
+
while (ms < rangeStartMs && steps < maxSteps) {
|
|
788
|
+
// A week from a weekday in the set is exactly stepsPerWeek steps and
|
|
789
|
+
// returns to the same weekday
|
|
790
|
+
if (daySet.has(weekday)) {
|
|
791
|
+
const limit = Math.min(rangeStartMs, nextSystemTransition);
|
|
792
|
+
let weeks = Math.ceil((limit - ms) / WEEK) - 1;
|
|
793
|
+
if (ms + weeks * WEEK >= limit) {
|
|
794
|
+
weeks--;
|
|
795
|
+
}
|
|
796
|
+
weeks = Math.min(weeks, Math.floor((maxSteps - steps) / stepsPerWeek));
|
|
797
|
+
if (weeks > 0) {
|
|
798
|
+
ms += weeks * WEEK;
|
|
799
|
+
steps += weeks * stepsPerWeek;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Single step, identical to the expansion loop
|
|
804
|
+
const days = dayDeltas[weekday];
|
|
805
|
+
const naiveMs = ms + days * DAY;
|
|
806
|
+
if (naiveMs >= rangeStartMs) {
|
|
807
|
+
break; // next step lands in the range
|
|
808
|
+
}
|
|
809
|
+
weekday = (weekday + days) % 7;
|
|
810
|
+
if (naiveMs >= nextSystemTransition) {
|
|
811
|
+
const cursor = new Date(ms);
|
|
812
|
+
cursor.setDate(cursor.getDate() + days);
|
|
813
|
+
ms = cursor.getTime();
|
|
814
|
+
nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
815
|
+
} else {
|
|
816
|
+
ms = naiveMs;
|
|
817
|
+
}
|
|
818
|
+
steps++;
|
|
819
|
+
}
|
|
820
|
+
return { ms, steps, weekday, nextSystemTransition };
|
|
821
|
+
}
|
|
822
|
+
|
|
279
823
|
/**
|
|
280
824
|
* Find the next system-timezone offset transition after fromMs.
|
|
281
825
|
* Cached module-wide: the system timezone is fixed for the process.
|
|
@@ -349,17 +893,7 @@ export class RecurrenceEngine {
|
|
|
349
893
|
// Group occurrences by period
|
|
350
894
|
const groups = new Map();
|
|
351
895
|
for (const occ of occurrences) {
|
|
352
|
-
|
|
353
|
-
switch (rule.freq) {
|
|
354
|
-
case 'YEARLY':
|
|
355
|
-
key = occ.start.getFullYear();
|
|
356
|
-
break;
|
|
357
|
-
case 'WEEKLY':
|
|
358
|
-
key = `${occ.start.getFullYear()}-W${DateUtils.getWeekNumber(occ.start)}`;
|
|
359
|
-
break;
|
|
360
|
-
default:
|
|
361
|
-
key = `${occ.start.getFullYear()}-${occ.start.getMonth()}`;
|
|
362
|
-
}
|
|
896
|
+
const key = this._bySetPosKey(occ, rule);
|
|
363
897
|
if (!groups.has(key)) groups.set(key, []);
|
|
364
898
|
groups.get(key).push(occ);
|
|
365
899
|
}
|
|
@@ -367,17 +901,49 @@ export class RecurrenceEngine {
|
|
|
367
901
|
// Filter each group by BYSETPOS positions
|
|
368
902
|
const filtered = [];
|
|
369
903
|
for (const group of groups.values()) {
|
|
370
|
-
|
|
371
|
-
const idx = pos > 0 ? pos - 1 : group.length + pos;
|
|
372
|
-
if (idx >= 0 && idx < group.length) {
|
|
373
|
-
filtered.push(group[idx]);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
904
|
+
filtered.push(...this._selectBySetPos(group, rule));
|
|
376
905
|
}
|
|
377
906
|
|
|
378
907
|
return filtered.sort((a, b) => a.start - b.start);
|
|
379
908
|
}
|
|
380
909
|
|
|
910
|
+
/**
|
|
911
|
+
* Key of the BYSETPOS period an occurrence belongs to
|
|
912
|
+
* @param {import('../types.js').EventOccurrence} occurrence - The occurrence
|
|
913
|
+
* @param {Object} rule - Recurrence rule
|
|
914
|
+
* @returns {string|number} Period key
|
|
915
|
+
* @private
|
|
916
|
+
*/
|
|
917
|
+
static _bySetPosKey(occurrence, rule) {
|
|
918
|
+
switch (rule.freq) {
|
|
919
|
+
case 'YEARLY':
|
|
920
|
+
return occurrence.start.getFullYear();
|
|
921
|
+
case 'WEEKLY':
|
|
922
|
+
return `${occurrence.start.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
|
|
923
|
+
default:
|
|
924
|
+
return `${occurrence.start.getFullYear()}-${occurrence.start.getMonth()}`;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Occurrences of one period selected by the rule's BYSETPOS positions,
|
|
930
|
+
* in BYSETPOS order
|
|
931
|
+
* @param {Array} group - Occurrences of a single period, in order
|
|
932
|
+
* @param {Object} rule - Rule with bySetPos
|
|
933
|
+
* @returns {Array} Selected occurrences
|
|
934
|
+
* @private
|
|
935
|
+
*/
|
|
936
|
+
static _selectBySetPos(group, rule) {
|
|
937
|
+
const selected = [];
|
|
938
|
+
for (const pos of rule.bySetPos) {
|
|
939
|
+
const idx = pos > 0 ? pos - 1 : group.length + pos;
|
|
940
|
+
if (idx >= 0 && idx < group.length) {
|
|
941
|
+
selected.push(group[idx]);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return selected;
|
|
945
|
+
}
|
|
946
|
+
|
|
381
947
|
/**
|
|
382
948
|
* Parse an RRULE string into a rule object
|
|
383
949
|
* @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
|