@forcecalendar/core 2.5.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.
@@ -216,10 +216,26 @@ export class RRuleParser {
216
216
  }
217
217
 
218
218
  /**
219
- * Validate and normalize rule
219
+ * Validate and normalize a rule.
220
+ *
221
+ * Works on a copy: rule objects handed in are typically the stored
222
+ * recurrenceRule of an Event, and normalising them in place would make
223
+ * the stored event differ from the data it was created from (a spurious
224
+ * update on the next reconcile) and let the engines' per-rule caches leak
225
+ * into it. The copy is shallow except for the array fields, which are
226
+ * copied as well.
227
+ * @param {Object} rule - Rule object (not modified)
228
+ * @returns {Object} Normalised copy
220
229
  * @private
221
230
  */
222
231
  static validateRule(rule) {
232
+ rule = { ...rule };
233
+ for (const field of ['byDay', 'bySetPos', 'exceptions']) {
234
+ if (Array.isArray(rule[field])) {
235
+ rule[field] = [...rule[field]];
236
+ }
237
+ }
238
+
223
239
  // Ensure frequency is set
224
240
  if (!rule.freq) {
225
241
  rule.freq = 'DAILY';
@@ -230,8 +246,8 @@ export class RRuleParser {
230
246
  throw new Error('RRULE cannot have both COUNT and UNTIL');
231
247
  }
232
248
 
233
- // Validate interval
234
- if (rule.interval < 1) {
249
+ // Validate interval (RFC 5545 default is 1; rule objects may omit it)
250
+ if (!Number.isInteger(rule.interval) || rule.interval < 1) {
235
251
  rule.interval = 1;
236
252
  }
237
253
 
@@ -4,6 +4,11 @@ import { RRuleParser } from './RRuleParser.js';
4
4
 
5
5
  const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
6
6
 
7
+ // Timezone databases know only local mean time before the 19th century, so
8
+ // a span that ends before this instant is checked at its ends rather than
9
+ // probed week by week
10
+ const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
11
+
7
12
  /**
8
13
  * RecurrenceEngine - Handles expansion of recurring events
9
14
  * Full support for RFC 5545 (iCalendar) RRULE specification
@@ -196,12 +201,12 @@ export class RecurrenceEngine {
196
201
  * const nextFive = RecurrenceEngine.takeOccurrences(event, 5, { after: new Date() });
197
202
  *
198
203
  * @param {import('./Event.js').Event} event - The event to query
199
- * @param {number} count - Maximum number of occurrences to return
204
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
200
205
  * @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
201
206
  * @returns {import('../types.js').EventOccurrence[]} Up to `count` occurrences in chronological order
202
207
  */
203
208
  static takeOccurrences(event, count, options = {}) {
204
- const limit = Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT);
209
+ const limit = Math.floor(Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT));
205
210
  const taken = [];
206
211
  if (!(limit > 0)) {
207
212
  return taken;
@@ -822,7 +827,9 @@ export class RecurrenceEngine {
822
827
 
823
828
  /**
824
829
  * Find the next system-timezone offset transition after fromMs.
825
- * Cached module-wide: the system timezone is fixed for the process.
830
+ * Cached module-wide: the system timezone is fixed for the process. The
831
+ * cache grows incrementally, so extending coverage (a view navigating
832
+ * forward, a series with a far-past DTSTART) scans only the new span.
826
833
  * @param {number} fromMs - Search from this timestamp (exclusive)
827
834
  * @param {number} toMs - Extend cache coverage at least this far
828
835
  * @returns {number} Transition timestamp, or Infinity if none within coverage
@@ -833,18 +840,34 @@ export class RecurrenceEngine {
833
840
  return Infinity;
834
841
  }
835
842
  let cache = this._systemTransitions;
836
- if (!cache || fromMs < cache.from || toMs > cache.to) {
837
- const from = Math.min(fromMs, cache ? cache.from : fromMs);
838
- const to = Math.max(toMs, cache ? cache.to : toMs);
839
- cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
843
+ if (!cache) {
844
+ cache = { from: fromMs, to: toMs, transitions: this._scanSystemTransitions(fromMs, toMs) };
840
845
  this._systemTransitions = cache;
846
+ } else {
847
+ if (fromMs < cache.from) {
848
+ cache.transitions = this._scanSystemTransitions(fromMs, cache.from).concat(
849
+ cache.transitions
850
+ );
851
+ cache.from = fromMs;
852
+ }
853
+ if (toMs > cache.to) {
854
+ cache.transitions = cache.transitions.concat(this._scanSystemTransitions(cache.to, toMs));
855
+ cache.to = toMs;
856
+ }
841
857
  }
842
- for (const t of cache.transitions) {
843
- if (t > fromMs) {
844
- return t;
858
+ // First transition after fromMs; the list is sorted
859
+ const transitions = cache.transitions;
860
+ let lo = 0;
861
+ let hi = transitions.length;
862
+ while (lo < hi) {
863
+ const mid = (lo + hi) >>> 1;
864
+ if (transitions[mid] > fromMs) {
865
+ hi = mid;
866
+ } else {
867
+ lo = mid + 1;
845
868
  }
846
869
  }
847
- return Infinity;
870
+ return lo < transitions.length ? transitions[lo] : Infinity;
848
871
  }
849
872
 
850
873
  /**
@@ -858,6 +881,12 @@ export class RecurrenceEngine {
858
881
  const transitions = [];
859
882
  let lo = fromMs;
860
883
  let loOffset = new Date(lo).getTimezoneOffset();
884
+ // The pre-tzdata era has a single offset (local mean time) and is
885
+ // skipped in one step; probing it week by week could take minutes
886
+ if (lo < PRE_TZDATA_FLOOR_MS) {
887
+ lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
888
+ loOffset = new Date(lo).getTimezoneOffset();
889
+ }
861
890
  while (lo < toMs) {
862
891
  const hi = Math.min(lo + WEEK, toMs);
863
892
  const hiOffset = new Date(hi).getTimezoneOffset();
@@ -918,8 +947,14 @@ export class RecurrenceEngine {
918
947
  switch (rule.freq) {
919
948
  case 'YEARLY':
920
949
  return occurrence.start.getFullYear();
921
- case 'WEEKLY':
922
- return `${occurrence.start.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
950
+ case 'WEEKLY': {
951
+ // ISO week-year: the week's Thursday decides the year, so the days
952
+ // of a week that straddles New Year share one period key
953
+ const thursday = new Date(occurrence.start);
954
+ thursday.setHours(0, 0, 0, 0);
955
+ thursday.setDate(thursday.getDate() + 4 - (thursday.getDay() || 7));
956
+ return `${thursday.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
957
+ }
923
958
  default:
924
959
  return `${occurrence.start.getFullYear()}-${occurrence.start.getMonth()}`;
925
960
  }
@@ -1037,11 +1072,21 @@ export class RecurrenceEngine {
1037
1072
  case 'MONTHLY':
1038
1073
  if (rule.byMonthDay && rule.byMonthDay.length > 0) {
1039
1074
  // Specific day(s) of month
1040
- const currentMonth = next.getMonth();
1041
- next.setMonth(currentMonth + rule.interval);
1042
- // Clamp to last day of month if day doesn't exist
1043
- const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
1044
- next.setDate(Math.min(rule.byMonthDay[0], daysInMonth));
1075
+ const monthDay = rule.byMonthDay[0];
1076
+ if (monthDay < 0) {
1077
+ // Counted from the end of the month (-1 is the last day). Move
1078
+ // to the first so the month step cannot overflow from a 31st.
1079
+ next.setDate(1);
1080
+ next.setMonth(next.getMonth() + rule.interval);
1081
+ const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
1082
+ next.setDate(Math.max(1, daysInMonth + monthDay + 1));
1083
+ } else {
1084
+ const currentMonth = next.getMonth();
1085
+ next.setMonth(currentMonth + rule.interval);
1086
+ // Clamp to last day of month if day doesn't exist
1087
+ const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
1088
+ next.setDate(Math.min(monthDay, daysInMonth));
1089
+ }
1045
1090
  } else if (rule.byDay && rule.byDay.length > 0) {
1046
1091
  // Specific weekday of month (e.g., "2nd Tuesday")
1047
1092
  next.setMonth(next.getMonth() + rule.interval);
@@ -12,6 +12,21 @@ const DAY = 86400000;
12
12
  // How far ahead of the iteration cursor DST transitions are scanned at a time
13
13
  const DST_SCAN_CHUNK = 100 * DAY;
14
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
+
15
30
  export class RecurrenceEngineV2 {
16
31
  // Hard limit to prevent resource exhaustion regardless of caller input
17
32
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
@@ -39,10 +54,15 @@ export class RecurrenceEngineV2 {
39
54
  /**
40
55
  * Expand recurring event with advanced handling
41
56
  *
42
- * Occurrences before rangeStart are skipped without being generated:
43
- * daily, weekly, hourly and minutely rules seek straight to the range, so
44
- * a series that started years before the queried window is expanded at
45
- * 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).
46
66
  *
47
67
  * @param {import('./Event.js').Event} event - Recurring event
48
68
  * @param {Date} rangeStart - Start of expansion range
@@ -69,7 +89,7 @@ export class RecurrenceEngineV2 {
69
89
  const maxOccurrences = Math.min(requestedMax, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
70
90
 
71
91
  // Check cache
72
- const cacheKey = this.getCacheKey(event.id, rangeStart, rangeEnd, options);
92
+ const cacheKey = this.getCacheKey(event, rangeStart, rangeEnd, options);
73
93
  if (this.occurrenceCache.has(cacheKey)) {
74
94
  return this.cloneOccurrences(this.occurrenceCache.get(cacheKey));
75
95
  }
@@ -145,6 +165,14 @@ export class RecurrenceEngineV2 {
145
165
  }
146
166
  }
147
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
+
148
176
  // Cache results
149
177
  this.cacheOccurrences(cacheKey, occurrences);
150
178
 
@@ -158,7 +186,7 @@ export class RecurrenceEngineV2 {
158
186
  * time and without the expansion cache: stored instance modifications
159
187
  * and exceptions are applied as each occurrence is produced, so changes
160
188
  * made through addModifiedInstance or addException are visible on the
161
- * next pull. Rules seekToRange can seek (plain daily and weekly, hourly,
189
+ * next pull. Rules seekToRange can seek (daily, weekly, hourly,
162
190
  * minutely) jump straight to `after`, and DST transitions are scanned
163
191
  * lazily ahead of the cursor instead of for the whole window up front.
164
192
  *
@@ -234,12 +262,12 @@ export class RecurrenceEngineV2 {
234
262
  * const nextFive = engine.takeOccurrences(event, 5, { after: new Date() });
235
263
  *
236
264
  * @param {import('./Event.js').Event} event - The event to query
237
- * @param {number} count - Maximum number of occurrences to return
265
+ * @param {number} count - Maximum number of occurrences to return (fractions are floored)
238
266
  * @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
239
267
  * @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
240
268
  */
241
269
  takeOccurrences(event, count, options = {}) {
242
- const limit = Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
270
+ const limit = Math.floor(Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT));
243
271
  const taken = [];
244
272
  if (!(limit > 0)) {
245
273
  return taken;
@@ -350,6 +378,7 @@ export class RecurrenceEngineV2 {
350
378
  }
351
379
  idleSteps++;
352
380
  if (idleSteps >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT) {
381
+ warnIterationLimit(event.id);
353
382
  return;
354
383
  }
355
384
  }
@@ -392,8 +421,9 @@ export class RecurrenceEngineV2 {
392
421
  * without stepping through every occurrence in between.
393
422
  *
394
423
  * Applies to rules whose step is a fixed duration between system-timezone
395
- * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
396
- * 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
397
427
  * what stepping from DTSTART would produce. Never seeks past UNTIL, and
398
428
  * counts skipped steps against COUNT.
399
429
  *
@@ -404,6 +434,10 @@ export class RecurrenceEngineV2 {
404
434
  * @param {string} timezone - Expansion timezone
405
435
  */
406
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
+ }
407
441
  const stepMs = this.getFixedStepMs(rule);
408
442
  if (stepMs <= 0) {
409
443
  return;
@@ -433,6 +467,127 @@ export class RecurrenceEngineV2 {
433
467
  state.count = seek.steps;
434
468
  }
435
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
+
436
591
  /**
437
592
  * Milliseconds per step for rules getNextDate advances by a fixed
438
593
  * duration while the system UTC offset is constant
@@ -556,33 +711,16 @@ export class RecurrenceEngineV2 {
556
711
  const next = new Date(date);
557
712
 
558
713
  if (rule.byDay && rule.byDay.length > 0) {
559
- // Find next matching weekday
560
- const dayMap = {
561
- SU: 0,
562
- MO: 1,
563
- TU: 2,
564
- WE: 3,
565
- TH: 4,
566
- FR: 5,
567
- SA: 6
568
- };
569
-
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);
570
717
  const currentDay = next.getDay();
571
- let daysToAdd = null;
572
-
573
- // Find next occurrence day
574
- for (const byDay of rule.byDay) {
575
- const targetDay = dayMap[byDay.weekday || byDay];
576
- if (targetDay > currentDay) {
577
- daysToAdd = targetDay - currentDay;
578
- break;
579
- }
580
- }
581
-
582
- // If no day found in current week, go to next week
583
- if (daysToAdd === null) {
584
- const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]];
585
- 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];
586
724
 
587
725
  // Apply interval for weekly recurrence
588
726
  if (rule.interval > 1) {
@@ -615,15 +753,17 @@ export class RecurrenceEngineV2 {
615
753
  // Found a day in current month
616
754
  next.setDate(targetDay);
617
755
  } else {
618
- // Move to next month
619
- next.setMonth(next.getMonth() + rule.interval);
620
-
621
- // Handle negative days (from end of month)
622
756
  targetDay = targetDays[0];
623
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);
624
762
  const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
625
- next.setDate(lastDay + targetDay + 1);
763
+ next.setDate(Math.max(1, lastDay + targetDay + 1));
626
764
  } else {
765
+ // Move to next month
766
+ next.setMonth(next.getMonth() + rule.interval);
627
767
  next.setDate(targetDay);
628
768
  }
629
769
  }
@@ -944,9 +1084,45 @@ export class RecurrenceEngineV2 {
944
1084
 
945
1085
  /**
946
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
947
1116
  */
948
- getCacheKey(eventId, start, end, options) {
949
- 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
+ }
950
1126
  }
951
1127
 
952
1128
  /**
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.5.0';
35
+ export const VERSION = '2.5.1';
36
36
 
37
37
  // Default export
38
38
  export { Calendar as default } from './calendar/Calendar.js';
@@ -24,6 +24,40 @@ export class EnhancedCalendar extends Calendar {
24
24
 
25
25
  // Setup event listeners for real-time indexing
26
26
  this.setupRealtimeIndexing();
27
+
28
+ // The enhanced engine keeps its own expansion cache, so drop the entries
29
+ // of every series the store changes
30
+ this._unsubscribeCacheInvalidation = this.eventStore.subscribe(change =>
31
+ this._invalidateOccurrenceCache(change)
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Invalidate the enhanced engine's cached expansions for a store change
37
+ * @param {import('../types.js').EventStoreChange} change - Store change
38
+ * @private
39
+ */
40
+ _invalidateOccurrenceCache(change) {
41
+ const engine = this.recurrenceEngine;
42
+ if (!engine || !change) {
43
+ return;
44
+ }
45
+ switch (change.type) {
46
+ case 'add':
47
+ case 'update':
48
+ case 'remove':
49
+ if (change.event) {
50
+ engine.clearEventCache(change.event.id);
51
+ }
52
+ break;
53
+ case 'batch':
54
+ for (const entry of change.changes || []) {
55
+ this._invalidateOccurrenceCache(entry);
56
+ }
57
+ break;
58
+ default:
59
+ engine.occurrenceCache.clear();
60
+ }
27
61
  }
28
62
 
29
63
  /**
@@ -440,6 +474,10 @@ export class EnhancedCalendar extends Calendar {
440
474
  this._clearReindexTimeout();
441
475
  this._clearReindexTimeout = null;
442
476
  }
477
+ if (typeof this._unsubscribeCacheInvalidation === 'function') {
478
+ this._unsubscribeCacheInvalidation();
479
+ this._unsubscribeCacheInvalidation = null;
480
+ }
443
481
 
444
482
  // Clean up worker
445
483
  if (this.searchManager) {
@@ -10,6 +10,11 @@ import { TimezoneDatabase } from './TimezoneDatabase.js';
10
10
  // Singleton instance for shared use across the application
11
11
  let sharedInstance = null;
12
12
 
13
+ // Timezone databases know only local mean time before the 19th century, so
14
+ // transition scans check a span that ends before this instant at its ends
15
+ // rather than probing it week by week
16
+ const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
17
+
13
18
  export class TimezoneManager {
14
19
  /**
15
20
  * Get the shared singleton instance of TimezoneManager
@@ -215,19 +220,43 @@ export class TimezoneManager {
215
220
  }
216
221
  timezone = this.database.resolveAlias(timezone);
217
222
  let cached = this.transitionCache.get(timezone);
218
- if (!cached || fromMs < cached.from || toMs > cached.to) {
219
- // Extend coverage generously so repeated expansions over the same
220
- // span hit the cache
221
- const from = Math.min(fromMs, cached ? cached.from : fromMs);
222
- const to = Math.max(toMs, cached ? cached.to : toMs);
223
- cached = { from, to, transitions: this._scanTransitions(timezone, from, to) };
223
+ if (!cached) {
224
+ cached = {
225
+ from: fromMs,
226
+ to: toMs,
227
+ transitions: this._scanTransitions(timezone, fromMs, toMs)
228
+ };
224
229
  this.transitionCache.set(timezone, cached);
230
+ } else {
231
+ // Extend coverage incrementally so only the uncovered span is scanned
232
+ if (fromMs < cached.from) {
233
+ cached.transitions = this._scanTransitions(timezone, fromMs, cached.from).concat(
234
+ cached.transitions
235
+ );
236
+ cached.from = fromMs;
237
+ }
238
+ if (toMs > cached.to) {
239
+ cached.transitions = cached.transitions.concat(
240
+ this._scanTransitions(timezone, cached.to, toMs)
241
+ );
242
+ cached.to = toMs;
243
+ }
225
244
  }
226
- for (const t of cached.transitions) {
227
- if (t > fromMs) {
228
- return t <= toMs ? t : Infinity;
245
+ // First transition after fromMs; the list is sorted
246
+ const transitions = cached.transitions;
247
+ let lo = 0;
248
+ let hi = transitions.length;
249
+ while (lo < hi) {
250
+ const mid = (lo + hi) >>> 1;
251
+ if (transitions[mid] > fromMs) {
252
+ hi = mid;
253
+ } else {
254
+ lo = mid + 1;
229
255
  }
230
256
  }
257
+ if (lo < transitions.length && transitions[lo] <= toMs) {
258
+ return transitions[lo];
259
+ }
231
260
  return Infinity;
232
261
  }
233
262
 
@@ -247,6 +276,13 @@ export class TimezoneManager {
247
276
  const offsetAt = ms => this.getTimezoneOffset(new Date(ms), timezone);
248
277
  let lo = fromMs;
249
278
  let loOffset = offsetAt(lo);
279
+ // Timezone databases know only local mean time before the 19th century:
280
+ // that era has a single offset and is skipped in one step (probing it
281
+ // week by week could take minutes for a very old DTSTART)
282
+ if (lo < PRE_TZDATA_FLOOR_MS) {
283
+ lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
284
+ loOffset = offsetAt(lo);
285
+ }
250
286
  while (lo < toMs) {
251
287
  const hi = Math.min(lo + WEEK, toMs);
252
288
  const hiOffset = offsetAt(hi);