@forcecalendar/core 0.2.0 → 0.2.1
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 +715 -0
- package/core/calendar/DateUtils.js +553 -0
- package/core/conflicts/ConflictDetector.js +517 -0
- package/core/events/Event.js +914 -0
- package/core/events/EventStore.js +1198 -0
- package/core/events/RRuleParser.js +420 -0
- package/core/events/RecurrenceEngine.js +382 -0
- package/core/ics/ICSHandler.js +389 -0
- package/core/ics/ICSParser.js +475 -0
- package/core/performance/AdaptiveMemoryManager.js +333 -0
- package/core/performance/LRUCache.js +118 -0
- package/core/performance/PerformanceOptimizer.js +523 -0
- package/core/search/EventSearch.js +476 -0
- package/core/state/StateManager.js +546 -0
- package/core/timezone/TimezoneDatabase.js +294 -0
- package/core/timezone/TimezoneManager.js +419 -0
- package/core/types.js +366 -0
- package/package.json +11 -9
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import { Event } from './Event.js';
|
|
2
|
+
import { DateUtils } from '../calendar/DateUtils.js';
|
|
3
|
+
import { RecurrenceEngine } from './RecurrenceEngine.js';
|
|
4
|
+
import { PerformanceOptimizer } from '../performance/PerformanceOptimizer.js';
|
|
5
|
+
import { ConflictDetector } from '../conflicts/ConflictDetector.js';
|
|
6
|
+
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* EventStore - Manages calendar events with efficient querying
|
|
10
|
+
* Uses Map for O(1) lookups and spatial indexing concepts for date queries
|
|
11
|
+
* Now with performance optimizations for large datasets
|
|
12
|
+
*/
|
|
13
|
+
export class EventStore {
|
|
14
|
+
constructor(config = {}) {
|
|
15
|
+
// Primary storage - Map for O(1) ID lookups
|
|
16
|
+
/** @type {Map<string, Event>} */
|
|
17
|
+
this.events = new Map();
|
|
18
|
+
|
|
19
|
+
// Indices for efficient queries (using UTC for consistent indexing)
|
|
20
|
+
this.indices = {
|
|
21
|
+
/** @type {Map<string, Set<string>>} UTC Date string -> Set of event IDs */
|
|
22
|
+
byDate: new Map(),
|
|
23
|
+
/** @type {Map<string, Set<string>>} YYYY-MM (UTC) -> Set of event IDs */
|
|
24
|
+
byMonth: new Map(),
|
|
25
|
+
/** @type {Set<string>} Set of recurring event IDs */
|
|
26
|
+
recurring: new Set(),
|
|
27
|
+
/** @type {Map<string, Set<string>>} Category -> Set of event IDs */
|
|
28
|
+
byCategory: new Map(),
|
|
29
|
+
/** @type {Map<string, Set<string>>} Status -> Set of event IDs */
|
|
30
|
+
byStatus: new Map()
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Timezone manager for conversions
|
|
34
|
+
this.timezoneManager = new TimezoneManager();
|
|
35
|
+
|
|
36
|
+
// Default timezone for the store (can be overridden)
|
|
37
|
+
this.defaultTimezone = config.timezone || this.timezoneManager.getSystemTimezone();
|
|
38
|
+
|
|
39
|
+
// Performance optimizer
|
|
40
|
+
this.optimizer = new PerformanceOptimizer(config.performance);
|
|
41
|
+
|
|
42
|
+
// Conflict detector
|
|
43
|
+
this.conflictDetector = new ConflictDetector(this);
|
|
44
|
+
|
|
45
|
+
// Batch operation state
|
|
46
|
+
this.isBatchMode = false;
|
|
47
|
+
this.batchNotifications = [];
|
|
48
|
+
this.batchBackup = null; // For rollback support
|
|
49
|
+
|
|
50
|
+
// Change tracking
|
|
51
|
+
/** @type {number} */
|
|
52
|
+
this.version = 0;
|
|
53
|
+
/** @type {Set<import('../../types.js').EventListener>} */
|
|
54
|
+
this.listeners = new Set();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add an event to the store
|
|
59
|
+
* @param {Event|import('../../types.js').EventData} event - The event to add
|
|
60
|
+
* @returns {Event} The added event
|
|
61
|
+
* @throws {Error} If event with same ID already exists
|
|
62
|
+
*/
|
|
63
|
+
addEvent(event) {
|
|
64
|
+
return this.optimizer.measure('addEvent', () => {
|
|
65
|
+
if (!(event instanceof Event)) {
|
|
66
|
+
event = new Event(event);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.events.has(event.id)) {
|
|
70
|
+
throw new Error(`Event with id ${event.id} already exists`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Store the event
|
|
74
|
+
this.events.set(event.id, event);
|
|
75
|
+
|
|
76
|
+
// Cache the event
|
|
77
|
+
this.optimizer.cache(event.id, event, 'event');
|
|
78
|
+
|
|
79
|
+
// Update indices
|
|
80
|
+
this._indexEvent(event);
|
|
81
|
+
|
|
82
|
+
// Notify listeners (batch if in batch mode)
|
|
83
|
+
if (this.isBatchMode) {
|
|
84
|
+
this.batchNotifications.push({
|
|
85
|
+
type: 'add',
|
|
86
|
+
event,
|
|
87
|
+
version: ++this.version
|
|
88
|
+
});
|
|
89
|
+
} else {
|
|
90
|
+
this._notifyChange({
|
|
91
|
+
type: 'add',
|
|
92
|
+
event,
|
|
93
|
+
version: ++this.version
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return event;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Update an existing event
|
|
103
|
+
* @param {string} eventId - The event ID
|
|
104
|
+
* @param {Partial<import('../../types.js').EventData>} updates - Properties to update
|
|
105
|
+
* @returns {Event} The updated event
|
|
106
|
+
* @throws {Error} If event not found
|
|
107
|
+
*/
|
|
108
|
+
updateEvent(eventId, updates) {
|
|
109
|
+
const existingEvent = this.events.get(eventId);
|
|
110
|
+
if (!existingEvent) {
|
|
111
|
+
throw new Error(`Event with id ${eventId} not found`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Remove old indices
|
|
115
|
+
this._unindexEvent(existingEvent);
|
|
116
|
+
|
|
117
|
+
// Create updated event
|
|
118
|
+
const updatedEvent = existingEvent.clone(updates);
|
|
119
|
+
|
|
120
|
+
// Store updated event
|
|
121
|
+
this.events.set(eventId, updatedEvent);
|
|
122
|
+
|
|
123
|
+
// Re-index
|
|
124
|
+
this._indexEvent(updatedEvent);
|
|
125
|
+
|
|
126
|
+
// Notify listeners
|
|
127
|
+
this._notifyChange({
|
|
128
|
+
type: 'update',
|
|
129
|
+
event: updatedEvent,
|
|
130
|
+
oldEvent: existingEvent,
|
|
131
|
+
version: ++this.version
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return updatedEvent;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Remove an event from the store
|
|
139
|
+
* @param {string} eventId - The event ID to remove
|
|
140
|
+
* @returns {boolean} True if removed, false if not found
|
|
141
|
+
*/
|
|
142
|
+
removeEvent(eventId) {
|
|
143
|
+
const event = this.events.get(eventId);
|
|
144
|
+
if (!event) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Remove from primary storage
|
|
149
|
+
this.events.delete(eventId);
|
|
150
|
+
|
|
151
|
+
// Remove from indices
|
|
152
|
+
this._unindexEvent(event);
|
|
153
|
+
|
|
154
|
+
// Notify listeners
|
|
155
|
+
this._notifyChange({
|
|
156
|
+
type: 'remove',
|
|
157
|
+
event,
|
|
158
|
+
version: ++this.version
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get an event by ID
|
|
166
|
+
* @param {string} eventId - The event ID
|
|
167
|
+
* @returns {Event|null} The event or null if not found
|
|
168
|
+
*/
|
|
169
|
+
getEvent(eventId) {
|
|
170
|
+
// Check cache first
|
|
171
|
+
const cached = this.optimizer.getFromCache(eventId, 'event');
|
|
172
|
+
if (cached) {
|
|
173
|
+
return cached;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Get from store
|
|
177
|
+
const event = this.events.get(eventId) || null;
|
|
178
|
+
|
|
179
|
+
// Cache if found
|
|
180
|
+
if (event) {
|
|
181
|
+
this.optimizer.cache(eventId, event, 'event');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return event;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Get all events
|
|
189
|
+
* @returns {Event[]} Array of all events
|
|
190
|
+
*/
|
|
191
|
+
getAllEvents() {
|
|
192
|
+
return Array.from(this.events.values());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Query events with filters
|
|
197
|
+
* @param {import('../../types.js').QueryFilters} [filters={}] - Query filters
|
|
198
|
+
* @returns {Event[]} Filtered events
|
|
199
|
+
*/
|
|
200
|
+
queryEvents(filters = {}) {
|
|
201
|
+
let results = Array.from(this.events.values());
|
|
202
|
+
|
|
203
|
+
// Filter by date range
|
|
204
|
+
if (filters.start || filters.end) {
|
|
205
|
+
const start = filters.start ? new Date(filters.start) : null;
|
|
206
|
+
const end = filters.end ? new Date(filters.end) : null;
|
|
207
|
+
|
|
208
|
+
results = results.filter(event => {
|
|
209
|
+
if (start && event.end < start) return false;
|
|
210
|
+
if (end && event.start > end) return false;
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Filter by specific date
|
|
216
|
+
if (filters.date) {
|
|
217
|
+
const date = new Date(filters.date);
|
|
218
|
+
results = results.filter(event => event.occursOn(date));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Filter by month
|
|
222
|
+
if (filters.month && filters.year) {
|
|
223
|
+
const monthKey = `${filters.year}-${String(filters.month).padStart(2, '0')}`;
|
|
224
|
+
const eventIds = this.indices.byMonth.get(monthKey) || new Set();
|
|
225
|
+
results = results.filter(event => eventIds.has(event.id));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Filter by all-day events
|
|
229
|
+
if (filters.hasOwnProperty('allDay')) {
|
|
230
|
+
results = results.filter(event => event.allDay === filters.allDay);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Filter by recurring
|
|
234
|
+
if (filters.hasOwnProperty('recurring')) {
|
|
235
|
+
results = results.filter(event => event.recurring === filters.recurring);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Filter by status
|
|
239
|
+
if (filters.status) {
|
|
240
|
+
results = results.filter(event => event.status === filters.status);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Filter by categories
|
|
244
|
+
if (filters.categories && filters.categories.length > 0) {
|
|
245
|
+
results = results.filter(event =>
|
|
246
|
+
filters.matchAllCategories
|
|
247
|
+
? event.hasAllCategories(filters.categories)
|
|
248
|
+
: event.hasAnyCategory(filters.categories)
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Filter by having attendees
|
|
253
|
+
if (filters.hasOwnProperty('hasAttendees')) {
|
|
254
|
+
results = results.filter(event => filters.hasAttendees ? event.hasAttendees : !event.hasAttendees);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Filter by organizer email
|
|
258
|
+
if (filters.organizerEmail) {
|
|
259
|
+
results = results.filter(event =>
|
|
260
|
+
event.organizer && event.organizer.email === filters.organizerEmail
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Sort results
|
|
265
|
+
if (filters.sort) {
|
|
266
|
+
results.sort((a, b) => {
|
|
267
|
+
switch (filters.sort) {
|
|
268
|
+
case 'start':
|
|
269
|
+
return a.start - b.start;
|
|
270
|
+
case 'end':
|
|
271
|
+
return a.end - b.end;
|
|
272
|
+
case 'duration':
|
|
273
|
+
return a.duration - b.duration;
|
|
274
|
+
case 'title':
|
|
275
|
+
return a.title.localeCompare(b.title);
|
|
276
|
+
default:
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return results;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get events for a specific date
|
|
287
|
+
* @param {Date} date - The date to query
|
|
288
|
+
* @param {string} [timezone] - Timezone for the query (defaults to store timezone)
|
|
289
|
+
* @returns {Event[]} Events occurring on the date, sorted by start time
|
|
290
|
+
*/
|
|
291
|
+
getEventsForDate(date, timezone = null) {
|
|
292
|
+
timezone = timezone || this.defaultTimezone;
|
|
293
|
+
|
|
294
|
+
// Use local date string for the query date (in the calendar's timezone)
|
|
295
|
+
const dateStr = DateUtils.getLocalDateString(date);
|
|
296
|
+
|
|
297
|
+
// Get all events indexed for this date
|
|
298
|
+
const allEvents = [];
|
|
299
|
+
|
|
300
|
+
// Since events might span multiple days in different timezones,
|
|
301
|
+
// we need to check events from surrounding dates too
|
|
302
|
+
const checkDate = new Date(date);
|
|
303
|
+
for (let offset = -1; offset <= 1; offset++) {
|
|
304
|
+
const tempDate = new Date(checkDate);
|
|
305
|
+
tempDate.setDate(tempDate.getDate() + offset);
|
|
306
|
+
const tempDateStr = DateUtils.getLocalDateString(tempDate);
|
|
307
|
+
const eventIds = this.indices.byDate.get(tempDateStr) || new Set();
|
|
308
|
+
|
|
309
|
+
for (const id of eventIds) {
|
|
310
|
+
const event = this.events.get(id);
|
|
311
|
+
if (event && !allEvents.find(e => e.id === event.id)) {
|
|
312
|
+
// Check if event actually occurs on the requested date in the given timezone
|
|
313
|
+
const eventStartLocal = event.getStartInTimezone(timezone);
|
|
314
|
+
const eventEndLocal = event.getEndInTimezone(timezone);
|
|
315
|
+
|
|
316
|
+
const startOfDay = new Date(date);
|
|
317
|
+
startOfDay.setHours(0, 0, 0, 0);
|
|
318
|
+
const endOfDay = new Date(date);
|
|
319
|
+
endOfDay.setHours(23, 59, 59, 999);
|
|
320
|
+
|
|
321
|
+
// Event overlaps with this day if it starts before end of day and ends after start of day
|
|
322
|
+
if (eventStartLocal <= endOfDay && eventEndLocal >= startOfDay) {
|
|
323
|
+
allEvents.push(event);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return allEvents.sort((a, b) => {
|
|
330
|
+
// Sort by start time in the specified timezone
|
|
331
|
+
const aStart = a.getStartInTimezone(timezone);
|
|
332
|
+
const bStart = b.getStartInTimezone(timezone);
|
|
333
|
+
const timeCompare = aStart - bStart;
|
|
334
|
+
if (timeCompare !== 0) return timeCompare;
|
|
335
|
+
return b.duration - a.duration; // Longer events first
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get events that overlap with a given time range
|
|
341
|
+
* @param {Date} start - Start time
|
|
342
|
+
* @param {Date} end - End time
|
|
343
|
+
* @param {string} [excludeId=null] - Optional event ID to exclude (useful when checking for conflicts)
|
|
344
|
+
* @returns {Event[]} Array of overlapping events
|
|
345
|
+
*/
|
|
346
|
+
getOverlappingEvents(start, end, excludeId = null) {
|
|
347
|
+
const overlapping = [];
|
|
348
|
+
|
|
349
|
+
// Get all events in the date range
|
|
350
|
+
const startDate = DateUtils.startOfDay(start);
|
|
351
|
+
const endDate = DateUtils.endOfDay(end);
|
|
352
|
+
const dates = DateUtils.getDateRange(startDate, endDate);
|
|
353
|
+
|
|
354
|
+
// Collect all events from those dates
|
|
355
|
+
const checkedIds = new Set();
|
|
356
|
+
dates.forEach(date => {
|
|
357
|
+
const dateStr = date.toDateString();
|
|
358
|
+
const eventIds = this.indices.byDate.get(dateStr) || new Set();
|
|
359
|
+
|
|
360
|
+
eventIds.forEach(id => {
|
|
361
|
+
if (!checkedIds.has(id) && id !== excludeId) {
|
|
362
|
+
checkedIds.add(id);
|
|
363
|
+
const event = this.events.get(id);
|
|
364
|
+
|
|
365
|
+
if (event && event.overlaps({ start, end })) {
|
|
366
|
+
overlapping.push(event);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return overlapping.sort((a, b) => a.start - b.start);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Check if an event would conflict with existing events
|
|
377
|
+
* @param {Date} start - Start time
|
|
378
|
+
* @param {Date} end - End time
|
|
379
|
+
* @param {string} excludeId - Optional event ID to exclude
|
|
380
|
+
* @returns {boolean} True if there are conflicts
|
|
381
|
+
*/
|
|
382
|
+
hasConflicts(start, end, excludeId = null) {
|
|
383
|
+
return this.getOverlappingEvents(start, end, excludeId).length > 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Get events grouped by overlapping time slots
|
|
388
|
+
* Useful for calculating event positions in week/day views
|
|
389
|
+
* @param {Date} date - The date to analyze
|
|
390
|
+
* @param {boolean} timedOnly - Only include timed events (not all-day)
|
|
391
|
+
* @returns {Array<Event[]>} Array of event groups that overlap
|
|
392
|
+
*/
|
|
393
|
+
getOverlapGroups(date, timedOnly = true) {
|
|
394
|
+
let events = this.getEventsForDate(date);
|
|
395
|
+
|
|
396
|
+
if (timedOnly) {
|
|
397
|
+
events = events.filter(e => !e.allDay);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const groups = [];
|
|
401
|
+
const processed = new Set();
|
|
402
|
+
|
|
403
|
+
events.forEach(event => {
|
|
404
|
+
if (processed.has(event.id)) return;
|
|
405
|
+
|
|
406
|
+
// Start a new group with this event
|
|
407
|
+
const group = [event];
|
|
408
|
+
processed.add(event.id);
|
|
409
|
+
|
|
410
|
+
// Find all events that overlap with any event in this group
|
|
411
|
+
let i = 0;
|
|
412
|
+
while (i < group.length) {
|
|
413
|
+
const currentEvent = group[i];
|
|
414
|
+
|
|
415
|
+
events.forEach(otherEvent => {
|
|
416
|
+
if (!processed.has(otherEvent.id) && currentEvent.overlaps(otherEvent)) {
|
|
417
|
+
group.push(otherEvent);
|
|
418
|
+
processed.add(otherEvent.id);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
i++;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
groups.push(group);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
return groups;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Calculate positions for overlapping events (for rendering)
|
|
433
|
+
* @param {Event[]} events - Array of overlapping events
|
|
434
|
+
* @returns {Map<string, {column: number, totalColumns: number}>} Position data for each event
|
|
435
|
+
*/
|
|
436
|
+
calculateEventPositions(events) {
|
|
437
|
+
const positions = new Map();
|
|
438
|
+
|
|
439
|
+
if (events.length === 0) return positions;
|
|
440
|
+
|
|
441
|
+
// Sort by start time, then by duration (longer events first)
|
|
442
|
+
events.sort((a, b) => {
|
|
443
|
+
const startDiff = a.start - b.start;
|
|
444
|
+
if (startDiff !== 0) return startDiff;
|
|
445
|
+
return (b.end - b.start) - (a.end - a.start);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Track which columns are occupied at each time
|
|
449
|
+
const columns = [];
|
|
450
|
+
|
|
451
|
+
events.forEach(event => {
|
|
452
|
+
// Find the first available column
|
|
453
|
+
let column = 0;
|
|
454
|
+
while (column < columns.length) {
|
|
455
|
+
const columnEvents = columns[column];
|
|
456
|
+
const hasConflict = columnEvents.some(e => e.overlaps(event));
|
|
457
|
+
|
|
458
|
+
if (!hasConflict) {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
column++;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Add event to the column
|
|
465
|
+
if (!columns[column]) {
|
|
466
|
+
columns[column] = [];
|
|
467
|
+
}
|
|
468
|
+
columns[column].push(event);
|
|
469
|
+
|
|
470
|
+
positions.set(event.id, {
|
|
471
|
+
column: column,
|
|
472
|
+
totalColumns: 0 // Will be updated after all events are placed
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Update total columns for all events
|
|
477
|
+
const totalColumns = columns.length;
|
|
478
|
+
positions.forEach(pos => {
|
|
479
|
+
pos.totalColumns = totalColumns;
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return positions;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get events for a date range
|
|
487
|
+
* @param {Date} start - Start date
|
|
488
|
+
* @param {Date} end - End date
|
|
489
|
+
* @param {boolean|string} expandRecurring - Whether to expand recurring events, or timezone string
|
|
490
|
+
* @param {string} [timezone] - Timezone for the query (if expandRecurring is boolean)
|
|
491
|
+
* @returns {Event[]}
|
|
492
|
+
*/
|
|
493
|
+
getEventsInRange(start, end, expandRecurring = true, timezone = null) {
|
|
494
|
+
// Handle overloaded parameters
|
|
495
|
+
if (typeof expandRecurring === 'string') {
|
|
496
|
+
timezone = expandRecurring;
|
|
497
|
+
expandRecurring = true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
timezone = timezone || this.defaultTimezone;
|
|
501
|
+
|
|
502
|
+
// Convert range to UTC for querying
|
|
503
|
+
const startUTC = this.timezoneManager.toUTC(start, timezone);
|
|
504
|
+
const endUTC = this.timezoneManager.toUTC(end, timezone);
|
|
505
|
+
|
|
506
|
+
// Query using UTC times
|
|
507
|
+
const baseEvents = this.queryEvents({
|
|
508
|
+
start: startUTC,
|
|
509
|
+
end: endUTC,
|
|
510
|
+
sort: 'start'
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (!expandRecurring) {
|
|
514
|
+
return baseEvents;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Expand recurring events
|
|
518
|
+
const expandedEvents = [];
|
|
519
|
+
baseEvents.forEach(event => {
|
|
520
|
+
if (event.recurring && event.recurrenceRule) {
|
|
521
|
+
const occurrences = this.expandRecurringEvent(event, start, end, timezone);
|
|
522
|
+
expandedEvents.push(...occurrences);
|
|
523
|
+
} else {
|
|
524
|
+
expandedEvents.push(event);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
return expandedEvents.sort((a, b) => {
|
|
529
|
+
// Sort by start time in the specified timezone
|
|
530
|
+
const aStart = a.getStartInTimezone(timezone);
|
|
531
|
+
const bStart = b.getStartInTimezone(timezone);
|
|
532
|
+
return aStart - bStart;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Expand a recurring event into individual occurrences
|
|
538
|
+
* @param {Event} event - The recurring event
|
|
539
|
+
* @param {Date} rangeStart - Start of the expansion range
|
|
540
|
+
* @param {Date} rangeEnd - End of the expansion range
|
|
541
|
+
* @param {string} [timezone] - Timezone for the expansion
|
|
542
|
+
* @returns {Event[]} Array of event occurrences
|
|
543
|
+
*/
|
|
544
|
+
expandRecurringEvent(event, rangeStart, rangeEnd, timezone = null) {
|
|
545
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
546
|
+
return [event];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
timezone = timezone || this.defaultTimezone;
|
|
550
|
+
|
|
551
|
+
// Expand in the event's timezone for accurate recurrence calculation
|
|
552
|
+
const eventTimezone = event.timeZone || timezone;
|
|
553
|
+
const occurrences = RecurrenceEngine.expandEvent(event, rangeStart, rangeEnd);
|
|
554
|
+
|
|
555
|
+
return occurrences.map((occurrence, index) => {
|
|
556
|
+
// Create a new event instance for each occurrence
|
|
557
|
+
const occurrenceEvent = event.clone({
|
|
558
|
+
id: `${event.id}_occurrence_${index}`,
|
|
559
|
+
start: occurrence.start,
|
|
560
|
+
end: occurrence.end,
|
|
561
|
+
timeZone: eventTimezone,
|
|
562
|
+
metadata: {
|
|
563
|
+
...event.metadata,
|
|
564
|
+
recurringEventId: event.id,
|
|
565
|
+
occurrenceIndex: index
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
return occurrenceEvent;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Clear all events
|
|
575
|
+
*/
|
|
576
|
+
clear() {
|
|
577
|
+
const oldEvents = this.getAllEvents();
|
|
578
|
+
|
|
579
|
+
this.events.clear();
|
|
580
|
+
this.indices.byDate.clear();
|
|
581
|
+
this.indices.byMonth.clear();
|
|
582
|
+
this.indices.recurring.clear();
|
|
583
|
+
|
|
584
|
+
this._notifyChange({
|
|
585
|
+
type: 'clear',
|
|
586
|
+
oldEvents,
|
|
587
|
+
version: ++this.version
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Bulk load events
|
|
593
|
+
* @param {Event[]} events - Array of events or event data
|
|
594
|
+
*/
|
|
595
|
+
loadEvents(events) {
|
|
596
|
+
this.clear();
|
|
597
|
+
|
|
598
|
+
for (const eventData of events) {
|
|
599
|
+
this.addEvent(eventData);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Subscribe to store changes
|
|
605
|
+
* @param {Function} callback - Callback function
|
|
606
|
+
* @returns {Function} Unsubscribe function
|
|
607
|
+
*/
|
|
608
|
+
subscribe(callback) {
|
|
609
|
+
this.listeners.add(callback);
|
|
610
|
+
|
|
611
|
+
return () => {
|
|
612
|
+
this.listeners.delete(callback);
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Index an event for efficient queries
|
|
618
|
+
* @private
|
|
619
|
+
*/
|
|
620
|
+
_indexEvent(event) {
|
|
621
|
+
// Check if should use lazy indexing for large date ranges
|
|
622
|
+
if (this.optimizer.shouldUseLazyIndexing(event)) {
|
|
623
|
+
this._indexEventLazy(event);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Index by local dates in the event's timezone
|
|
628
|
+
// This ensures events appear on the correct calendar day
|
|
629
|
+
const eventStartLocal = event.getStartInTimezone(event.timeZone);
|
|
630
|
+
const eventEndLocal = event.getEndInTimezone(event.endTimeZone || event.timeZone);
|
|
631
|
+
|
|
632
|
+
const startDate = DateUtils.startOfDay(eventStartLocal);
|
|
633
|
+
const endDate = DateUtils.endOfDay(eventEndLocal);
|
|
634
|
+
|
|
635
|
+
// For each day the event spans (in local time), add to date index
|
|
636
|
+
const dates = DateUtils.getDateRange(startDate, endDate);
|
|
637
|
+
|
|
638
|
+
dates.forEach(date => {
|
|
639
|
+
const dateStr = DateUtils.getLocalDateString(date);
|
|
640
|
+
|
|
641
|
+
if (!this.indices.byDate.has(dateStr)) {
|
|
642
|
+
this.indices.byDate.set(dateStr, new Set());
|
|
643
|
+
}
|
|
644
|
+
this.indices.byDate.get(dateStr).add(event.id);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// Index by month(s) using UTC
|
|
648
|
+
const startMonth = `${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}`;
|
|
649
|
+
const endMonth = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}`;
|
|
650
|
+
|
|
651
|
+
// Add to all months the event spans
|
|
652
|
+
const currentMonth = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
|
|
653
|
+
while (currentMonth <= endDate) {
|
|
654
|
+
const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
655
|
+
|
|
656
|
+
if (!this.indices.byMonth.has(monthKey)) {
|
|
657
|
+
this.indices.byMonth.set(monthKey, new Set());
|
|
658
|
+
}
|
|
659
|
+
this.indices.byMonth.get(monthKey).add(event.id);
|
|
660
|
+
|
|
661
|
+
currentMonth.setMonth(currentMonth.getMonth() + 1);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Index by categories
|
|
665
|
+
if (event.categories && event.categories.length > 0) {
|
|
666
|
+
event.categories.forEach(category => {
|
|
667
|
+
if (!this.indices.byCategory.has(category)) {
|
|
668
|
+
this.indices.byCategory.set(category, new Set());
|
|
669
|
+
}
|
|
670
|
+
this.indices.byCategory.get(category).add(event.id);
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Index by status
|
|
675
|
+
if (event.status) {
|
|
676
|
+
if (!this.indices.byStatus.has(event.status)) {
|
|
677
|
+
this.indices.byStatus.set(event.status, new Set());
|
|
678
|
+
}
|
|
679
|
+
this.indices.byStatus.get(event.status).add(event.id);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Index recurring events
|
|
683
|
+
if (event.recurring) {
|
|
684
|
+
this.indices.recurring.add(event.id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Lazy index for events with large date ranges
|
|
690
|
+
* @private
|
|
691
|
+
*/
|
|
692
|
+
_indexEventLazy(event) {
|
|
693
|
+
// Create lazy index markers
|
|
694
|
+
const markers = this.optimizer.createLazyIndexMarkers(event);
|
|
695
|
+
|
|
696
|
+
// Index only the boundaries initially (in event's local timezone)
|
|
697
|
+
const eventStartLocal = event.getStartInTimezone(event.timeZone);
|
|
698
|
+
const eventEndLocal = event.getEndInTimezone(event.endTimeZone || event.timeZone);
|
|
699
|
+
|
|
700
|
+
const startDate = DateUtils.startOfDay(eventStartLocal);
|
|
701
|
+
const endDate = DateUtils.endOfDay(eventEndLocal);
|
|
702
|
+
|
|
703
|
+
// Index first week
|
|
704
|
+
const firstWeekEnd = new Date(startDate);
|
|
705
|
+
firstWeekEnd.setDate(firstWeekEnd.getDate() + 7);
|
|
706
|
+
const firstWeekDates = DateUtils.getDateRange(startDate,
|
|
707
|
+
firstWeekEnd < endDate ? firstWeekEnd : endDate);
|
|
708
|
+
|
|
709
|
+
firstWeekDates.forEach(date => {
|
|
710
|
+
const dateStr = DateUtils.getLocalDateString(date);
|
|
711
|
+
if (!this.indices.byDate.has(dateStr)) {
|
|
712
|
+
this.indices.byDate.set(dateStr, new Set());
|
|
713
|
+
}
|
|
714
|
+
this.indices.byDate.get(dateStr).add(event.id);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// Index last week if different from first
|
|
718
|
+
if (endDate > firstWeekEnd) {
|
|
719
|
+
const lastWeekStart = new Date(endDate);
|
|
720
|
+
lastWeekStart.setDate(lastWeekStart.getDate() - 7);
|
|
721
|
+
const lastWeekDates = DateUtils.getDateRange(
|
|
722
|
+
lastWeekStart > startDate ? lastWeekStart : startDate,
|
|
723
|
+
endDate
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
lastWeekDates.forEach(date => {
|
|
727
|
+
const dateStr = DateUtils.getLocalDateString(date);
|
|
728
|
+
if (!this.indices.byDate.has(dateStr)) {
|
|
729
|
+
this.indices.byDate.set(dateStr, new Set());
|
|
730
|
+
}
|
|
731
|
+
this.indices.byDate.get(dateStr).add(event.id);
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Index months as normal
|
|
736
|
+
const currentMonth = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
|
|
737
|
+
while (currentMonth <= endDate) {
|
|
738
|
+
const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}`;
|
|
739
|
+
if (!this.indices.byMonth.has(monthKey)) {
|
|
740
|
+
this.indices.byMonth.set(monthKey, new Set());
|
|
741
|
+
}
|
|
742
|
+
this.indices.byMonth.get(monthKey).add(event.id);
|
|
743
|
+
currentMonth.setMonth(currentMonth.getMonth() + 1);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Index other properties normally
|
|
747
|
+
if (event.categories && event.categories.length > 0) {
|
|
748
|
+
event.categories.forEach(category => {
|
|
749
|
+
if (!this.indices.byCategory.has(category)) {
|
|
750
|
+
this.indices.byCategory.set(category, new Set());
|
|
751
|
+
}
|
|
752
|
+
this.indices.byCategory.get(category).add(event.id);
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (event.status) {
|
|
757
|
+
if (!this.indices.byStatus.has(event.status)) {
|
|
758
|
+
this.indices.byStatus.set(event.status, new Set());
|
|
759
|
+
}
|
|
760
|
+
this.indices.byStatus.get(event.status).add(event.id);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (event.recurring) {
|
|
764
|
+
this.indices.recurring.add(event.id);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Remove event from indices
|
|
770
|
+
* @private
|
|
771
|
+
*/
|
|
772
|
+
_unindexEvent(event) {
|
|
773
|
+
// Remove from date indices
|
|
774
|
+
for (const [dateStr, eventIds] of this.indices.byDate) {
|
|
775
|
+
eventIds.delete(event.id);
|
|
776
|
+
if (eventIds.size === 0) {
|
|
777
|
+
this.indices.byDate.delete(dateStr);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Remove from month indices
|
|
782
|
+
for (const [monthKey, eventIds] of this.indices.byMonth) {
|
|
783
|
+
eventIds.delete(event.id);
|
|
784
|
+
if (eventIds.size === 0) {
|
|
785
|
+
this.indices.byMonth.delete(monthKey);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Remove from recurring index
|
|
790
|
+
this.indices.recurring.delete(event.id);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Notify listeners of changes
|
|
795
|
+
* @private
|
|
796
|
+
*/
|
|
797
|
+
_notifyChange(change) {
|
|
798
|
+
for (const listener of this.listeners) {
|
|
799
|
+
try {
|
|
800
|
+
listener(change);
|
|
801
|
+
} catch (error) {
|
|
802
|
+
console.error('Error in EventStore listener:', error);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Get store statistics
|
|
809
|
+
* @returns {Object}
|
|
810
|
+
*/
|
|
811
|
+
getStats() {
|
|
812
|
+
return {
|
|
813
|
+
totalEvents: this.events.size,
|
|
814
|
+
recurringEvents: this.indices.recurring.size,
|
|
815
|
+
indexedDates: this.indices.byDate.size,
|
|
816
|
+
indexedMonths: this.indices.byMonth.size,
|
|
817
|
+
indexedCategories: this.indices.byCategory.size,
|
|
818
|
+
indexedStatuses: this.indices.byStatus.size,
|
|
819
|
+
version: this.version,
|
|
820
|
+
performanceMetrics: this.optimizer.getMetrics()
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ============ Batch Operations ============
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Start batch mode for bulk operations
|
|
828
|
+
* Delays notifications until batch is committed
|
|
829
|
+
* @param {boolean} [enableRollback=false] - Enable rollback support (creates backup)
|
|
830
|
+
*/
|
|
831
|
+
startBatch(enableRollback = false) {
|
|
832
|
+
this.isBatchMode = true;
|
|
833
|
+
this.batchNotifications = [];
|
|
834
|
+
|
|
835
|
+
// Create backup for rollback if requested
|
|
836
|
+
if (enableRollback) {
|
|
837
|
+
this.batchBackup = {
|
|
838
|
+
events: new Map(this.events),
|
|
839
|
+
indices: {
|
|
840
|
+
byDate: new Map(Array.from(this.indices.byDate.entries()).map(([k, v]) => [k, new Set(v)])),
|
|
841
|
+
byMonth: new Map(Array.from(this.indices.byMonth.entries()).map(([k, v]) => [k, new Set(v)])),
|
|
842
|
+
recurring: new Set(this.indices.recurring),
|
|
843
|
+
byCategory: new Map(Array.from(this.indices.byCategory.entries()).map(([k, v]) => [k, new Set(v)])),
|
|
844
|
+
byStatus: new Map(Array.from(this.indices.byStatus.entries()).map(([k, v]) => [k, new Set(v)]))
|
|
845
|
+
},
|
|
846
|
+
version: this.version
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Commit batch operations
|
|
853
|
+
* Sends all notifications at once
|
|
854
|
+
*/
|
|
855
|
+
commitBatch() {
|
|
856
|
+
if (!this.isBatchMode) return;
|
|
857
|
+
|
|
858
|
+
this.isBatchMode = false;
|
|
859
|
+
|
|
860
|
+
// Clear backup after successful commit
|
|
861
|
+
this.batchBackup = null;
|
|
862
|
+
|
|
863
|
+
// Send a single bulk notification
|
|
864
|
+
if (this.batchNotifications.length > 0) {
|
|
865
|
+
this._notifyChange({
|
|
866
|
+
type: 'batch',
|
|
867
|
+
changes: this.batchNotifications,
|
|
868
|
+
count: this.batchNotifications.length,
|
|
869
|
+
version: ++this.version
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
this.batchNotifications = [];
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Rollback batch operations
|
|
878
|
+
* Restores state to before batch started
|
|
879
|
+
*/
|
|
880
|
+
rollbackBatch() {
|
|
881
|
+
if (!this.isBatchMode) return;
|
|
882
|
+
|
|
883
|
+
this.isBatchMode = false;
|
|
884
|
+
|
|
885
|
+
// Restore backup if available
|
|
886
|
+
if (this.batchBackup) {
|
|
887
|
+
this.events = this.batchBackup.events;
|
|
888
|
+
this.indices = this.batchBackup.indices;
|
|
889
|
+
this.version = this.batchBackup.version;
|
|
890
|
+
this.batchBackup = null;
|
|
891
|
+
|
|
892
|
+
// Clear cache
|
|
893
|
+
this.optimizer.clearCache();
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
this.batchNotifications = [];
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Execute batch operation with automatic rollback on error
|
|
901
|
+
* @param {Function} operation - Operation to execute
|
|
902
|
+
* @param {boolean} [enableRollback=true] - Enable automatic rollback on error
|
|
903
|
+
* @returns {*} Result of operation
|
|
904
|
+
* @throws {Error} If operation fails
|
|
905
|
+
*/
|
|
906
|
+
async executeBatch(operation, enableRollback = true) {
|
|
907
|
+
this.startBatch(enableRollback);
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
const result = await operation();
|
|
911
|
+
this.commitBatch();
|
|
912
|
+
return result;
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (enableRollback) {
|
|
915
|
+
this.rollbackBatch();
|
|
916
|
+
}
|
|
917
|
+
throw error;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Add multiple events in batch
|
|
923
|
+
* @param {Array<Event|import('../../types.js').EventData>} events - Events to add
|
|
924
|
+
* @returns {Event[]} Added events
|
|
925
|
+
*/
|
|
926
|
+
addEvents(events) {
|
|
927
|
+
return this.optimizer.measure('addEvents', () => {
|
|
928
|
+
this.startBatch();
|
|
929
|
+
const results = [];
|
|
930
|
+
const errors = [];
|
|
931
|
+
|
|
932
|
+
for (const eventData of events) {
|
|
933
|
+
try {
|
|
934
|
+
results.push(this.addEvent(eventData));
|
|
935
|
+
} catch (error) {
|
|
936
|
+
errors.push({ event: eventData, error: error.message });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
this.commitBatch();
|
|
941
|
+
|
|
942
|
+
if (errors.length > 0) {
|
|
943
|
+
console.warn(`Failed to add ${errors.length} events:`, errors);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return results;
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Update multiple events in batch
|
|
952
|
+
* @param {Array<{id: string, updates: Object}>} updates - Update operations
|
|
953
|
+
* @returns {Event[]} Updated events
|
|
954
|
+
*/
|
|
955
|
+
updateEvents(updates) {
|
|
956
|
+
return this.optimizer.measure('updateEvents', () => {
|
|
957
|
+
this.startBatch();
|
|
958
|
+
const results = [];
|
|
959
|
+
const errors = [];
|
|
960
|
+
|
|
961
|
+
for (const { id, updates: eventUpdates } of updates) {
|
|
962
|
+
try {
|
|
963
|
+
results.push(this.updateEvent(id, eventUpdates));
|
|
964
|
+
} catch (error) {
|
|
965
|
+
errors.push({ id, error: error.message });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
this.commitBatch();
|
|
970
|
+
|
|
971
|
+
if (errors.length > 0) {
|
|
972
|
+
console.warn(`Failed to update ${errors.length} events:`, errors);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return results;
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* Remove multiple events in batch
|
|
981
|
+
* @param {string[]} eventIds - Event IDs to remove
|
|
982
|
+
* @returns {number} Number of events removed
|
|
983
|
+
*/
|
|
984
|
+
removeEvents(eventIds) {
|
|
985
|
+
return this.optimizer.measure('removeEvents', () => {
|
|
986
|
+
this.startBatch();
|
|
987
|
+
let removed = 0;
|
|
988
|
+
|
|
989
|
+
for (const id of eventIds) {
|
|
990
|
+
if (this.removeEvent(id)) {
|
|
991
|
+
removed++;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
this.commitBatch();
|
|
996
|
+
return removed;
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ============ Performance Methods ============
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Get performance metrics
|
|
1004
|
+
* @returns {Object} Performance metrics
|
|
1005
|
+
*/
|
|
1006
|
+
getPerformanceMetrics() {
|
|
1007
|
+
return this.optimizer.getMetrics();
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Clear all caches
|
|
1012
|
+
*/
|
|
1013
|
+
clearCaches() {
|
|
1014
|
+
this.optimizer.eventCache.clear();
|
|
1015
|
+
this.optimizer.queryCache.clear();
|
|
1016
|
+
this.optimizer.dateRangeCache.clear();
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Optimize indices by removing old or irrelevant entries
|
|
1021
|
+
* @param {Date} [cutoffDate] - Remove indices older than this date
|
|
1022
|
+
*/
|
|
1023
|
+
optimizeIndices(cutoffDate) {
|
|
1024
|
+
if (!cutoffDate) {
|
|
1025
|
+
cutoffDate = new Date();
|
|
1026
|
+
cutoffDate.setMonth(cutoffDate.getMonth() - 6); // Default: 6 months ago
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const cutoffStr = cutoffDate.toDateString();
|
|
1030
|
+
let removed = 0;
|
|
1031
|
+
|
|
1032
|
+
// Clean up date indices
|
|
1033
|
+
for (const [dateStr, eventIds] of this.indices.byDate) {
|
|
1034
|
+
const date = new Date(dateStr);
|
|
1035
|
+
if (date < cutoffDate) {
|
|
1036
|
+
// Check if any events still need this index
|
|
1037
|
+
let stillNeeded = false;
|
|
1038
|
+
for (const eventId of eventIds) {
|
|
1039
|
+
const event = this.events.get(eventId);
|
|
1040
|
+
if (event && event.end >= cutoffDate) {
|
|
1041
|
+
stillNeeded = true;
|
|
1042
|
+
break;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (!stillNeeded) {
|
|
1047
|
+
this.indices.byDate.delete(dateStr);
|
|
1048
|
+
removed++;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
console.log(`Optimized indices: removed ${removed} old date entries`);
|
|
1054
|
+
return removed;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Destroy the store and clean up resources
|
|
1059
|
+
*/
|
|
1060
|
+
destroy() {
|
|
1061
|
+
this.clear();
|
|
1062
|
+
this.optimizer.destroy();
|
|
1063
|
+
this.listeners.clear();
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ============ Conflict Detection Methods ============
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Check for conflicts for an event
|
|
1070
|
+
* @param {Event|import('../../types.js').EventData} event - Event to check
|
|
1071
|
+
* @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
1072
|
+
* @returns {import('../../types.js').ConflictSummary} Conflict summary
|
|
1073
|
+
*/
|
|
1074
|
+
checkConflicts(event, options = {}) {
|
|
1075
|
+
return this.conflictDetector.checkConflicts(event, options);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Check conflicts between two events
|
|
1080
|
+
* @param {string} eventId1 - First event ID
|
|
1081
|
+
* @param {string} eventId2 - Second event ID
|
|
1082
|
+
* @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
1083
|
+
* @returns {import('../../types.js').ConflictDetails[]} Conflicts between events
|
|
1084
|
+
*/
|
|
1085
|
+
checkEventPairConflicts(eventId1, eventId2, options = {}) {
|
|
1086
|
+
const event1 = this.getEvent(eventId1);
|
|
1087
|
+
const event2 = this.getEvent(eventId2);
|
|
1088
|
+
|
|
1089
|
+
if (!event1 || !event2) {
|
|
1090
|
+
throw new Error('One or both events not found');
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return this.conflictDetector.checkEventPairConflicts(event1, event2, options);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Get all conflicts in a date range
|
|
1098
|
+
* @param {Date} start - Start date
|
|
1099
|
+
* @param {Date} end - End date
|
|
1100
|
+
* @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
|
|
1101
|
+
* @returns {import('../../types.js').ConflictSummary} All conflicts in range
|
|
1102
|
+
*/
|
|
1103
|
+
getAllConflicts(start, end, options = {}) {
|
|
1104
|
+
const events = this.getEventsInRange(start, end, false);
|
|
1105
|
+
const allConflicts = [];
|
|
1106
|
+
const checkedPairs = new Set();
|
|
1107
|
+
|
|
1108
|
+
for (let i = 0; i < events.length; i++) {
|
|
1109
|
+
for (let j = i + 1; j < events.length; j++) {
|
|
1110
|
+
const pairKey = `${events[i].id}-${events[j].id}`;
|
|
1111
|
+
if (!checkedPairs.has(pairKey)) {
|
|
1112
|
+
checkedPairs.add(pairKey);
|
|
1113
|
+
const conflicts = this.conflictDetector.checkEventPairConflicts(
|
|
1114
|
+
events[i],
|
|
1115
|
+
events[j],
|
|
1116
|
+
options
|
|
1117
|
+
);
|
|
1118
|
+
allConflicts.push(...conflicts);
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return this.conflictDetector._buildConflictSummary(
|
|
1124
|
+
allConflicts,
|
|
1125
|
+
new Set(events.map(e => e.id)),
|
|
1126
|
+
new Set()
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Get busy periods for attendees
|
|
1132
|
+
* @param {string[]} attendeeEmails - Attendee emails
|
|
1133
|
+
* @param {Date} start - Start date
|
|
1134
|
+
* @param {Date} end - End date
|
|
1135
|
+
* @param {Object} [options={}] - Options
|
|
1136
|
+
* @returns {Array<{start: Date, end: Date, eventIds: string[]}>} Busy periods
|
|
1137
|
+
*/
|
|
1138
|
+
getBusyPeriods(attendeeEmails, start, end, options = {}) {
|
|
1139
|
+
return this.conflictDetector.getBusyPeriods(attendeeEmails, start, end, options);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Get free periods for scheduling
|
|
1144
|
+
* @param {Date} start - Start date
|
|
1145
|
+
* @param {Date} end - End date
|
|
1146
|
+
* @param {number} durationMinutes - Required duration in minutes
|
|
1147
|
+
* @param {Object} [options={}] - Options
|
|
1148
|
+
* @returns {Array<{start: Date, end: Date}>} Free periods
|
|
1149
|
+
*/
|
|
1150
|
+
getFreePeriods(start, end, durationMinutes, options = {}) {
|
|
1151
|
+
return this.conflictDetector.getFreePeriods(start, end, durationMinutes, options);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Add event with conflict checking
|
|
1156
|
+
* @param {Event|import('../../types.js').EventData} event - Event to add
|
|
1157
|
+
* @param {boolean} [allowConflicts=true] - Whether to allow adding with conflicts
|
|
1158
|
+
* @returns {{event: Event, conflicts: import('../../types.js').ConflictSummary}} Result
|
|
1159
|
+
*/
|
|
1160
|
+
addEventWithConflictCheck(event, allowConflicts = true) {
|
|
1161
|
+
// Check conflicts before adding
|
|
1162
|
+
const conflicts = this.checkConflicts(event);
|
|
1163
|
+
|
|
1164
|
+
if (!allowConflicts && conflicts.hasConflicts) {
|
|
1165
|
+
throw new Error(`Cannot add event: ${conflicts.totalConflicts} conflicts detected`);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Add the event
|
|
1169
|
+
const addedEvent = this.addEvent(event);
|
|
1170
|
+
|
|
1171
|
+
return {
|
|
1172
|
+
event: addedEvent,
|
|
1173
|
+
conflicts
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Find events with conflicts
|
|
1179
|
+
* @param {Object} [options={}] - Options
|
|
1180
|
+
* @returns {Array<{event: Event, conflicts: import('../../types.js').ConflictDetails[]}>} Events with conflicts
|
|
1181
|
+
*/
|
|
1182
|
+
findEventsWithConflicts(options = {}) {
|
|
1183
|
+
const eventsWithConflicts = [];
|
|
1184
|
+
const allEvents = this.getAllEvents();
|
|
1185
|
+
|
|
1186
|
+
for (const event of allEvents) {
|
|
1187
|
+
const conflicts = this.checkConflicts(event, options);
|
|
1188
|
+
if (conflicts.hasConflicts) {
|
|
1189
|
+
eventsWithConflicts.push({
|
|
1190
|
+
event,
|
|
1191
|
+
conflicts: conflicts.conflicts
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
return eventsWithConflicts;
|
|
1197
|
+
}
|
|
1198
|
+
}
|