@forcecalendar/core 2.1.68 → 2.1.70

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.
@@ -2,6 +2,8 @@ import { DateUtils } from '../calendar/DateUtils.js';
2
2
  import { TimezoneManager } from '../timezone/TimezoneManager.js';
3
3
  import { RRuleParser } from './RRuleParser.js';
4
4
 
5
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
6
+
5
7
  /**
6
8
  * RecurrenceEngine - Handles expansion of recurring events
7
9
  * Full support for RFC 5545 (iCalendar) RRULE specification
@@ -10,6 +12,11 @@ export class RecurrenceEngine {
10
12
  // Hard limit to prevent resource exhaustion regardless of caller input
11
13
  static MAX_OCCURRENCES_HARD_LIMIT = 10000;
12
14
 
15
+ // expandEvent is typically called many times with the same RRULE string
16
+ // (every view render), so parsed rules are cached by their source string
17
+ static _ruleCache = new Map();
18
+ static _RULE_CACHE_MAX = 500;
19
+
13
20
  /**
14
21
  * Expand a recurring event into individual occurrences
15
22
  * @param {import('./Event.js').Event} event - The recurring event
@@ -26,14 +33,14 @@ export class RecurrenceEngine {
26
33
  return [{ start: event.start, end: event.end, timezone: event.timeZone }];
27
34
  }
28
35
 
29
- const rule = this.parseRule(event.recurrenceRule);
36
+ const rule = this._getParsedRule(event.recurrenceRule);
30
37
  const occurrences = [];
31
38
  const duration = event.end - event.start;
32
39
  const eventTimezone = timezone || event.timeZone || 'UTC';
33
40
  const tzManager = TimezoneManager.getInstance();
34
41
 
35
42
  // Work in event's timezone for accurate recurrence calculation
36
- let currentDate = new Date(event.start);
43
+ const currentDate = new Date(event.start);
37
44
  let count = 0;
38
45
 
39
46
  // If UNTIL is specified, use it as the range end
@@ -48,11 +55,18 @@ export class RecurrenceEngine {
48
55
  let stuckCount = 0;
49
56
  const maxStuckIterations = 3;
50
57
 
51
- while (currentDate <= rangeEnd && count < maxOccurrences) {
58
+ // Compare on numeric timestamps in the loop — Date-object comparisons
59
+ // re-coerce through valueOf on every check
60
+ const rangeStartMs = rangeStart.getTime();
61
+ const rangeEndMs = rangeEnd.getTime();
62
+ const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
63
+ let currentMs = currentDate.getTime();
64
+
65
+ while (currentMs <= rangeEndMs && count < maxOccurrences) {
52
66
  // Check if this occurrence is within the range
53
- if (currentDate >= rangeStart) {
54
- const occurrenceStart = new Date(currentDate);
55
- const occurrenceEnd = new Date(currentDate.getTime() + duration);
67
+ if (currentMs >= rangeStartMs) {
68
+ const occurrenceStart = new Date(currentMs);
69
+ const occurrenceEnd = new Date(currentMs + duration);
56
70
 
57
71
  // Handle DST transitions
58
72
  const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
@@ -65,7 +79,7 @@ export class RecurrenceEngine {
65
79
  lastOffset = currentOffset;
66
80
 
67
81
  // Apply exceptions if any
68
- if (!this.isException(occurrenceStart, rule, event.id)) {
82
+ if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
69
83
  occurrences.push({
70
84
  start: occurrenceStart,
71
85
  end: occurrenceEnd,
@@ -77,12 +91,13 @@ export class RecurrenceEngine {
77
91
  }
78
92
 
79
93
  // Calculate next occurrence
80
- const previousTimestamp = currentDate.getTime();
81
- currentDate = this.getNextOccurrence(currentDate, rule, eventTimezone);
94
+ this._advanceInPlace(currentDate, rule);
95
+ const previousTimestamp = currentMs;
96
+ currentMs = currentDate.getTime();
82
97
  count++;
83
98
 
84
99
  // Safeguard: detect if date is not advancing (infinite loop risk)
85
- if (currentDate.getTime() === previousTimestamp) {
100
+ if (currentMs === previousTimestamp) {
86
101
  stuckCount++;
87
102
  if (stuckCount >= maxStuckIterations) {
88
103
  console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
@@ -158,6 +173,28 @@ export class RecurrenceEngine {
158
173
  return RRuleParser.parse(ruleString);
159
174
  }
160
175
 
176
+ /**
177
+ * Parse a rule with caching for string rules (internal use by expandEvent).
178
+ * Cached rule objects are shared across calls and must not be mutated.
179
+ * @param {string|Object} recurrenceRule - RRULE string or rule object
180
+ * @returns {import('../../types.js').RecurrenceRule} Parsed rule object
181
+ * @private
182
+ */
183
+ static _getParsedRule(recurrenceRule) {
184
+ if (typeof recurrenceRule !== 'string') {
185
+ return this.parseRule(recurrenceRule);
186
+ }
187
+ let rule = this._ruleCache.get(recurrenceRule);
188
+ if (!rule) {
189
+ rule = this.parseRule(recurrenceRule);
190
+ if (this._ruleCache.size >= this._RULE_CACHE_MAX) {
191
+ this._ruleCache.clear();
192
+ }
193
+ this._ruleCache.set(recurrenceRule, rule);
194
+ }
195
+ return rule;
196
+ }
197
+
161
198
  /**
162
199
  * Calculate the next occurrence based on the rule
163
200
  * @param {Date} currentDate - Current occurrence date
@@ -167,7 +204,18 @@ export class RecurrenceEngine {
167
204
  */
