@forcecalendar/core 2.2.0 → 2.4.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
  */
@@ -34,20 +47,74 @@ export class RecurrenceEngine {
34
47
  }
35
48
 
36
49
  const rule = this._getParsedRule(event.recurrenceRule);
37
- const occurrences = [];
38
50
  const duration = event.end - event.start;
39
51
  const eventTimezone = timezone || event.timeZone || 'UTC';
40
52
  const tzManager = TimezoneManager.getInstance();
41
53
 
42
- // Work in event's timezone for accurate recurrence calculation
43
- const currentDate = new Date(event.start);
44
- let count = 0;
45
-
46
54
  // If UNTIL is specified, use it as the range end
47
55
  if (rule.until && rule.until < rangeEnd) {
48
56
  rangeEnd = rule.until;
49
57
  }
50
58
 
59
+ // DAILY and WEEKLY series iterate on numeric timestamps (no Date
60
+ // arithmetic per step); other frequencies use the general loop
61
+ let occurrences = null;
62
+ if (rule.freq === 'DAILY' || rule.freq === 'WEEKLY') {
63
+ occurrences = this._expandFast(
64
+ event,
65
+ rule,
66
+ rangeStart.getTime(),
67
+ rangeEnd.getTime(),
68
+ maxOccurrences,
69
+ eventTimezone,
70
+ tzManager,
71
+ duration
72
+ );
73
+ }
74
+ if (!occurrences) {
75
+ occurrences = this._expandGeneral(
76
+ event,
77
+ rule,
78
+ rangeStart.getTime(),
79
+ rangeEnd.getTime(),
80
+ maxOccurrences,
81
+ eventTimezone,
82
+ tzManager,
83
+ duration
84
+ );
85
+ }
86
+
87
+ // Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
88
+ if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
89
+ return this._applyBySetPos(occurrences, rule);
90
+ }
91
+
92
+ return occurrences;
93
+ }
94
+
95
+ /**
96
+ * General expansion loop: advances a Date cursor per step. Handles every
97
+ * frequency and degenerate rules (non-advancing dates, invalid intervals).
98
+ * @private
99
+ */
100
+ static _expandGeneral(
101
+ event,
102
+ rule,
103
+ rangeStartMs,
104
+ rangeEndMs,
105
+ maxOccurrences,
106
+ eventTimezone,
107
+ tzManager,
108
+ duration
109
+ ) {
110
+ const occurrences = [];
111
+
112
+ // Work in event's timezone for accurate recurrence calculation
113
+ const currentDate = new Date(event.start);
114
+ // Steps taken from DTSTART: RFC 5545 COUNT is measured from there,
115
+ // independent of how many occurrences fall inside the range
116
+ let count = 0;
117
+
51
118
  // Track DST transitions for proper timezone handling
52
119
  let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
53
120
 
@@ -57,12 +124,34 @@ export class RecurrenceEngine {
57
124
 
58
125
  // Compare on numeric timestamps in the loop — Date-object comparisons
59
126
  // re-coerce through valueOf on every check
60
- const rangeStartMs = rangeStart.getTime();
61
- const rangeEndMs = rangeEnd.getTime();
62
127
  const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
63
128
  let currentMs = currentDate.getTime();
64
129
 
65
- while (currentMs <= rangeEndMs && count < maxOccurrences) {
130
+ // Sub-daily rules step a fixed number of milliseconds, so the span
131
+ // before the range is skipped arithmetically instead of one step at a
132
+ // time; other frequencies take few enough steps per year to just walk
133
+ const stepMs = this._fixedStepMs(rule);
134
+ if (stepMs > 0 && currentMs < rangeStartMs) {
135
+ const seek = this._seekFixedStep(
136
+ currentMs,
137
+ rangeStartMs,
138
+ rangeEndMs,
139
+ stepMs,
140
+ rule.count ? rule.count - 1 : Infinity,
141
+ cursor => this._advanceInPlace(cursor, rule)
142
+ );
143
+ currentMs = seek.ms;
144
+ count = seek.steps;
145
+ currentDate.setTime(currentMs);
146
+ }
147
+
148
+ let iterations = 0;
149
+ while (
150
+ currentMs <= rangeEndMs &&
151
+ occurrences.length < maxOccurrences &&
152
+ iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
153
+ ) {
154
+ iterations++;
66
155
  // Check if this occurrence is within the range
67
156
  if (currentMs >= rangeStartMs) {
68
157
  const occurrenceStart = new Date(currentMs);
@@ -113,14 +202,355 @@ export class RecurrenceEngine {
113
202
  }
114
203
  }
115
204
 
116
- // Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
117
- if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
118
- return this._applyBySetPos(occurrences, rule);
205
+ return occurrences;
206
+ }
207
+
208
+ /**
209
+ * Numeric expansion loop for DAILY and WEEKLY rules.
210
+ *
211
+ * Between DST transitions a wall-clock-preserving day step is a constant
212
+ * number of milliseconds, so the loop is pure numeric addition. Transition
213
+ * instants — in the system timezone (which defines Date arithmetic) and in
214
+ * the event's timezone (which drives occurrence adjustment) — are
215
+ * discovered by binary search and cached, so only the one step that
216
+ * crosses a transition falls back to Date arithmetic, and only the first
217
+ * occurrence after a transition queries a timezone offset.
218
+ *
219
+ * Produces output identical to _expandGeneral for the rules it accepts;
220
+ * returns null to delegate anything it cannot handle exactly.
221
+ * @private
222
+ */
223
+ static _expandFast(
224
+ event,
225
+ rule,
226
+ rangeStartMs,
227
+ rangeEndMs,
228
+ maxOccurrences,
229
+ eventTimezone,
230
+ tzManager,
231
+ duration
232
+ ) {
233
+ const DAY = 86400000;
234
+ let dayDeltas = null;
235
+ let stepDays = 0;
236
+ let weekday = 0;
237
+
238
+ const startDate = new Date(event.start);
239
+ if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
240
+ const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
241
+ if (daySet.size === 0) {
242
+ return null; // invalid byDay — general loop handles the fallback warning
243
+ }
244
+ dayDeltas = rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
245
+ weekday = startDate.getDay();
246
+ } else {
247
+ stepDays = (rule.freq === 'DAILY' ? 1 : 7) * rule.interval;
248
+ if (!Number.isInteger(stepDays) || stepDays <= 0) {
249
+ return null; // degenerate interval — general loop's stuck detection applies
250
+ }
251
+ }
252
+
253
+ const occurrences = [];
254
+ let currentMs = startDate.getTime();
255
+ if (Number.isNaN(currentMs)) {
256
+ return null;
257
+ }
258
+ let count = 0;
259
+ const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
260
+
261
+ let lastOffset = tzManager.getTimezoneOffset(startDate, eventTimezone);
262
+ let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
263
+ let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
264
+
265
+ // Seek to the last occurrence before the range. nextEventTzTransition is
266
+ // deliberately left as computed from DTSTART: if the seek passed an
267
+ // event-timezone transition, the first in-range occurrence must still
268
+ // compare its offset with lastOffset, exactly as the general loop does.
269
+ if (currentMs < rangeStartMs) {
270
+ const maxSteps = rule.count ? rule.count - 1 : Infinity;
271
+ const seek = dayDeltas
272
+ ? this._seekWeekCycle(currentMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps)
273
+ : this._seekFixedStep(
274
+ currentMs,
275
+ rangeStartMs,
276
+ rangeEndMs,
277
+ stepDays * DAY,
278
+ maxSteps,
279
+ cursor => cursor.setDate(cursor.getDate() + stepDays)
280
+ );
281
+ currentMs = seek.ms;
282
+ count = seek.steps;
283
+ nextSystemTransition = seek.nextSystemTransition;
284
+ if (dayDeltas) {
285
+ weekday = seek.weekday;
286
+ }
287
+ }
288
+
289
+ let iterations = 0;
290
+ while (
291
+ currentMs <= rangeEndMs &&
292
+ occurrences.length < maxOccurrences &&
293
+ iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
294
+ ) {
295
+ iterations++;
296
+ if (currentMs >= rangeStartMs) {
297
+ const occurrenceStart = new Date(currentMs);
298
+ const occurrenceEnd = new Date(currentMs + duration);
299
+
300
+ // Only the first occurrence past a transition needs an offset check
301
+ if (currentMs >= nextEventTzTransition) {
302
+ const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
303
+ if (currentOffset !== lastOffset) {
304
+ const offsetDiff = lastOffset - currentOffset;
305
+ occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
306
+ occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
307
+ lastOffset = currentOffset;
308
+ }
309
+ nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
310
+ }
311
+
312
+ if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
313
+ occurrences.push({
314
+ start: occurrenceStart,
315
+ end: occurrenceEnd,
316
+ recurringEventId: event.id,
317
+ timezone: eventTimezone,
318
+ originalStart: event.start
319
+ });
320
+ }
321
+ }
322
+
323
+ // Advance: pure addition unless the step crosses a system-timezone
324
+ // transition, where Date arithmetic reproduces wall-clock semantics
325
+ const days = dayDeltas ? dayDeltas[weekday] : stepDays;
326
+ if (dayDeltas) {
327
+ weekday = (weekday + days) % 7;
328
+ }
329
+ const naiveMs = currentMs + days * DAY;
330
+ if (naiveMs >= nextSystemTransition) {
331
+ const cursor = new Date(currentMs);
332
+ cursor.setDate(cursor.getDate() + days);
333
+ currentMs = cursor.getTime();
334
+ nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
335
+ } else {
336
+ currentMs = naiveMs;
337
+ }
338
+ count++;
339
+
340
+ if (rule.count && count >= rule.count) {
341
+ break;
342
+ }
119
343
  }
120
344
 
121
345
  return occurrences;
122
346
  }
123
347
 
348
+ /**
349
+ * Milliseconds per step for rules whose step is a fixed duration while
350
+ * the system UTC offset is constant. Only the sub-daily frequencies are
351
+ * reported here: DAILY and WEEKLY have their own numeric loop, and the
352
+ * calendar-based frequencies take too few steps per year to need seeking.
353
+ * @param {Object} rule - Parsed recurrence rule
354
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
355
+ * @private
356
+ */
357
+ static _fixedStepMs(rule) {
358
+ const interval = rule.interval;
359
+ if (!Number.isInteger(interval) || interval <= 0) {
360
+ return 0;
361
+ }
362
+ switch (rule.freq) {
363
+ case 'SECONDLY':
364
+ return interval * 1000;
365
+ case 'MINUTELY':
366
+ return interval * 60000;
367
+ case 'HOURLY':
368
+ return interval * 3600000;
369
+ default:
370
+ return 0;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Skip the occurrences of a fixed-step rule that fall before
376
+ * rangeStartMs without visiting each one.
377
+ *
378
+ * While the system UTC offset is constant, a wall-clock step of the
379
+ * cursor is a constant number of milliseconds, so a whole run of steps
380
+ * collapses into one multiplication. The single step that crosses a
381
+ * system-timezone transition is taken with `advance` instead, so the
382
+ * cursor ends up exactly where stepping every occurrence would have put
383
+ * it. Stops at the last occurrence before rangeStartMs; the caller's loop
384
+ * takes the step into the range.
385
+ *
386
+ * @param {number} fromMs - Cursor position (an occurrence instant)
387
+ * @param {number} rangeStartMs - Seek target
388
+ * @param {number} rangeEndMs - Upper bound for transition lookup
389
+ * @param {number} stepMs - Step length while the UTC offset is constant
390
+ * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
391
+ * @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor
392
+ * @returns {{ ms: number, steps: number, nextSystemTransition: number }}
393
+ * Cursor position, steps taken and the next system transition after it
394
+ * @private
395
+ */
396
+ static _seekFixedStep(fromMs, rangeStartMs, rangeEndMs, stepMs, maxSteps, advance) {
397
+ let ms = fromMs;
398
+ let steps = 0;
399
+ let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
400
+ if (!Number.isFinite(rangeStartMs) || !(stepMs > 0)) {
401
+ return { ms, steps, nextSystemTransition };
402
+ }
403
+ // Comparisons are written so an invalid (NaN) cursor ends the seek
404
+ while (ms < rangeStartMs && steps < maxSteps) {
405
+ const limit = Math.min(rangeStartMs, nextSystemTransition);
406
+ // Largest k with ms + k * stepMs < limit
407
+ let k = Math.ceil((limit - ms) / stepMs) - 1;
408
+ if (ms + k * stepMs >= limit) {
409
+ k--; // division rounded up
410
+ }
411
+ k = Math.min(k, maxSteps - steps);
412
+ if (k > 0) {
413
+ ms += k * stepMs;
414
+ steps += k;
415
+ continue;
416
+ }
417
+ if (ms + stepMs >= rangeStartMs) {
418
+ break; // next step lands in the range
419
+ }
420
+ // Next step crosses a system-timezone transition
421
+ const cursor = new Date(ms);
422
+ advance(cursor);
423
+ ms = cursor.getTime();
424
+ steps++;
425
+ nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
426
+ }
427
+ return { ms, steps, nextSystemTransition };
428
+ }
429
+
430
+ /**
431
+ * Seek for WEEKLY BYDAY rules, whose step pattern repeats every week:
432
+ * whole weeks are skipped arithmetically from any weekday in the BYDAY
433
+ * set, and single steps (identical to the expansion loop's) are only
434
+ * taken to reach the set, around system-timezone transitions and in the
435
+ * last week before the range.
436
+ *
437
+ * @param {number} fromMs - Cursor position (an occurrence instant)
438
+ * @param {number} weekday - Weekday of the cursor (Date#getDay)
439
+ * @param {number} rangeStartMs - Seek target
440
+ * @param {number} rangeEndMs - Upper bound for transition lookup
441
+ * @param {Object} rule - Parsed rule with compiled _byDaySet/_byDayDeltas
442
+ * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
443
+ * @returns {{ ms: number, steps: number, weekday: number, nextSystemTransition: number }}
444
+ * @private
445
+ */
446
+ static _seekWeekCycle(fromMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps) {
447
+ const DAY = 86400000;
448
+ const WEEK = 7 * DAY;
449
+ const daySet = rule._byDaySet;
450
+ const dayDeltas = rule._byDayDeltas;
451
+ const stepsPerWeek = daySet.size;
452
+ let ms = fromMs;
453
+ let steps = 0;
454
+ let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
455
+ if (!Number.isFinite(rangeStartMs)) {
456
+ return { ms, steps, weekday, nextSystemTransition };
457
+ }
458
+ while (ms < rangeStartMs && steps < maxSteps) {
459
+ // A week from a weekday in the set is exactly stepsPerWeek steps and
460
+ // returns to the same weekday
461
+ if (daySet.has(weekday)) {
462
+ const limit = Math.min(rangeStartMs, nextSystemTransition);
463
+ let weeks = Math.ceil((limit - ms) / WEEK) - 1;
464
+ if (ms + weeks * WEEK >= limit) {
465
+ weeks--;
466
+ }
467
+ weeks = Math.min(weeks, Math.floor((maxSteps - steps) / stepsPerWeek));
468
+ if (weeks > 0) {
469
+ ms += weeks * WEEK;
470
+ steps += weeks * stepsPerWeek;
471
+ continue;
472
+ }
473
+ }
474
+ // Single step, identical to the expansion loop
475
+ const days = dayDeltas[weekday];
476
+ const naiveMs = ms + days * DAY;
477
+ if (naiveMs >= rangeStartMs) {
478
+ break; // next step lands in the range
479
+ }
480
+ weekday = (weekday + days) % 7;
481
+ if (naiveMs >= nextSystemTransition) {
482
+ const cursor = new Date(ms);
483
+ cursor.setDate(cursor.getDate() + days);
484
+ ms = cursor.getTime();
485
+ nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
486
+ } else {
487
+ ms = naiveMs;
488
+ }
489
+ steps++;
490
+ }
491
+ return { ms, steps, weekday, nextSystemTransition };
492
+ }
493
+
494
+ /**
495
+ * Find the next system-timezone offset transition after fromMs.
496
+ * Cached module-wide: the system timezone is fixed for the process.
497
+ * @param {number} fromMs - Search from this timestamp (exclusive)
498
+ * @param {number} toMs - Extend cache coverage at least this far
499
+ * @returns {number} Transition timestamp, or Infinity if none within coverage
500
+ * @private
501
+ */
502
+ static _nextSystemTransition(fromMs, toMs) {
503
+ if (fromMs >= toMs) {
504
+ return Infinity;
505
+ }
506
+ let cache = this._systemTransitions;
507
+ if (!cache || fromMs < cache.from || toMs > cache.to) {
508
+ const from = Math.min(fromMs, cache ? cache.from : fromMs);
509
+ const to = Math.max(toMs, cache ? cache.to : toMs);
510
+ cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
511
+ this._systemTransitions = cache;
512
+ }
513
+ for (const t of cache.transitions) {
514
+ if (t > fromMs) {
515
+ return t;
516
+ }
517
+ }
518
+ return Infinity;
519
+ }
520
+
521
+ /**
522
+ * Scan for system-timezone offset transitions via Date#getTimezoneOffset.
523
+ * Probes weekly (shorter than any real-world gap between transitions)
524
+ * and binary-searches each change to the exact millisecond.
525
+ * @private
526
+ */
527
+ static _scanSystemTransitions(fromMs, toMs) {
528
+ const WEEK = 7 * 86400000;
529
+ const transitions = [];
530
+ let lo = fromMs;
531
+ let loOffset = new Date(lo).getTimezoneOffset();
532
+ while (lo < toMs) {
533
+ const hi = Math.min(lo + WEEK, toMs);
534
+ const hiOffset = new Date(hi).getTimezoneOffset();
535
+ if (hiOffset !== loOffset) {
536
+ let a = lo;
537
+ let b = hi;
538
+ while (b - a > 1) {
539
+ const mid = Math.floor((a + b) / 2);
540
+ if (new Date(mid).getTimezoneOffset() === loOffset) {
541
+ a = mid;
542
+ } else {
543
+ b = mid;
544
+ }
545
+ }
546
+ transitions.push(b);
547
+ loOffset = hiOffset;
548
+ }
549
+ lo = hi;
550
+ }
551
+ return transitions;
552
+ }
553
+
124
554
  /**
125
555
  * Apply BYSETPOS to filter occurrences within each frequency period
126
556
  * @param {Array} occurrences - Generated occurrences
@@ -4,12 +4,20 @@
4
4
  */
5
5
 
6
6
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
7
+ import { RecurrenceEngine } from './RecurrenceEngine.js';
7
8
  import { RRuleParser } from './RRuleParser.js';
8
9
 
10
+ const DAY = 86400000;
11
+
9
12
  export class RecurrenceEngineV2 {
10
13
  // Hard limit to prevent resource exhaustion regardless of caller input
11
14
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
15
 
16
+ // Hard limit on expansion loop iterations (every occurrence stepped
17
+ // through, inside the range or not) so a rule that cannot be seeked
18
+ // arithmetically still terminates in bounded time
19
+ static MAX_ITERATIONS_HARD_LIMIT = 100000;
20
+
13
21
  constructor() {
14
22
  // Use singleton to share cache across all components
15
23
  this.tzManager = TimezoneManager.getInstance();
@@ -27,10 +35,22 @@ export class RecurrenceEngineV2 {
27
35
 
28
36
  /**
29
37
  * Expand recurring event with advanced handling
30
- * @param {Event} event - Recurring event
38
+ *
39
+ * Occurrences before rangeStart are skipped without being generated:
40
+ * daily, weekly, hourly and minutely rules seek straight to the range, so
41
+ * a series that started years before the queried window is expanded at
42
+ * the same cost as one that started yesterday.
43
+ *
44
+ * @param {import('./Event.js').Event} event - Recurring event
31
45
  * @param {Date} rangeStart - Start of expansion range
32
46
  * @param {Date} rangeEnd - End of expansion range
33
47
  * @param {Object} options - Expansion options
48
+ * @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
49
+ * return. Only occurrences inside the range count towards this limit.
50
+ * @param {boolean} [options.includeModified=true] - Apply stored instance modifications
51
+ * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
52
+ * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
53
+ * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
34
54
  * @returns {Array} Expanded occurrences
35
55
  */
36
56
  expandEvent(event, rangeStart, rangeEnd, options = {}) {
@@ -59,7 +79,8 @@ export class RecurrenceEngineV2 {
59
79
  const occurrences = [];
60
80
  const duration = event.end - event.start;
61
81
 
62
- // Initialize expansion state
82
+ // Initialize expansion state. `count` is the number of steps taken from
83
+ // DTSTART, which is what RFC 5545 COUNT measures.
63
84
  const state = {
64
85
  currentDate: new Date(event.start),
65
86
  count: 0,
@@ -73,8 +94,16 @@ export class RecurrenceEngineV2 {
73
94
  state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
74
95
  }
75
96
 
97
+ this.seekToRange(state, rule, rangeStart, rangeEnd, timezone);
98
+
76
99
  // Expand occurrences
77
- while (state.currentDate <= rangeEnd && state.count < maxOccurrences) {
100
+ let iterations = 0;
101
+ while (
102
+ state.currentDate <= rangeEnd &&
103
+ occurrences.length < maxOccurrences &&
104
+ iterations < RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT
105
+ ) {
106
+ iterations++;
78
107
  if (state.currentDate >= rangeStart) {
79
108
  const occurrence = this.generateOccurrence(
80
109
  event,
@@ -144,6 +173,77 @@ export class RecurrenceEngineV2 {
144
173
  return this.cloneOccurrences(occurrences);
145
174
  }
146
175
 
176
+ /**
177
+ * Move the expansion cursor to the last occurrence before the range
178
+ * without stepping through every occurrence in between.
179
+ *
180
+ * Applies to rules whose step is a fixed duration between system-timezone
181
+ * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
182
+ * crosses a transition is taken with getNextDate so the result is exactly
183
+ * what stepping from DTSTART would produce. Never seeks past UNTIL, and
184
+ * counts skipped steps against COUNT.
185
+ *
186
+ * @param {Object} state - Expansion state (currentDate and count are updated)
187
+ * @param {Object} rule - Parsed recurrence rule
188
+ * @param {Date} rangeStart - Start of expansion range
189
+ * @param {Date} rangeEnd - End of expansion range
190
+ * @param {string} timezone - Expansion timezone
191
+ */
192
+ seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
193
+ const stepMs = this.getFixedStepMs(rule);
194
+ if (stepMs <= 0) {
195
+ return;
196
+ }
197
+ let targetMs = rangeStart.getTime();
198
+ if (rule.until) {
199
+ // Rule objects may carry UNTIL as a string; an unparseable value
200
+ // compares false and leaves the target alone
201
+ const untilMs = new Date(rule.until).getTime();
202
+ if (untilMs < targetMs) {
203
+ targetMs = untilMs;
204
+ }
205
+ }
206
+ const fromMs = state.currentDate.getTime();
207
+ if (!(fromMs < targetMs)) {
208
+ return;
209
+ }
210
+ const seek = RecurrenceEngine._seekFixedStep(
211
+ fromMs,
212
+ targetMs,
213
+ rangeEnd.getTime(),
214
+ stepMs,
215
+ rule.count ? rule.count - 1 : Infinity,
216
+ cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime())
217
+ );
218
+ state.currentDate = new Date(seek.ms);
219
+ state.count = seek.steps;
220
+ }
221
+
222
+ /**
223
+ * Milliseconds per step for rules getNextDate advances by a fixed
224
+ * duration while the system UTC offset is constant
225
+ * @param {Object} rule - Parsed recurrence rule
226
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
227
+ */
228
+ getFixedStepMs(rule) {
229
+ const interval = rule.interval;
230
+ if (!Number.isInteger(interval) || interval <= 0) {
231
+ return 0;
232
+ }
233
+ switch (rule.freq) {
234
+ case 'DAILY':
235
+ return rule.byHour && rule.byHour.length > 0 ? 0 : interval * DAY;
236
+ case 'WEEKLY':
237
+ return rule.byDay && rule.byDay.length > 0 ? 0 : 7 * interval * DAY;
238
+ case 'HOURLY':
239
+ return interval * 3600000;
240
+ case 'MINUTELY':
241
+ return interval * 60000;
242
+ default:
243
+ return 0;
244
+ }
245
+ }
246
+
147
247
  /**
148
248
  * Generate a single occurrence with timezone handling
149
249
  */
package/core/index.js CHANGED
@@ -32,7 +32,7 @@ export { ConflictDetector } from './conflicts/ConflictDetector.js';
32
32
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
33
33
 
34
34
  // Version — keep in sync with package.json
35
- export const VERSION = '2.2.0';
35
+ export const VERSION = '2.4.0';
36
36
 
37
37
  // Default export
38
38
  export { Calendar as default } from './calendar/Calendar.js';