@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,517 @@
1
+ /**
2
+ * ConflictDetector - Detects scheduling conflicts between events
3
+ * Checks for time overlaps, attendee conflicts, and resource conflicts
4
+ */
5
+
6
+ import { DateUtils } from '../calendar/DateUtils.js';
7
+
8
+ export class ConflictDetector {
9
+ /**
10
+ * Create a new ConflictDetector
11
+ * @param {import('../events/EventStore.js').EventStore} eventStore - Event store instance
12
+ */
13
+ constructor(eventStore) {
14
+ this.eventStore = eventStore;
15
+ this.conflictIdCounter = 0;
16
+ }
17
+
18
+ /**
19
+ * Check for conflicts for a specific event
20
+ * @param {import('../events/Event.js').Event|import('../../types.js').EventData} event - Event to check
21
+ * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
22
+ * @returns {import('../../types.js').ConflictSummary} Conflict summary
23
+ */
24
+ checkConflicts(event, options = {}) {
25
+ // Default options
26
+ const opts = {
27
+ checkAttendees: true,
28
+ checkResources: true,
29
+ checkLocation: true,
30
+ ignoreAllDay: false,
31
+ excludeEventIds: [],
32
+ includeStatuses: ['confirmed', 'tentative'],
33
+ bufferMinutes: 0,
34
+ ...options
35
+ };
36
+
37
+ // Ensure we have an Event object
38
+ if (!event.start || !event.end) {
39
+ throw new Error('Event must have start and end dates');
40
+ }
41
+
42
+ const conflicts = [];
43
+ const affectedEventIds = new Set();
44
+ const affectedAttendees = new Set();
45
+
46
+ // Get potential conflicting events in the time range
47
+ const searchStart = new Date(event.start.getTime() - opts.bufferMinutes * 60000);
48
+ const searchEnd = new Date(event.end.getTime() + opts.bufferMinutes * 60000);
49
+
50
+ const potentialConflicts = this.eventStore.getEventsInRange(searchStart, searchEnd, false)
51
+ .filter(e => {
52
+ // Exclude self
53
+ if (e.id === event.id) return false;
54
+ // Exclude specified event IDs
55
+ if (opts.excludeEventIds.includes(e.id)) return false;
56
+ // Filter by status
57
+ if (!opts.includeStatuses.includes(e.status)) return false;
58
+ // Ignore all-day events if specified
59
+ if (opts.ignoreAllDay && (e.allDay || event.allDay)) return false;
60
+ // Ignore cancelled events
61
+ if (e.status === 'cancelled') return false;
62
+ return true;
63
+ });
64
+
65
+ // Check each potential conflict
66
+ for (const conflictingEvent of potentialConflicts) {
67
+ const eventConflicts = this._detectEventConflicts(
68
+ event,
69
+ conflictingEvent,
70
+ opts
71
+ );
72
+
73
+ if (eventConflicts.length > 0) {
74
+ conflicts.push(...eventConflicts);
75
+ affectedEventIds.add(event.id);
76
+ affectedEventIds.add(conflictingEvent.id);
77
+
78
+ // Track affected attendees
79
+ if (event.attendees) {
80
+ event.attendees.forEach(a => affectedAttendees.add(a.email));
81
+ }
82
+ if (conflictingEvent.attendees) {
83
+ conflictingEvent.attendees.forEach(a => affectedAttendees.add(a.email));
84
+ }
85
+ }
86
+ }
87
+
88
+ // Build summary
89
+ return this._buildConflictSummary(conflicts, affectedEventIds, affectedAttendees);
90
+ }
91
+
92
+ /**
93
+ * Check for conflicts between two specific events
94
+ * @param {import('../events/Event.js').Event} event1 - First event
95
+ * @param {import('../events/Event.js').Event} event2 - Second event
96
+ * @param {import('../../types.js').ConflictCheckOptions} [options={}] - Check options
97
+ * @returns {import('../../types.js').ConflictDetails[]} Array of conflicts
98
+ */
99
+ checkEventPairConflicts(event1, event2, options = {}) {
100
+ const opts = {
101
+ checkAttendees: true,
102
+ checkResources: true,
103
+ checkLocation: true,
104
+ bufferMinutes: 0,
105
+ ...options
106
+ };
107
+
108
+ return this._detectEventConflicts(event1, event2, opts);
109
+ }
110
+
111
+ /**
112
+ * Get busy periods for a set of attendees
113
+ * @param {string[]} attendeeEmails - Attendee email addresses
114
+ * @param {Date} start - Start of period
115
+ * @param {Date} end - End of period
116
+ * @param {Object} [options={}] - Options
117
+ * @returns {Array<{start: Date, end: Date, eventIds: string[]}>} Busy periods
118
+ */
119
+ getBusyPeriods(attendeeEmails, start, end, options = {}) {
120
+ const opts = {
121
+ includeStatuses: ['confirmed', 'tentative'],
122
+ mergePeriods: true,
123
+ ...options
124
+ };
125
+
126
+ const busyPeriods = [];
127
+ const events = this.eventStore.getEventsInRange(start, end, false);
128
+
129
+ // Find events with these attendees
130
+ const attendeeEvents = events.filter(event => {
131
+ if (!opts.includeStatuses.includes(event.status)) return false;
132
+ if (event.status === 'cancelled') return false;
133
+
134
+ return event.attendees && event.attendees.some(attendee =>
135
+ attendeeEmails.includes(attendee.email)
136
+ );
137
+ });
138
+
139
+ // Convert to busy periods
140
+ attendeeEvents.forEach(event => {
141
+ busyPeriods.push({
142
+ start: event.start,
143
+ end: event.end,
144
+ eventIds: [event.id]
145
+ });
146
+ });
147
+
148
+ // Merge overlapping periods if requested
149
+ if (opts.mergePeriods && busyPeriods.length > 1) {
150
+ return this._mergeBusyPeriods(busyPeriods);
151
+ }
152
+
153
+ return busyPeriods.sort((a, b) => a.start - b.start);
154
+ }
155
+
156
+ /**
157
+ * Get free time slots
158
+ * @param {Date} start - Start of search period
159
+ * @param {Date} end - End of search period
160
+ * @param {number} duration - Required duration in minutes
161
+ * @param {Object} [options={}] - Options
162
+ * @returns {Array<{start: Date, end: Date}>} Free time slots
163
+ */
164
+ getFreePeriods(start, end, duration, options = {}) {
165
+ const opts = {
166
+ attendeeEmails: [],
167
+ businessHoursOnly: false,
168
+ businessHours: { start: '09:00', end: '17:00' },
169
+ excludeWeekends: false,
170
+ ...options
171
+ };
172
+
173
+ const freePeriods = [];
174
+
175
+ // Get busy periods
176
+ const busyPeriods = opts.attendeeEmails.length > 0
177
+ ? this.getBusyPeriods(opts.attendeeEmails, start, end)
178
+ : this._getAllBusyPeriods(start, end);
179
+
180
+ // Find gaps between busy periods
181
+ let currentTime = new Date(start);
182
+
183
+ for (const busy of busyPeriods) {
184
+ if (currentTime < busy.start) {
185
+ // Found a gap
186
+ const gapDuration = (busy.start - currentTime) / 60000; // minutes
187
+ if (gapDuration >= duration) {
188
+ // Check if within business hours if required
189
+ if (!opts.businessHoursOnly || this._isWithinBusinessHours(currentTime, busy.start, opts)) {
190
+ freePeriods.push({
191
+ start: new Date(currentTime),
192
+ end: new Date(busy.start)
193
+ });
194
+ }
195
+ }
196
+ }
197
+ currentTime = new Date(Math.max(currentTime.getTime(), busy.end.getTime()));
198
+ }
199
+
200
+ // Check final period
201
+ if (currentTime < end) {
202
+ const gapDuration = (end - currentTime) / 60000;
203
+ if (gapDuration >= duration) {
204
+ if (!opts.businessHoursOnly || this._isWithinBusinessHours(currentTime, end, opts)) {
205
+ freePeriods.push({
206
+ start: new Date(currentTime),
207
+ end: new Date(end)
208
+ });
209
+ }
210
+ }
211
+ }
212
+
213
+ return freePeriods;
214
+ }
215
+
216
+ /**
217
+ * Detect conflicts between two events
218
+ * @private
219
+ */
220
+ _detectEventConflicts(event1, event2, options) {
221
+ const conflicts = [];
222
+
223
+ // Check time overlap with buffer
224
+ const hasTimeOverlap = this._checkTimeOverlap(
225
+ event1,
226
+ event2,
227
+ options.bufferMinutes
228
+ );
229
+
230
+ if (hasTimeOverlap) {
231
+ // Time conflict
232
+ const timeConflict = this._createTimeConflict(event1, event2);
233
+ conflicts.push(timeConflict);
234
+
235
+ // Check attendee conflicts (only if time overlaps)
236
+ if (options.checkAttendees) {
237
+ const attendeeConflicts = this._checkAttendeeConflicts(event1, event2);
238
+ conflicts.push(...attendeeConflicts);
239
+ }
240
+
241
+ // Check resource conflicts (only if time overlaps)
242
+ if (options.checkResources) {
243
+ const resourceConflicts = this._checkResourceConflicts(event1, event2);
244
+ conflicts.push(...resourceConflicts);
245
+ }
246
+
247
+ // Check location conflicts (only if time overlaps)
248
+ if (options.checkLocation) {
249
+ const locationConflict = this._checkLocationConflict(event1, event2);
250
+ if (locationConflict) {
251
+ conflicts.push(locationConflict);
252
+ }
253
+ }
254
+ }
255
+
256
+ return conflicts;
257
+ }
258
+
259
+ /**
260
+ * Check for time overlap between events
261
+ * @private
262
+ */
263
+ _checkTimeOverlap(event1, event2, bufferMinutes = 0) {
264
+ const buffer = bufferMinutes * 60000; // Convert to milliseconds
265
+
266
+ const start1 = event1.start.getTime() - buffer;
267
+ const end1 = event1.end.getTime() + buffer;
268
+ const start2 = event2.start.getTime();
269
+ const end2 = event2.end.getTime();
270
+
271
+ return !(end1 <= start2 || end2 <= start1);
272
+ }
273
+
274
+ /**
275
+ * Create time conflict details
276
+ * @private
277
+ */
278
+ _createTimeConflict(event1, event2) {
279
+ const overlapStart = new Date(Math.max(event1.start.getTime(), event2.start.getTime()));
280
+ const overlapEnd = new Date(Math.min(event1.end.getTime(), event2.end.getTime()));
281
+ const overlapMinutes = (overlapEnd - overlapStart) / 60000;
282
+
283
+ // Determine severity based on overlap duration and event importance
284
+ let severity = 'low';
285
+ if (overlapMinutes >= 60) {
286
+ severity = 'high';
287
+ } else if (overlapMinutes >= 30) {
288
+ severity = 'medium';
289
+ }
290
+
291
+ // Increase severity for confirmed events
292
+ if (event1.status === 'confirmed' && event2.status === 'confirmed') {
293
+ severity = severity === 'low' ? 'medium' : severity === 'medium' ? 'high' : 'critical';
294
+ }
295
+
296
+ return {
297
+ id: `conflict_${++this.conflictIdCounter}`,
298
+ type: 'time',
299
+ severity,
300
+ eventId: event1.id,
301
+ conflictingEventId: event2.id,
302
+ description: `Time overlap: ${event1.title} conflicts with ${event2.title}`,
303
+ overlapStart,
304
+ overlapEnd,
305
+ overlapMinutes,
306
+ metadata: {
307
+ event1Title: event1.title,
308
+ event2Title: event2.title,
309
+ event1Status: event1.status,
310
+ event2Status: event2.status
311
+ }
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Check for attendee conflicts
317
+ * @private
318
+ */
319
+ _checkAttendeeConflicts(event1, event2) {
320
+ const conflicts = [];
321
+
322
+ if (!event1.attendees || !event2.attendees) {
323
+ return conflicts;
324
+ }
325
+
326
+ const conflictingAttendees = [];
327
+
328
+ for (const attendee1 of event1.attendees) {
329
+ for (const attendee2 of event2.attendees) {
330
+ if (attendee1.email === attendee2.email) {
331
+ // Same attendee in both events
332
+ conflictingAttendees.push(attendee1.email);
333
+ }
334
+ }
335
+ }
336
+
337
+ if (conflictingAttendees.length > 0) {
338
+ // Determine severity based on attendee responses
339
+ let severity = 'medium';
340
+
341
+ // Check if any conflicting attendee has accepted both
342
+ const hasAcceptedBoth = conflictingAttendees.some(email => {
343
+ const a1 = event1.attendees.find(a => a.email === email);
344
+ const a2 = event2.attendees.find(a => a.email === email);
345
+ return a1?.responseStatus === 'accepted' && a2?.responseStatus === 'accepted';
346
+ });
347
+
348
+ if (hasAcceptedBoth) {
349
+ severity = 'critical';
350
+ }
351
+
352
+ conflicts.push({
353
+ id: `conflict_${++this.conflictIdCounter}`,
354
+ type: 'attendee',
355
+ severity,
356
+ eventId: event1.id,
357
+ conflictingEventId: event2.id,
358
+ description: `Attendee conflict: ${conflictingAttendees.length} attendee(s) double-booked`,
359
+ conflictingAttendees,
360
+ metadata: {
361
+ attendeeCount: conflictingAttendees.length,
362
+ attendeeEmails: conflictingAttendees
363
+ }
364
+ });
365
+ }
366
+
367
+ return conflicts;
368
+ }
369
+
370
+ /**
371
+ * Check for resource conflicts
372
+ * @private
373
+ */
374
+ _checkResourceConflicts(event1, event2) {
375
+ const conflicts = [];
376
+
377
+ // Check if events have resource attendees
378
+ const resources1 = event1.attendees?.filter(a => a.resource) || [];
379
+ const resources2 = event2.attendees?.filter(a => a.resource) || [];
380
+
381
+ for (const resource1 of resources1) {
382
+ for (const resource2 of resources2) {
383
+ if (resource1.email === resource2.email) {
384
+ conflicts.push({
385
+ id: `conflict_${++this.conflictIdCounter}`,
386
+ type: 'resource',
387
+ severity: 'critical', // Resource conflicts are always critical
388
+ eventId: event1.id,
389
+ conflictingEventId: event2.id,
390
+ description: `Resource conflict: ${resource1.name} is double-booked`,
391
+ conflictingResource: resource1.email,
392
+ metadata: {
393
+ resourceName: resource1.name,
394
+ resourceEmail: resource1.email
395
+ }
396
+ });
397
+ }
398
+ }
399
+ }
400
+
401
+ return conflicts;
402
+ }
403
+
404
+ /**
405
+ * Check for location conflicts
406
+ * @private
407
+ */
408
+ _checkLocationConflict(event1, event2) {
409
+ if (!event1.location || !event2.location) {
410
+ return null;
411
+ }
412
+
413
+ // Normalize locations for comparison
414
+ const loc1 = event1.location.trim().toLowerCase();
415
+ const loc2 = event2.location.trim().toLowerCase();
416
+
417
+ if (loc1 === loc2) {
418
+ return {
419
+ id: `conflict_${++this.conflictIdCounter}`,
420
+ type: 'location',
421
+ severity: 'high', // Location conflicts are typically high severity
422
+ eventId: event1.id,
423
+ conflictingEventId: event2.id,
424
+ description: `Location conflict: ${event1.location} is double-booked`,
425
+ metadata: {
426
+ location: event1.location
427
+ }
428
+ };
429
+ }
430
+
431
+ return null;
432
+ }
433
+
434
+ /**
435
+ * Build conflict summary
436
+ * @private
437
+ */
438
+ _buildConflictSummary(conflicts, affectedEventIds, affectedAttendees) {
439
+ const conflictsByType = {};
440
+ const conflictsBySeverity = {};
441
+
442
+ // Count by type and severity
443
+ for (const conflict of conflicts) {
444
+ conflictsByType[conflict.type] = (conflictsByType[conflict.type] || 0) + 1;
445
+ conflictsBySeverity[conflict.severity] = (conflictsBySeverity[conflict.severity] || 0) + 1;
446
+ }
447
+
448
+ return {
449
+ hasConflicts: conflicts.length > 0,
450
+ totalConflicts: conflicts.length,
451
+ conflicts,
452
+ conflictsByType,
453
+ conflictsBySeverity,
454
+ affectedEventIds: Array.from(affectedEventIds),
455
+ affectedAttendees: Array.from(affectedAttendees)
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Merge overlapping busy periods
461
+ * @private
462
+ */
463
+ _mergeBusyPeriods(periods) {
464
+ if (periods.length <= 1) return periods;
465
+
466
+ // Sort by start time
467
+ periods.sort((a, b) => a.start - b.start);
468
+
469
+ const merged = [periods[0]];
470
+
471
+ for (let i = 1; i < periods.length; i++) {
472
+ const current = periods[i];
473
+ const last = merged[merged.length - 1];
474
+
475
+ if (current.start <= last.end) {
476
+ // Overlapping or adjacent, merge them
477
+ last.end = new Date(Math.max(last.end.getTime(), current.end.getTime()));
478
+ last.eventIds.push(...current.eventIds);
479
+ } else {
480
+ // No overlap, add as new period
481
+ merged.push(current);
482
+ }
483
+ }
484
+
485
+ return merged;
486
+ }
487
+
488
+ /**
489
+ * Get all busy periods
490
+ * @private
491
+ */
492
+ _getAllBusyPeriods(start, end) {
493
+ const events = this.eventStore.getEventsInRange(start, end, false)
494
+ .filter(e => e.status !== 'cancelled');
495
+
496
+ return events.map(event => ({
497
+ start: event.start,
498
+ end: event.end,
499
+ eventIds: [event.id]
500
+ })).sort((a, b) => a.start - b.start);
501
+ }
502
+
503
+ /**
504
+ * Check if time period is within business hours
505
+ * @private
506
+ */
507
+ _isWithinBusinessHours(start, end, options) {
508
+ // Simple implementation - can be enhanced
509
+ const startHour = start.getHours();
510
+ const endHour = end.getHours();
511
+
512
+ const businessStart = parseInt(options.businessHours.start.split(':')[0]);
513
+ const businessEnd = parseInt(options.businessHours.end.split(':')[0]);
514
+
515
+ return startHour >= businessStart && endHour <= businessEnd;
516
+ }
517
+ }