@forcecalendar/core 2.1.70 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/calendar/Calendar.js +5 -5
- package/core/conflicts/ConflictDetector.js +5 -5
- package/core/events/Event.js +17 -17
- package/core/events/EventStore.js +16 -16
- package/core/events/RecurrenceEngine.js +229 -14
- package/core/ics/ICSHandler.js +5 -3
- package/core/index.js +5 -1
- package/core/state/StateManager.js +3 -3
- package/core/timezone/TimezoneManager.js +79 -1
- package/core/types.js +17 -14
- package/package.json +40 -20
- package/types/calendar/Calendar.d.ts +287 -0
- package/types/calendar/DateUtils.d.ts +298 -0
- package/types/conflicts/ConflictDetector.d.ts +103 -0
- package/types/events/Event.d.ts +329 -0
- package/types/events/EventStore.d.ts +341 -0
- package/types/events/RRuleParser.d.ts +60 -0
- package/types/events/RecurrenceEngine.d.ts +167 -0
- package/types/events/RecurrenceEngineV2.d.ts +140 -0
- package/types/ics/ICSHandler.d.ts +111 -0
- package/types/ics/ICSParser.d.ts +111 -0
- package/types/index.d.ts +22 -0
- package/types/integration/EnhancedCalendar.d.ts +76 -0
- package/types/performance/AdaptiveMemoryManager.d.ts +104 -0
- package/types/performance/LRUCache.d.ts +59 -0
- package/types/performance/PerformanceOptimizer.d.ts +134 -0
- package/types/search/EventSearch.d.ts +91 -0
- package/types/search/SearchWorkerManager.d.ts +116 -0
- package/types/state/StateManager.d.ts +200 -0
- package/types/timezone/TimezoneDatabase.d.ts +993 -0
- package/types/timezone/TimezoneManager.d.ts +154 -0
- package/types/types.d.ts +1225 -0
|
@@ -12,7 +12,7 @@ import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
|
12
12
|
export class Calendar {
|
|
13
13
|
/**
|
|
14
14
|
* Create a new Calendar instance
|
|
15
|
-
* @param {import('
|
|
15
|
+
* @param {import('../types.js').CalendarConfig} [config={}] - Configuration options
|
|
16
16
|
*/
|
|
17
17
|
constructor(config = {}) {
|
|
18
18
|
// Initialize timezone manager first (use singleton to share cache)
|
|
@@ -69,7 +69,7 @@ export class Calendar {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Set the calendar view
|
|
72
|
-
* @param {import('
|
|
72
|
+
* @param {import('../types.js').ViewType} viewType - The view type ('month', 'week', 'day', 'list')
|
|
73
73
|
* @param {Date} [date=null] - Optional date to navigate to
|
|
74
74
|
*/
|
|
75
75
|
setView(viewType, date = null) {
|
|
@@ -87,7 +87,7 @@ export class Calendar {
|
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
89
|
* Get the current view type
|
|
90
|
-
* @returns {import('
|
|
90
|
+
* @returns {import('../types.js').ViewType} The current view type
|
|
91
91
|
*/
|
|
92
92
|
getView() {
|
|
93
93
|
return this.state.get('view');
|
|
@@ -160,7 +160,7 @@ export class Calendar {
|
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
162
|
* Add an event
|
|
163
|
-
* @param {import('../events/Event.js').Event|import('
|
|
163
|
+
* @param {import('../events/Event.js').Event|import('../types.js').EventData} eventData - Event data or Event instance
|
|
164
164
|
* @returns {import('../events/Event.js').Event} The added event
|
|
165
165
|
*/
|
|
166
166
|
addEvent(eventData) {
|
|
@@ -389,7 +389,7 @@ export class Calendar {
|
|
|
389
389
|
|
|
390
390
|
/**
|
|
391
391
|
* Get the current view's data
|
|
392
|
-
* @returns {import('
|
|
392
|
+
* @returns {import('../types.js').MonthViewData|import('../types.js').WeekViewData|import('../types.js').DayViewData|import('../types.js').ListViewData|null} View-specific data
|
|
393
393
|
*/
|
|
394
394
|
getViewData() {
|
|
395
395
|
const view = this.state.get('view');
|
|
@@ -14,9 +14,9 @@ export class ConflictDetector {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Check for conflicts for a specific event
|
|
17
|
-
* @param {import('../events/Event.js').Event|import('
|
|
18
|
-
* @param {import('
|
|
19
|
-
* @returns {import('
|
|
17
|
+
* @param {import('../events/Event.js').Event|import('../types.js').EventData} event - Event to check
|
|
18
|
+
* @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
19
|
+
* @returns {import('../types.js').ConflictSummary} Conflict summary
|
|
20
20
|
*/
|
|
21
21
|
checkConflicts(event, options = {}) {
|
|
22
22
|
// Default options
|
|
@@ -87,8 +87,8 @@ export class ConflictDetector {
|
|
|
87
87
|
* Check for conflicts between two specific events
|
|
88
88
|
* @param {import('../events/Event.js').Event} event1 - First event
|
|
89
89
|
* @param {import('../events/Event.js').Event} event2 - Second event
|
|
90
|
-
* @param {import('
|
|
91
|
-
* @returns {import('
|
|
90
|
+
* @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
91
|
+
* @returns {import('../types.js').ConflictDetails[]} Array of conflicts
|
|
92
92
|
*/
|
|
93
93
|
checkEventPairConflicts(event1, event2, options = {}) {
|
|
94
94
|
const opts = {
|
package/core/events/Event.js
CHANGED
|
@@ -18,8 +18,8 @@ export class Event {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Normalize event data
|
|
21
|
-
* @param {import('
|
|
22
|
-
* @returns {import('
|
|
21
|
+
* @param {import('../types.js').EventData} data - Raw event data
|
|
22
|
+
* @returns {import('../types.js').EventData} Normalized event data
|
|
23
23
|
*/
|
|
24
24
|
static normalize(data) {
|
|
25
25
|
const normalized = { ...data };
|
|
@@ -109,7 +109,7 @@ export class Event {
|
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Validate event data
|
|
112
|
-
* @param {import('
|
|
112
|
+
* @param {import('../types.js').EventData} data - Normalized event data
|
|
113
113
|
* @throws {Error} If validation fails
|
|
114
114
|
*/
|
|
115
115
|
static validate(data) {
|
|
@@ -173,7 +173,7 @@ export class Event {
|
|
|
173
173
|
try {
|
|
174
174
|
new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
|
|
175
175
|
} catch (e) {
|
|
176
|
-
throw new Error(`Invalid timezone: ${data.timeZone}
|
|
176
|
+
throw new Error(`Invalid timezone: ${data.timeZone}`, { cause: e });
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
@@ -182,14 +182,14 @@ export class Event {
|
|
|
182
182
|
try {
|
|
183
183
|
new Intl.DateTimeFormat('en-US', { timeZone: data.endTimeZone });
|
|
184
184
|
} catch (e) {
|
|
185
|
-
throw new Error(`Invalid end timezone: ${data.endTimeZone}
|
|
185
|
+
throw new Error(`Invalid end timezone: ${data.endTimeZone}`, { cause: e });
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
191
|
* Create a new Event instance
|
|
192
|
-
* @param {import('
|
|
192
|
+
* @param {import('../types.js').EventData} eventData - Event data object
|
|
193
193
|
* @throws {Error} If required fields are missing or invalid
|
|
194
194
|
*/
|
|
195
195
|
constructor({
|
|
@@ -426,7 +426,7 @@ export class Event {
|
|
|
426
426
|
|
|
427
427
|
/**
|
|
428
428
|
* Backward-compatible alias for recurrenceRule
|
|
429
|
-
* @returns {import('
|
|
429
|
+
* @returns {import('../types.js').RecurrenceRule|string|null}
|
|
430
430
|
*/
|
|
431
431
|
get recurrence() {
|
|
432
432
|
return this.recurrenceRule;
|
|
@@ -513,7 +513,7 @@ export class Event {
|
|
|
513
513
|
|
|
514
514
|
/**
|
|
515
515
|
* Clone the event with optional updates
|
|
516
|
-
* @param {Partial<import('
|
|
516
|
+
* @param {Partial<import('../types.js').EventData>} [updates={}] - Properties to update in the clone
|
|
517
517
|
* @returns {Event} New Event instance with updated properties
|
|
518
518
|
*/
|
|
519
519
|
clone(updates = {}) {
|
|
@@ -548,7 +548,7 @@ export class Event {
|
|
|
548
548
|
|
|
549
549
|
/**
|
|
550
550
|
* Convert event to plain object
|
|
551
|
-
* @returns {import('
|
|
551
|
+
* @returns {import('../types.js').EventData} Plain object representation of the event
|
|
552
552
|
*/
|
|
553
553
|
toObject() {
|
|
554
554
|
return {
|
|
@@ -580,7 +580,7 @@ export class Event {
|
|
|
580
580
|
|
|
581
581
|
/**
|
|
582
582
|
* Create Event from plain object
|
|
583
|
-
* @param {import('
|
|
583
|
+
* @param {import('../types.js').EventData} obj - Plain object with event properties
|
|
584
584
|
* @returns {Event} New Event instance
|
|
585
585
|
*/
|
|
586
586
|
static fromObject(obj) {
|
|
@@ -613,7 +613,7 @@ export class Event {
|
|
|
613
613
|
|
|
614
614
|
/**
|
|
615
615
|
* Add an attendee to the event
|
|
616
|
-
* @param {import('
|
|
616
|
+
* @param {import('../types.js').Attendee} attendee - Attendee to add
|
|
617
617
|
* @returns {boolean} True if attendee was added, false if already exists
|
|
618
618
|
*/
|
|
619
619
|
addAttendee(attendee) {
|
|
@@ -662,7 +662,7 @@ export class Event {
|
|
|
662
662
|
/**
|
|
663
663
|
* Update an attendee's response status
|
|
664
664
|
* @param {string} email - Attendee's email
|
|
665
|
-
* @param {import('
|
|
665
|
+
* @param {import('../types.js').AttendeeResponseStatus} responseStatus - New response status
|
|
666
666
|
* @returns {boolean} True if attendee was updated
|
|
667
667
|
*/
|
|
668
668
|
updateAttendeeResponse(email, responseStatus) {
|
|
@@ -678,7 +678,7 @@ export class Event {
|
|
|
678
678
|
/**
|
|
679
679
|
* Get an attendee by email
|
|
680
680
|
* @param {string} email - Attendee's email
|
|
681
|
-
* @returns {import('
|
|
681
|
+
* @returns {import('../types.js').Attendee|null} The attendee or null
|
|
682
682
|
*/
|
|
683
683
|
getAttendee(email) {
|
|
684
684
|
return this.attendees.find(a => a.email === email) || null;
|
|
@@ -695,8 +695,8 @@ export class Event {
|
|
|
695
695
|
|
|
696
696
|
/**
|
|
697
697
|
* Get attendees by response status
|
|
698
|
-
* @param {import('
|
|
699
|
-
* @returns {import('
|
|
698
|
+
* @param {import('../types.js').AttendeeResponseStatus} status - Response status to filter by
|
|
699
|
+
* @returns {import('../types.js').Attendee[]} Filtered attendees
|
|
700
700
|
*/
|
|
701
701
|
getAttendeesByStatus(status) {
|
|
702
702
|
return this.attendees.filter(a => a.responseStatus === status);
|
|
@@ -718,7 +718,7 @@ export class Event {
|
|
|
718
718
|
|
|
719
719
|
/**
|
|
720
720
|
* Add a reminder to the event
|
|
721
|
-
* @param {import('
|
|
721
|
+
* @param {import('../types.js').Reminder} reminder - Reminder to add
|
|
722
722
|
* @returns {boolean} True if reminder was added
|
|
723
723
|
*/
|
|
724
724
|
addReminder(reminder) {
|
|
@@ -764,7 +764,7 @@ export class Event {
|
|
|
764
764
|
|
|
765
765
|
/**
|
|
766
766
|
* Get active reminders
|
|
767
|
-
* @returns {import('
|
|
767
|
+
* @returns {import('../types.js').Reminder[]} Active reminders
|
|
768
768
|
*/
|
|
769
769
|
getActiveReminders() {
|
|
770
770
|
return this.reminders.filter(r => r.enabled !== false);
|
|
@@ -56,13 +56,13 @@ export class EventStore {
|
|
|
56
56
|
// Change tracking
|
|
57
57
|
/** @type {number} */
|
|
58
58
|
this.version = 0;
|
|
59
|
-
/** @type {Set<import('
|
|
59
|
+
/** @type {Set<import('../types.js').EventListener>} */
|
|
60
60
|
this.listeners = new Set();
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* Add an event to the store
|
|
65
|
-
* @param {Event|import('
|
|
65
|
+
* @param {Event|import('../types.js').EventData} event - The event to add
|
|
66
66
|
* @returns {Event} The added event
|
|
67
67
|
* @throws {Error} If event with same ID already exists
|
|
68
68
|
*/
|
|
@@ -107,7 +107,7 @@ export class EventStore {
|
|
|
107
107
|
/**
|
|
108
108
|
* Update an existing event
|
|
109
109
|
* @param {string} eventId - The event ID
|
|
110
|
-
* @param {Partial<import('
|
|
110
|
+
* @param {Partial<import('../types.js').EventData>} updates - Properties to update
|
|
111
111
|
* @returns {Event} The updated event
|
|
112
112
|
* @throws {Error} If event not found
|
|
113
113
|
*/
|
|
@@ -212,7 +212,7 @@ export class EventStore {
|
|
|
212
212
|
|
|
213
213
|
/**
|
|
214
214
|
* Query events with filters
|
|
215
|
-
* @param {import('
|
|
215
|
+
* @param {import('../types.js').QueryFilters} [filters={}] - Query filters
|
|
216
216
|
* @returns {Event[]} Filtered events
|
|
217
217
|
*/
|
|
218
218
|
queryEvents(filters = {}) {
|
|
@@ -566,7 +566,7 @@ export class EventStore {
|
|
|
566
566
|
* @returns {Event[]}
|
|
567
567
|
*/
|
|
568
568
|
getEventsInRange(start, end, expandRecurringOrOptions = true, timezone = null) {
|
|
569
|
-
let expandRecurring
|
|
569
|
+
let expandRecurring;
|
|
570
570
|
|
|
571
571
|
if (typeof expandRecurringOrOptions === 'object' && expandRecurringOrOptions !== null) {
|
|
572
572
|
// Options object form: getEventsInRange(start, end, { expandRecurring, timezone })
|
|
@@ -1117,7 +1117,7 @@ export class EventStore {
|
|
|
1117
1117
|
|
|
1118
1118
|
/**
|
|
1119
1119
|
* Add multiple events in batch
|
|
1120
|
-
* @param {Array<Event|import('
|
|
1120
|
+
* @param {Array<Event|import('../types.js').EventData>} events - Events to add
|
|
1121
1121
|
* @returns {Event[]} Added events
|
|
1122
1122
|
*/
|
|
1123
1123
|
addEvents(events) {
|
|
@@ -1277,9 +1277,9 @@ export class EventStore {
|
|
|
1277
1277
|
|
|
1278
1278
|
/**
|
|
1279
1279
|
* Check for conflicts for an event
|
|
1280
|
-
* @param {Event|import('
|
|
1281
|
-
* @param {import('
|
|
1282
|
-
* @returns {import('
|
|
1280
|
+
* @param {Event|import('../types.js').EventData} event - Event to check
|
|
1281
|
+
* @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
1282
|
+
* @returns {import('../types.js').ConflictSummary} Conflict summary
|
|
1283
1283
|
*/
|
|
1284
1284
|
checkConflicts(event, options = {}) {
|
|
1285
1285
|
return this.conflictDetector.checkConflicts(event, options);
|
|
@@ -1289,8 +1289,8 @@ export class EventStore {
|
|
|
1289
1289
|
* Check conflicts between two events
|
|
1290
1290
|
* @param {string} eventId1 - First event ID
|
|
1291
1291
|
* @param {string} eventId2 - Second event ID
|
|
1292
|
-
* @param {import('
|
|
1293
|
-
* @returns {import('
|
|
1292
|
+
* @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
1293
|
+
* @returns {import('../types.js').ConflictDetails[]} Conflicts between events
|
|
1294
1294
|
*/
|
|
1295
1295
|
checkEventPairConflicts(eventId1, eventId2, options = {}) {
|
|
1296
1296
|
const event1 = this.getEvent(eventId1);
|
|
@@ -1307,8 +1307,8 @@ export class EventStore {
|
|
|
1307
1307
|
* Get all conflicts in a date range
|
|
1308
1308
|
* @param {Date} start - Start date
|
|
1309
1309
|
* @param {Date} end - End date
|
|
1310
|
-
* @param {import('
|
|
1311
|
-
* @returns {import('
|
|
1310
|
+
* @param {import('../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
1311
|
+
* @returns {import('../types.js').ConflictSummary} All conflicts in range
|
|
1312
1312
|
*/
|
|
1313
1313
|
getAllConflicts(start, end, options = {}) {
|
|
1314
1314
|
const events = this.getEventsInRange(start, end, false);
|
|
@@ -1363,9 +1363,9 @@ export class EventStore {
|
|
|
1363
1363
|
|
|
1364
1364
|
/**
|
|
1365
1365
|
* Add event with conflict checking
|
|
1366
|
-
* @param {Event|import('
|
|
1366
|
+
* @param {Event|import('../types.js').EventData} event - Event to add
|
|
1367
1367
|
* @param {boolean} [allowConflicts=true] - Whether to allow adding with conflicts
|
|
1368
|
-
* @returns {{event: Event, conflicts: import('
|
|
1368
|
+
* @returns {{event: Event, conflicts: import('../types.js').ConflictSummary}} Result
|
|
1369
1369
|
*/
|
|
1370
1370
|
addEventWithConflictCheck(event, allowConflicts = true) {
|
|
1371
1371
|
// Check conflicts before adding
|
|
@@ -1387,7 +1387,7 @@ export class EventStore {
|
|
|
1387
1387
|
/**
|
|
1388
1388
|
* Find events with conflicts
|
|
1389
1389
|
* @param {Object} [options={}] - Options
|
|
1390
|
-
* @returns {Array<{event: Event, conflicts: import('
|
|
1390
|
+
* @returns {Array<{event: Event, conflicts: import('../types.js').ConflictDetails[]}>} Events with conflicts
|
|
1391
1391
|
*/
|
|
1392
1392
|
findEventsWithConflicts(options = {}) {
|
|
1393
1393
|
const eventsWithConflicts = [];
|
|
@@ -24,7 +24,7 @@ export class RecurrenceEngine {
|
|
|
24
24
|
* @param {Date} rangeEnd - End of the expansion range
|
|
25
25
|
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
|
|
26
26
|
* @param {string} [timezone] - Timezone for expansion (important for DST)
|
|
27
|
-
* @returns {import('
|
|
27
|
+
* @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
|
|
28
28
|
*/
|
|
29
29
|
static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
|
|
30
30
|
// Enforce hard limit regardless of caller-provided value
|
|
@@ -34,20 +34,72 @@ export class RecurrenceEngine {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
const rule = this._getParsedRule(event.recurrenceRule);
|
|
37
|
-
const occurrences = [];
|
|
38
37
|
const duration = event.end - event.start;
|
|
39
38
|
const eventTimezone = timezone || event.timeZone || 'UTC';
|
|
40
39
|
const tzManager = TimezoneManager.getInstance();
|
|
41
40
|
|
|
42
|
-
// Work in event's timezone for accurate recurrence calculation
|
|
43
|
-
const currentDate = new Date(event.start);
|
|
44
|
-
let count = 0;
|
|
45
|
-
|
|
46
41
|
// If UNTIL is specified, use it as the range end
|
|
47
42
|
if (rule.until && rule.until < rangeEnd) {
|
|
48
43
|
rangeEnd = rule.until;
|
|
49
44
|
}
|
|
50
45
|
|
|
46
|
+
// DAILY and WEEKLY series iterate on numeric timestamps (no Date
|
|
47
|
+
// arithmetic per step); other frequencies use the general loop
|
|
48
|
+
let occurrences = null;
|
|
49
|
+
if (rule.freq === 'DAILY' || rule.freq === 'WEEKLY') {
|
|
50
|
+
occurrences = this._expandFast(
|
|
51
|
+
event,
|
|
52
|
+
rule,
|
|
53
|
+
rangeStart.getTime(),
|
|
54
|
+
rangeEnd.getTime(),
|
|
55
|
+
maxOccurrences,
|
|
56
|
+
eventTimezone,
|
|
57
|
+
tzManager,
|
|
58
|
+
duration
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!occurrences) {
|
|
62
|
+
occurrences = this._expandGeneral(
|
|
63
|
+
event,
|
|
64
|
+
rule,
|
|
65
|
+
rangeStart.getTime(),
|
|
66
|
+
rangeEnd.getTime(),
|
|
67
|
+
maxOccurrences,
|
|
68
|
+
eventTimezone,
|
|
69
|
+
tzManager,
|
|
70
|
+
duration
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
|
|
75
|
+
if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
|
|
76
|
+
return this._applyBySetPos(occurrences, rule);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return occurrences;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* General expansion loop: advances a Date cursor per step. Handles every
|
|
84
|
+
* frequency and degenerate rules (non-advancing dates, invalid intervals).
|
|
85
|
+
* @private
|
|
86
|
+
*/
|
|
87
|
+
static _expandGeneral(
|
|
88
|
+
event,
|
|
89
|
+
rule,
|
|
90
|
+
rangeStartMs,
|
|
91
|
+
rangeEndMs,
|
|
92
|
+
maxOccurrences,
|
|
93
|
+
eventTimezone,
|
|
94
|
+
tzManager,
|
|
95
|
+
duration
|
|
96
|
+
) {
|
|
97
|
+
const occurrences = [];
|
|
98
|
+
|
|
99
|
+
// Work in event's timezone for accurate recurrence calculation
|
|
100
|
+
const currentDate = new Date(event.start);
|
|
101
|
+
let count = 0;
|
|
102
|
+
|
|
51
103
|
// Track DST transitions for proper timezone handling
|
|
52
104
|
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
53
105
|
|
|
@@ -57,8 +109,6 @@ export class RecurrenceEngine {
|
|
|
57
109
|
|
|
58
110
|
// Compare on numeric timestamps in the loop — Date-object comparisons
|
|
59
111
|
// re-coerce through valueOf on every check
|
|
60
|
-
const rangeStartMs = rangeStart.getTime();
|
|
61
|
-
const rangeEndMs = rangeEnd.getTime();
|
|
62
112
|
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
63
113
|
let currentMs = currentDate.getTime();
|
|
64
114
|
|
|
@@ -113,14 +163,179 @@ export class RecurrenceEngine {
|
|
|
113
163
|
}
|
|
114
164
|
}
|
|
115
165
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
166
|
+
return occurrences;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Numeric expansion loop for DAILY and WEEKLY rules.
|
|
171
|
+
*
|
|
172
|
+
* Between DST transitions a wall-clock-preserving day step is a constant
|
|
173
|
+
* number of milliseconds, so the loop is pure numeric addition. Transition
|
|
174
|
+
* instants — in the system timezone (which defines Date arithmetic) and in
|
|
175
|
+
* the event's timezone (which drives occurrence adjustment) — are
|
|
176
|
+
* discovered by binary search and cached, so only the one step that
|
|
177
|
+
* crosses a transition falls back to Date arithmetic, and only the first
|
|
178
|
+
* occurrence after a transition queries a timezone offset.
|
|
179
|
+
*
|
|
180
|
+
* Produces output identical to _expandGeneral for the rules it accepts;
|
|
181
|
+
* returns null to delegate anything it cannot handle exactly.
|
|
182
|
+
* @private
|
|
183
|
+
*/
|
|
184
|
+
static _expandFast(
|
|
185
|
+
event,
|
|
186
|
+
rule,
|
|
187
|
+
rangeStartMs,
|
|
188
|
+
rangeEndMs,
|
|
189
|
+
maxOccurrences,
|
|
190
|
+
eventTimezone,
|
|
191
|
+
tzManager,
|
|
192
|
+
duration
|
|
193
|
+
) {
|
|
194
|
+
const DAY = 86400000;
|
|
195
|
+
let dayDeltas = null;
|
|
196
|
+
let stepDays = 0;
|
|
197
|
+
let weekday = 0;
|
|
198
|
+
|
|
199
|
+
const startDate = new Date(event.start);
|
|
200
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
201
|
+
const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
|
|
202
|
+
if (daySet.size === 0) {
|
|
203
|
+
return null; // invalid byDay — general loop handles the fallback warning
|
|
204
|
+
}
|
|
205
|
+
dayDeltas = rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
|
|
206
|
+
weekday = startDate.getDay();
|
|
207
|
+
} else {
|
|
208
|
+
stepDays = (rule.freq === 'DAILY' ? 1 : 7) * rule.interval;
|
|
209
|
+
if (!Number.isInteger(stepDays) || stepDays <= 0) {
|
|
210
|
+
return null; // degenerate interval — general loop's stuck detection applies
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const occurrences = [];
|
|
215
|
+
let currentMs = startDate.getTime();
|
|
216
|
+
if (Number.isNaN(currentMs)) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
let count = 0;
|
|
220
|
+
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
221
|
+
|
|
222
|
+
let lastOffset = tzManager.getTimezoneOffset(startDate, eventTimezone);
|
|
223
|
+
let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
|
|
224
|
+
let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
|
|
225
|
+
|
|
226
|
+
while (currentMs <= rangeEndMs && count < maxOccurrences) {
|
|
227
|
+
if (currentMs >= rangeStartMs) {
|
|
228
|
+
const occurrenceStart = new Date(currentMs);
|
|
229
|
+
const occurrenceEnd = new Date(currentMs + duration);
|
|
230
|
+
|
|
231
|
+
// Only the first occurrence past a transition needs an offset check
|
|
232
|
+
if (currentMs >= nextEventTzTransition) {
|
|
233
|
+
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
234
|
+
if (currentOffset !== lastOffset) {
|
|
235
|
+
const offsetDiff = lastOffset - currentOffset;
|
|
236
|
+
occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
|
|
237
|
+
occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
|
|
238
|
+
lastOffset = currentOffset;
|
|
239
|
+
}
|
|
240
|
+
nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
|
|
244
|
+
occurrences.push({
|
|
245
|
+
start: occurrenceStart,
|
|
246
|
+
end: occurrenceEnd,
|
|
247
|
+
recurringEventId: event.id,
|
|
248
|
+
timezone: eventTimezone,
|
|
249
|
+
originalStart: event.start
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Advance: pure addition unless the step crosses a system-timezone
|
|
255
|
+
// transition, where Date arithmetic reproduces wall-clock semantics
|
|
256
|
+
const days = dayDeltas ? dayDeltas[weekday] : stepDays;
|
|
257
|
+
if (dayDeltas) {
|
|
258
|
+
weekday = (weekday + days) % 7;
|
|
259
|
+
}
|
|
260
|
+
const naiveMs = currentMs + days * DAY;
|
|
261
|
+
if (naiveMs >= nextSystemTransition) {
|
|
262
|
+
const cursor = new Date(currentMs);
|
|
263
|
+
cursor.setDate(cursor.getDate() + days);
|
|
264
|
+
currentMs = cursor.getTime();
|
|
265
|
+
nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
|
|
266
|
+
} else {
|
|
267
|
+
currentMs = naiveMs;
|
|
268
|
+
}
|
|
269
|
+
count++;
|
|
270
|
+
|
|
271
|
+
if (rule.count && count >= rule.count) {
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
119
274
|
}
|
|
120
275
|
|
|
121
276
|
return occurrences;
|
|
122
277
|
}
|
|
123
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Find the next system-timezone offset transition after fromMs.
|
|
281
|
+
* Cached module-wide: the system timezone is fixed for the process.
|
|
282
|
+
* @param {number} fromMs - Search from this timestamp (exclusive)
|
|
283
|
+
* @param {number} toMs - Extend cache coverage at least this far
|
|
284
|
+
* @returns {number} Transition timestamp, or Infinity if none within coverage
|
|
285
|
+
* @private
|
|
286
|
+
*/
|
|
287
|
+
static _nextSystemTransition(fromMs, toMs) {
|
|
288
|
+
if (fromMs >= toMs) {
|
|
289
|
+
return Infinity;
|
|
290
|
+
}
|
|
291
|
+
let cache = this._systemTransitions;
|
|
292
|
+
if (!cache || fromMs < cache.from || toMs > cache.to) {
|
|
293
|
+
const from = Math.min(fromMs, cache ? cache.from : fromMs);
|
|
294
|
+
const to = Math.max(toMs, cache ? cache.to : toMs);
|
|
295
|
+
cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
|
|
296
|
+
this._systemTransitions = cache;
|
|
297
|
+
}
|
|
298
|
+
for (const t of cache.transitions) {
|
|
299
|
+
if (t > fromMs) {
|
|
300
|
+
return t;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return Infinity;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Scan for system-timezone offset transitions via Date#getTimezoneOffset.
|
|
308
|
+
* Probes weekly (shorter than any real-world gap between transitions)
|
|
309
|
+
* and binary-searches each change to the exact millisecond.
|
|
310
|
+
* @private
|
|
311
|
+
*/
|
|
312
|
+
static _scanSystemTransitions(fromMs, toMs) {
|
|
313
|
+
const WEEK = 7 * 86400000;
|
|
314
|
+
const transitions = [];
|
|
315
|
+
let lo = fromMs;
|
|
316
|
+
let loOffset = new Date(lo).getTimezoneOffset();
|
|
317
|
+
while (lo < toMs) {
|
|
318
|
+
const hi = Math.min(lo + WEEK, toMs);
|
|
319
|
+
const hiOffset = new Date(hi).getTimezoneOffset();
|
|
320
|
+
if (hiOffset !== loOffset) {
|
|
321
|
+
let a = lo;
|
|
322
|
+
let b = hi;
|
|
323
|
+
while (b - a > 1) {
|
|
324
|
+
const mid = Math.floor((a + b) / 2);
|
|
325
|
+
if (new Date(mid).getTimezoneOffset() === loOffset) {
|
|
326
|
+
a = mid;
|
|
327
|
+
} else {
|
|
328
|
+
b = mid;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
transitions.push(b);
|
|
332
|
+
loOffset = hiOffset;
|
|
333
|
+
}
|
|
334
|
+
lo = hi;
|
|
335
|
+
}
|
|
336
|
+
return transitions;
|
|
337
|
+
}
|
|
338
|
+
|
|
124
339
|
/**
|
|
125
340
|
* Apply BYSETPOS to filter occurrences within each frequency period
|
|
126
341
|
* @param {Array} occurrences - Generated occurrences
|
|
@@ -165,8 +380,8 @@ export class RecurrenceEngine {
|
|
|
165
380
|
|
|
166
381
|
/**
|
|
167
382
|
* Parse an RRULE string into a rule object
|
|
168
|
-
* @param {string|import('
|
|
169
|
-
* @returns {import('
|
|
383
|
+
* @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
|
|
384
|
+
* @returns {import('../types.js').RecurrenceRule} Parsed rule object
|
|
170
385
|
*/
|
|
171
386
|
static parseRule(ruleString) {
|
|
172
387
|
// Use the new comprehensive parser
|
|
@@ -177,7 +392,7 @@ export class RecurrenceEngine {
|
|
|
177
392
|
* Parse a rule with caching for string rules (internal use by expandEvent).
|
|
178
393
|
* Cached rule objects are shared across calls and must not be mutated.
|
|
179
394
|
* @param {string|Object} recurrenceRule - RRULE string or rule object
|
|
180
|
-
* @returns {import('
|
|
395
|
+
* @returns {import('../types.js').RecurrenceRule} Parsed rule object
|
|
181
396
|
* @private
|
|
182
397
|
*/
|
|
183
398
|
static _getParsedRule(recurrenceRule) {
|
package/core/ics/ICSHandler.js
CHANGED
|
@@ -106,7 +106,7 @@ export class ICSHandler {
|
|
|
106
106
|
|
|
107
107
|
return results;
|
|
108
108
|
} catch (error) {
|
|
109
|
-
throw new Error(`ICS import failed: ${error.message}
|
|
109
|
+
throw new Error(`ICS import failed: ${error.message}`, { cause: error });
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -221,9 +221,11 @@ export class ICSHandler {
|
|
|
221
221
|
}
|
|
222
222
|
} catch (error) {
|
|
223
223
|
if (error.name === 'AbortError') {
|
|
224
|
-
throw new Error(`Failed to import from URL: request timed out after ${requestTimeout}ms
|
|
224
|
+
throw new Error(`Failed to import from URL: request timed out after ${requestTimeout}ms`, {
|
|
225
|
+
cause: error
|
|
226
|
+
});
|
|
225
227
|
}
|
|
226
|
-
throw new Error(`Failed to import from URL: ${error.message}
|
|
228
|
+
throw new Error(`Failed to import from URL: ${error.message}`, { cause: error });
|
|
227
229
|
}
|
|
228
230
|
}
|
|
229
231
|
|
package/core/index.js
CHANGED
|
@@ -24,11 +24,15 @@ export { RecurrenceEngine } from './events/RecurrenceEngine.js';
|
|
|
24
24
|
export { RecurrenceEngineV2 } from './events/RecurrenceEngineV2.js';
|
|
25
25
|
export { RRuleParser } from './events/RRuleParser.js';
|
|
26
26
|
|
|
27
|
+
// Timezone and Conflicts
|
|
28
|
+
export { TimezoneManager } from './timezone/TimezoneManager.js';
|
|
29
|
+
export { ConflictDetector } from './conflicts/ConflictDetector.js';
|
|
30
|
+
|
|
27
31
|
// Enhanced Integration
|
|
28
32
|
export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
|
|
29
33
|
|
|
30
34
|
// Version — keep in sync with package.json
|
|
31
|
-
export const VERSION = '2.
|
|
35
|
+
export const VERSION = '2.3.0';
|
|
32
36
|
|
|
33
37
|
// Default export
|
|
34
38
|
export { Calendar as default } from './calendar/Calendar.js';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export class StateManager {
|
|
6
6
|
/**
|
|
7
7
|
* Create a new StateManager instance
|
|
8
|
-
* @param {Partial<import('
|
|
8
|
+
* @param {Partial<import('../types.js').CalendarState>} [initialState={}] - Initial state values
|
|
9
9
|
*/
|
|
10
10
|
constructor(initialState = {}) {
|
|
11
11
|
this.state = {
|
|
@@ -75,7 +75,7 @@ export class StateManager {
|
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Get the current state
|
|
78
|
-
* @returns {import('
|
|
78
|
+
* @returns {import('../types.js').CalendarState} Current state (frozen)
|
|
79
79
|
*/
|
|
80
80
|
getState() {
|
|
81
81
|
return Object.freeze({ ...this.state });
|
|
@@ -83,7 +83,7 @@ export class StateManager {
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Get a specific state value
|
|
86
|
-
* @param {keyof import('
|
|
86
|
+
* @param {keyof import('../types.js').CalendarState} key - The state key
|
|
87
87
|
* @returns {any} The state value
|
|
88
88
|
*/
|
|
89
89
|
get(key) {
|