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