@forcecalendar/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EnhancedCalendar - Integration of advanced search and recurrence features
|
|
3
|
+
* Demonstrates how to use the new scalable components
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Calendar } from '../calendar/Calendar.js';
|
|
7
|
+
import { SearchWorkerManager } from '../search/SearchWorkerManager.js';
|
|
8
|
+
import { RecurrenceEngineV2 } from '../events/RecurrenceEngineV2.js';
|
|
9
|
+
|
|
10
|
+
export class EnhancedCalendar extends Calendar {
|
|
11
|
+
constructor(config) {
|
|
12
|
+
super(config);
|
|
13
|
+
|
|
14
|
+
// Initialize enhanced components
|
|
15
|
+
this.searchManager = new SearchWorkerManager(this.eventStore);
|
|
16
|
+
this.recurrenceEngine = new RecurrenceEngineV2();
|
|
17
|
+
|
|
18
|
+
// Performance monitoring
|
|
19
|
+
this.performanceMetrics = {
|
|
20
|
+
searchTime: [],
|
|
21
|
+
expansionTime: [],
|
|
22
|
+
renderTime: []
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Setup event listeners for real-time indexing
|
|
26
|
+
this.setupRealtimeIndexing();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enhanced search with worker support
|
|
31
|
+
*/
|
|
32
|
+
async search(query, options = {}) {
|
|
33
|
+
const startTime = performance.now();
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// Use enhanced search manager
|
|
37
|
+
const results = await this.searchManager.search(query, {
|
|
38
|
+
fields: options.fields || ['title', 'description', 'location', 'category'],
|
|
39
|
+
fuzzy: options.fuzzy !== false,
|
|
40
|
+
limit: options.limit || 50,
|
|
41
|
+
prefixMatch: options.autocomplete || false,
|
|
42
|
+
...options
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const endTime = performance.now();
|
|
46
|
+
this.recordMetric('searchTime', endTime - startTime);
|
|
47
|
+
|
|
48
|
+
// Transform results to match expected format
|
|
49
|
+
return results.map(r => r.event);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Search error:', error);
|
|
52
|
+
// Fallback to basic search
|
|
53
|
+
return super.search ? super.search(query, options) : [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get events with enhanced recurrence expansion
|
|
59
|
+
*/
|
|
60
|
+
async getEventsInRange(startDate, endDate, options = {}) {
|
|
61
|
+
const startTime = performance.now();
|
|
62
|
+
|
|
63
|
+
const regularEvents = [];
|
|
64
|
+
const recurringEvents = [];
|
|
65
|
+
|
|
66
|
+
// Separate regular and recurring events
|
|
67
|
+
const allEvents = this.eventStore.getEventsInDateRange(startDate, endDate);
|
|
68
|
+
|
|
69
|
+
for (const event of allEvents) {
|
|
70
|
+
if (event.recurring) {
|
|
71
|
+
recurringEvents.push(event);
|
|
72
|
+
} else {
|
|
73
|
+
regularEvents.push(event);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Expand recurring events with enhanced engine
|
|
78
|
+
const expandedOccurrences = [];
|
|
79
|
+
|
|
80
|
+
for (const event of recurringEvents) {
|
|
81
|
+
const occurrences = this.recurrenceEngine.expandEvent(
|
|
82
|
+
event,
|
|
83
|
+
startDate,
|
|
84
|
+
endDate,
|
|
85
|
+
{
|
|
86
|
+
maxOccurrences: options.maxOccurrences || 365,
|
|
87
|
+
includeModified: options.includeModified !== false,
|
|
88
|
+
includeCancelled: options.includeCancelled || false,
|
|
89
|
+
timezone: options.timezone || event.timeZone,
|
|
90
|
+
handleDST: options.handleDST !== false
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expandedOccurrences.push(...occurrences);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const endTime = performance.now();
|
|
98
|
+
this.recordMetric('expansionTime', endTime - startTime);
|
|
99
|
+
|
|
100
|
+
// Combine and sort
|
|
101
|
+
const allEventsInRange = [...regularEvents, ...expandedOccurrences];
|
|
102
|
+
allEventsInRange.sort((a, b) => a.start - b.start);
|
|
103
|
+
|
|
104
|
+
return allEventsInRange;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Modify a single occurrence of a recurring event
|
|
109
|
+
*/
|
|
110
|
+
modifyOccurrence(eventId, occurrenceDate, modifications) {
|
|
111
|
+
// Add to modified instances
|
|
112
|
+
this.recurrenceEngine.addModifiedInstance(
|
|
113
|
+
eventId,
|
|
114
|
+
occurrenceDate,
|
|
115
|
+
modifications
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Emit change event
|
|
119
|
+
this.emit('occurrence:modified', {
|
|
120
|
+
eventId,
|
|
121
|
+
occurrenceDate,
|
|
122
|
+
modifications
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Trigger re-render if in view
|
|
126
|
+
this.refreshView();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Cancel a single occurrence of a recurring event
|
|
131
|
+
*/
|
|
132
|
+
cancelOccurrence(eventId, occurrenceDate, reason = 'Cancelled') {
|
|
133
|
+
// Add exception
|
|
134
|
+
this.recurrenceEngine.addException(eventId, occurrenceDate, reason);
|
|
135
|
+
|
|
136
|
+
// Emit change event
|
|
137
|
+
this.emit('occurrence:cancelled', {
|
|
138
|
+
eventId,
|
|
139
|
+
occurrenceDate,
|
|
140
|
+
reason
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Trigger re-render
|
|
144
|
+
this.refreshView();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Bulk operations for recurring events
|
|
149
|
+
*/
|
|
150
|
+
async bulkModifyOccurrences(eventId, dateRange, modifications) {
|
|
151
|
+
const event = this.eventStore.getEvent(eventId);
|
|
152
|
+
if (!event || !event.recurring) {
|
|
153
|
+
throw new Error('Event not found or not recurring');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Get all occurrences in range
|
|
157
|
+
const occurrences = this.recurrenceEngine.expandEvent(
|
|
158
|
+
event,
|
|
159
|
+
dateRange.start,
|
|
160
|
+
dateRange.end
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Apply modifications to each
|
|
164
|
+
for (const occurrence of occurrences) {
|
|
165
|
+
this.recurrenceEngine.addModifiedInstance(
|
|
166
|
+
eventId,
|
|
167
|
+
occurrence.start,
|
|
168
|
+
modifications
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Emit bulk change event
|
|
173
|
+
this.emit('occurrences:bulk-modified', {
|
|
174
|
+
eventId,
|
|
175
|
+
count: occurrences.length,
|
|
176
|
+
modifications
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
this.refreshView();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Advanced search with filters and recurrence awareness
|
|
184
|
+
*/
|
|
185
|
+
async advancedSearch(query, filters = {}, options = {}) {
|
|
186
|
+
// First get search results
|
|
187
|
+
const searchResults = await this.search(query, options);
|
|
188
|
+
|
|
189
|
+
// Apply additional filters
|
|
190
|
+
let filtered = searchResults;
|
|
191
|
+
|
|
192
|
+
// Date range filter with recurrence expansion
|
|
193
|
+
if (filters.dateRange) {
|
|
194
|
+
const expandedEvents = await this.getEventsInRange(
|
|
195
|
+
filters.dateRange.start,
|
|
196
|
+
filters.dateRange.end,
|
|
197
|
+
{ includeModified: true }
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const expandedIds = new Set(expandedEvents.map(e =>
|
|
201
|
+
e.recurringEventId || e.id
|
|
202
|
+
));
|
|
203
|
+
|
|
204
|
+
filtered = filtered.filter(e => expandedIds.has(e.id));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Category filter
|
|
208
|
+
if (filters.categories && filters.categories.length > 0) {
|
|
209
|
+
const categorySet = new Set(filters.categories);
|
|
210
|
+
filtered = filtered.filter(e =>
|
|
211
|
+
e.categories && e.categories.some(c => categorySet.has(c))
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Status filter
|
|
216
|
+
if (filters.status) {
|
|
217
|
+
filtered = filtered.filter(e => e.status === filters.status);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Modified only filter
|
|
221
|
+
if (filters.modifiedOnly) {
|
|
222
|
+
filtered = filtered.filter(e => {
|
|
223
|
+
const modifications = this.recurrenceEngine.modifiedInstances.get(e.id);
|
|
224
|
+
return modifications && modifications.size > 0;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return filtered;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Setup real-time indexing for search
|
|
233
|
+
*/
|
|
234
|
+
setupRealtimeIndexing() {
|
|
235
|
+
// Re-index when events are added
|
|
236
|
+
this.on('event:added', (event) => {
|
|
237
|
+
this.searchManager.indexEvents();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Re-index when events are modified
|
|
241
|
+
this.on('event:updated', (event) => {
|
|
242
|
+
this.searchManager.indexEvents();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Re-index when events are removed
|
|
246
|
+
this.on('event:removed', (eventId) => {
|
|
247
|
+
this.searchManager.indexEvents();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Batch re-indexing for bulk operations
|
|
251
|
+
let reindexTimeout;
|
|
252
|
+
this.on('events:bulk-operation', () => {
|
|
253
|
+
clearTimeout(reindexTimeout);
|
|
254
|
+
reindexTimeout = setTimeout(() => {
|
|
255
|
+
this.searchManager.indexEvents();
|
|
256
|
+
}, 100);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get search suggestions (autocomplete)
|
|
262
|
+
*/
|
|
263
|
+
async getSuggestions(partial, field = 'title') {
|
|
264
|
+
if (partial.length < 2) {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Use search with prefix matching
|
|
269
|
+
const results = await this.searchManager.search(partial, {
|
|
270
|
+
fields: [field],
|
|
271
|
+
prefixMatch: true,
|
|
272
|
+
limit: 10
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Extract unique values
|
|
276
|
+
const suggestions = new Set();
|
|
277
|
+
for (const result of results) {
|
|
278
|
+
const value = result.event[field];
|
|
279
|
+
if (value) {
|
|
280
|
+
suggestions.add(value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return Array.from(suggestions);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Performance monitoring
|
|
289
|
+
*/
|
|
290
|
+
recordMetric(type, value) {
|
|
291
|
+
this.performanceMetrics[type].push(value);
|
|
292
|
+
|
|
293
|
+
// Keep only last 100 measurements
|
|
294
|
+
if (this.performanceMetrics[type].length > 100) {
|
|
295
|
+
this.performanceMetrics[type].shift();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get performance statistics
|
|
301
|
+
*/
|
|
302
|
+
getPerformanceStats() {
|
|
303
|
+
const stats = {};
|
|
304
|
+
|
|
305
|
+
for (const [metric, values] of Object.entries(this.performanceMetrics)) {
|
|
306
|
+
if (values.length === 0) {
|
|
307
|
+
stats[metric] = { avg: 0, min: 0, max: 0, p95: 0 };
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
312
|
+
const sum = sorted.reduce((a, b) => a + b, 0);
|
|
313
|
+
|
|
314
|
+
stats[metric] = {
|
|
315
|
+
avg: sum / sorted.length,
|
|
316
|
+
min: sorted[0],
|
|
317
|
+
max: sorted[sorted.length - 1],
|
|
318
|
+
p95: sorted[Math.floor(sorted.length * 0.95)]
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return stats;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Export calendar with recurrence data
|
|
327
|
+
*/
|
|
328
|
+
exportWithRecurrence(format = 'json') {
|
|
329
|
+
const data = {
|
|
330
|
+
events: this.eventStore.getAllEvents(),
|
|
331
|
+
modifiedInstances: {},
|
|
332
|
+
exceptions: {}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// Include modified instances
|
|
336
|
+
for (const [eventId, modifications] of this.recurrenceEngine.modifiedInstances) {
|
|
337
|
+
data.modifiedInstances[eventId] = Array.from(modifications.entries());
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Include exceptions
|
|
341
|
+
for (const [eventId, exceptions] of this.recurrenceEngine.exceptionStore) {
|
|
342
|
+
data.exceptions[eventId] = Array.from(exceptions.entries());
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (format === 'json') {
|
|
346
|
+
return JSON.stringify(data, null, 2);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Could add ICS export here
|
|
350
|
+
return data;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Import calendar with recurrence data
|
|
355
|
+
*/
|
|
356
|
+
importWithRecurrence(data, format = 'json') {
|
|
357
|
+
if (format === 'json') {
|
|
358
|
+
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
359
|
+
|
|
360
|
+
// Import events
|
|
361
|
+
for (const event of parsed.events) {
|
|
362
|
+
this.addEvent(event);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Import modified instances
|
|
366
|
+
if (parsed.modifiedInstances) {
|
|
367
|
+
for (const [eventId, modifications] of Object.entries(parsed.modifiedInstances)) {
|
|
368
|
+
for (const [dateKey, mods] of modifications) {
|
|
369
|
+
this.recurrenceEngine.addModifiedInstance(
|
|
370
|
+
eventId,
|
|
371
|
+
new Date(dateKey),
|
|
372
|
+
mods
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Import exceptions
|
|
379
|
+
if (parsed.exceptions) {
|
|
380
|
+
for (const [eventId, exceptions] of Object.entries(parsed.exceptions)) {
|
|
381
|
+
for (const [dateKey, reason] of exceptions) {
|
|
382
|
+
this.recurrenceEngine.addException(
|
|
383
|
+
eventId,
|
|
384
|
+
new Date(dateKey),
|
|
385
|
+
reason
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Clean up resources
|
|
395
|
+
*/
|
|
396
|
+
destroy() {
|
|
397
|
+
// Clean up worker
|
|
398
|
+
if (this.searchManager) {
|
|
399
|
+
this.searchManager.destroy();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Clear caches
|
|
403
|
+
if (this.recurrenceEngine) {
|
|
404
|
+
this.recurrenceEngine.occurrenceCache.clear();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Call parent destroy if exists
|
|
408
|
+
if (super.destroy) {
|
|
409
|
+
super.destroy();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Usage Example
|
|
415
|
+
export function createEnhancedCalendar(config) {
|
|
416
|
+
const calendar = new EnhancedCalendar(config);
|
|
417
|
+
|
|
418
|
+
// Example: Add a complex recurring event
|
|
419
|
+
calendar.addEvent({
|
|
420
|
+
id: 'meeting-1',
|
|
421
|
+
title: 'Weekly Team Standup',
|
|
422
|
+
start: new Date('2024-01-01T10:00:00'),
|
|
423
|
+
end: new Date('2024-01-01T10:30:00'),
|
|
424
|
+
recurring: true,
|
|
425
|
+
recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR;UNTIL=20241231T235959Z',
|
|
426
|
+
timeZone: 'America/New_York',
|
|
427
|
+
categories: ['meetings', 'team']
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Example: Modify a single occurrence
|
|
431
|
+
calendar.modifyOccurrence(
|
|
432
|
+
'meeting-1',
|
|
433
|
+
new Date('2024-01-08T10:00:00'),
|
|
434
|
+
{
|
|
435
|
+
title: 'Extended Team Standup - Sprint Planning',
|
|
436
|
+
end: new Date('2024-01-08T11:30:00'),
|
|
437
|
+
location: 'Conference Room A'
|
|
438
|
+
}
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
// Example: Cancel an occurrence
|
|
442
|
+
calendar.cancelOccurrence(
|
|
443
|
+
'meeting-1',
|
|
444
|
+
new Date('2024-01-15T10:00:00'),
|
|
445
|
+
'Public Holiday'
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// Example: Advanced search
|
|
449
|
+
calendar.advancedSearch('standup', {
|
|
450
|
+
dateRange: {
|
|
451
|
+
start: new Date('2024-01-01'),
|
|
452
|
+
end: new Date('2024-01-31')
|
|
453
|
+
},
|
|
454
|
+
categories: ['meetings'],
|
|
455
|
+
modifiedOnly: false
|
|
456
|
+
}).then(results => {
|
|
457
|
+
console.log('Search results:', results);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
return calendar;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export default EnhancedCalendar;
|