@forcecalendar/core 2.1.69 → 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.
- package/core/events/RecurrenceEngine.js +120 -26
- package/core/index.js +1 -1
- package/core/timezone/TimezoneManager.js +91 -53
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
54
|
-
const occurrenceStart = new Date(
|
|
55
|
-
const occurrenceEnd = new Date(
|
|
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
|
-
|
|
81
|
-
|
|
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 (
|
|
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
|
-
//
|
|
191
|
-
//
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
31
|
+
export const VERSION = '2.1.70';
|
|
32
32
|
|
|
33
33
|
// Default export
|
|
34
34
|
export { Calendar as default } from './calendar/Calendar.js';
|
|
@@ -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
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
181
|
+
offset = tzData.offset;
|
|
163
182
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
170
|
-
|
|
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
|
-
//
|
|
467
|
-
|
|
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());
|