@forcecalendar/core 2.2.0 → 2.4.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.
@@ -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/core/types.js CHANGED
@@ -275,13 +275,55 @@
275
275
 
276
276
  /**
277
277
  * @typedef {Object} EventStoreChange
278
- * @property {('add'|'update'|'remove'|'clear')} type - Type of change
278
+ * @property {('add'|'update'|'remove'|'clear'|'batch')} type - Type of change
279
279
  * @property {import('./events/Event.js').Event} [event] - Affected event
280
280
  * @property {import('./events/Event.js').Event} [oldEvent] - Previous event state (for updates)
281
281
  * @property {import('./events/Event.js').Event[]} [oldEvents] - Previous events (for clear)
282
+ * @property {EventStoreChange[]} [changes] - Individual changes (for batch)
283
+ * @property {number} [count] - Number of individual changes (for batch)
282
284
  * @property {number} version - Store version number
283
285
  */
284
286
 
287
+ /**
288
+ * @typedef {(a: import('./events/Event.js').Event, b: import('./events/Event.js').Event) => boolean} EventEquivalenceFn
289
+ */
290
+
291
+ /**
292
+ * @typedef {Object} ReconcileOptions
293
+ * @property {boolean} [removeMissing=true] - Remove stored events that are absent from the snapshot
294
+ * @property {EventEquivalenceFn} [isEquivalent] - Comparator deciding whether a stored event is unchanged (defaults to Event.isEquivalent)
295
+ */
296
+
297
+ /**
298
+ * @typedef {Object} ReconciledUpdate
299
+ * @property {import('./events/Event.js').Event} event - Event now in the store
300
+ * @property {import('./events/Event.js').Event} oldEvent - Event it replaced
301
+ */
302
+
303
+ /**
304
+ * @typedef {Object} ReconcileResult
305
+ * @property {import('./events/Event.js').Event[]} added - Events that were not in the store before
306
+ * @property {ReconciledUpdate[]} updated - Events whose data changed
307
+ * @property {import('./events/Event.js').Event[]} removed - Events removed from the store
308
+ * @property {import('./events/Event.js').Event[]} unchanged - Stored events left untouched (same instances)
309
+ */
310
+
311
+ /**
312
+ * @typedef {Object} SetEventsOptions
313
+ * @property {boolean} [reconcile=false] - Apply only the differences instead of clearing and re-adding
314
+ * @property {boolean} [removeMissing=true] - Reconcile only: remove stored events absent from the snapshot
315
+ * @property {EventEquivalenceFn} [isEquivalent] - Reconcile only: custom equivalence comparator
316
+ */
317
+
318
+ /**
319
+ * @typedef {Object} EventsSetPayload
320
+ * @property {import('./events/Event.js').Event[]} events - All events after the operation
321
+ * @property {import('./events/Event.js').Event[]} added - Events added by the operation
322
+ * @property {ReconciledUpdate[]} updated - Events replaced by the operation
323
+ * @property {import('./events/Event.js').Event[]} removed - Events removed by the operation
324
+ * @property {import('./events/Event.js').Event[]} unchanged - Events left untouched
325
+ */
326
+
285
327
  /**
286
328
  * @typedef {Object} QueryFilters
287
329
  * @property {Date} [start] - Start date for range query
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forcecalendar/core",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
@@ -110,9 +110,66 @@ export declare class Calendar {
110
110
  getEvents(): Event[];
111
111
  /**
112
112
  * Set all events (replaces existing)
113
- * @param {Event[]} events - Array of events
114
- */
115
- setEvents(events: Event[]): void;
113
+ *
114
+ * By default this clears the store and re-adds every entry, so every stored
115
+ * {@link Event} instance is replaced. Pass `{ reconcile: true }` to apply only
116
+ * the differences instead (see {@link Calendar#reconcileEvents}).
117
+ *
118
+ * Emits a single `eventsSet` event whose payload lists the resulting
119
+ * `events` plus the `added`, `updated`, `removed` and `unchanged` sets, so
120
+ * listeners can tell a snapshot load apart from user mutations (no
121
+ * `eventAdd`/`eventUpdate`/`eventRemove` events are emitted).
122
+ *
123
+ * @example
124
+ * calendar.on('eventsSet', ({ added, updated, removed }) => {
125
+ * if (added.length || updated.length || removed.length) render();
126
+ * });
127
+ * calendar.setEvents(snapshot, { reconcile: true });
128
+ *
129
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Array of events
130
+ * @param {import('../types.js').SetEventsOptions} [options={}] - Load options
131
+ * @returns {import('../types.js').EventsSetPayload} The applied change set
132
+ */
133
+ setEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').SetEventsOptions): import('../types.js').EventsSetPayload;
134
+ /**
135
+ * Reconcile the calendar with a snapshot of events, applying only the differences.
136
+ *
137
+ * Intended for consumers that receive periodic full snapshots (polling a
138
+ * server, a reactive `events` prop). Unchanged events keep their existing
139
+ * {@link Event} instance, changed ones are replaced, new ones are added and
140
+ * events missing from the snapshot are removed (unless
141
+ * `removeMissing: false`). Equivalence is decided by
142
+ * {@link Event.isEquivalent} unless an `isEquivalent` comparator is supplied.
143
+ * Plain event data without a `timeZone` defaults to the calendar timezone,
144
+ * exactly as with {@link Calendar#addEvent}.
145
+ *
146
+ * The store emits one `eventStoreChange` of type `batch` (or none when
147
+ * nothing differs) and the calendar emits a single `eventsSet` event
148
+ * carrying the change set. Per-event `eventAdd`/`eventUpdate`/`eventRemove`
149
+ * events are not emitted, so listeners that forward those to a backend are
150
+ * not triggered by a snapshot load.
151
+ *
152
+ * @example
153
+ * const { added, updated, removed, unchanged } = calendar.reconcileEvents(rows);
154
+ * updated.forEach(({ event, oldEvent }) => console.log(oldEvent.title, '->', event.title));
155
+ *
156
+ * @param {Array<import('../events/Event.js').Event|import('../types.js').EventData>} events - Complete snapshot of events
157
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
158
+ * @returns {import('../types.js').EventsSetPayload} Resulting events and the applied change set
159
+ * @throws {Error} If an entry fails validation or two entries share an id
160
+ */
161
+ reconcileEvents(events: Array<import('../events/Event.js').Event | import('../types.js').EventData>, options?: import('../types.js').ReconcileOptions): import('../types.js').EventsSetPayload;
162
+ /**
163
+ * Get the event store's change counter
164
+ *
165
+ * The counter increases with every add/update/remove/clear and every
166
+ * committed batch, so comparing two readings is a cheap way to find out
167
+ * whether {@link Calendar#setEvents} or {@link Calendar#reconcileEvents}
168
+ * changed anything.
169
+ *
170
+ * @returns {number} Current store version
171
+ */
172
+ getEventsVersion(): number;
116
173
  /**
117
174
  * Query events with filters
118
175
  * @param {Object} filters - Query filters
@@ -156,6 +156,42 @@ export declare class Event {
156
156
  * @returns {boolean} True if events are equal
157
157
  */
158
158
  equals(other: Event): boolean;
159
+ /**
160
+ * Fields compared by {@link Event.isEquivalent}, in comparison order.
161
+ * Scalars are compared with strict equality, dates by timestamp and
162
+ * structured fields (recurrence rule, organizer, attendees, reminders,
163
+ * categories, attachments, conference data, metadata) structurally.
164
+ * @type {ReadonlyArray<string>}
165
+ */
166
+ static EQUIVALENCE_FIELDS: ReadonlyArray<string>;
167
+ /**
168
+ * Deep equivalence check over the full event data surface.
169
+ *
170
+ * Unlike {@link Event#equals} (which only looks at identity, title, dates,
171
+ * description, location, recurrence and status) this compares every field
172
+ * that can be supplied through {@link EventData}: timezones, all-day flag,
173
+ * colours, visibility, organizer, attendees, reminders, categories,
174
+ * attachments, conference data and metadata. Dates are compared by
175
+ * timestamp; structured fields are compared structurally (arrays are
176
+ * order-sensitive, object key order is ignored). Plain event data objects
177
+ * are normalized through the {@link Event} constructor before comparison so
178
+ * that `{ color: 'red' }` and `{ backgroundColor: 'red', borderColor: 'red' }`
179
+ * describe the same event. Two events with different ids are never
180
+ * equivalent.
181
+ *
182
+ * This is the default comparator used by `EventStore.reconcile()` to decide
183
+ * whether an incoming snapshot entry replaces the stored event.
184
+ *
185
+ * @example
186
+ * Event.isEquivalent(stored, { ...stored.toObject(), backgroundColor: '#f00' }); // false
187
+ * Event.isEquivalent(stored, stored.clone()); // true
188
+ *
189
+ * @param {Event|import('../types.js').EventData} a - First event or raw event data
190
+ * @param {Event|import('../types.js').EventData} b - Second event or raw event data
191
+ * @returns {boolean} True when both describe the same event data
192
+ * @throws {Error} If raw event data fails {@link Event.validate}
193
+ */
194
+ static isEquivalent(a: Event | import('../types.js').EventData, b: Event | import('../types.js').EventData): boolean;
159
195
  /**
160
196
  * Add an attendee to the event
161
197
  * @param {import('../types.js').Attendee} attendee - Attendee to add
@@ -70,12 +70,26 @@ export declare class EventStore {
70
70
  * @throws {Error} If event not found
71
71
  */
72
72
  updateEvent(eventId: string, updates: Partial<import('../types.js').EventData>): Event;
73
+ /**
74
+ * Swap a stored event for a new instance with the same id, keeping
75
+ * indices and caches in sync. Does not notify listeners.
76
+ * @param {Event} existingEvent - Event currently in the store
77
+ * @param {Event} replacement - Event instance that takes its place
78
+ * @private
79
+ */
80
+ private _replaceEvent;
73
81
  /**
74
82
  * Remove an event from the store
75
83
  * @param {string} eventId - The event ID to remove
76
84
  * @returns {boolean} True if removed, false if not found
77
85
  */
78
86
  removeEvent(eventId: string): boolean;
87
+ /**
88
+ * Remove an event from storage, caches and indices. Does not notify listeners.
89
+ * @param {Event} event - Event currently in the store
90
+ * @private
91
+ */
92
+ private _detachEvent;
79
93
  /**
80
94
  * Get an event by ID
81
95
  * @param {string} eventId - The event ID
@@ -161,6 +175,35 @@ export declare class EventStore {
161
175
  * @param {Event[]} events - Array of events or event data
162
176
  */
163
177
  loadEvents(events: Event[]): void;
178
+ /**
179
+ * Reconcile the store with a snapshot of events, applying only the differences.
180
+ *
181
+ * Compared with {@link EventStore#loadEvents} (clear + re-add everything) this:
182
+ * - keeps the existing {@link Event} instance for every entry that is
183
+ * equivalent to the stored one (identity is preserved, no notification),
184
+ * - replaces stored events whose incoming data differs (`update` change),
185
+ * - adds events whose id is not in the store (`add` change),
186
+ * - removes stored events missing from the snapshot (`remove` change),
187
+ * unless `removeMissing` is `false`,
188
+ * - emits a single `batch` notification listing those changes, or nothing at
189
+ * all when the snapshot matches the store. When called while a batch is
190
+ * already open the changes are queued on that batch instead.
191
+ *
192
+ * Input is validated up front: invalid event data or duplicate ids throw
193
+ * before the store is modified. Any error raised while applying the diff
194
+ * rolls the store back to its previous state.
195
+ *
196
+ * @example
197
+ * // periodic server snapshot
198
+ * const { added, updated, removed } = store.reconcile(rowsFromServer);
199
+ * if (added.length || updated.length || removed.length) rerender();
200
+ *
201
+ * @param {Array<Event|import('../types.js').EventData>} events - Complete snapshot of events
202
+ * @param {import('../types.js').ReconcileOptions} [options={}] - Reconcile options
203
+ * @returns {import('../types.js').ReconcileResult} Events that were added, updated, removed and left untouched
204
+ * @throws {Error} If an entry fails validation or two entries share an id
205
+ */
206
+ reconcile(events: Array<Event | import('../types.js').EventData>, options?: import('../types.js').ReconcileOptions): import('../types.js').ReconcileResult;
164
207
  /**
165
208
  * Subscribe to store changes
166
209
  * @param {Function} callback - Callback function
@@ -201,7 +244,13 @@ export declare class EventStore {
201
244
  * Notify listeners of changes
202
245
  * @private
203
246
  */
204
- private _notifyChange;
247
+ /**
248
+ * Deliver a change now, or queue it when a batch is open
249
+ * @param {import('../types.js').EventStoreChange} change - Change to deliver
250
+ * @private
251
+ */
252
+ private _queueChange;
253
+ _notifyChange(change: any): void;
205
254
  /**
206
255
  * Get store statistics
207
256
  * @returns {Object}
@@ -3,19 +3,117 @@
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;
8
+ static MAX_ITERATIONS_HARD_LIMIT: number;
7
9
  static _ruleCache: Map<any, any>;
8
10
  static _RULE_CACHE_MAX: number;
9
11
  /**
10
12
  * Expand a recurring event into individual occurrences
13
+ *
14
+ * Occurrences before rangeStart are skipped without being generated:
15
+ * daily, weekly and sub-daily rules seek straight to the range, so the
16
+ * cost of a query does not grow with the age of the series, and a series
17
+ * that started years before the queried window is still expanded.
18
+ *
11
19
  * @param {import('./Event.js').Event} event - The recurring event
12
20
  * @param {Date} rangeStart - Start of the expansion range
13
21
  * @param {Date} rangeEnd - End of the expansion range
14
- * @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
22
+ * @param {number} [maxOccurrences=365] - Maximum number of occurrences to return.
23
+ * Only occurrences inside the range count towards this limit; occurrences
24
+ * between the series start and rangeStart do not consume it.
15
25
  * @param {string} [timezone] - Timezone for expansion (important for DST)
16
26
  * @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
17
27
  */
18
28
  static expandEvent(event: import('./Event.js').Event, rangeStart: Date, rangeEnd: Date, maxOccurrences?: number, timezone?: string): import('../types.js').EventOccurrence[];
29
+ /**
30
+ * General expansion loop: advances a Date cursor per step. Handles every
31
+ * frequency and degenerate rules (non-advancing dates, invalid intervals).
32
+ * @private
33
+ */
34
+ private static _expandGeneral;
35
+ /**
36
+ * Numeric expansion loop for DAILY and WEEKLY rules.
37
+ *
38
+ * Between DST transitions a wall-clock-preserving day step is a constant
39
+ * number of milliseconds, so the loop is pure numeric addition. Transition
40
+ * instants — in the system timezone (which defines Date arithmetic) and in
41
+ * the event's timezone (which drives occurrence adjustment) — are
42
+ * discovered by binary search and cached, so only the one step that
43
+ * crosses a transition falls back to Date arithmetic, and only the first
44
+ * occurrence after a transition queries a timezone offset.
45
+ *
46
+ * Produces output identical to _expandGeneral for the rules it accepts;
47
+ * returns null to delegate anything it cannot handle exactly.
48
+ * @private
49
+ */
50
+ private static _expandFast;
51
+ /**
52
+ * Milliseconds per step for rules whose step is a fixed duration while
53
+ * the system UTC offset is constant. Only the sub-daily frequencies are
54
+ * reported here: DAILY and WEEKLY have their own numeric loop, and the
55
+ * calendar-based frequencies take too few steps per year to need seeking.
56
+ * @param {Object} rule - Parsed recurrence rule
57
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
58
+ * @private
59
+ */
60
+ private static _fixedStepMs;
61
+ /**
62
+ * Skip the occurrences of a fixed-step rule that fall before
63
+ * rangeStartMs without visiting each one.
64
+ *
65
+ * While the system UTC offset is constant, a wall-clock step of the
66
+ * cursor is a constant number of milliseconds, so a whole run of steps
67
+ * collapses into one multiplication. The single step that crosses a
68
+ * system-timezone transition is taken with `advance` instead, so the
69
+ * cursor ends up exactly where stepping every occurrence would have put
70
+ * it. Stops at the last occurrence before rangeStartMs; the caller's loop
71
+ * takes the step into the range.
72
+ *
73
+ * @param {number} fromMs - Cursor position (an occurrence instant)
74
+ * @param {number} rangeStartMs - Seek target
75
+ * @param {number} rangeEndMs - Upper bound for transition lookup
76
+ * @param {number} stepMs - Step length while the UTC offset is constant
77
+ * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
78
+ * @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor
79
+ * @returns {{ ms: number, steps: number, nextSystemTransition: number }}
80
+ * Cursor position, steps taken and the next system transition after it
81
+ * @private
82
+ */
83
+ private static _seekFixedStep;
84
+ /**
85
+ * Seek for WEEKLY BYDAY rules, whose step pattern repeats every week:
86
+ * whole weeks are skipped arithmetically from any weekday in the BYDAY
87
+ * set, and single steps (identical to the expansion loop's) are only
88
+ * taken to reach the set, around system-timezone transitions and in the
89
+ * last week before the range.
90
+ *
91
+ * @param {number} fromMs - Cursor position (an occurrence instant)
92
+ * @param {number} weekday - Weekday of the cursor (Date#getDay)
93
+ * @param {number} rangeStartMs - Seek target
94
+ * @param {number} rangeEndMs - Upper bound for transition lookup
95
+ * @param {Object} rule - Parsed rule with compiled _byDaySet/_byDayDeltas
96
+ * @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
97
+ * @returns {{ ms: number, steps: number, weekday: number, nextSystemTransition: number }}
98
+ * @private
99
+ */
100
+ private static _seekWeekCycle;
101
+ /**
102
+ * Find the next system-timezone offset transition after fromMs.
103
+ * Cached module-wide: the system timezone is fixed for the process.
104
+ * @param {number} fromMs - Search from this timestamp (exclusive)
105
+ * @param {number} toMs - Extend cache coverage at least this far
106
+ * @returns {number} Transition timestamp, or Infinity if none within coverage
107
+ * @private
108
+ */
109
+ private static _nextSystemTransition;
110
+ /**
111
+ * Scan for system-timezone offset transitions via Date#getTimezoneOffset.
112
+ * Probes weekly (shorter than any real-world gap between transitions)
113
+ * and binary-searches each change to the exact millisecond.
114
+ * @private
115
+ */
116
+ private static _scanSystemTransitions;
19
117
  /**
20
118
  * Apply BYSETPOS to filter occurrences within each frequency period
21
119
  * @param {Array} occurrences - Generated occurrences
@@ -10,16 +10,59 @@ export declare class RecurrenceEngineV2 {
10
10
  modifiedInstances: Map<any, any>;
11
11
  exceptionStore: Map<any, any>;
12
12
  static MAX_OCCURRENCES_HARD_LIMIT: number;
13
+ static MAX_ITERATIONS_HARD_LIMIT: number;
13
14
  constructor();
14
15
  /**
15
16
  * Expand recurring event with advanced handling
16
- * @param {Event} event - Recurring event
17
+ *
18
+ * Occurrences before rangeStart are skipped without being generated:
19
+ * daily, weekly, hourly and minutely rules seek straight to the range, so
20
+ * a series that started years before the queried window is expanded at
21
+ * the same cost as one that started yesterday.
22
+ *
23
+ * @param {import('./Event.js').Event} event - Recurring event
17
24
  * @param {Date} rangeStart - Start of expansion range
18
25
  * @param {Date} rangeEnd - End of expansion range
19
26
  * @param {Object} options - Expansion options
27
+ * @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
28
+ * return. Only occurrences inside the range count towards this limit.
29
+ * @param {boolean} [options.includeModified=true] - Apply stored instance modifications
30
+ * @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
31
+ * @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
32
+ * @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
20
33
  * @returns {Array} Expanded occurrences
21
34
  */
22
- expandEvent(event: Event, rangeStart: Date, rangeEnd: Date, options?: Object): any[];
35
+ expandEvent(event: import('./Event.js').Event, rangeStart: Date, rangeEnd: Date, options?: {
36
+ maxOccurrences?: number;
37
+ includeModified?: boolean;
38
+ includeCancelled?: boolean;
39
+ timezone?: string;
40
+ handleDST?: boolean;
41
+ }): any[];
42
+ /**
43
+ * Move the expansion cursor to the last occurrence before the range
44
+ * without stepping through every occurrence in between.
45
+ *
46
+ * Applies to rules whose step is a fixed duration between system-timezone
47
+ * transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
48
+ * crosses a transition is taken with getNextDate so the result is exactly
49
+ * what stepping from DTSTART would produce. Never seeks past UNTIL, and
50
+ * counts skipped steps against COUNT.
51
+ *
52
+ * @param {Object} state - Expansion state (currentDate and count are updated)
53
+ * @param {Object} rule - Parsed recurrence rule
54
+ * @param {Date} rangeStart - Start of expansion range
55
+ * @param {Date} rangeEnd - End of expansion range
56
+ * @param {string} timezone - Expansion timezone
57
+ */
58
+ seekToRange(state: Object, rule: Object, rangeStart: Date, rangeEnd: Date, timezone: string): void;
59
+ /**
60
+ * Milliseconds per step for rules getNextDate advances by a fixed
61
+ * duration while the system UTC offset is constant
62
+ * @param {Object} rule - Parsed recurrence rule
63
+ * @returns {number} Step length in milliseconds, or 0 when not fixed
64
+ */
65
+ getFixedStepMs(rule: Object): number;
23
66
  /**
24
67
  * Generate a single occurrence with timezone handling
25
68
  */
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.4.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