@forcecalendar/core 2.2.0 → 2.3.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.
@@ -34,20 +34,72 @@ export class RecurrenceEngine {
34
34
  }
35
35
 
36
36
  const rule = this._getParsedRule(event.recurrenceRule);
37
- const occurrences = [];
38
37
  const duration = event.end - event.start;
39
38
  const eventTimezone = timezone || event.timeZone || 'UTC';
40
39
  const tzManager = TimezoneManager.getInstance();
41
40
 
42
- // Work in event's timezone for accurate recurrence calculation
43
- const currentDate = new Date(event.start);
44
- let count = 0;
45
-
46
41
  // If UNTIL is specified, use it as the range end
47
42
  if (rule.until && rule.until < rangeEnd) {
48
43
  rangeEnd = rule.until;
49
44
  }
50
45
 
46
+ // DAILY and WEEKLY series iterate on numeric timestamps (no Date
47
+ // arithmetic per step); other frequencies use the general loop
48
+ let occurrences = null;
49
+ if (rule.freq === 'DAILY' || rule.freq === 'WEEKLY') {
50
+ occurrences = this._expandFast(
51
+ event,
52
+ rule,
53
+ rangeStart.getTime(),
54
+ rangeEnd.getTime(),
55
+ maxOccurrences,
56
+ eventTimezone,
57
+ tzManager,
58
+ duration
59
+ );
60
+ }
61
+ if (!occurrences) {
62
+ occurrences = this._expandGeneral(
63
+ event,
64
+ rule,
65
+ rangeStart.getTime(),
66
+ rangeEnd.getTime(),
67
+ maxOccurrences,
68
+ eventTimezone,
69
+ tzManager,
70
+ duration
71
+ );
72
+ }
73
+
74
+ // Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
75
+ if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
76
+ return this._applyBySetPos(occurrences, rule);
77
+ }
78
+
79
+ return occurrences;
80
+ }
81
+
82
+ /**
83
+ * General expansion loop: advances a Date cursor per step. Handles every
84
+ * frequency and degenerate rules (non-advancing dates, invalid intervals).
85
+ * @private
86
+ */
87
+ static _expandGeneral(
88
+ event,
89
+ rule,
90
+ rangeStartMs,
91
+ rangeEndMs,
92
+ maxOccurrences,
93
+ eventTimezone,
94
+ tzManager,
95
+ duration
96
+ ) {
97
+ const occurrences = [];
98
+
99
+ // Work in event's timezone for accurate recurrence calculation
100
+ const currentDate = new Date(event.start);
101
+ let count = 0;
102
+
51
103
  // Track DST transitions for proper timezone handling
52
104
  let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
53
105
 
@@ -57,8 +109,6 @@ export class RecurrenceEngine {
57
109
 
58
110
  // Compare on numeric timestamps in the loop — Date-object comparisons
59
111
  // re-coerce through valueOf on every check
60
- const rangeStartMs = rangeStart.getTime();
61
- const rangeEndMs = rangeEnd.getTime();
62
112
  const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
63
113
  let currentMs = currentDate.getTime();
64
114
 
@@ -113,14 +163,179 @@ export class RecurrenceEngine {
113
163
  }
114
164
  }
115
165
 
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);
166
+ return occurrences;
167
+ }
168
+
169
+ /**
170
+ * Numeric expansion loop for DAILY and WEEKLY rules.
171
+ *
172
+ * Between DST transitions a wall-clock-preserving day step is a constant
173
+ * number of milliseconds, so the loop is pure numeric addition. Transition
174
+ * instants — in the system timezone (which defines Date arithmetic) and in
175
+ * the event's timezone (which drives occurrence adjustment) — are
176
+ * discovered by binary search and cached, so only the one step that
177
+ * crosses a transition falls back to Date arithmetic, and only the first
178
+ * occurrence after a transition queries a timezone offset.
179
+ *
180
+ * Produces output identical to _expandGeneral for the rules it accepts;
181
+ * returns null to delegate anything it cannot handle exactly.
182
+ * @private
183
+ */
184
+ static _expandFast(
185
+ event,
186
+ rule,
187
+ rangeStartMs,
188
+ rangeEndMs,
189
+ maxOccurrences,
190
+ eventTimezone,
191
+ tzManager,
192
+ duration
193
+ ) {
194
+ const DAY = 86400000;
195
+ let dayDeltas = null;
196
+ let stepDays = 0;
197
+ let weekday = 0;
198
+
199
+ const startDate = new Date(event.start);
200
+ if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
201
+ const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
202
+ if (daySet.size === 0) {
203
+ return null; // invalid byDay — general loop handles the fallback warning
204
+ }
205
+ dayDeltas = rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
206
+ weekday = startDate.getDay();
207
+ } else {
208
+ stepDays = (rule.freq === 'DAILY' ? 1 : 7) * rule.interval;
209
+ if (!Number.isInteger(stepDays) || stepDays <= 0) {
210
+ return null; // degenerate interval — general loop's stuck detection applies
211
+ }
212
+ }
213
+
214
+ const occurrences = [];
215
+ let currentMs = startDate.getTime();
216
+ if (Number.isNaN(currentMs)) {
217
+ return null;
218
+ }
219
+ let count = 0;
220
+ const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
221
+
222
+ let lastOffset = tzManager.getTimezoneOffset(startDate, eventTimezone);
223
+ let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
224
+ let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
225
+
226
+ while (currentMs <= rangeEndMs && count < maxOccurrences) {
227
+ if (currentMs >= rangeStartMs) {
228
+ const occurrenceStart = new Date(currentMs);
229
+ const occurrenceEnd = new Date(currentMs + duration);
230
+
231
+ // Only the first occurrence past a transition needs an offset check
232
+ if (currentMs >= nextEventTzTransition) {
233
+ const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
234
+ if (currentOffset !== lastOffset) {
235
+ const offsetDiff = lastOffset - currentOffset;
236
+ occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
237
+ occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
238
+ lastOffset = currentOffset;
239
+ }
240
+ nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
241
+ }
242
+
243
+ if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
244
+ occurrences.push({
245
+ start: occurrenceStart,
246
+ end: occurrenceEnd,
247
+ recurringEventId: event.id,
248
+ timezone: eventTimezone,
249
+ originalStart: event.start
250
+ });
251
+ }
252
+ }
253
+
254
+ // Advance: pure addition unless the step crosses a system-timezone
255
+ // transition, where Date arithmetic reproduces wall-clock semantics
256
+ const days = dayDeltas ? dayDeltas[weekday] : stepDays;
257
+ if (dayDeltas) {
258
+ weekday = (weekday + days) % 7;
259
+ }
260
+ const naiveMs = currentMs + days * DAY;
261
+ if (naiveMs >= nextSystemTransition) {
262
+ const cursor = new Date(currentMs);
263
+ cursor.setDate(cursor.getDate() + days);
264
+ currentMs = cursor.getTime();
265
+ nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
266
+ } else {
267
+ currentMs = naiveMs;
268
+ }
269
+ count++;
270
+
271
+ if (rule.count && count >= rule.count) {
272
+ break;
273
+ }
119
274
  }
