@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
|
@@ -216,10 +216,26 @@ export class RRuleParser {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Validate and normalize rule
|
|
219
|
+
* Validate and normalize a rule.
|
|
220
|
+
*
|
|
221
|
+
* Works on a copy: rule objects handed in are typically the stored
|
|
222
|
+
* recurrenceRule of an Event, and normalising them in place would make
|
|
223
|
+
* the stored event differ from the data it was created from (a spurious
|
|
224
|
+
* update on the next reconcile) and let the engines' per-rule caches leak
|
|
225
|
+
* into it. The copy is shallow except for the array fields, which are
|
|
226
|
+
* copied as well.
|
|
227
|
+
* @param {Object} rule - Rule object (not modified)
|
|
228
|
+
* @returns {Object} Normalised copy
|
|
220
229
|
* @private
|
|
221
230
|
*/
|
|
222
231
|
static validateRule(rule) {
|
|
232
|
+
rule = { ...rule };
|
|
233
|
+
for (const field of ['byDay', 'bySetPos', 'exceptions']) {
|
|
234
|
+
if (Array.isArray(rule[field])) {
|
|
235
|
+
rule[field] = [...rule[field]];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
223
239
|
// Ensure frequency is set
|
|
224
240
|
if (!rule.freq) {
|
|
225
241
|
rule.freq = 'DAILY';
|
|
@@ -230,8 +246,8 @@ export class RRuleParser {
|
|
|
230
246
|
throw new Error('RRULE cannot have both COUNT and UNTIL');
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
// Validate interval
|
|
234
|
-
if (rule.interval < 1) {
|
|
249
|
+
// Validate interval (RFC 5545 default is 1; rule objects may omit it)
|
|
250
|
+
if (!Number.isInteger(rule.interval) || rule.interval < 1) {
|
|
235
251
|
rule.interval = 1;
|
|
236
252
|
}
|
|
237
253
|
|
|
@@ -4,6 +4,11 @@ import { RRuleParser } from './RRuleParser.js';
|
|
|
4
4
|
|
|
5
5
|
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
6
6
|
|
|
7
|
+
// Timezone databases know only local mean time before the 19th century, so
|
|
8
|
+
// a span that ends before this instant is checked at its ends rather than
|
|
9
|
+
// probed week by week
|
|
10
|
+
const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* RecurrenceEngine - Handles expansion of recurring events
|
|
9
14
|
* Full support for RFC 5545 (iCalendar) RRULE specification
|
|
@@ -92,6 +97,335 @@ export class RecurrenceEngine {
|
|
|
92
97
|
return occurrences;
|
|
93
98
|
}
|
|
94
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Lazily iterate the occurrences of an event in chronological order.
|
|
102
|
+
*
|
|
103
|
+
* Yields the same occurrence objects, in the same order, that expandEvent
|
|
104
|
+
* returns for the window — but one at a time, so a caller can stop after
|
|
105
|
+
* any number of them without the rest of the series being generated.
|
|
106
|
+
* Daily, weekly and sub-daily rules are seeked to `after` arithmetically,
|
|
107
|
+
* so finding the first occurrence after a far-away instant does not step
|
|
108
|
+
* through the series from its start.
|
|
109
|
+
*
|
|
110
|
+
* Both bounds are exclusive unless `inclusive` is set: an occurrence that
|
|
111
|
+
* starts exactly at `after` or `before` is skipped by default, which lets
|
|
112
|
+
* `iterateOccurrences(event, { after: previous.start })` continue a series
|
|
113
|
+
* without repeating `previous`. With `inclusive: true` the window is
|
|
114
|
+
* closed on both ends, exactly like expandEvent's range. An omitted bound
|
|
115
|
+
* leaves that end of the window open.
|
|
116
|
+
*
|
|
117
|
+
* COUNT, UNTIL, INTERVAL, BYDAY/BYMONTHDAY/BYSETPOS and exception dates
|
|
118
|
+
* are honoured as in expandEvent; BYSETPOS rules are yielded one period at
|
|
119
|
+
* a time, since the set positions of a period are only known once it is
|
|
120
|
+
* complete. A non-recurring event yields its single occurrence when it
|
|
121
|
+
* falls inside the window. Iteration ends at COUNT or UNTIL, at `before`,
|
|
122
|
+
* or — as a guard for rules that produce no occurrences — after
|
|
123
|
+
* MAX_ITERATIONS_HARD_LIMIT consecutive steps without one.
|
|
124
|
+
*
|
|
125
|
+
* The generator is single-use; call this method again for a fresh one.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* for (const occurrence of RecurrenceEngine.iterateOccurrences(event, { after: new Date() })) {
|
|
129
|
+
* if (occurrence.start > deadline) break;
|
|
130
|
+
* schedule(occurrence);
|
|
131
|
+
* }
|
|
132
|
+
*
|
|
133
|
+
* @param {import('./Event.js').Event} event - The event to iterate
|
|
134
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
135
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>} Occurrences in chronological order
|
|
136
|
+
* @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
|
|
137
|
+
*/
|
|
138
|
+
static iterateOccurrences(event, options = {}) {
|
|
139
|
+
const window = this._occurrenceWindow(options);
|
|
140
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
141
|
+
return this._iterateSingle(
|
|
142
|
+
{ start: event.start, end: event.end, timezone: event.timeZone },
|
|
143
|
+
window
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rule = this._getParsedRule(event.recurrenceRule);
|
|
148
|
+
let rangeEndMs = window.endMs;
|
|
149
|
+
// Same UNTIL clamp as expandEvent (a non-Date UNTIL compares false)
|
|
150
|
+
if (rule.until && rule.until < rangeEndMs) {
|
|
151
|
+
rangeEndMs = rule.until.valueOf();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let occurrences = this._iterateRule(
|
|
155
|
+
event,
|
|
156
|
+
rule,
|
|
157
|
+
window.startMs,
|
|
158
|
+
rangeEndMs,
|
|
159
|
+
options.timezone || event.timeZone || 'UTC',
|
|
160
|
+
TimezoneManager.getInstance(),
|
|
161
|
+
event.end - event.start
|
|
162
|
+
);
|
|
163
|
+
if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
|
|
164
|
+
occurrences = this._iterateBySetPos(occurrences, rule);
|
|
165
|
+
}
|
|
166
|
+
return occurrences;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* First occurrence of an event after an instant, or null when the series
|
|
171
|
+
* has no occurrence after it (past COUNT or UNTIL, or a non-recurring
|
|
172
|
+
* event that already started).
|
|
173
|
+
*
|
|
174
|
+
* `after` is exclusive unless `options.inclusive` is set, so passing the
|
|
175
|
+
* start of a known occurrence returns the one that follows it. Not to be
|
|
176
|
+
* confused with getNextOccurrence, which steps a parsed rule once
|
|
177
|
+
* without regard to COUNT, UNTIL or exceptions.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const upcoming = RecurrenceEngine.nextOccurrence(event, new Date());
|
|
181
|
+
*
|
|
182
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
183
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
184
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Further window options
|
|
185
|
+
* @returns {import('../types.js').EventOccurrence|null} The next occurrence, or null
|
|
186
|
+
*/
|
|
187
|
+
static nextOccurrence(event, after = null, options = {}) {
|
|
188
|
+
for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
|
|
189
|
+
return occurrence;
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The first `count` occurrences of an event inside a window, generated
|
|
196
|
+
* lazily so an open-ended series costs only the occurrences taken.
|
|
197
|
+
* `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
|
|
198
|
+
* when the series or the window ends first.
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* const nextFive = RecurrenceEngine.takeOccurrences(event, 5, { after: new Date() });
|
|
202
|
+
*
|
|
203
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
204
|
+
* @param {number} count - Maximum number of occurrences to return (fractions are floored)
|
|
205
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
206
|
+
* @returns {import('../types.js').EventOccurrence[]} Up to `count` occurrences in chronological order
|
|
207
|
+
*/
|
|
208
|
+
static takeOccurrences(event, count, options = {}) {
|
|
209
|
+
const limit = Math.floor(Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT));
|
|
210
|
+
const taken = [];
|
|
211
|
+
if (!(limit > 0)) {
|
|
212
|
+
return taken;
|
|
213
|
+
}
|
|
214
|
+
for (const occurrence of this.iterateOccurrences(event, options)) {
|
|
215
|
+
taken.push(occurrence);
|
|
216
|
+
if (taken.length >= limit) {
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return taken;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve iterator options into a closed window on numeric timestamps.
|
|
225
|
+
* Exclusive bounds are shifted by one millisecond, the resolution of
|
|
226
|
+
* Date, so the expansion loops only ever compare inclusively.
|
|
227
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} options - Iterator options
|
|
228
|
+
* @returns {{ startMs: number, endMs: number }} Inclusive bounds (infinite when open)
|
|
229
|
+
* @throws {TypeError} If a bound is not a valid Date or timestamp
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
static _occurrenceWindow(options) {
|
|
233
|
+
const { after = null, before = null, inclusive = false } = options;
|
|
234
|
+
const shift = inclusive ? 0 : 1;
|
|
235
|
+
return {
|
|
236
|
+
startMs: after == null ? -Infinity : this._boundMs(after, 'after') + shift,
|
|
237
|
+
endMs: before == null ? Infinity : this._boundMs(before, 'before') - shift
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Timestamp of a window bound given as a Date or a number
|
|
243
|
+
* @param {Date|number} value - Bound to convert
|
|
244
|
+
* @param {string} name - Option name for the error message
|
|
245
|
+
* @returns {number} Timestamp in milliseconds
|
|
246
|
+
* @throws {TypeError} If the value is not a valid Date or timestamp
|
|
247
|
+
* @private
|
|
248
|
+
*/
|
|
249
|
+
static _boundMs(value, name) {
|
|
250
|
+
const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : NaN;
|
|
251
|
+
if (Number.isNaN(ms)) {
|
|
252
|
+
throw new TypeError(`RecurrenceEngine: ${name} must be a valid Date or timestamp`);
|
|
253
|
+
}
|
|
254
|
+
return ms;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Yield a single occurrence if it starts inside the window
|
|
259
|
+
* @param {import('../types.js').EventOccurrence} occurrence - The occurrence
|
|
260
|
+
* @param {{ startMs: number, endMs: number }} window - Inclusive bounds
|
|
261
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
|
|
262
|
+
* @private
|
|
263
|
+
*/
|
|
264
|
+
static *_iterateSingle(occurrence, window) {
|
|
265
|
+
const ms = new Date(occurrence.start).getTime();
|
|
266
|
+
if (ms >= window.startMs && ms <= window.endMs) {
|
|
267
|
+
yield occurrence;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Lazy counterpart of the expansion loops: seeks to the window, then
|
|
273
|
+
* advances a Date cursor per step and yields each in-window occurrence,
|
|
274
|
+
* applying the same DST adjustment and exception filtering.
|
|
275
|
+
* @private
|
|
276
|
+
*/
|
|
277
|
+
static *_iterateRule(event, rule, rangeStartMs, rangeEndMs, eventTimezone, tzManager, duration) {
|
|
278
|
+
const currentDate = new Date(event.start);
|
|
279
|
+
let currentMs = currentDate.getTime();
|
|
280
|
+
if (Number.isNaN(currentMs) || rangeStartMs > rangeEndMs) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Steps taken from DTSTART, which is what COUNT measures
|
|
284
|
+
let count = 0;
|
|
285
|
+
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
286
|
+
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
287
|
+
|
|
288
|
+
if (currentMs < rangeStartMs) {
|
|
289
|
+
const seek = this._seekToWindow(currentMs, currentDate.getDay(), rule, rangeStartMs);
|
|
290
|
+
if (seek) {
|
|
291
|
+
currentMs = seek.ms;
|
|
292
|
+
count = seek.steps;
|
|
293
|
+
currentDate.setTime(currentMs);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let stuckCount = 0;
|
|
298
|
+
let idleSteps = 0;
|
|
299
|
+
while (currentMs <= rangeEndMs) {
|
|
300
|
+
if (currentMs >= rangeStartMs) {
|
|
301
|
+
const occurrenceStart = new Date(currentMs);
|
|
302
|
+
const occurrenceEnd = new Date(currentMs + duration);
|
|
303
|
+
|
|
304
|
+
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
305
|
+
if (currentOffset !== lastOffset) {
|
|
306
|
+
const offsetDiff = lastOffset - currentOffset;
|
|
307
|
+
occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
|
|
308
|
+
occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
|
|
309
|
+
}
|
|
310
|
+
lastOffset = currentOffset;
|
|
311
|
+
|
|
312
|
+
if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
|
|
313
|
+
idleSteps = 0;
|
|
314
|
+
yield {
|
|
315
|
+
start: occurrenceStart,
|
|
316
|
+
end: occurrenceEnd,
|
|
317
|
+
recurringEventId: event.id,
|
|
318
|
+
timezone: eventTimezone,
|
|
319
|
+
originalStart: event.start
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (rule.count && count + 1 >= rule.count) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this._advanceInPlace(currentDate, rule);
|
|
329
|
+
const previousMs = currentMs;
|
|
330
|
+
currentMs = currentDate.getTime();
|
|
331
|
+
count++;
|
|
332
|
+
|
|
333
|
+
if (currentMs === previousMs) {
|
|
334
|
+
stuckCount++;
|
|
335
|
+
if (stuckCount >= 3) {
|
|
336
|
+
console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
stuckCount = 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
idleSteps++;
|
|
344
|
+
if (idleSteps >= RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Seek the iteration cursor to the last occurrence before rangeStartMs
|
|
352
|
+
* using the same arithmetic as expandEvent. The system-transition scan
|
|
353
|
+
* is bounded by the target itself, so an open-ended window costs no
|
|
354
|
+
* more than a closed one.
|
|
355
|
+
* @param {number} fromMs - Cursor position (DTSTART)
|
|
356
|
+
* @param {number} weekday - Weekday of the cursor
|
|
357
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
358
|
+
* @param {number} rangeStartMs - Seek target
|
|
359
|
+
* @returns {{ ms: number, steps: number }|null} New cursor, or null for rules that cannot seek
|
|
360
|
+
* @private
|
|
361
|
+
*/
|
|
362
|
+
static _seekToWindow(fromMs, weekday, rule, rangeStartMs) {
|
|
363
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
364
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
365
|
+
const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
|
|
366
|
+
if (daySet.size === 0) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
rule._byDayDeltas = rule._byDayDeltas || this._buildByDayDeltas(daySet);
|
|
370
|
+
return this._seekWeekCycle(fromMs, weekday, rangeStartMs, rangeStartMs, rule, maxSteps);
|
|
371
|
+
}
|
|
372
|
+
const stepMs = this._seekStepMs(rule);
|
|
373
|
+
if (stepMs <= 0) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
return this._seekFixedStep(fromMs, rangeStartMs, rangeStartMs, stepMs, maxSteps, cursor =>
|
|
377
|
+
this._advanceInPlace(cursor, rule)
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Milliseconds per _advanceInPlace step for every rule whose step is a
|
|
383
|
+
* fixed duration between system-timezone transitions: the sub-daily
|
|
384
|
+
* frequencies, DAILY and plain WEEKLY
|
|
385
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
386
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
387
|
+
* @private
|
|
388
|
+
*/
|
|
389
|
+
static _seekStepMs(rule) {
|
|
390
|
+
const interval = rule.interval;
|
|
391
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
if (rule.freq === 'DAILY') {
|
|
395
|
+
return interval * 86400000;
|
|
396
|
+
}
|
|
397
|
+
if (rule.freq === 'WEEKLY') {
|
|
398
|
+
return 7 * interval * 86400000;
|
|
399
|
+
}
|
|
400
|
+
return this._fixedStepMs(rule);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Streaming BYSETPOS filter: buffers the occurrences of one period and
|
|
405
|
+
* yields its selected positions once the next period begins. Periods
|
|
406
|
+
* arrive contiguously and in order, so the result matches _applyBySetPos.
|
|
407
|
+
* @param {Iterable<import('../types.js').EventOccurrence>} source - Occurrences in order
|
|
408
|
+
* @param {Object} rule - Rule with bySetPos
|
|
409
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
|
|
410
|
+
* @private
|
|
411
|
+
*/
|
|
412
|
+
static *_iterateBySetPos(source, rule) {
|
|
413
|
+
let key = null;
|
|
414
|
+
let group = [];
|
|
415
|
+
for (const occurrence of source) {
|
|
416
|
+
const occurrenceKey = this._bySetPosKey(occurrence, rule);
|
|
417
|
+
if (occurrenceKey !== key && group.length > 0) {
|
|
418
|
+
yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
|
|
419
|
+
group = [];
|
|
420
|
+
}
|
|
421
|
+
key = occurrenceKey;
|
|
422
|
+
group.push(occurrence);
|
|
423
|
+
}
|
|
424
|
+
if (group.length > 0) {
|
|
425
|
+
yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
95
429
|
/**
|
|
96
430
|
* General expansion loop: advances a Date cursor per step. Handles every
|
|
97
431
|
* frequency and degenerate rules (non-advancing dates, invalid intervals).
|
|
@@ -493,7 +827,9 @@ export class RecurrenceEngine {
|
|
|
493
827
|
|
|
494
828
|
/**
|
|
495
829
|
* Find the next system-timezone offset transition after fromMs.
|
|
496
|
-
* Cached module-wide: the system timezone is fixed for the process.
|
|
830
|
+
* Cached module-wide: the system timezone is fixed for the process. The
|
|
831
|
+
* cache grows incrementally, so extending coverage (a view navigating
|
|
832
|
+
* forward, a series with a far-past DTSTART) scans only the new span.
|
|
497
833
|
* @param {number} fromMs - Search from this timestamp (exclusive)
|
|
498
834
|
* @param {number} toMs - Extend cache coverage at least this far
|
|
499
835
|
* @returns {number} Transition timestamp, or Infinity if none within coverage
|
|
@@ -504,18 +840,34 @@ export class RecurrenceEngine {
|
|
|
504
840
|
return Infinity;
|
|
505
841
|
}
|
|
506
842
|
let cache = this._systemTransitions;
|
|
507
|
-
if (!cache
|
|
508
|
-
|
|
509
|
-
const to = Math.max(toMs, cache ? cache.to : toMs);
|
|
510
|
-
cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
|
|
843
|
+
if (!cache) {
|
|
844
|
+
cache = { from: fromMs, to: toMs, transitions: this._scanSystemTransitions(fromMs, toMs) };
|
|
511
845
|
this._systemTransitions = cache;
|
|
846
|
+
} else {
|
|
847
|
+
if (fromMs < cache.from) {
|
|
848
|
+
cache.transitions = this._scanSystemTransitions(fromMs, cache.from).concat(
|
|
849
|
+
cache.transitions
|
|
850
|
+
);
|
|
851
|
+
cache.from = fromMs;
|
|
852
|
+
}
|
|
853
|
+
if (toMs > cache.to) {
|
|
854
|
+
cache.transitions = cache.transitions.concat(this._scanSystemTransitions(cache.to, toMs));
|
|
855
|
+
cache.to = toMs;
|
|
856
|
+
}
|
|
512
857
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
858
|
+
// First transition after fromMs; the list is sorted
|
|
859
|
+
const transitions = cache.transitions;
|
|
860
|
+
let lo = 0;
|
|
861
|
+
let hi = transitions.length;
|
|
862
|
+
while (lo < hi) {
|
|
863
|
+
const mid = (lo + hi) >>> 1;
|
|
864
|
+
if (transitions[mid] > fromMs) {
|
|
865
|
+
hi = mid;
|
|
866
|
+
} else {
|
|
867
|
+
lo = mid + 1;
|
|
516
868
|
}
|
|
517
869
|
}
|
|
518
|
-
return Infinity;
|
|
870
|
+
return lo < transitions.length ? transitions[lo] : Infinity;
|
|
519
871
|
}
|
|
520
872
|
|
|
521
873
|
/**
|
|
@@ -529,6 +881,12 @@ export class RecurrenceEngine {
|
|
|
529
881
|
const transitions = [];
|
|
530
882
|
let lo = fromMs;
|
|
531
883
|
let loOffset = new Date(lo).getTimezoneOffset();
|
|
884
|
+
// The pre-tzdata era has a single offset (local mean time) and is
|
|
885
|
+
// skipped in one step; probing it week by week could take minutes
|
|
886
|
+
if (lo < PRE_TZDATA_FLOOR_MS) {
|
|
887
|
+
lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
|
|
888
|
+
loOffset = new Date(lo).getTimezoneOffset();
|
|
889
|
+
}
|
|
532
890
|
while (lo < toMs) {
|
|
533
891
|
const hi = Math.min(lo + WEEK, toMs);
|
|
534
892
|
const hiOffset = new Date(hi).getTimezoneOffset();
|
|
@@ -564,17 +922,7 @@ export class RecurrenceEngine {
|
|
|
564
922
|
// Group occurrences by period
|
|
565
923
|
const groups = new Map();
|
|
566
924
|
for (const occ of occurrences) {
|
|
567
|
-
|
|
568
|
-
switch (rule.freq) {
|
|
569
|
-
case 'YEARLY':
|
|
570
|
-
key = occ.start.getFullYear();
|
|
571
|
-
break;
|
|
572
|
-
case 'WEEKLY':
|
|
573
|
-
key = `${occ.start.getFullYear()}-W${DateUtils.getWeekNumber(occ.start)}`;
|
|
574
|
-
break;
|
|
575
|
-
default:
|
|
576
|
-
key = `${occ.start.getFullYear()}-${occ.start.getMonth()}`;
|
|
577
|
-
}
|
|
925
|
+
const key = this._bySetPosKey(occ, rule);
|
|
578
926
|
if (!groups.has(key)) groups.set(key, []);
|
|
579
927
|
groups.get(key).push(occ);
|
|
580
928
|
}
|
|
@@ -582,17 +930,55 @@ export class RecurrenceEngine {
|
|
|
582
930
|
// Filter each group by BYSETPOS positions
|
|
583
931
|
const filtered = [];
|
|
584
932
|
for (const group of groups.values()) {
|
|
585
|
-
|
|
586
|
-
const idx = pos > 0 ? pos - 1 : group.length + pos;
|
|
587
|
-
if (idx >= 0 && idx < group.length) {
|
|
588
|
-
filtered.push(group[idx]);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
933
|
+
filtered.push(...this._selectBySetPos(group, rule));
|
|
591
934
|
}
|
|
592
935
|
|
|
593
936
|
return filtered.sort((a, b) => a.start - b.start);
|
|
594
937
|
}
|
|
595
938
|
|
|
939
|
+
/**
|
|
940
|
+
* Key of the BYSETPOS period an occurrence belongs to
|
|
941
|
+
* @param {import('../types.js').EventOccurrence} occurrence - The occurrence
|
|
942
|
+
* @param {Object} rule - Recurrence rule
|
|
943
|
+
* @returns {string|number} Period key
|
|
944
|
+
* @private
|
|
945
|
+
*/
|
|
946
|
+
static _bySetPosKey(occurrence, rule) {
|
|
947
|
+
switch (rule.freq) {
|
|
948
|
+
case 'YEARLY':
|
|
949
|
+
return occurrence.start.getFullYear();
|
|
950
|
+
case 'WEEKLY': {
|
|
951
|
+
// ISO week-year: the week's Thursday decides the year, so the days
|
|
952
|
+
// of a week that straddles New Year share one period key
|
|
953
|
+
const thursday = new Date(occurrence.start);
|
|
954
|
+
thursday.setHours(0, 0, 0, 0);
|
|
955
|
+
thursday.setDate(thursday.getDate() + 4 - (thursday.getDay() || 7));
|
|
956
|
+
return `${thursday.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
|
|
957
|
+
}
|
|
958
|
+
default:
|
|
959
|
+
return `${occurrence.start.getFullYear()}-${occurrence.start.getMonth()}`;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Occurrences of one period selected by the rule's BYSETPOS positions,
|
|
965
|
+
* in BYSETPOS order
|
|
966
|
+
* @param {Array} group - Occurrences of a single period, in order
|
|
967
|
+
* @param {Object} rule - Rule with bySetPos
|
|
968
|
+
* @returns {Array} Selected occurrences
|
|
969
|
+
* @private
|
|
970
|
+
*/
|
|
971
|
+
static _selectBySetPos(group, rule) {
|
|
972
|
+
const selected = [];
|
|
973
|
+
for (const pos of rule.bySetPos) {
|
|
974
|
+
const idx = pos > 0 ? pos - 1 : group.length + pos;
|
|
975
|
+
if (idx >= 0 && idx < group.length) {
|
|
976
|
+
selected.push(group[idx]);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return selected;
|
|
980
|
+
}
|
|
981
|
+
|
|
596
982
|
/**
|
|
597
983
|
* Parse an RRULE string into a rule object
|
|
598
984
|
* @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
|
|
@@ -686,11 +1072,21 @@ export class RecurrenceEngine {
|
|
|
686
1072
|
case 'MONTHLY':
|
|
687
1073
|
if (rule.byMonthDay && rule.byMonthDay.length > 0) {
|
|
688
1074
|
// Specific day(s) of month
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1075
|
+
const monthDay = rule.byMonthDay[0];
|
|
1076
|
+
if (monthDay < 0) {
|
|
1077
|
+
// Counted from the end of the month (-1 is the last day). Move
|
|
1078
|
+
// to the first so the month step cannot overflow from a 31st.
|
|
1079
|
+
next.setDate(1);
|
|
1080
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
1081
|
+
const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
|
|
1082
|
+
next.setDate(Math.max(1, daysInMonth + monthDay + 1));
|
|
1083
|
+
} else {
|
|
1084
|
+
const currentMonth = next.getMonth();
|
|
1085
|
+
next.setMonth(currentMonth + rule.interval);
|
|
1086
|
+
// Clamp to last day of month if day doesn't exist
|
|
1087
|
+
const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
|
|
1088
|
+
next.setDate(Math.min(monthDay, daysInMonth));
|
|
1089
|
+
}
|
|
694
1090
|
} else if (rule.byDay && rule.byDay.length > 0) {
|
|
695
1091
|
// Specific weekday of month (e.g., "2nd Tuesday")
|
|
696
1092
|
next.setMonth(next.getMonth() + rule.interval);
|