@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.
- package/core/calendar/Calendar.js +96 -4
- package/core/events/Event.js +112 -0
- package/core/events/EventStore.js +176 -39
- package/core/events/RecurrenceEngine.js +442 -12
- package/core/events/RecurrenceEngineV2.js +103 -3
- package/core/index.js +1 -1
- package/core/timezone/TimezoneManager.js +79 -1
- package/core/types.js +43 -1
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +60 -3
- package/types/events/Event.d.ts +36 -0
- package/types/events/EventStore.d.ts +50 -1
- package/types/events/RecurrenceEngine.d.ts +99 -1
- package/types/events/RecurrenceEngineV2.d.ts +45 -2
- package/types/index.d.ts +1 -1
- package/types/timezone/TimezoneManager.d.ts +20 -0
- package/types/types.d.ts +121 -2
|
@@ -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
|
-
|
|
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
|
@@ -110,9 +110,66 @@ export declare class Calendar {
|
|
|
110
110
|
getEvents(): Event[];
|
|
111
111
|
/**
|
|
112
112
|
* Set all events (replaces existing)
|
|
113
|
-
*
|
|
114
|
-
|
|
115
|
-
|
|
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
|
package/types/events/Event.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
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?:
|
|
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.
|
|
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
|