120
275
 
121
276
  return occurrences;
122
277
  }
123
278
 
279
+ /**
280
+ * Find the next system-timezone offset transition after fromMs.
281
+ * Cached module-wide: the system timezone is fixed for the process.
282
+ * @param {number} fromMs - Search from this timestamp (exclusive)
283
+ * @param {number} toMs - Extend cache coverage at least this far
284
+ * @returns {number} Transition timestamp, or Infinity if none within coverage
285
+ * @private
286
+ */
287
+ static _nextSystemTransition(fromMs, toMs) {
288
+ if (fromMs >= toMs) {
289
+ return Infinity;
290
+ }
291
+ let cache = this._systemTransitions;
292
+ if (!cache || fromMs < cache.from || toMs > cache.to) {
293
+ const from = Math.min(fromMs, cache ? cache.from : fromMs);
294
+ const to = Math.max(toMs, cache ? cache.to : toMs);
295
+ cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
296
+ this._systemTransitions = cache;
297
+ }
298
+ for (const t of cache.transitions) {
299
+ if (t > fromMs) {
300
+ return t;
301
+ }
302
+ }
303
+ return Infinity;
304
+ }
305
+
306
+ /**
307
+ * Scan for system-timezone offset transitions via Date#getTimezoneOffset.
308
+ * Probes weekly (shorter than any real-world gap between transitions)
309
+ * and binary-searches each change to the exact millisecond.
310
+ * @private
311
+ */
312
+ static _scanSystemTransitions(fromMs, toMs) {
313
+ const WEEK = 7 * 86400000;
314
+ const transitions = [];
315
+ let lo = fromMs;
316
+ let loOffset = new Date(lo).getTimezoneOffset();
317
+ while (lo < toMs) {
318
+ const hi = Math.min(lo + WEEK, toMs);
319
+ const hiOffset = new Date(hi).getTimezoneOffset();
320
+ if (hiOffset !== loOffset) {
321
+ let a = lo;
322
+ let b = hi;
323
+ while (b - a > 1) {
324
+ const mid = Math.floor((a + b) / 2);
325
+ if (new Date(mid).getTimezoneOffset() === loOffset) {
326
+ a = mid;
327
+ } else {
328
+ b = mid;
329
+ }
330
+ }
331
+ transitions.push(b);
332
+ loOffset = hiOffset;
333
+ }
334
+ lo = hi;
335
+ }
336
+ return transitions;
337
+ }
338
+
124
339
  /**
125
340
  * Apply BYSETPOS to filter occurrences within each frequency period
126
341
  * @param {Array} occurrences - Generated occurrences
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.3.0';
36
36
 
37
37
  // Default export
38
38
  export { Calendar as default } from './calendar/Calendar.js';
@@ -47,6 +47,11 @@ export class TimezoneManager {
47
47
  // so formatters are cached per timezone and reused
48
48
  this.formatterCache = new Map();
49
49
 
50
+ // Discovered offset-transition instants per zone:
51
+ // Map<timezone, {from: number, to: number, transitions: number[]}>
52
+ // covering [from, to] with a sorted list of transition timestamps
53
+ this.transitionCache = new Map();
54
+
50
55
  // Cache size management
51
56
  this.maxCacheSize = 1000;
52
57
  // ~20k 15-minute buckets per zone (≈ a few hundred KB worst case) covers
@@ -165,7 +170,11 @@ export class TimezoneManager {
165
170
  }
166
171
  }
167
172
  const tzDate = new Date(year, month - 1, day, hour, minute, second);
168
- offset = -((tzDate.getTime() - date.getTime()) / (1000 * 60));
173
+ // formatToParts carries no milliseconds, so compare against the
174
+ // whole-second part of the input or sub-second noise leaks into
175
+ // the offset (e.g. 660.0042 instead of 660)
176
+ const wholeSecondMs = Math.floor(date.getTime() / 1000) * 1000;
177
+ offset = -((tzDate.getTime() - wholeSecondMs) / (1000 * 60));
169
178
  } catch (e) {
170
179
  // Fallback to database calculation
171
180
  }
@@ -193,6 +202,74 @@ export class TimezoneManager {
193
202
  return offset;
194
203
  }
195
204
 
205
+ /**
206
+ * Find the next instant at which the zone's UTC offset changes
207
+ * @param {string} timezone - Timezone identifier
208
+ * @param {number} fromMs - Search from this timestamp (exclusive)
209
+ * @param {number} toMs - Search up to this timestamp (inclusive)
210
+ * @returns {number} Timestamp of the first offset change after fromMs, or Infinity
211
+ */
212
+ getNextTransition(timezone, fromMs, toMs) {
213
+ if (fromMs >= toMs) {
214
+ return Infinity;
215
+ }
216
+ timezone = this.database.resolveAlias(timezone);
217
+ 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) };
224
+ this.transitionCache.set(timezone, cached);
225
+ }
226
+ for (const t of cached.transitions) {
227
+ if (t > fromMs) {
228
+ return t <= toMs ? t : Infinity;
229
+ }
230
+ }
231
+ return Infinity;
232
+ }
233
+
234
+ /**
235
+ * Scan a range for offset transitions. Probes in 7-day steps (shorter
236
+ * than any gap between real-world transitions, including Ramadan DST
237
+ * suspensions) and binary-searches each change to the exact instant.
238
+ * @param {string} timezone - Resolved timezone identifier
239
+ * @param {number} fromMs - Range start
240
+ * @param {number} toMs - Range end
241
+ * @returns {number[]} Sorted transition timestamps
242
+ * @private
243
+ */
244
+ _scanTransitions(timezone, fromMs, toMs) {
245
+ const WEEK = 7 * 86400000;
246
+ const transitions = [];
247
+ const offsetAt = ms => this.getTimezoneOffset(new Date(ms), timezone);
248
+ let lo = fromMs;
249
+ let loOffset = offsetAt(lo);
250
+ while (lo < toMs) {
251
+ const hi = Math.min(lo + WEEK, toMs);
252
+ const hiOffset = offsetAt(hi);
253
+ if (hiOffset !== loOffset) {
254
+ // Binary search for the first ms with the new offset
255
+ let a = lo;
256
+ let b = hi;
257
+ while (b - a > 1) {
258
+ const mid = Math.floor((a + b) / 2);
259
+ if (offsetAt(mid) === loOffset) {
260
+ a = mid;
261
+ } else {
262
+ b = mid;
263
+ }
264
+ }
265
+ transitions.push(b);
266
+ loOffset = hiOffset;
267
+ }
268
+ lo = hi;
269
+ }
270
+ return transitions;
271
+ }
272
+
196
273
  /**
197
274
  * Get a cached Intl.DateTimeFormat for a timezone
198
275
  * @param {string} timezone - Timezone identifier
@@ -471,6 +548,7 @@ export class TimezoneManager {
471
548
  clearCache() {
472
549
  this.offsetCache.clear();
473
550
  this.dstCache.clear();
551
+ this.transitionCache.clear();
474
552
  this.cacheHits = 0;
475
553
  this.cacheMisses = 0;
476
554
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
@@ -3,6 +3,7 @@
3
3
  * Full support for RFC 5545 (iCalendar) RRULE specification
4
4
  */
