@forcecalendar/core 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,6 +92,335 @@ export class RecurrenceEngine {
92
92
  return occurrences;
93
93
  }
94
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
+
95
424
  /**
96
425
  * General expansion loop: advances a Date cursor per step. Handles every
97
426
  * frequency and degenerate rules (non-advancing dates, invalid intervals).
@@ -564,17 +893,7 @@ export class RecurrenceEngine {
564
893
  // Group occurrences by period
565
894
  const groups = new Map();
566
895
  for (const occ of occurrences) {
567
- let key;
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
- }
896
+ const key = this._bySetPosKey(occ, rule);
578
897
  if (!groups.has(key)) groups.set(key, []);
579
898
  groups.get(key).push(occ);
580
899
  }
@@ -582,17 +901,49 @@ export class RecurrenceEngine {
582
901
  // Filter each group by BYSETPOS positions
583
902
  const filtered = [];
584
903
  for (const group of groups.values()) {
585
- for (const pos of rule.bySetPos) {
586
- const idx = pos > 0 ? pos - 1 : group.length + pos;
587
- if (idx >= 0 && idx < group.length) {
588
- filtered.push(group[idx]);
589
- }
590
- }
904
+ filtered.push(...this._selectBySetPos(group, rule));
591
905
  }
592
906
 
593
907
  return filtered.sort((a, b) => a.start - b.start);
594
908
  }
595
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
+
596
947
  /**
597
948
  * Parse an RRULE string into a rule object
598
949
  * @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object