@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.
@@ -9,6 +9,24 @@ import { RRuleParser } from './RRuleParser.js';
9
9
 
10
10
  const DAY = 86400000;
11
11
 
12
+ // How far ahead of the iteration cursor DST transitions are scanned at a time
13
+ const DST_SCAN_CHUNK = 100 * DAY;
14
+
15
+ const WEEKDAYS = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
16
+
17
+ // An expansion cut short by MAX_ITERATIONS_HARD_LIMIT is reported once per
18
+ // process rather than on every render
19
+ let iterationLimitWarned = false;
20
+ function warnIterationLimit(eventId) {
21
+ if (iterationLimitWarned) {
22
+ return;
23
+ }
24
+ iterationLimitWarned = true;
25
+ console.warn(
26
+ `RecurrenceEngineV2: expansion of event ${eventId} stopped after ${RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT} steps without reaching the end of the range; results are truncated`
27
+ );
28
+ }
29
+
12
30
  export class RecurrenceEngineV2 {
13
31
  // Hard limit to prevent resource exhaustion regardless of caller input
14
32
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
@@ -36,10 +54,15 @@ export class RecurrenceEngineV2 {
36
54
  /**
37
55
  * Expand recurring event with advanced handling
38
56
  *
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.
57
+ * Occurrences before rangeStart are skipped without being generated for
58
+ * the rules seekToRange can seek: DAILY (without BYHOUR), WEEKLY (with or
59
+ * without BYDAY), HOURLY and MINUTELY. Such a series that started years
60
+ * before the queried window is expanded at the same cost as one that
61
+ * started yesterday. MONTHLY and YEARLY rules, and DAILY with BYHOUR,
62
+ * are stepped from DTSTART; they take few enough steps per year that
63
+ * this is cheap, but an expansion that would need more than
64
+ * MAX_ITERATIONS_HARD_LIMIT steps is truncated (with one console.warn
65
+ * per process).
43
66
  *
44
67
  * @param {import('./Event.js').Event} event - Recurring event
45
68
  * @param {Date} rangeStart - Start of expansion range
@@ -51,7 +74,7 @@ export class RecurrenceEngineV2 {
51
74
  * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
52
75
  * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
53
76
  * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
54
- * @returns {Array} Expanded occurrences
77
+ * @returns {import('../types.js').ExpandedOccurrence[]} Expanded occurrences
55
78
  */
56
79
  expandEvent(event, rangeStart, rangeEnd, options = {}) {
57
80
  const {
@@ -66,7 +89,7 @@ export class RecurrenceEngineV2 {
66
89
  const maxOccurrences = Math.min(requestedMax, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
67
90
 
68
91
  // Check cache
69
- const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options);
92
+ const cacheKey = this.getCacheKey(event, rangeStart, rangeEnd, options);
70
93
  if (this.occurrenceCache.has(cacheKey)) {
71
94
  return this.cloneOccurrences(this.occurrenceCache.get(cacheKey));
72
95
  }
@@ -105,40 +128,15 @@ export class RecurrenceEngineV2 {
105
128
  ) {
106
129
  iterations++;
107
130
  if (state.currentDate >= rangeStart) {
108
- const occurrence = this.generateOccurrence(
131
+ const occurrence = this._applyOverrides(
109
132
  event,
110
- state.currentDate,
111
- duration,
112
- timezone,
113
- state
133
+ this.generateOccurrence(event, state.currentDate, duration, timezone, state),
134
+ rule,
135
+ includeCancelled,
136
+ includeModified
114
137
  );
115
-
116
- // Check exceptions and modifications
117
138
  if (occurrence) {
118
- let shouldInclude = true;
119
-
120
- // Skip if exception
121
- if (this.isException(event.id, occurrence.start, rule)) {
122
- if (!includeCancelled) {
123
- shouldInclude = false;
124
- } else {
125
- occurrence.status = 'cancelled';
126
- occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
127
- }
128
- }
129
-
130
- // Apply modifications if any
131
- if (shouldInclude && includeModified) {
132
- const modified = this.getModifiedInstance(event.id, occurrence.start);
133
- if (modified) {
134
- Object.assign(occurrence, modified);
135
- occurrence.isModified = true;
136
- }
137
- }
138
-
139
- if (shouldInclude) {
140
- occurrences.push(occurrence);
141
- }
139
+ occurrences.push(occurrence);
142
140
  }
143
141
  }
144
142
 
@@ -167,19 +165,265 @@ export class RecurrenceEngineV2 {
167
165
  }
168
166
  }
169
167
 
168
+ if (
169
+ iterations >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT &&
170
+ state.currentDate <= rangeEnd &&
171
+ occurrences.length < maxOccurrences
172
+ ) {
173
+ warnIterationLimit(event.id);
174
+ }
175
+
170
176
  // Cache results
171
177
  this.cacheOccurrences(cacheKey, occurrences);
172
178
 
173
179
  return this.cloneOccurrences(occurrences);
174
180
  }
175
181
 
182
+ /**
183
+ * Lazily iterate the occurrences of an event in chronological order.
184
+ *
185
+ * Yields what expandEvent returns for the window, one occurrence at a
186
+ * time and without the expansion cache: stored instance modifications
187
+ * and exceptions are applied as each occurrence is produced, so changes
188
+ * made through addModifiedInstance or addException are visible on the
189
+ * next pull. Rules seekToRange can seek (daily, weekly, hourly,
190
+ * minutely) jump straight to `after`, and DST transitions are scanned
191
+ * lazily ahead of the cursor instead of for the whole window up front.
192
+ *
193
+ * Both bounds are exclusive unless `inclusive` is set: an occurrence that
194
+ * starts exactly at `after` or `before` is skipped by default, so
195
+ * iterating from a known occurrence's start continues the series without
196
+ * repeating it; with `inclusive: true` the window is closed on both ends
197
+ * like expandEvent's range. A non-recurring event yields its single
198
+ * occurrence when it falls inside the window. Iteration ends at COUNT or
199
+ * UNTIL, at `before`, or — as a guard for rules that produce no
200
+ * occurrences — after MAX_ITERATIONS_HARD_LIMIT consecutive steps
201
+ * without one. The generator is single-use; call again for a fresh one.
202
+ *
203
+ * @example
204
+ * const engine = new RecurrenceEngineV2();
205
+ * for (const occurrence of engine.iterateOccurrences(event, { after: new Date() })) {
206
+ * if (occurrence.start > deadline) break;
207
+ * schedule(occurrence);
208
+ * }
209
+ *
210
+ * @param {import('./Event.js').Event} event - The event to iterate
211
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
212
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>} Occurrences in chronological order
213
+ * @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
214
+ */
215
+ iterateOccurrences(event, options = {}) {
216
+ const window = RecurrenceEngine._occurrenceWindow(options);
217
+ if (!event.recurring || !event.recurrenceRule) {
218
+ return this._iterateSingle(event, window);
219
+ }
220
+ const {
221
+ includeModified = true,
222
+ includeCancelled = false,
223
+ timezone = event.timeZone || 'UTC',
224
+ handleDST = true
225
+ } = options;
226
+ return this._iterateRule(event, RRuleParser.parse(event.recurrenceRule), window, {
227
+ includeModified,
228
+ includeCancelled,
229
+ timezone,
230
+ handleDST
231
+ });
232
+ }
233
+
234
+ /**
235
+ * First occurrence of an event after an instant, or null when the series
236
+ * has no occurrence after it. `after` is exclusive unless
237
+ * `options.inclusive` is set, so passing the start of a known occurrence
238
+ * returns the one that follows it.
239
+ *
240
+ * @example
241
+ * const upcoming = engine.nextOccurrence(event, new Date());
242
+ *
243
+ * @param {import('./Event.js').Event} event - The event to query
244
+ * @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
245
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Further options
246
+ * @returns {import('../types.js').ExpandedOccurrence|null} The next occurrence, or null
247
+ */
248
+ nextOccurrence(event, after = null, options = {}) {
249
+ for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
250
+ return occurrence;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ /**
256
+ * The first `count` occurrences of an event inside a window, generated
257
+ * lazily so an open-ended series costs only the occurrences taken.
258
+ * `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
259
+ * when the series or the window ends first.
260
+ *
261
+ * @example
262
+ * const nextFive = engine.takeOccurrences(event, 5, { after: new Date() });
263
+ *
264
+ * @param {import('./Event.js').Event} event - The event to query
265
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
266
+ * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
267
+ * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
268
+ */
269
+ takeOccurrences(event, count, options = {}) {
270
+ const limit = Math.floor(Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT));
271
+ const taken = [];
272
+ if (!(limit > 0)) {
273
+ return taken;
274
+ }
275
+ for (const occurrence of this.iterateOccurrences(event, options)) {
276
+ taken.push(occurrence);
277
+ if (taken.length >= limit) {
278
+ break;
279
+ }
280
+ }
281
+ return taken;
282
+ }
283
+
284
+ /**
285
+ * Yield a non-recurring event's single occurrence if it starts inside
286
+ * the window
287
+ * @param {import('./Event.js').Event} event - The event
288
+ * @param {{ startMs: number, endMs: number }} window - Inclusive bounds
289
+ * @returns {Generator<import('../types.js').ExpandedOccurrence, void, undefined>}
290
+ * @private
291
+ */
292
+ *_iterateSingle(event, window) {
293
+ const ms = new Date(event.start).getTime();
294
+ if (ms >= window.startMs && ms <= window.endMs) {
295
+ yield this.cloneOccurrence(this.createOccurrence(event, event.start, event.end));
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Lazy counterpart of the expandEvent loop: seeks to the window, then
301
+ * steps the cursor and yields each in-window occurrence with the same
302
+ * DST adjustment, exception handling and instance modifications.
303
+ * @private
304
+ */
305
+ *_iterateRule(event, rule, window, options) {
306
+ const { includeModified, includeCancelled, timezone, handleDST } = options;
307
+ const duration = event.end - event.start;
308
+ const state = {
309
+ currentDate: new Date(event.start),
310
+ count: 0,
311
+ tzOffsets: new Map(),
312
+ dstTransitions: [],
313
+ stuckIterations: 0
314
+ };
315
+ if (Number.isNaN(state.currentDate.getTime()) || window.startMs > window.endMs) {
316
+ return;
317
+ }
318
+
319
+ // DST transitions are found on the same day grid expandEvent walks,
320
+ // starting from the window start (DTSTART for an open window) and
321
+ // extended in chunks ahead of the cursor
322
+ let dstScan = null;
323
+ if (handleDST) {
324
+ const scanStart = Number.isFinite(window.startMs)
325
+ ? window.startMs
326
+ : state.currentDate.getTime();
327
+ dstScan = { cursor: new Date(scanStart), lastOffset: 0 };
328
+ dstScan.lastOffset = this.tzManager.getTimezoneOffset(dstScan.cursor, timezone);
329
+ }
330
+
331
+ if (Number.isFinite(window.startMs)) {
332
+ const rangeStart = new Date(window.startMs);
333
+ this.seekToRange(state, rule, rangeStart, rangeStart, timezone);
334
+ }
335
+
336
+ let idleSteps = 0;
337
+ while (state.currentDate.getTime() <= window.endMs) {
338
+ const currentMs = state.currentDate.getTime();
339
+ if (currentMs >= window.startMs) {
340
+ if (dstScan) {
341
+ this._scanDSTTransitions(
342
+ dstScan,
343
+ state.dstTransitions,
344
+ Math.min(currentMs + DST_SCAN_CHUNK, window.endMs),
345
+ timezone
346
+ );
347
+ }
348
+ const occurrence = this._applyOverrides(
349
+ event,
350
+ this.generateOccurrence(event, state.currentDate, duration, timezone, state),
351
+ rule,
352
+ includeCancelled,
353
+ includeModified
354
+ );
355
+ if (occurrence) {
356
+ idleSteps = 0;
357
+ yield this.cloneOccurrence(occurrence);
358
+ }
359
+ }
360
+
361
+ state.currentDate = this.getNextDate(state.currentDate, rule, timezone, state);
362
+ state.count++;
363
+
364
+ if (state.currentDate.getTime() <= currentMs) {
365
+ state.stuckIterations++;
366
+ if (state.stuckIterations >= 3) {
367
+ return;
368
+ }
369
+ } else {
370
+ state.stuckIterations = 0;
371
+ }
372
+
373
+ if (rule.count && state.count >= rule.count) {
374
+ return;
375
+ }
376
+ if (rule.until && state.currentDate > rule.until) {
377
+ return;
378
+ }
379
+ idleSteps++;
380
+ if (idleSteps >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT) {
381
+ warnIterationLimit(event.id);
382
+ return;
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Apply exceptions and stored instance modifications to a generated
389
+ * occurrence
390
+ * @param {import('./Event.js').Event} event - The recurring event
391
+ * @param {Object} occurrence - Occurrence from generateOccurrence
392
+ * @param {Object} rule - Parsed recurrence rule
393
+ * @param {boolean} includeCancelled - Return exception dates as cancelled occurrences
394
+ * @param {boolean} includeModified - Apply stored instance modifications
395
+ * @returns {Object|null} The occurrence, or null when it is excluded
396
+ * @private
397
+ */
398
+ _applyOverrides(event, occurrence, rule, includeCancelled, includeModified) {
399
+ if (!occurrence) {
400
+ return null;
401
+ }
402
+ if (this.isException(event.id, occurrence.start, rule)) {
403
+ if (!includeCancelled) {
404
+ return null;
405
+ }
406
+ occurrence.status = 'cancelled';
407
+ occurrence.cancellationReason = this.getExceptionReason(event.id, occurrence.start);
408
+ }
409
+ if (includeModified) {
410
+ const modified = this.getModifiedInstance(event.id, occurrence.start);
411
+ if (modified) {
412
+ Object.assign(occurrence, modified);
413
+ occurrence.isModified = true;
414
+ }
415
+ }
416
+ return occurrence;
417
+ }
418
+
176
419
  /**
177
420
  * Move the expansion cursor to the last occurrence before the range
178
421
  * without stepping through every occurrence in between.
179
422
  *
180
423
  * 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
424
+ * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY) and to WEEKLY
425
+ * rules with BYDAY, whose steps repeat in a weekly cycle; the steps that
426
+ * cross a transition are taken with getNextDate so the result is exactly
183
427
  * what stepping from DTSTART would produce. Never seeks past UNTIL, and
184
428
  * counts skipped steps against COUNT.
185
429
  *
@@ -190,6 +434,10 @@ export class RecurrenceEngineV2 {
190
434
  * @param {string} timezone - Expansion timezone
191
435
  */
192
436
  seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
437
+ if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
438
+ this._seekWeekCycle(state, rule, rangeStart, rangeEnd, timezone);
439
+ return;
440
+ }
193
441
  const stepMs = this.getFixedStepMs(rule);
194
442
  if (stepMs <= 0) {
195
443
  return;
@@ -219,6 +467,127 @@ export class RecurrenceEngineV2 {
219
467
  state.count = seek.steps;
220
468
  }
221
469
 
470
+ /**
471
+ * Seek for WEEKLY rules with BYDAY. getNextWeekly picks the next weekday
472
+ * from the BYDAY list (in list order), so the step from each weekday is
473
+ * fixed and the walk from DTSTART settles into a cycle of weekdays that
474
+ * repeats every whole number of weeks. The cursor is stepped one
475
+ * occurrence at a time until it is on that cycle (at most six steps),
476
+ * then whole cycles are skipped arithmetically between system-timezone
477
+ * transitions, exactly as seekToRange does for fixed steps.
478
+ * @param {Object} state - Expansion state (currentDate and count are updated)
479
+ * @param {Object} rule - Parsed recurrence rule
480
+ * @param {Date} rangeStart - Start of expansion range
481
+ * @param {Date} rangeEnd - End of expansion range
482
+ * @param {string} timezone - Expansion timezone
483
+ * @private
484
+ */
485
+ _seekWeekCycle(state, rule, rangeStart, rangeEnd, timezone) {
486
+ const deltas = this._weekdayDeltas(rule);
487
+ if (!deltas) {
488
+ return;
489
+ }
490
+ let targetMs = rangeStart.getTime();
491
+ if (rule.until) {
492
+ const untilMs = new Date(rule.until).getTime();
493
+ if (untilMs < targetMs) {
494
+ targetMs = untilMs;
495
+ }
496
+ }
497
+ if (!(state.currentDate.getTime() < targetMs)) {
498
+ return;
499
+ }
500
+ const maxSteps = rule.count ? rule.count - 1 : Infinity;
501
+
502
+ // Follow the weekday graph from the cursor until a weekday repeats:
503
+ // the steps before the repeat lead in to the cycle
504
+ const path = [];
505
+ const seen = new Map();
506
+ let weekday = state.currentDate.getDay();
507
+ while (!seen.has(weekday)) {
508
+ seen.set(weekday, path.length);
509
+ path.push(weekday);
510
+ weekday = (weekday + deltas[weekday]) % 7;
511
+ }
512
+ const leadIn = seen.get(weekday);
513
+ const cycleSteps = path.length - leadIn;
514
+ let cycleDays = 0;
515
+ for (let i = leadIn; i < path.length; i++) {
516
+ cycleDays += deltas[path[i]];
517
+ }
518
+
519
+ // Lead-in: single steps, identical to the expansion loop's
520
+ for (let i = 0; i < leadIn; i++) {
521
+ if (state.count >= maxSteps) {
522
+ return;
523
+ }
524
+ const next = this.getNextDate(state.currentDate, rule, timezone, state);
525
+ if (!(next.getTime() < targetMs)) {
526
+ return; // the next step lands in the range; the loop takes it
527
+ }
528
+ state.currentDate = next;
529
+ state.count++;
530
+ }
531
+
532
+ const seek = RecurrenceEngine._seekFixedStep(
533
+ state.currentDate.getTime(),
534
+ targetMs,
535
+ rangeEnd.getTime(),
536
+ cycleDays * DAY,
537
+ Math.floor((maxSteps - state.count) / cycleSteps),
538
+ cursor => {
539
+ for (let i = 0; i < cycleSteps; i++) {
540
+ cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime());
541
+ }
542
+ }
543
+ );
544
+ state.currentDate = new Date(seek.ms);
545
+ state.count += seek.steps * cycleSteps;
546
+ }
547
+
548
+ /**
549
+ * Days getNextWeekly adds from each weekday (index 0-6) for a WEEKLY
550
+ * rule with BYDAY, or null when the rule cannot be seeked (an invalid
551
+ * interval or day code, which the expansion loop handles as before)
552
+ * @param {Object} rule - Parsed recurrence rule
553
+ * @returns {number[]|null} Delta table indexed by Date#getDay()
554
+ * @private
555
+ */
556
+ _weekdayDeltas(rule) {
557
+ const interval = rule.interval;
558
+ if (!Number.isInteger(interval) || interval <= 0) {
559
+ return null;
560
+ }
561
+ const targets = this._weekdayTargets(rule);
562
+ if (targets.some(target => target === undefined)) {
563
+ return null;
564
+ }
565
+ const deltas = [];
566
+ for (let weekday = 0; weekday < 7; weekday++) {
567
+ const next = targets.find(target => target > weekday);
568
+ deltas[weekday] =
569
+ next !== undefined ? next - weekday : 7 - weekday + targets[0] + 7 * (interval - 1);
570
+ }
571
+ return deltas;
572
+ }
573
+
574
+ /**
575
+ * Weekday numbers (Date#getDay) of a rule's BYDAY entries in ascending
576
+ * order, computed once per parsed rule. Invalid day codes map to
577
+ * undefined and sort last.
578
+ * @param {Object} rule - Parsed recurrence rule with byDay
579
+ * @returns {number[]} Sorted weekday numbers
580
+ * @private
581
+ */
582
+ _weekdayTargets(rule) {
583
+ if (!rule._weekdayTargets) {
584
+ rule._weekdayTargets = rule.byDay
585
+ .map(byDay => WEEKDAYS[byDay.weekday || byDay])
586
+ .sort((a, b) => (a === undefined) - (b === undefined) || a - b);
587
+ }
588
+ return rule._weekdayTargets;
589
+ }
590
+
222
591
  /**
223
592
  * Milliseconds per step for rules getNextDate advances by a fixed
224
593
  * duration while the system UTC offset is constant
@@ -342,33 +711,16 @@ export class RecurrenceEngineV2 {
342
711
  const next = new Date(date);
343
712
 
344
713
  if (rule.byDay && rule.byDay.length > 0) {
345
- // Find next matching weekday
346
- const dayMap = {
347
- SU: 0,
348
- MO: 1,
349
- TU: 2,
350
- WE: 3,
351
- TH: 4,
352
- FR: 5,
353
- SA: 6
354
- };
355
-
714
+ // BYDAY is a set: the next weekday in it after the current one, or the
715
+ // earliest one INTERVAL weeks on when the week has none left
716
+ const targets = this._weekdayTargets(rule);
356
717
  const currentDay = next.getDay();
357
- let daysToAdd = null;
358
-
359
- // Find next occurrence day
360
- for (const byDay of rule.byDay) {
361
- const targetDay = dayMap[byDay.weekday || byDay];
362
- if (targetDay > currentDay) {
363
- daysToAdd = targetDay - currentDay;
364
- break;
365
- }
366
- }
367
-
368
- // If no day found in current week, go to next week
369
- if (daysToAdd === null) {
370
- const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]];
371
- daysToAdd = 7 - currentDay + firstDay;
718
+ const nextDay = targets.find(target => target > currentDay);
719
+ let daysToAdd;
720
+ if (nextDay !== undefined) {
721
+ daysToAdd = nextDay - currentDay;
722
+ } else {
723
+ daysToAdd = 7 - currentDay + targets[0];
372
724
 
373
725
  // Apply interval for weekly recurrence
374
726
  if (rule.interval > 1) {
@@ -401,15 +753,17 @@ export class RecurrenceEngineV2 {
401
753
  // Found a day in current month
402
754
  next.setDate(targetDay);
403
755
  } else {
404
- // Move to next month
405
- next.setMonth(next.getMonth() + rule.interval);
406
-
407
- // Handle negative days (from end of month)
408
756
  targetDay = targetDays[0];
409
757
  if (targetDay < 0) {
758
+ // Counted from the end of the month (-1 is the last day). Move to
759
+ // the first so the month step cannot overflow from a 31st.
760
+ next.setDate(1);
761
+ next.setMonth(next.getMonth() + rule.interval);
410
762
  const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
411
- next.setDate(lastDay + targetDay + 1);
763
+ next.setDate(Math.max(1, lastDay + targetDay + 1));
412
764
  } else {
765
+ // Move to next month
766
+ next.setMonth(next.getMonth() + rule.interval);
413
767
  next.setDate(targetDay);
414
768
  }
415
769
  }
@@ -573,28 +927,38 @@ export class RecurrenceEngineV2 {
573
927
  */
574
928
  findDSTTransitions(start, end, timezone) {
575
929
  const transitions = [];
576
- const current = new Date(start);
577
-
578
- // Check each day for offset changes
579
- let lastOffset = this.tzManager.getTimezoneOffset(current, timezone);
930
+ const scan = { cursor: new Date(start), lastOffset: 0 };
931
+ scan.lastOffset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
932
+ this._scanDSTTransitions(scan, transitions, new Date(end).getTime(), timezone);
933
+ return transitions;
934
+ }
580
935
 
581
- while (current <= end) {
582
- const offset = this.tzManager.getTimezoneOffset(current, timezone);
936
+ /**
937
+ * Walk the scan cursor one day at a time up to untilMs, appending each
938
+ * offset change. The cursor and last offset persist in `scan`, so the
939
+ * walk can be resumed later on the same day grid.
940
+ * @param {{ cursor: Date, lastOffset: number }} scan - Resumable scan position (mutated)
941
+ * @param {Array} transitions - Transition list to append to
942
+ * @param {number} untilMs - Scan through this timestamp (inclusive)
943
+ * @param {string} timezone - Timezone to probe
944
+ * @private
945
+ */
946
+ _scanDSTTransitions(scan, transitions, untilMs, timezone) {
947
+ while (scan.cursor.getTime() <= untilMs) {
948
+ const offset = this.tzManager.getTimezoneOffset(scan.cursor, timezone);
583
949
 
584
- if (offset !== lastOffset) {
950
+ if (offset !== scan.lastOffset) {
585
951
  transitions.push({
586
- date: new Date(current),
587
- oldOffset: lastOffset,
952
+ date: new Date(scan.cursor),
953
+ oldOffset: scan.lastOffset,
588
954
  newOffset: offset,
589
- type: offset < lastOffset ? 'spring-forward' : 'fall-back'
955
+ type: offset < scan.lastOffset ? 'spring-forward' : 'fall-back'
590
956
  });
591
957
  }
592
958
 
593
- lastOffset = offset;
594
- current.setDate(current.getDate() + 1);
959
+ scan.lastOffset = offset;
960
+ scan.cursor.setDate(scan.cursor.getDate() + 1);
595
961
  }
596
-
597
- return transitions;
598
962
  }
599
963
 
600
964
  /**
@@ -720,9 +1084,45 @@ export class RecurrenceEngineV2 {
720
1084
 
721
1085
  /**
722
1086
  * Create cache key
1087
+ *
1088
+ * When given the event itself the key also covers everything the
1089
+ * expansion depends on (DTSTART, end, recurrence rule), so a series that
1090
+ * is updated, replaced or re-added under the same id can never be served
1091
+ * a stale expansion. Keys always start with `<eventId>_`, which is what
1092
+ * {@link RecurrenceEngineV2#clearEventCache} matches on.
1093
+ * @param {import('./Event.js').Event|string} event - Recurring event, or just its id
1094
+ * @param {Date} start - Start of expansion range
1095
+ * @param {Date} end - End of expansion range
1096
+ * @param {Object} options - Expansion options
1097
+ * @returns {string} Cache key
1098
+ */
1099
+ getCacheKey(event, start, end, options) {
1100
+ const eventId = typeof event === 'string' ? event : event.id;
1101
+ const key = `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`;
1102
+ if (typeof event === 'string') {
1103
+ return key;
1104
+ }
1105
+ const startMs = new Date(event.start).getTime();
1106
+ const endMs = new Date(event.end).getTime();
1107
+ return `${key}|${startMs}|${endMs}|${this._ruleFingerprint(event.recurrenceRule)}`;
1108
+ }
1109
+
1110
+ /**
1111
+ * Stable text form of a recurrence rule for cache keys. A rule that
1112
+ * cannot be serialised gets a unique fingerprint, i.e. is never cached.
1113
+ * @param {string|Object} rule - RRULE string or rule object
1114
+ * @returns {string} Fingerprint
1115
+ * @private
723
1116
  */
724
- getCacheKey(eventId, start, end, options) {
725
- return `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`;
1117
+ _ruleFingerprint(rule) {
1118
+ if (typeof rule === 'string') {
1119
+ return rule;
1120
+ }
1121
+ try {
1122
+ return JSON.stringify(rule);
1123
+ } catch {
1124
+ return `uncacheable:${Date.now()}:${Math.random()}`;
1125
+ }
726
1126
  }
727
1127
 
728
1128
  /**
@@ -742,7 +1142,16 @@ export class RecurrenceEngineV2 {
742
1142
  * Clone occurrence results before returning or caching.
743
1143
  */
744
1144
  cloneOccurrences(occurrences) {
745
- return occurrences.map(occurrence => ({
1145
+ return occurrences.map(occurrence => this.cloneOccurrence(occurrence));
1146
+ }
1147
+
1148
+ /**
1149
+ * Clone a single occurrence, copying its Date and array fields.
1150
+ * @param {import('../types.js').ExpandedOccurrence} occurrence - Occurrence to clone
1151
+ * @returns {import('../types.js').ExpandedOccurrence} Independent copy
1152
+ */
1153
+ cloneOccurrence(occurrence) {
1154
+ return {
746
1155
  ...occurrence,
747
1156
  start: occurrence.start ? new Date(occurrence.start) : occurrence.start,
748
1157
  end: occurrence.end ? new Date(occurrence.end) : occurrence.end,
@@ -754,7 +1163,7 @@ export class RecurrenceEngineV2 {
754
1163
  categories: Array.isArray(occurrence.categories)
755
1164
  ? [...occurrence.categories]
756
1165
  : occurrence.categories
757
- }));
1166
+ };
758
1167
  }
759
1168
 
760
1169
  /**