@forcecalendar/core 2.3.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 +218 -3
- package/core/events/RecurrenceEngineV2.js +103 -3
- package/core/index.js +1 -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 +60 -1
- package/types/events/RecurrenceEngineV2.d.ts +45 -2
- package/types/index.d.ts +1 -1
- package/types/types.d.ts +121 -2
|
@@ -12,6 +12,11 @@ export class RecurrenceEngine {
|
|
|
12
12
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
13
13
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
14
14
|
|
|
15
|
+
// Hard limit on expansion loop iterations (every occurrence stepped
|
|
16
|
+
// through, inside the range or not) so a rule that cannot be seeked
|
|
17
|
+
// arithmetically still terminates in bounded time
|
|
18
|
+
static MAX_ITERATIONS_HARD_LIMIT = 100000;
|
|
19
|
+
|
|
15
20
|
// expandEvent is typically called many times with the same RRULE string
|
|
16
21
|
// (every view render), so parsed rules are cached by their source string
|
|
17
22
|
static _ruleCache = new Map();
|
|
@@ -19,10 +24,18 @@ export class RecurrenceEngine {
|
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Expand a recurring event into individual occurrences
|
|
27
|
+
*
|
|
28
|
+
* Occurrences before rangeStart are skipped without being generated:
|
|
29
|
+
* daily, weekly and sub-daily rules seek straight to the range, so the
|
|
30
|
+
* cost of a query does not grow with the age of the series, and a series
|
|
31
|
+
* that started years before the queried window is still expanded.
|
|
32
|
+
*
|
|
22
33
|
* @param {import('./Event.js').Event} event - The recurring event
|
|
23
34
|
* @param {Date} rangeStart - Start of the expansion range
|
|
24
35
|
* @param {Date} rangeEnd - End of the expansion range
|
|
25
|
-
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to
|
|
36
|
+
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to return.
|
|
37
|
+
* Only occurrences inside the range count towards this limit; occurrences
|
|
38
|
+
* between the series start and rangeStart do not consume it.
|
|
26
39
|
* @param {string} [timezone] - Timezone for expansion (important for DST)
|
|
27
40
|
* @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
|
|
28
41
|
*/
|
|
@@ -98,6 +111,8 @@ export class RecurrenceEngine {
|
|
|
98
111
|
|
|
99
112
|
// Work in event's timezone for accurate recurrence calculation
|
|
100
113
|
const currentDate = new Date(event.start);
|
|
114
|
+
// Steps taken from DTSTART: RFC 5545 COUNT is measured from there,
|
|
115
|
+
// independent of how many occurrences fall inside the range
|
|
101
116
|
let count = 0;
|
|
102
117
|
|
|
103
118
|
// Track DST transitions for proper timezone handling
|
|
@@ -112,7 +127,31 @@ export class RecurrenceEngine {
|
|
|
112
127
|
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
113
128
|
let currentMs = currentDate.getTime();
|
|
114
129
|
|
|
115
|
-
|
|
130
|
+
// Sub-daily rules step a fixed number of milliseconds, so the span
|
|
131
|
+
// before the range is skipped arithmetically instead of one step at a
|
|
132
|
+
// time; other frequencies take few enough steps per year to just walk
|
|
133
|
+
const stepMs = this._fixedStepMs(rule);
|
|
134
|
+
if (stepMs > 0 && currentMs < rangeStartMs) {
|
|
135
|
+
const seek = this._seekFixedStep(
|
|
136
|
+
currentMs,
|
|
137
|
+
rangeStartMs,
|
|
138
|
+
rangeEndMs,
|
|
139
|
+
stepMs,
|
|
140
|
+
rule.count ? rule.count - 1 : Infinity,
|
|
141
|
+
cursor => this._advanceInPlace(cursor, rule)
|
|
142
|
+
);
|
|
143
|
+
currentMs = seek.ms;
|
|
144
|
+
count = seek.steps;
|
|
145
|
+
currentDate.setTime(currentMs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let iterations = 0;
|
|
149
|
+
while (
|
|
150
|
+
currentMs <= rangeEndMs &&
|
|
151
|
+
occurrences.length < maxOccurrences &&
|
|
152
|
+
iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
|
|
153
|
+
) {
|
|
154
|
+
iterations++;
|
|
116
155
|
// Check if this occurrence is within the range
|
|
117
156
|
if (currentMs >= rangeStartMs) {
|
|
118
157
|
const occurrenceStart = new Date(currentMs);
|
|
@@ -223,7 +262,37 @@ export class RecurrenceEngine {
|
|
|
223
262
|
let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
|
|
224
263
|
let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
|
|
225
264
|
|
|
226
|
-
|
|
265
|
+
// Seek to the last occurrence before the range. nextEventTzTransition is
|
|
266
|
+
// deliberately left as computed from DTSTART: if the seek passed an
|
|
267
|
+
// event-timezone transition, the first in-range occurrence must still
|
|
268
|
+
// compare its offset with lastOffset, exactly as the general loop does.
|
|
269
|
+
if (currentMs < rangeStartMs) {
|
|
270
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
271
|
+
const seek = dayDeltas
|
|
272
|
+
? this._seekWeekCycle(currentMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps)
|
|
273
|
+
: this._seekFixedStep(
|
|
274
|
+
currentMs,
|
|
275
|
+
rangeStartMs,
|
|
276
|
+
rangeEndMs,
|
|
277
|
+
stepDays * DAY,
|
|
278
|
+
maxSteps,
|
|
279
|
+
cursor => cursor.setDate(cursor.getDate() + stepDays)
|
|
280
|
+
);
|
|
281
|
+
currentMs = seek.ms;
|
|
282
|
+
count = seek.steps;
|
|
283
|
+
nextSystemTransition = seek.nextSystemTransition;
|
|
284
|
+
if (dayDeltas) {
|
|
285
|
+
weekday = seek.weekday;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let iterations = 0;
|
|
290
|
+
while (
|
|
291
|
+
currentMs <= rangeEndMs &&
|
|
292
|
+
occurrences.length < maxOccurrences &&
|
|
293
|
+
iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
|
|
294
|
+
) {
|
|
295
|
+
iterations++;
|
|
227
296
|
if (currentMs >= rangeStartMs) {
|
|
228
297
|
const occurrenceStart = new Date(currentMs);
|
|
229
298
|
const occurrenceEnd = new Date(currentMs + duration);
|
|
@@ -276,6 +345,152 @@ export class RecurrenceEngine {
|
|
|
276
345
|
return occurrences;
|
|
277
346
|
}
|
|
278
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Milliseconds per step for rules whose step is a fixed duration while
|
|
350
|
+
* the system UTC offset is constant. Only the sub-daily frequencies are
|
|
351
|
+
* reported here: DAILY and WEEKLY have their own numeric loop, and the
|
|
352
|
+
* calendar-based frequencies take too few steps per year to need seeking.
|
|
353
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
354
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
static _fixedStepMs(rule) {
|
|
358
|
+
const interval = rule.interval;
|
|
359
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
switch (rule.freq) {
|
|
363
|
+
case 'SECONDLY':
|
|
364
|
+
return interval * 1000;
|
|
365
|
+
case 'MINUTELY':
|
|
366
|
+
return interval * 60000;
|
|
367
|
+
case 'HOURLY':
|
|
368
|
+
return interval * 3600000;
|
|
369
|
+
default:
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Skip the occurrences of a fixed-step rule that fall before
|
|
376
|
+
* rangeStartMs without visiting each one.
|
|
377
|
+
*
|
|
378
|
+
* While the system UTC offset is constant, a wall-clock step of the
|
|
379
|
+
* cursor is a constant number of milliseconds, so a whole run of steps
|
|
380
|
+
* collapses into one multiplication. The single step that crosses a
|
|
381
|
+
* system-timezone transition is taken with `advance` instead, so the
|
|
382
|
+
* cursor ends up exactly where stepping every occurrence would have put
|
|
383
|
+
* it. Stops at the last occurrence before rangeStartMs; the caller's loop
|
|
384
|
+
* takes the step into the range.
|
|
385
|
+
*
|
|
386
|
+
* @param {number} fromMs - Cursor position (an occurrence instant)
|
|
387
|
+
* @param {number} rangeStartMs - Seek target
|
|
388
|
+
* @param {number} rangeEndMs - Upper bound for transition lookup
|
|
389
|
+
* @param {number} stepMs - Step length while the UTC offset is constant
|
|
390
|
+
* @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
|
|
391
|
+
* @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor
|
|
392
|
+
* @returns {{ ms: number, steps: number, nextSystemTransition: number }}
|
|
393
|
+
* Cursor position, steps taken and the next system transition after it
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
static _seekFixedStep(fromMs, rangeStartMs, rangeEndMs, stepMs, maxSteps, advance) {
|
|
397
|
+
let ms = fromMs;
|
|
398
|
+
let steps = 0;
|
|
399
|
+
let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
400
|
+
if (!Number.isFinite(rangeStartMs) || !(stepMs > 0)) {
|
|
401
|
+
return { ms, steps, nextSystemTransition };
|
|
402
|
+
}
|
|
403
|
+
// Comparisons are written so an invalid (NaN) cursor ends the seek
|
|
404
|
+
while (ms < rangeStartMs && steps < maxSteps) {
|
|
405
|
+
const limit = Math.min(rangeStartMs, nextSystemTransition);
|
|
406
|
+
// Largest k with ms + k * stepMs < limit
|
|
407
|
+
let k = Math.ceil((limit - ms) / stepMs) - 1;
|
|
408
|
+
if (ms + k * stepMs >= limit) {
|
|
409
|
+
k--; // division rounded up
|
|
410
|
+
}
|
|
411
|
+
k = Math.min(k, maxSteps - steps);
|
|
412
|
+
if (k > 0) {
|
|
413
|
+
ms += k * stepMs;
|
|
414
|
+
steps += k;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (ms + stepMs >= rangeStartMs) {
|
|
418
|
+
break; // next step lands in the range
|
|
419
|
+
}
|
|
420
|
+
// Next step crosses a system-timezone transition
|
|
421
|
+
const cursor = new Date(ms);
|
|
422
|
+
advance(cursor);
|
|
423
|
+
ms = cursor.getTime();
|
|
424
|
+
steps++;
|
|
425
|
+
nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
426
|
+
}
|
|
427
|
+
return { ms, steps, nextSystemTransition };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Seek for WEEKLY BYDAY rules, whose step pattern repeats every week:
|
|
432
|
+
* whole weeks are skipped arithmetically from any weekday in the BYDAY
|
|
433
|
+
* set, and single steps (identical to the expansion loop's) are only
|
|
434
|
+
* taken to reach the set, around system-timezone transitions and in the
|
|
435
|
+
* last week before the range.
|
|
436
|
+
*
|
|
437
|
+
* @param {number} fromMs - Cursor position (an occurrence instant)
|
|
438
|
+
* @param {number} weekday - Weekday of the cursor (Date#getDay)
|
|
439
|
+
* @param {number} rangeStartMs - Seek target
|
|
440
|
+
* @param {number} rangeEndMs - Upper bound for transition lookup
|
|
441
|
+
* @param {Object} rule - Parsed rule with compiled _byDaySet/_byDayDeltas
|
|
442
|
+
* @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
|
|
443
|
+
* @returns {{ ms: number, steps: number, weekday: number, nextSystemTransition: number }}
|
|
444
|
+
* @private
|
|
445
|
+
*/
|
|
446
|
+
static _seekWeekCycle(fromMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps) {
|
|
447
|
+
const DAY = 86400000;
|
|
448
|
+
const WEEK = 7 * DAY;
|
|
449
|
+
const daySet = rule._byDaySet;
|
|
450
|
+
const dayDeltas = rule._byDayDeltas;
|
|
451
|
+
const stepsPerWeek = daySet.size;
|
|
452
|
+
let ms = fromMs;
|
|
453
|
+
let steps = 0;
|
|
454
|
+
let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
455
|
+
if (!Number.isFinite(rangeStartMs)) {
|
|
456
|
+
return { ms, steps, weekday, nextSystemTransition };
|
|
457
|
+
}
|
|
458
|
+
while (ms < rangeStartMs && steps < maxSteps) {
|
|
459
|
+
// A week from a weekday in the set is exactly stepsPerWeek steps and
|
|
460
|
+
// returns to the same weekday
|
|
461
|
+
if (daySet.has(weekday)) {
|
|
462
|
+
const limit = Math.min(rangeStartMs, nextSystemTransition);
|
|
463
|
+
let weeks = Math.ceil((limit - ms) / WEEK) - 1;
|
|
464
|
+
if (ms + weeks * WEEK >= limit) {
|
|
465
|
+
weeks--;
|
|
466
|
+
}
|
|
467
|
+
weeks = Math.min(weeks, Math.floor((maxSteps - steps) / stepsPerWeek));
|
|
468
|
+
if (weeks > 0) {
|
|
469
|
+
ms += weeks * WEEK;
|
|
470
|
+
steps += weeks * stepsPerWeek;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Single step, identical to the expansion loop
|
|
475
|
+
const days = dayDeltas[weekday];
|
|
476
|
+
const naiveMs = ms + days * DAY;
|
|
477
|
+
if (naiveMs >= rangeStartMs) {
|
|
478
|
+
break; // next step lands in the range
|
|
479
|
+
}
|
|
480
|
+
weekday = (weekday + days) % 7;
|
|
481
|
+
if (naiveMs >= nextSystemTransition) {
|
|
482
|
+
const cursor = new Date(ms);
|
|
483
|
+
cursor.setDate(cursor.getDate() + days);
|
|
484
|
+
ms = cursor.getTime();
|
|
485
|
+
nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
486
|
+
} else {
|
|
487
|
+
ms = naiveMs;
|
|
488
|
+
}
|
|
489
|
+
steps++;
|
|
490
|
+
}
|
|
491
|
+
return { ms, steps, weekday, nextSystemTransition };
|
|
492
|
+
}
|
|
493
|
+
|
|
279
494
|
/**
|
|
280
495
|
* Find the next system-timezone offset transition after fromMs.
|
|
281
496
|
* Cached module-wide: the system timezone is fixed for the process.
|
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
7
|
+
import { RecurrenceEngine } from './RecurrenceEngine.js';
|
|
7
8
|
import { RRuleParser } from './RRuleParser.js';
|
|
8
9
|
|
|
10
|
+
const DAY = 86400000;
|
|
11
|
+
|
|
9
12
|
export class RecurrenceEngineV2 {
|
|
10
13
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
11
14
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
12
15
|
|
|
16
|
+
// Hard limit on expansion loop iterations (every occurrence stepped
|
|
17
|
+
// through, inside the range or not) so a rule that cannot be seeked
|
|
18
|
+
// arithmetically still terminates in bounded time
|
|
19
|
+
static MAX_ITERATIONS_HARD_LIMIT = 100000;
|
|
20
|
+
|
|
13
21
|
constructor() {
|
|
14
22
|
// Use singleton to share cache across all components
|
|
15
23
|
this.tzManager = TimezoneManager.getInstance();
|
|
@@ -27,10 +35,22 @@ export class RecurrenceEngineV2 {
|
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* Expand recurring event with advanced handling
|
|
30
|
-
*
|
|
38
|
+
*
|
|
39
|
+
* Occurrences before rangeStart are skipped without being generated:
|
|
40
|
+
* daily, weekly, hourly and minutely rules seek straight to the range, so
|
|
41
|
+
* a series that started years before the queried window is expanded at
|
|
42
|
+
* the same cost as one that started yesterday.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('./Event.js').Event} event - Recurring event
|
|
31
45
|
* @param {Date} rangeStart - Start of expansion range
|
|
32
46
|
* @param {Date} rangeEnd - End of expansion range
|
|
33
47
|
* @param {Object} options - Expansion options
|
|
48
|
+
* @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
|
|
49
|
+
* return. Only occurrences inside the range count towards this limit.
|
|
50
|
+
* @param {boolean} [options.includeModified=true] - Apply stored instance modifications
|
|
51
|
+
* @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
|
|
52
|
+
* @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
|
|
53
|
+
* @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
|
|
34
54
|
* @returns {Array} Expanded occurrences
|
|
35
55
|
*/
|
|
36
56
|
expandEvent(event, rangeStart, rangeEnd, options = {}) {
|
|
@@ -59,7 +79,8 @@ export class RecurrenceEngineV2 {
|
|
|
59
79
|
const occurrences = [];
|
|
60
80
|
const duration = event.end - event.start;
|
|
61
81
|
|
|
62
|
-
// Initialize expansion state
|
|
82
|
+
// Initialize expansion state. `count` is the number of steps taken from
|
|
83
|
+
// DTSTART, which is what RFC 5545 COUNT measures.
|
|
63
84
|
const state = {
|
|
64
85
|
currentDate: new Date(event.start),
|
|
65
86
|
count: 0,
|
|
@@ -73,8 +94,16 @@ export class RecurrenceEngineV2 {
|
|
|
73
94
|
state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
|
|
74
95
|
}
|
|
75
96
|
|
|
97
|
+
this.seekToRange(state, rule, rangeStart, rangeEnd, timezone);
|
|
98
|
+
|
|
76
99
|
// Expand occurrences
|
|
77
|
-
|
|
100
|
+
let iterations = 0;
|
|
101
|
+
while (
|
|
102
|
+
state.currentDate <= rangeEnd &&
|
|
103
|
+
occurrences.length < maxOccurrences &&
|
|
104
|
+
iterations < RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT
|
|
105
|
+
) {
|
|
106
|
+
iterations++;
|
|
78
107
|
if (state.currentDate >= rangeStart) {
|
|
79
108
|
const occurrence = this.generateOccurrence(
|
|
80
109
|
event,
|
|
@@ -144,6 +173,77 @@ export class RecurrenceEngineV2 {
|
|
|
144
173
|
return this.cloneOccurrences(occurrences);
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Move the expansion cursor to the last occurrence before the range
|
|
178
|
+
* without stepping through every occurrence in between.
|
|
179
|
+
*
|
|
180
|
+
* Applies to rules whose step is a fixed duration between system-timezone
|
|
181
|
+
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
|
|
182
|
+
* crosses a transition is taken with getNextDate so the result is exactly
|
|
183
|
+
* what stepping from DTSTART would produce. Never seeks past UNTIL, and
|
|
184
|
+
* counts skipped steps against COUNT.
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} state - Expansion state (currentDate and count are updated)
|
|
187
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
188
|
+
* @param {Date} rangeStart - Start of expansion range
|
|
189
|
+
* @param {Date} rangeEnd - End of expansion range
|
|
190
|
+
* @param {string} timezone - Expansion timezone
|
|
191
|
+
*/
|
|
192
|
+
seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
|
|
193
|
+
const stepMs = this.getFixedStepMs(rule);
|
|
194
|
+
if (stepMs <= 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let targetMs = rangeStart.getTime();
|
|
198
|
+
if (rule.until) {
|
|
199
|
+
// Rule objects may carry UNTIL as a string; an unparseable value
|
|
200
|
+
// compares false and leaves the target alone
|
|
201
|
+
const untilMs = new Date(rule.until).getTime();
|
|
202
|
+
if (untilMs < targetMs) {
|
|
203
|
+
targetMs = untilMs;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const fromMs = state.currentDate.getTime();
|
|
207
|
+
if (!(fromMs < targetMs)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const seek = RecurrenceEngine._seekFixedStep(
|
|
211
|
+
fromMs,
|
|
212
|
+
targetMs,
|
|
213
|
+
rangeEnd.getTime(),
|
|
214
|
+
stepMs,
|
|
215
|
+
rule.count ? rule.count - 1 : Infinity,
|
|
216
|
+
cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime())
|
|
217
|
+
);
|
|
218
|
+
state.currentDate = new Date(seek.ms);
|
|
219
|
+
state.count = seek.steps;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Milliseconds per step for rules getNextDate advances by a fixed
|
|
224
|
+
* duration while the system UTC offset is constant
|
|
225
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
226
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
227
|
+
*/
|
|
228
|
+
getFixedStepMs(rule) {
|
|
229
|
+
const interval = rule.interval;
|
|
230
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
switch (rule.freq) {
|
|
234
|
+
case 'DAILY':
|
|
235
|
+
return rule.byHour && rule.byHour.length > 0 ? 0 : interval * DAY;
|
|
236
|
+
case 'WEEKLY':
|
|
237
|
+
return rule.byDay && rule.byDay.length > 0 ? 0 : 7 * interval * DAY;
|
|
238
|
+
case 'HOURLY':
|
|
239
|
+
return interval * 3600000;
|
|
240
|
+
case 'MINUTELY':
|
|
241
|
+
return interval * 60000;
|
|
242
|
+
default:
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
147
247
|
/**
|
|
148
248
|
* Generate a single occurrence with timezone handling
|
|
149
249
|
*/
|
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.
|
|
35
|
+
export const VERSION = '2.4.0';
|
|
36
36
|
|
|
37
37
|
// Default export
|
|
38
38
|
export { Calendar as default } from './calendar/Calendar.js';
|
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}
|