168
205
  static getNextOccurrence(currentDate, rule, _timezone = 'UTC') {
169
206
  const next = new Date(currentDate);
207
+ this._advanceInPlace(next, rule);
208
+ return next;
209
+ }
170
210
 
211
+ /**
212
+ * Advance a date to the next occurrence, mutating it in place.
213
+ * Used by expandEvent to avoid one Date allocation per step.
214
+ * @param {Date} next - Date to advance (mutated)
215
+ * @param {Object} rule - Recurrence rule object
216
+ * @private
217
+ */
218
+ static _advanceInPlace(next, rule) {
171
219
  switch (rule.freq) {
172
220
  case 'SECONDLY':
173
221
  next.setSeconds(next.getSeconds() + rule.interval);
@@ -187,21 +235,17 @@ export class RecurrenceEngine {
187
235
 
188
236
  case 'WEEKLY':
189
237
  if (rule.byDay && rule.byDay.length > 0) {
190
- // Find next day that matches byDay
191
- // Limit iterations to prevent infinite loop with malformed byDay
192
- const maxIterations = 8; // 7 days + 1 for safety
193
- let iterations = 0;
194
- const originalDate = next.getDate();
195
- next.setDate(next.getDate() + 1);
196
- while (!this.matchesByDay(next, rule.byDay) && iterations < maxIterations) {
197
- next.setDate(next.getDate() + 1);
198
- iterations++;
199
- }
200
- // If no match found, fall back to simple weekly interval from original date
201
- if (iterations >= maxIterations) {
238
+ // Jump straight to the next matching weekday using a delta table
239
+ // precompiled once per rule instead of stepping day by day
240
+ const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
241
+ if (daySet.size > 0) {
242
+ const deltas =
243
+ rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
244
+ next.setDate(next.getDate() + deltas[next.getDay()]);
245
+ } else {
246
+ // No valid day codes: fall back to simple weekly interval
202
247
  console.warn('RecurrenceEngine: Invalid byDay rule, falling back to weekly interval');
203
- // Reset to original and add weekly interval
204
- next.setDate(originalDate + 7 * rule.interval);
248
+ next.setDate(next.getDate() + 7 * rule.interval);
205
249
  }
206
250
  } else {
207
251
  // Simple weekly recurrence
@@ -215,7 +259,7 @@ export class RecurrenceEngine {
215
259
  const currentMonth = next.getMonth();
216
260
  next.setMonth(currentMonth + rule.interval);
217
261
  // Clamp to last day of month if day doesn't exist
218
- const daysInMonth = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
262
+ const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
219
263
  next.setDate(Math.min(rule.byMonthDay[0], daysInMonth));
220
264
  } else if (rule.byDay && rule.byDay.length > 0) {
221
265
  // Specific weekday of month (e.g., "2nd Tuesday")
@@ -246,8 +290,58 @@ export class RecurrenceEngine {
246
290
  // Unsupported frequency
247
291
  next.setTime(next.getTime() + 24 * 60 * 60 * 1000); // Daily fallback
248
292
  }
293
+ }
294
+
295
+ /**
296
+ * Days to add from each weekday (index 0-6) to reach the next weekday
297
+ * present in the given set
298
+ * @param {Set<number>} daySet - Non-empty set of weekday numbers
299
+ * @returns {number[]} Delta table indexed by Date#getDay()
300
+ * @private
301
+ */
302
+ static _buildByDayDeltas(daySet) {
303
+ const deltas = new Array(7);
304
+ for (let dow = 0; dow < 7; dow++) {
305
+ for (let d = 1; d <= 7; d++) {
306
+ if (daySet.has((dow + d) % 7)) {
307
+ deltas[dow] = d;
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ return deltas;
313
+ }
249
314
 
250
- return next;
315
+ /**
316
+ * Number of days in a month without allocating a Date
317
+ * @param {number} year - Full year
318
+ * @param {number} month - Month index (0-11)
319
+ * @returns {number}
320
+ * @private
321
+ */
322
+ static _daysInMonth(year, month) {
323
+ if (month === 1) {
324
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
325
+ }
326
+ return DAYS_IN_MONTH[month];
327
+ }
328
+
329
+ /**
330
+ * Build a Set of numeric weekdays (0-6) from BYDAY codes
331
+ * @param {Array<string>} byDay - Array of day codes (e.g., ['MO', '2TU'])
332
+ * @returns {Set<number>} Weekday numbers; invalid codes are skipped
333
+ * @private
334
+ */
335
+ static _buildByDaySet(byDay) {
336
+ const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
337
+ const set = new Set();
338
+ for (const day of byDay) {
339
+ const match = /^(-?\d+)?([A-Z]{2})$/.exec(day);
340
+ if (match && dayMap[match[2]] !== undefined) {
341
+ set.add(dayMap[match[2]]);
342
+ }
343
+ }
344
+ return set;
251
345
  }
252
346
 
253
347
  /**
package/core/index.js CHANGED
@@ -28,7 +28,7 @@ export { RRuleParser } from './events/RRuleParser.js';
28
28
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
29
29
 
30
30
  // Version — keep in sync with package.json
31
- export const VERSION = '2.1.68';
31
+ export const VERSION = '2.1.70';
32
32
 
33
33
  // Default export
34
34
  export { Calendar as default } from './calendar/Calendar.js';
@@ -187,7 +187,7 @@ export class AdaptiveMemoryManager {
187
187
  * Increase cache sizes when memory is available
188
188
  */
189
189
  increaseCacheSizes() {
190
- for (const cacheInfo of this.caches.values()) {
190
+ for (const [name, cacheInfo] of this.caches.entries()) {
191
191
  // Only increase if cache is being actively used
192
192
  const timeSinceAccess = Date.now() - cacheInfo.lastAccess;
193
193
  if (timeSinceAccess < 60000) {
@@ -39,11 +39,19 @@ export class TimezoneManager {
39
39
  this.database = new TimezoneDatabase();
40
40
 
41
41
  // Cache timezone offsets for performance
42
+ // offsetCache: Map<timezone, Map<15-minute UTC bucket, offset>>
42
43
  this.offsetCache = new Map();
43
44
  this.dstCache = new Map();
44
45
 
46
+ // Intl.DateTimeFormat construction is ~50x the cost of using one,
47
+ // so formatters are cached per timezone and reused
48
+ this.formatterCache = new Map();
49
+
45
50
  // Cache size management
46
51
  this.maxCacheSize = 1000;
52
+ // ~20k 15-minute buckets per zone (≈ a few hundred KB worst case) covers
53
+ // multi-year expansions without evicting entries mid-scan
54
+ this.maxOffsetBucketsPerZone = 20000;
47
55
  this.cacheHits = 0;
48
56
  this.cacheMisses = 0;
49
57
  }
@@ -109,68 +117,106 @@ export class TimezoneManager {
109
117
  // Resolve any aliases
110
118
  timezone = this.database.resolveAlias(timezone);
111
119
 
112
- // Check cache first
113
- const cacheKey = `${timezone}_${date.getFullYear()}_${date.getMonth()}_${date.getDate()}_${date.getHours()}`;
114
- if (this.offsetCache.has(cacheKey)) {
115
- this.cacheHits++;
116
- this._manageCacheSize();
117
- return this.offsetCache.get(cacheKey);
120
+ // Offsets only change at DST transitions, which occur on 15-minute UTC
121
+ // boundaries worldwide — one cached entry covers each 15-minute bucket
122
+ const bucket = Math.floor(date.getTime() / 900000);
123
+ let zoneCache = this.offsetCache.get(timezone);
124
+ if (zoneCache) {
125
+ const cached = zoneCache.get(bucket);
126
+ if (cached !== undefined) {
127
+ this.cacheHits++;
128
+ return cached;
129
+ }
130
+ } else {
131
+ zoneCache = new Map();
132
+ this.offsetCache.set(timezone, zoneCache);
118
133
  }
119
134
 
120
135
  this.cacheMisses++;
121
136
 
137
+ let offset;
138
+
122
139
  // Try using Intl API if available (best option for browser/Node.js environments)
123
140
  if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
124
141
  try {
125
- const formatter = new Intl.DateTimeFormat('en-US', {
126
- timeZone: timezone,
127
- year: 'numeric',
128
- month: '2-digit',
129
- day: '2-digit',
130
- hour: '2-digit',
131
- minute: '2-digit',
132
- second: '2-digit',
133
- hour12: false
134
- });
135
-
136
142
  // Create same date in target timezone
137
- const parts = formatter.formatToParts(date);
138
- const tzDate = new Date(
139
- parts.find(p => p.type === 'year').value,
140
- parts.find(p => p.type === 'month').value - 1,
141
- parts.find(p => p.type === 'day').value,
142
- parts.find(p => p.type === 'hour').value,
143
- parts.find(p => p.type === 'minute').value,
144
- parts.find(p => p.type === 'second').value
145
- );
146
-
147
- const offset = (tzDate.getTime() - date.getTime()) / (1000 * 60);
148
- this.offsetCache.set(cacheKey, -offset);
149
- this._manageCacheSize();
150
- return -offset;
143
+ const parts = this._getFormatter(timezone).formatToParts(date);
144
+ let year, month, day, hour, minute, second;
145
+ for (const part of parts) {
146
+ switch (part.type) {
147
+ case 'year':
148
+ year = +part.value;
149
+ break;
150
+ case 'month':
151
+ month = +part.value;
152
+ break;
153
+ case 'day':
154
+ day = +part.value;
155
+ break;
156
+ case 'hour':
157
+ hour = +part.value;
158
+ break;
159
+ case 'minute':
160
+ minute = +part.value;
161
+ break;
162
+ case 'second':
163
+ second = +part.value;
164
+ break;
165
+ }
166
+ }
167
+ const tzDate = new Date(year, month - 1, day, hour, minute, second);
168
+ offset = -((tzDate.getTime() - date.getTime()) / (1000 * 60));
151
169
  } catch (e) {
152
170
  // Fallback to database calculation
153
171
  }
154
172
  }
155
173
 
156
- // Fallback: Use timezone database
157
- const tzData = this.database.getTimezone(timezone);
158
- if (!tzData) {
159
- throw new Error(`Unknown timezone: ${timezone}`);
160
- }
174
+ if (offset === undefined) {
175
+ // Fallback: Use timezone database
176
+ const tzData = this.database.getTimezone(timezone);
177
+ if (!tzData) {
178
+ throw new Error(`Unknown timezone: ${timezone}`);
179
+ }
161
180
 
162
- let offset = tzData.offset;
181
+ offset = tzData.offset;
163
182
 
164
- // Apply DST if applicable
165
- if (tzData.dst && this.isDST(date, timezone, tzData.dst)) {
166
- offset += tzData.dst.offset;
183
+ // Apply DST if applicable
184
+ if (tzData.dst && this.isDST(date, timezone, tzData.dst)) {
185
+ offset += tzData.dst.offset;
186
+ }
167
187
  }
168
188
 
169
- this.offsetCache.set(cacheKey, offset);
170
- this._manageCacheSize();
189
+ if (zoneCache.size >= this.maxOffsetBucketsPerZone) {
190
+ zoneCache.clear();
191
+ }
192
+ zoneCache.set(bucket, offset);
171
193
  return offset;
172
194
  }
173
195
 
196
+ /**
197
+ * Get a cached Intl.DateTimeFormat for a timezone
198
+ * @param {string} timezone - Timezone identifier
199
+ * @returns {Intl.DateTimeFormat}
200
+ * @private
201
+ */
202
+ _getFormatter(timezone) {
203
+ let formatter = this.formatterCache.get(timezone);
204
+ if (!formatter) {
205
+ formatter = new Intl.DateTimeFormat('en-US', {
206
+ timeZone: timezone,
207
+ year: 'numeric',
208
+ month: '2-digit',
209
+ day: '2-digit',
210
+ hour: '2-digit',
211
+ minute: '2-digit',
212
+ second: '2-digit',
213
+ hour12: false
214
+ });
215
+ this.formatterCache.set(timezone, formatter);
216
+ }
217
+ return formatter;
218
+ }
219
+
174
220
  /**
175
221
  * Check if date is in DST for given timezone
176
222
  * @param {Date} date - Date to check
@@ -449,7 +495,7 @@ export class TimezoneManager {
449
495
  : 0;
450
496
 
451
497
  return {
452
- offsetCacheSize: this.offsetCache.size,
498
+ offsetCacheSize: [...this.offsetCache.values()].reduce((n, m) => n + m.size, 0),
453
499
  dstCacheSize: this.dstCache.size,
454
500
  maxCacheSize: this.maxCacheSize,
455
501
  cacheHits: this.cacheHits,
@@ -463,16 +509,8 @@ export class TimezoneManager {
463
509
  * @private
464
510
  */
465
511
  _manageCacheSize() {
466
- // Clear caches if they get too large
467
- if (this.offsetCache.size > this.maxCacheSize) {
468
- // Remove first half of entries (oldest)
469
- const entriesToRemove = Math.floor(this.offsetCache.size / 2);
470
- const keys = Array.from(this.offsetCache.keys());
471
- for (let i = 0; i < entriesToRemove; i++) {
472
- this.offsetCache.delete(keys[i]);
473
- }
474
- }
475
-
512
+ // Offset cache size is managed per-zone at insertion time in
513
+ // getTimezoneOffset; only the DST cache needs periodic eviction here
476
514
  if (this.dstCache.size > this.maxCacheSize / 2) {
477
515
  const entriesToRemove = Math.floor(this.dstCache.size / 2);
478
516
  const keys = Array.from(this.dstCache.keys());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.1.68",
3
+ "version": "2.1.70",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",