@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.
@@ -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 generate
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
- while (currentMs <= rangeEndMs && count < maxOccurrences) {
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
- while (currentMs <= rangeEndMs && count < maxOccurrences) {
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
- let key;
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
- for (const pos of rule.bySetPos) {
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