@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.
@@ -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
+ }