5
5
  export declare class RecurrenceEngine {
6
+ static _systemTransitions: any;
6
7
  static MAX_OCCURRENCES_HARD_LIMIT: number;
7
8
  static _ruleCache: Map<any, any>;
8
9
  static _RULE_CACHE_MAX: number;
@@ -16,6 +17,44 @@ export declare class RecurrenceEngine {
16
17
  * @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
17
18
  */
18
19
  static expandEvent(event: import('./Event.js').Event, rangeStart: Date, rangeEnd: Date, maxOccurrences?: number, timezone?: string): import('../types.js').EventOccurrence[];
20
+ /**
21
+ * General expansion loop: advances a Date cursor per step. Handles every
22
+ * frequency and degenerate rules (non-advancing dates, invalid intervals).
23
+ * @private
24
+ */
25
+ private static _expandGeneral;
26
+ /**
27
+ * Numeric expansion loop for DAILY and WEEKLY rules.
28
+ *
29
+ * Between DST transitions a wall-clock-preserving day step is a constant
30
+ * number of milliseconds, so the loop is pure numeric addition. Transition
31
+ * instants — in the system timezone (which defines Date arithmetic) and in
32
+ * the event's timezone (which drives occurrence adjustment) — are
33
+ * discovered by binary search and cached, so only the one step that
34
+ * crosses a transition falls back to Date arithmetic, and only the first
35
+ * occurrence after a transition queries a timezone offset.
36
+ *
37
+ * Produces output identical to _expandGeneral for the rules it accepts;
38
+ * returns null to delegate anything it cannot handle exactly.
39
+ * @private
40
+ */
41
+ private static _expandFast;
42
+ /**
43
+ * Find the next system-timezone offset transition after fromMs.
44
+ * Cached module-wide: the system timezone is fixed for the process.
45
+ * @param {number} fromMs - Search from this timestamp (exclusive)
46
+ * @param {number} toMs - Extend cache coverage at least this far
47
+ * @returns {number} Transition timestamp, or Infinity if none within coverage
48
+ * @private
49
+ */
50
+ private static _nextSystemTransition;
51
+ /**
52
+ * Scan for system-timezone offset transitions via Date#getTimezoneOffset.
53
+ * Probes weekly (shorter than any real-world gap between transitions)
54
+ * and binary-searches each change to the exact millisecond.
55
+ * @private
56
+ */
57
+ private static _scanSystemTransitions;
19
58
  /**
20
59
  * Apply BYSETPOS to filter occurrences within each frequency period
21
60
  * @param {Array} occurrences - Generated occurrences
package/types/index.d.ts CHANGED
@@ -18,5 +18,5 @@ export { RRuleParser } from './events/RRuleParser.js';
18
18
  export { TimezoneManager } from './timezone/TimezoneManager.js';
19
19
  export { ConflictDetector } from './conflicts/ConflictDetector.js';
20
20
  export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
21
- export declare const VERSION = "2.2.0";
21
+ export declare const VERSION = "2.3.0";
22
22
  export { Calendar as default } from './calendar/Calendar.js';
@@ -10,6 +10,7 @@ export declare class TimezoneManager {
10
10
  offsetCache: Map<any, any>;
11
11
  dstCache: Map<any, any>;
12
12
  formatterCache: Map<any, any>;
13
+ transitionCache: Map<any, any>;
13
14
  maxCacheSize: number;
14
15
  maxOffsetBucketsPerZone: number;
15
16
  cacheHits: number;
@@ -55,6 +56,25 @@ export declare class TimezoneManager {
55
56
  * @returns {number} Offset in minutes from UTC
56
57
  */
57
58
  getTimezoneOffset(date: Date, timezone: string): number;
59
+ /**
60
+ * Find the next instant at which the zone's UTC offset changes
61
+ * @param {string} timezone - Timezone identifier
62
+ * @param {number} fromMs - Search from this timestamp (exclusive)
63
+ * @param {number} toMs - Search up to this timestamp (inclusive)
64
+ * @returns {number} Timestamp of the first offset change after fromMs, or Infinity
65
+ */
66
+ getNextTransition(timezone: string, fromMs: number, toMs: number): number;
67
+ /**
68
+ * Scan a range for offset transitions. Probes in 7-day steps (shorter
69
+ * than any gap between real-world transitions, including Ramadan DST
70
+ * suspensions) and binary-searches each change to the exact instant.
71
+ * @param {string} timezone - Resolved timezone identifier
72
+ * @param {number} fromMs - Range start
73
+ * @param {number} toMs - Range end
74
+ * @returns {number[]} Sorted transition timestamps
75
+ * @private
76
+ */
77
+ private _scanTransitions;
58
78
  /**
59
79
  * Get a cached Intl.DateTimeFormat for a timezone
60
80
  * @param {string} timezone - Timezone identifier