@forcecalendar/core 2.1.69 → 2.2.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 +123 -29
- 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 +91 -53
- 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 +128 -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 +134 -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 = [];
|
|
@@ -2,6 +2,8 @@ import { DateUtils } from '../calendar/DateUtils.js';
|
|
|
2
2
|
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
3
3
|
import { RRuleParser } from './RRuleParser.js';
|
|
4
4
|
|
|
5
|
+
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* RecurrenceEngine - Handles expansion of recurring events
|
|
7
9
|
* Full support for RFC 5545 (iCalendar) RRULE specification
|
|
@@ -10,6 +12,11 @@ export class RecurrenceEngine {
|
|
|
10
12
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
11
13
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
12
14
|
|
|
15
|
+
// expandEvent is typically called many times with the same RRULE string
|
|
16
|
+
// (every view render), so parsed rules are cached by their source string
|
|
17
|
+
static _ruleCache = new Map();
|
|
18
|
+
static _RULE_CACHE_MAX = 500;
|
|
19
|
+
|
|
13
20
|
/**
|
|
14
21
|
* Expand a recurring event into individual occurrences
|
|
15
22
|
* @param {import('./Event.js').Event} event - The recurring event
|
|
@@ -17,7 +24,7 @@ export class RecurrenceEngine {
|
|
|
17
24
|
* @param {Date} rangeEnd - End of the expansion range
|
|
18
25
|
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
|
|
19
26
|
* @param {string} [timezone] - Timezone for expansion (important for DST)
|
|
20
|
-
* @returns {import('
|
|
27
|
+
* @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
|
|
21
28
|
*/
|
|
22
29
|
static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
|
|
23
30
|
// Enforce hard limit regardless of caller-provided value
|
|
@@ -26,14 +33,14 @@ export class RecurrenceEngine {
|
|
|
26
33
|
return [{ start: event.start, end: event.end, timezone: event.timeZone }];
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
const rule = this.
|
|
36
|
+
const rule = this._getParsedRule(event.recurrenceRule);
|
|
30
37
|
const occurrences = [];
|
|
31
38
|
const duration = event.end - event.start;
|
|
32
39
|
const eventTimezone = timezone || event.timeZone || 'UTC';
|
|
33
40
|
const tzManager = TimezoneManager.getInstance();
|
|
34
41
|
|
|
35
42
|
// Work in event's timezone for accurate recurrence calculation
|
|
36
|
-
|
|
43
|
+
const currentDate = new Date(event.start);
|
|
37
44
|
let count = 0;
|
|
38
45
|
|
|
39
46
|
// If UNTIL is specified, use it as the range end
|
|
@@ -48,11 +55,18 @@ export class RecurrenceEngine {
|
|
|
48
55
|
let stuckCount = 0;
|
|
49
56
|
const maxStuckIterations = 3;
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
// Compare on numeric timestamps in the loop — Date-object comparisons
|
|
59
|
+
// re-coerce through valueOf on every check
|
|
60
|
+
const rangeStartMs = rangeStart.getTime();
|
|
61
|
+
const rangeEndMs = rangeEnd.getTime();
|
|
62
|
+
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
63
|
+
let currentMs = currentDate.getTime();
|
|
64
|
+
|
|
65
|
+
while (currentMs <= rangeEndMs && count < maxOccurrences) {
|
|
52
66
|
// Check if this occurrence is within the range
|
|
53
|
-
if (
|
|
54
|
-
const occurrenceStart = new Date(
|
|
55
|
-
const occurrenceEnd = new Date(
|
|
67
|
+
if (currentMs >= rangeStartMs) {
|
|
68
|
+
const occurrenceStart = new Date(currentMs);
|
|
69
|
+
const occurrenceEnd = new Date(currentMs + duration);
|
|
56
70
|
|
|
57
71
|
// Handle DST transitions
|
|
58
72
|
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
@@ -65,7 +79,7 @@ export class RecurrenceEngine {
|
|
|
65
79
|
lastOffset = currentOffset;
|
|
66
80
|
|
|
67
81
|
// Apply exceptions if any
|
|
68
|
-
if (!this.isException(occurrenceStart, rule, event.id)) {
|
|
82
|
+
if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
|
|
69
83
|
occurrences.push({
|
|
70
84
|
start: occurrenceStart,
|
|
71
85
|
end: occurrenceEnd,
|
|
@@ -77,12 +91,13 @@ export class RecurrenceEngine {
|
|
|
77
91
|
}
|
|
78
92
|
|
|
79
93
|
// Calculate next occurrence
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
this._advanceInPlace(currentDate, rule);
|
|
95
|
+
const previousTimestamp = currentMs;
|
|
96
|
+
currentMs = currentDate.getTime();
|
|
82
97
|
count++;
|
|
83
98
|
|
|
84
99
|
// Safeguard: detect if date is not advancing (infinite loop risk)
|
|
85
|
-
if (
|
|
100
|
+
if (currentMs === previousTimestamp) {
|
|
86
101
|
stuckCount++;
|
|
87
102
|
if (stuckCount >= maxStuckIterations) {
|
|
88
103
|
console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
|
|
@@ -150,14 +165,36 @@ export class RecurrenceEngine {
|
|
|
150
165
|
|
|
151
166
|
/**
|
|
152
167
|
* Parse an RRULE string into a rule object
|
|
153
|
-
* @param {string|import('
|
|
154
|
-
* @returns {import('
|
|
168
|
+
* @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
|
|
169
|
+
* @returns {import('../types.js').RecurrenceRule} Parsed rule object
|
|
155
170
|
*/
|
|
156
171
|
static parseRule(ruleString) {
|
|
157
172
|
// Use the new comprehensive parser
|
|
158
173
|
return RRuleParser.parse(ruleString);
|
|
159
174
|
}
|
|
160
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Parse a rule with caching for string rules (internal use by expandEvent).
|
|
178
|
+
* Cached rule objects are shared across calls and must not be mutated.
|
|
179
|
+
* @param {string|Object} recurrenceRule - RRULE string or rule object
|
|
180
|
+
* @returns {import('../types.js').RecurrenceRule} Parsed rule object
|
|
181
|
+
* @private
|
|
182
|
+
*/
|
|
183
|
+
static _getParsedRule(recurrenceRule) {
|
|
184
|
+
if (typeof recurrenceRule !== 'string') {
|
|
185
|
+
return this.parseRule(recurrenceRule);
|
|
186
|
+
}
|
|
187
|
+
let rule = this._ruleCache.get(recurrenceRule);
|
|
188
|
+
if (!rule) {
|
|
189
|
+
rule = this.parseRule(recurrenceRule);
|
|
190
|
+
if (this._ruleCache.size >= this._RULE_CACHE_MAX) {
|
|
191
|
+
this._ruleCache.clear();
|
|
192
|
+
}
|
|
193
|
+
this._ruleCache.set(recurrenceRule, rule);
|
|
194
|
+
}
|
|
195
|
+
return rule;
|
|
196
|
+
}
|
|
197
|
+
|
|
161
198
|
/**
|
|
162
199
|
* Calculate the next occurrence based on the rule
|
|
163
200
|
* @param {Date} currentDate - Current occurrence date
|
|
@@ -167,7 +204,18 @@ export class RecurrenceEngine {
|
|
|
167
204
|
*/
|
|
168
205
|
static getNextOccurrence(currentDate, rule, _timezone = 'UTC') {
|
|
169
206
|
const next = new Date(currentDate);
|
|
207
|
+
this._advanceInPlace(next, rule);
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
170
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Advance a date to the next occurrence, mutating it in place.
|
|
213
|
+
* Used by expandEvent to avoid one Date allocation per step.
|
|
214
|
+
* @param {Date} next - Date to advance (mutated)
|
|
215
|
+
* @param {Object} rule - Recurrence rule object
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
static _advanceInPlace(next, rule) {
|
|
171
219
|
switch (rule.freq) {
|
|
172
220
|
case 'SECONDLY':
|
|
173
221
|
next.setSeconds(next.getSeconds() + rule.interval);
|
|
@@ -187,21 +235,17 @@ export class RecurrenceEngine {
|
|
|
187
235
|
|
|
188
236
|
case 'WEEKLY':
|
|
189
237
|
if (rule.byDay && rule.byDay.length > 0) {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
// If no match found, fall back to simple weekly interval from original date
|
|
201
|
-
if (iterations >= maxIterations) {
|
|
238
|
+
// Jump straight to the next matching weekday using a delta table
|
|
239
|
+
// precompiled once per rule instead of stepping day by day
|
|
240
|
+
const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
|
|
241
|
+
if (daySet.size > 0) {
|
|
242
|
+
const deltas =
|
|
243
|
+
rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
|
|
244
|
+
next.setDate(next.getDate() + deltas[next.getDay()]);
|
|
245
|
+
} else {
|
|
246
|
+
// No valid day codes: fall back to simple weekly interval
|
|
202
247
|
console.warn('RecurrenceEngine: Invalid byDay rule, falling back to weekly interval');
|
|
203
|
-
|
|
204
|
-
next.setDate(originalDate + 7 * rule.interval);
|
|
248
|
+
next.setDate(next.getDate() + 7 * rule.interval);
|
|
205
249
|
}
|
|
206
250
|
} else {
|
|
207
251
|
// Simple weekly recurrence
|
|
@@ -215,7 +259,7 @@ export class RecurrenceEngine {
|
|
|
215
259
|
const currentMonth = next.getMonth();
|
|
216
260
|
next.setMonth(currentMonth + rule.interval);
|
|
217
261
|
// Clamp to last day of month if day doesn't exist
|
|
218
|
-
const daysInMonth =
|
|
262
|
+
const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
|
|
219
263
|
next.setDate(Math.min(rule.byMonthDay[0], daysInMonth));
|
|
220
264
|
} else if (rule.byDay && rule.byDay.length > 0) {
|
|
221
265
|
// Specific weekday of month (e.g., "2nd Tuesday")
|
|
@@ -246,8 +290,58 @@ export class RecurrenceEngine {
|
|
|
246
290
|
// Unsupported frequency
|
|
247
291
|
next.setTime(next.getTime() + 24 * 60 * 60 * 1000); // Daily fallback
|
|
248
292
|
}
|
|
293
|
+
}
|
|
249
294
|
|
|
250
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Days to add from each weekday (index 0-6) to reach the next weekday
|
|
297
|
+
* present in the given set
|
|
298
|
+
* @param {Set<number>} daySet - Non-empty set of weekday numbers
|
|
299
|
+
* @returns {number[]} Delta table indexed by Date#getDay()
|
|
300
|
+
* @private
|
|
301
|
+
*/
|
|
302
|
+
static _buildByDayDeltas(daySet) {
|
|
303
|
+
const deltas = new Array(7);
|
|
304
|
+
for (let dow = 0; dow < 7; dow++) {
|
|
305
|
+
for (let d = 1; d <= 7; d++) {
|
|
306
|
+
if (daySet.has((dow + d) % 7)) {
|
|
307
|
+
deltas[dow] = d;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return deltas;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Number of days in a month without allocating a Date
|
|
317
|
+
* @param {number} year - Full year
|
|
318
|
+
* @param {number} month - Month index (0-11)
|
|
319
|
+
* @returns {number}
|
|
320
|
+
* @private
|
|
321
|
+
*/
|
|
322
|
+
static _daysInMonth(year, month) {
|
|
323
|
+
if (month === 1) {
|
|
324
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
325
|
+
}
|
|
326
|
+
return DAYS_IN_MONTH[month];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build a Set of numeric weekdays (0-6) from BYDAY codes
|
|
331
|
+
* @param {Array<string>} byDay - Array of day codes (e.g., ['MO', '2TU'])
|
|
332
|
+
* @returns {Set<number>} Weekday numbers; invalid codes are skipped
|
|
333
|
+
* @private
|
|
334
|
+
*/
|
|
335
|
+
static _buildByDaySet(byDay) {
|
|
336
|
+
const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
|
|
337
|
+
const set = new Set();
|
|
338
|
+
for (const day of byDay) {
|
|
339
|
+
const match = /^(-?\d+)?([A-Z]{2})$/.exec(day);
|
|
340
|
+
if (match && dayMap[match[2]] !== undefined) {
|
|
341
|
+
set.add(dayMap[match[2]]);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return set;
|
|
251
345
|
}
|
|
252
346
|
|
|
253
347
|
/**
|
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.2.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) {
|