@shin1ohno/sage 0.7.8 → 0.8.4

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.
Files changed (43) hide show
  1. package/README.md +26 -8
  2. package/dist/config/loader.d.ts.map +1 -1
  3. package/dist/config/loader.js +30 -1
  4. package/dist/config/loader.js.map +1 -1
  5. package/dist/config/validation.d.ts +130 -0
  6. package/dist/config/validation.d.ts.map +1 -0
  7. package/dist/config/validation.js +53 -0
  8. package/dist/config/validation.js.map +1 -0
  9. package/dist/index.js +677 -214
  10. package/dist/index.js.map +1 -1
  11. package/dist/integrations/calendar-service.d.ts +6 -3
  12. package/dist/integrations/calendar-service.d.ts.map +1 -1
  13. package/dist/integrations/calendar-service.js +26 -4
  14. package/dist/integrations/calendar-service.js.map +1 -1
  15. package/dist/integrations/calendar-source-manager.d.ts +302 -0
  16. package/dist/integrations/calendar-source-manager.d.ts.map +1 -0
  17. package/dist/integrations/calendar-source-manager.js +862 -0
  18. package/dist/integrations/calendar-source-manager.js.map +1 -0
  19. package/dist/integrations/google-calendar-service.d.ts +176 -0
  20. package/dist/integrations/google-calendar-service.d.ts.map +1 -0
  21. package/dist/integrations/google-calendar-service.js +745 -0
  22. package/dist/integrations/google-calendar-service.js.map +1 -0
  23. package/dist/oauth/google-oauth-handler.d.ts +149 -0
  24. package/dist/oauth/google-oauth-handler.d.ts.map +1 -0
  25. package/dist/oauth/google-oauth-handler.js +365 -0
  26. package/dist/oauth/google-oauth-handler.js.map +1 -0
  27. package/dist/types/config.d.ts +15 -0
  28. package/dist/types/config.d.ts.map +1 -1
  29. package/dist/types/config.js +21 -0
  30. package/dist/types/config.js.map +1 -1
  31. package/dist/types/google-calendar-types.d.ts +139 -0
  32. package/dist/types/google-calendar-types.d.ts.map +1 -0
  33. package/dist/types/google-calendar-types.js +46 -0
  34. package/dist/types/google-calendar-types.js.map +1 -0
  35. package/dist/types/index.d.ts +1 -0
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/types/index.js +1 -0
  38. package/dist/types/index.js.map +1 -1
  39. package/dist/version.d.ts +2 -2
  40. package/dist/version.d.ts.map +1 -1
  41. package/dist/version.js +22 -4
  42. package/dist/version.js.map +1 -1
  43. package/package.json +4 -3
@@ -0,0 +1,862 @@
1
+ /**
2
+ * Calendar Source Manager
3
+ * Requirements: 9, 11
4
+ * Design: .claude/specs/google-calendar-api/design.md (CalendarSourceManager section)
5
+ *
6
+ * Manages multiple calendar sources (EventKit, Google Calendar) with automatic
7
+ * source selection, fallback handling, and unified MCP tool interface.
8
+ */
9
+ /**
10
+ * Calendar Source Manager
11
+ *
12
+ * Manages multiple calendar sources (EventKit, Google Calendar) with:
13
+ * - Automatic source detection and selection
14
+ * - Unified event operations across sources
15
+ * - Fallback handling for source failures
16
+ * - Event deduplication across sources
17
+ */
18
+ export class CalendarSourceManager {
19
+ calendarService;
20
+ googleCalendarService;
21
+ config;
22
+ /**
23
+ * Constructor
24
+ *
25
+ * @param options - Optional services and config
26
+ */
27
+ constructor(options) {
28
+ this.calendarService = options?.calendarService;
29
+ this.googleCalendarService = options?.googleCalendarService;
30
+ this.config = options?.config;
31
+ }
32
+ /**
33
+ * Detect available calendar sources
34
+ * Requirement: 9 (Platform detection and source availability)
35
+ *
36
+ * Uses detectPlatform() pattern from CalendarService to determine which
37
+ * calendar sources are available on the current platform.
38
+ *
39
+ * @returns Object indicating which sources are available
40
+ */
41
+ async detectAvailableSources() {
42
+ const result = {
43
+ eventkit: false,
44
+ google: false,
45
+ };
46
+ // Check EventKit availability (macOS only)
47
+ if (this.calendarService) {
48
+ try {
49
+ const platform = await this.calendarService.detectPlatform();
50
+ result.eventkit = platform.platform === 'macos';
51
+ }
52
+ catch {
53
+ result.eventkit = false;
54
+ }
55
+ }
56
+ else {
57
+ // No CalendarService instance, check platform directly
58
+ if (typeof process !== 'undefined' && process.platform === 'darwin') {
59
+ result.eventkit = true;
60
+ }
61
+ }
62
+ // Check Google Calendar availability (OAuth configured)
63
+ if (this.googleCalendarService) {
64
+ try {
65
+ result.google = await this.googleCalendarService.isAvailable();
66
+ }
67
+ catch {
68
+ result.google = false;
69
+ }
70
+ }
71
+ return result;
72
+ }
73
+ /**
74
+ * Enable a calendar source
75
+ * Requirement: 11 (Calendar source management)
76
+ *
77
+ * Updates config to enable the specified source and validates that
78
+ * at least one source remains enabled.
79
+ *
80
+ * @param source - Calendar source to enable ('eventkit' | 'google')
81
+ * @throws Error if config is not available
82
+ */
83
+ async enableSource(source) {
84
+ if (!this.config) {
85
+ throw new Error('Config not available. Cannot enable source.');
86
+ }
87
+ // Ensure calendar.sources exists
88
+ if (!this.config.calendar.sources) {
89
+ this.config.calendar.sources = {
90
+ eventkit: { enabled: false },
91
+ google: {
92
+ enabled: false,
93
+ defaultCalendar: 'primary',
94
+ excludedCalendars: [],
95
+ syncInterval: 300,
96
+ enableNotifications: true,
97
+ },
98
+ };
99
+ }
100
+ // Enable the specified source
101
+ if (source === 'eventkit') {
102
+ this.config.calendar.sources.eventkit.enabled = true;
103
+ }
104
+ else if (source === 'google') {
105
+ this.config.calendar.sources.google.enabled = true;
106
+ }
107
+ // Note: Config persistence is handled by caller (ConfigManager.save())
108
+ }
109
+ /**
110
+ * Disable a calendar source
111
+ * Requirement: 11 (Calendar source management)
112
+ *
113
+ * Updates config to disable the specified source and validates that
114
+ * at least one source remains enabled.
115
+ *
116
+ * @param source - Calendar source to disable ('eventkit' | 'google')
117
+ * @throws Error if disabling would leave no sources enabled
118
+ */
119
+ async disableSource(source) {
120
+ if (!this.config) {
121
+ throw new Error('Config not available. Cannot disable source.');
122
+ }
123
+ // Ensure calendar.sources exists
124
+ if (!this.config.calendar.sources) {
125
+ throw new Error('Calendar sources not configured.');
126
+ }
127
+ // Check if at least one source will remain enabled
128
+ const sources = this.config.calendar.sources;
129
+ const eventkitEnabled = source === 'eventkit' ? false : sources.eventkit.enabled;
130
+ const googleEnabled = source === 'google' ? false : sources.google.enabled;
131
+ if (!eventkitEnabled && !googleEnabled) {
132
+ throw new Error('Cannot disable source: at least one calendar source must be enabled.');
133
+ }
134
+ // Disable the specified source
135
+ if (source === 'eventkit') {
136
+ this.config.calendar.sources.eventkit.enabled = false;
137
+ }
138
+ else if (source === 'google') {
139
+ this.config.calendar.sources.google.enabled = false;
140
+ }
141
+ // Note: Config persistence is handled by caller (ConfigManager.save())
142
+ }
143
+ /**
144
+ * Get enabled calendar sources
145
+ * Requirement: 9 (Source configuration reading)
146
+ *
147
+ * Reads from config.calendar.sources to determine which sources are enabled.
148
+ *
149
+ * @returns Array of enabled source names
150
+ */
151
+ getEnabledSources() {
152
+ if (!this.config?.calendar?.sources) {
153
+ // Default: EventKit on macOS, Google Calendar elsewhere
154
+ const isMacOS = typeof process !== 'undefined' && process.platform === 'darwin';
155
+ return isMacOS ? ['eventkit'] : ['google'];
156
+ }
157
+ const sources = [];
158
+ const calendarSources = this.config.calendar.sources;
159
+ if (calendarSources.eventkit.enabled) {
160
+ sources.push('eventkit');
161
+ }
162
+ if (calendarSources.google.enabled) {
163
+ sources.push('google');
164
+ }
165
+ return sources;
166
+ }
167
+ /**
168
+ * Get events from enabled calendar sources
169
+ * Requirement: 7, 10, 11 (Multi-source event retrieval with deduplication and fallback)
170
+ *
171
+ * Fetches events from all enabled sources with fallback handling and deduplication.
172
+ * - Task 17a: Basic parallel fetching
173
+ * - Task 17b: Deduplication and fallback logic
174
+ *
175
+ * Fallback behavior: If one source fails, continues with other sources.
176
+ * Only throws if ALL sources fail.
177
+ *
178
+ * @param startDate - Start date (ISO 8601)
179
+ * @param endDate - End date (ISO 8601)
180
+ * @param calendarId - Optional calendar ID filter
181
+ * @returns Array of deduplicated calendar events from all enabled sources
182
+ */
183
+ async getEvents(startDate, endDate, calendarId) {
184
+ const enabledSources = this.getEnabledSources();
185
+ // Check if at least one source is enabled
186
+ if (enabledSources.length === 0) {
187
+ throw new Error('No calendar sources are enabled');
188
+ }
189
+ const allEvents = [];
190
+ const errors = [];
191
+ // Try EventKit with fallback handling
192
+ if (enabledSources.includes('eventkit') && this.calendarService) {
193
+ try {
194
+ const eventkitResponse = await this.calendarService.listEvents({
195
+ startDate,
196
+ endDate,
197
+ calendarName: calendarId,
198
+ });
199
+ allEvents.push(...eventkitResponse.events);
200
+ }
201
+ catch (error) {
202
+ console.error('EventKit failed:', error);
203
+ errors.push(error instanceof Error ? error : new Error(String(error)));
204
+ }
205
+ }
206
+ // Try Google Calendar with fallback handling
207
+ if (enabledSources.includes('google') && this.googleCalendarService) {
208
+ try {
209
+ const googleEvents = await this.googleCalendarService.listEvents({
210
+ startDate,
211
+ endDate,
212
+ calendarId,
213
+ });
214
+ allEvents.push(...googleEvents);
215
+ }
216
+ catch (error) {
217
+ console.error('Google Calendar failed:', error);
218
+ errors.push(error instanceof Error ? error : new Error(String(error)));
219
+ }
220
+ }
221
+ // If all sources failed, throw error
222
+ if (allEvents.length === 0 && errors.length > 0) {
223
+ throw new Error(`All calendar sources failed: ${errors.map((e) => e.message).join(', ')}`);
224
+ }
225
+ // Deduplicate events across sources
226
+ const uniqueEvents = this.deduplicateEvents(allEvents);
227
+ return uniqueEvents;
228
+ }
229
+ /**
230
+ * Check if two events are duplicates
231
+ * Requirement: 10 (Event deduplication)
232
+ *
233
+ * Uses two methods to detect duplicates:
234
+ * 1. iCalUID comparison (most reliable, RFC 5545 standard)
235
+ * 2. Title + time matching (fallback for events without iCalUID)
236
+ *
237
+ * Note: iCalUID support will be added in Task 24. Until then, this uses
238
+ * type assertions to access the optional iCalUID property.
239
+ *
240
+ * @param event1 - First event
241
+ * @param event2 - Second event
242
+ * @returns True if events are duplicates
243
+ */
244
+ areEventsDuplicate(event1, event2) {
245
+ // Method 1: iCalUID comparison (most reliable)
246
+ // Note: iCalUID will be added to CalendarEvent interface in Task 24
247
+ const iCalUID1 = event1.iCalUID;
248
+ const iCalUID2 = event2.iCalUID;
249
+ if (iCalUID1 && iCalUID2 && iCalUID1 === iCalUID2) {
250
+ return true;
251
+ }
252
+ // Method 2: Title + time matching (fallback)
253
+ const titleMatch = event1.title.toLowerCase() === event2.title.toLowerCase();
254
+ const startMatch = event1.start === event2.start;
255
+ const endMatch = event1.end === event2.end;
256
+ return titleMatch && startMatch && endMatch;
257
+ }
258
+ /**
259
+ * Deduplicate events array
260
+ * Requirement: 10 (Event deduplication)
261
+ *
262
+ * Removes duplicate events keeping the first occurrence.
263
+ * Uses areEventsDuplicate() for comparison.
264
+ *
265
+ * @param events - Array of events to deduplicate
266
+ * @returns Array of unique events
267
+ */
268
+ deduplicateEvents(events) {
269
+ return events.filter((event, index) => {
270
+ return !events
271
+ .slice(0, index)
272
+ .some((prevEvent) => this.areEventsDuplicate(event, prevEvent));
273
+ });
274
+ }
275
+ /**
276
+ * Create event in preferred calendar source
277
+ * Requirement: 3, 10, 11 (Multi-source event creation with routing and fallback)
278
+ *
279
+ * Creates an event in the specified source with fallback handling:
280
+ * 1. If preferredSource is specified, try that source first
281
+ * 2. If preferred source fails or not specified, try other enabled sources
282
+ * 3. Throws error if all sources fail
283
+ *
284
+ * Note: EventKit does not support event creation in current implementation,
285
+ * so only Google Calendar is supported for event creation.
286
+ *
287
+ * @param request - Event creation request
288
+ * @param preferredSource - Preferred source ('eventkit' | 'google')
289
+ * @returns Created calendar event
290
+ * @throws Error if all sources fail or no sources are enabled
291
+ */
292
+ async createEvent(request, preferredSource) {
293
+ const enabledSources = this.getEnabledSources();
294
+ // Check if any sources are enabled
295
+ if (enabledSources.length === 0) {
296
+ throw new Error('No calendar sources enabled. Please enable at least one source.');
297
+ }
298
+ // Determine source order to try
299
+ let sourcesToTry;
300
+ if (preferredSource && enabledSources.includes(preferredSource)) {
301
+ // Try preferred source first, then other enabled sources
302
+ sourcesToTry = [
303
+ preferredSource,
304
+ ...enabledSources.filter((s) => s !== preferredSource),
305
+ ];
306
+ }
307
+ else {
308
+ // Try all enabled sources in order
309
+ sourcesToTry = enabledSources;
310
+ }
311
+ const errors = [];
312
+ // Try each source in order
313
+ for (const source of sourcesToTry) {
314
+ try {
315
+ if (source === 'eventkit' && this.calendarService) {
316
+ // Note: EventKit (CalendarService) does not support createEvent in current implementation
317
+ // Skip EventKit and continue to next source
318
+ throw new Error('EventKit does not support event creation in current implementation');
319
+ }
320
+ if (source === 'google' && this.googleCalendarService) {
321
+ // Try Google Calendar
322
+ const event = await this.googleCalendarService.createEvent(request);
323
+ console.log(`Successfully created event in Google Calendar: ${event.id}`);
324
+ return event;
325
+ }
326
+ // Source is enabled but service is not available
327
+ throw new Error(`${source} service is not available (not initialized)`);
328
+ }
329
+ catch (error) {
330
+ const err = error instanceof Error ? error : new Error(String(error));
331
+ errors.push({ source, error: err });
332
+ console.error(`Failed to create event in ${source}:`, err.message);
333
+ }
334
+ }
335
+ // All sources failed
336
+ const errorMessages = errors
337
+ .map((e) => `${e.source}: ${e.error.message}`)
338
+ .join('; ');
339
+ throw new Error(`Failed to create event in all sources. Errors: ${errorMessages}`);
340
+ }
341
+ /**
342
+ * Delete event from calendar source
343
+ * Requirement: 5, 10, 11 (Multi-source event deletion with routing and error handling)
344
+ *
345
+ * Deletes an event from the specified source, or attempts deletion
346
+ * from all sources if source is not specified.
347
+ *
348
+ * If source specified:
349
+ * - Deletes from that source only
350
+ * - Throws error if source is not enabled or service not available
351
+ *
352
+ * If source not specified:
353
+ * - Attempts to delete from ALL enabled sources
354
+ * - Event may exist in both EventKit and Google Calendar (duplicate)
355
+ * - Uses Promise.allSettled() to try all sources
356
+ * - 404 errors (event not found) do not cause failure
357
+ * - Only throws if ALL attempts fail with non-404 errors
358
+ *
359
+ * @param eventId - Event ID to delete
360
+ * @param source - Optional source ('eventkit' | 'google')
361
+ * @throws Error if source specified and not available, or all sources fail with non-404 errors
362
+ */
363
+ async deleteEvent(eventId, source) {
364
+ const enabledSources = this.getEnabledSources();
365
+ // If source specified, delete from that source only
366
+ if (source) {
367
+ if (!enabledSources.includes(source)) {
368
+ throw new Error(`Source ${source} is not enabled`);
369
+ }
370
+ if (source === 'eventkit' && this.calendarService) {
371
+ // Note: EventKit (CalendarService) does not support deleteEvent in current implementation
372
+ throw new Error('EventKit does not support event deletion in current implementation');
373
+ }
374
+ if (source === 'google' && this.googleCalendarService) {
375
+ await this.googleCalendarService.deleteEvent(eventId);
376
+ return;
377
+ }
378
+ throw new Error(`Service for ${source} is not available`);
379
+ }
380
+ // No source specified - try all enabled sources
381
+ const promises = [];
382
+ if (enabledSources.includes('eventkit') && this.calendarService) {
383
+ // Note: EventKit does not support deleteEvent yet
384
+ // Skip EventKit for now (no-op)
385
+ promises.push(Promise.resolve().then(() => {
386
+ // EventKit deletion not supported
387
+ console.log('EventKit event deletion not supported, skipping...');
388
+ }));
389
+ }
390
+ if (enabledSources.includes('google') && this.googleCalendarService) {
391
+ promises.push(this.googleCalendarService
392
+ .deleteEvent(eventId)
393
+ .catch((error) => ({ source: 'google', error })));
394
+ }
395
+ // If no promises to execute, nothing to delete
396
+ if (promises.length === 0) {
397
+ throw new Error('No calendar sources available for deletion');
398
+ }
399
+ const results = await Promise.allSettled(promises);
400
+ // Check if at least one deletion succeeded or got 404
401
+ let hasSuccess = false;
402
+ const errors = [];
403
+ for (const result of results) {
404
+ if (result.status === 'fulfilled') {
405
+ // Check if fulfilled value is an error object or void
406
+ const value = result.value;
407
+ if (value && typeof value === 'object' && 'error' in value) {
408
+ // This is an error from .catch()
409
+ const errorResult = value;
410
+ const message = errorResult.error.message.toLowerCase();
411
+ if (message.includes('not found') || message.includes('404')) {
412
+ // 404 is OK - event already deleted
413
+ hasSuccess = true;
414
+ }
415
+ else {
416
+ // Other error - collect it
417
+ errors.push(errorResult);
418
+ }
419
+ }
420
+ else {
421
+ // Successful deletion
422
+ hasSuccess = true;
423
+ }
424
+ }
425
+ }
426
+ // If no success and we have errors, throw
427
+ if (!hasSuccess && errors.length > 0) {
428
+ const errorMessages = errors
429
+ .map((e) => `${e.source}: ${e.error.message}`)
430
+ .join('; ');
431
+ throw new Error(`Failed to delete event from all sources. Errors: ${errorMessages}`);
432
+ }
433
+ }
434
+ /**
435
+ * Find available time slots considering all enabled sources
436
+ * Requirement: 7 (Multi-source slot detection)
437
+ * Task 20a: Basic filtering implementation
438
+ * Task 20b: Suitability calculation integration
439
+ *
440
+ * Fetches events from all enabled sources, merges and deduplicates,
441
+ * then calculates available slots based on working hours and preferences.
442
+ * Applies suitability scoring and sorts by suitability.
443
+ *
444
+ * @param request - Slot search request
445
+ * @returns Array of available time slots sorted by suitability
446
+ */
447
+ async findAvailableSlots(request) {
448
+ // Get all events from enabled sources (already merged/deduplicated by getEvents)
449
+ const events = await this.getEvents(request.startDate, request.endDate);
450
+ // Sort events by start time
451
+ const sortedEvents = events.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
452
+ // Get working hours from config or use request.workingHours
453
+ const workingHours = request.workingHours || this.getDefaultWorkingHours();
454
+ // Get duration parameters
455
+ const minDuration = request.minDurationMinutes || 25;
456
+ const maxDuration = request.maxDurationMinutes || 480; // 8 hours
457
+ // Generate slots for each day in the date range
458
+ const slots = [];
459
+ const startDate = new Date(request.startDate);
460
+ const endDate = new Date(request.endDate);
461
+ // Iterate through each day in the range
462
+ for (let currentDate = new Date(startDate); currentDate <= endDate; currentDate.setDate(currentDate.getDate() + 1)) {
463
+ const daySlots = this.findDaySlots(currentDate, sortedEvents, workingHours, minDuration, maxDuration);
464
+ slots.push(...daySlots);
465
+ }
466
+ // Apply suitability scoring to all slots
467
+ const slotsWithSuitability = this.calculateSuitabilityForSlots(slots);
468
+ // Sort by suitability (excellent > good > acceptable)
469
+ // Then by start time as secondary sort
470
+ const sortedSlots = slotsWithSuitability.sort((a, b) => {
471
+ const suitabilityOrder = {
472
+ excellent: 0,
473
+ good: 1,
474
+ acceptable: 2,
475
+ };
476
+ const suitabilityDiff = suitabilityOrder[a.suitability] - suitabilityOrder[b.suitability];
477
+ if (suitabilityDiff !== 0) {
478
+ return suitabilityDiff;
479
+ }
480
+ // Secondary sort by start time
481
+ return new Date(a.start).getTime() - new Date(b.start).getTime();
482
+ });
483
+ return sortedSlots;
484
+ }
485
+ /**
486
+ * Get default working hours
487
+ * Requirement: 7
488
+ *
489
+ * Returns working hours from config or default (09:00 - 18:00)
490
+ *
491
+ * @returns Working hours configuration
492
+ */
493
+ getDefaultWorkingHours() {
494
+ // Check if config has working hours defined
495
+ if (this.config?.calendar?.sources?.google?.enableNotifications !== undefined) {
496
+ // Future: config.workingHours could be added here
497
+ // For now, return default
498
+ }
499
+ return { start: '09:00', end: '18:00' };
500
+ }
501
+ /**
502
+ * Calculate suitability for all slots
503
+ * Requirement: 7 (Task 20b)
504
+ *
505
+ * Applies suitability scoring to slots based on:
506
+ * - Day type (deep work vs meeting heavy) from config
507
+ * - Time of day (morning slots preferred)
508
+ * - Slot duration (longer slots better for deep work)
509
+ *
510
+ * @param slots - Array of slots to score
511
+ * @returns Array of slots with suitability applied
512
+ */
513
+ calculateSuitabilityForSlots(slots) {
514
+ // Get working cadence config (deep work days, meeting heavy days)
515
+ const deepWorkDays = this.config?.calendar?.deepWorkDays || [];
516
+ const meetingHeavyDays = this.config?.calendar?.meetingHeavyDays || [];
517
+ return slots.map((slot) => {
518
+ const slotDate = new Date(slot.start);
519
+ const dayName = slotDate.toLocaleDateString('en-US', { weekday: 'long' });
520
+ const hour = slotDate.getHours();
521
+ let suitability = 'good';
522
+ let dayType = 'normal';
523
+ let reason = slot.reason;
524
+ // Determine day type and base suitability
525
+ if (deepWorkDays.includes(dayName)) {
526
+ dayType = 'deep-work';
527
+ // Deep work days are excellent for focused work
528
+ suitability = 'excellent';
529
+ reason = `${dayName} is a deep work day - excellent for focused tasks`;
530
+ }
531
+ else if (meetingHeavyDays.includes(dayName)) {
532
+ dayType = 'meeting-heavy';
533
+ // Meeting heavy days are acceptable (not ideal but usable)
534
+ suitability = 'acceptable';
535
+ reason = `${dayName} is a meeting-heavy day - consider rescheduling for deep work`;
536
+ }
537
+ else {
538
+ dayType = 'normal';
539
+ // Normal days are good
540
+ suitability = 'good';
541
+ }
542
+ // Adjust suitability based on time of day and duration
543
+ // Morning slots (before 12:00) are generally better for deep work
544
+ if (hour < 12 && slot.durationMinutes >= 60) {
545
+ if (suitability === 'good') {
546
+ suitability = 'excellent';
547
+ reason = `Morning slot with ${slot.durationMinutes} minutes - ideal for deep work`;
548
+ }
549
+ }
550
+ // Very short slots (<25 min) are less suitable
551
+ if (slot.durationMinutes < 25) {
552
+ if (suitability === 'excellent') {
553
+ suitability = 'good';
554
+ }
555
+ else if (suitability === 'good') {
556
+ suitability = 'acceptable';
557
+ }
558
+ reason = `Short slot (${slot.durationMinutes} minutes) - best for quick tasks`;
559
+ }
560
+ // Very long slots (>4 hours) on deep work days are excellent
561
+ if (slot.durationMinutes > 240 && dayType === 'deep-work') {
562
+ suitability = 'excellent';
563
+ reason = `Extended ${slot.durationMinutes} minute slot on ${dayName} - perfect for deep work`;
564
+ }
565
+ return {
566
+ ...slot,
567
+ suitability,
568
+ dayType,
569
+ reason,
570
+ };
571
+ });
572
+ }
573
+ /**
574
+ * Find available slots for a single day
575
+ * Requirement: 7
576
+ *
577
+ * Calculates gaps between events within working hours for a specific day.
578
+ *
579
+ * @param date - Date to find slots for
580
+ * @param events - All events (sorted by start time)
581
+ * @param workingHours - Working hours configuration
582
+ * @param minDuration - Minimum slot duration in minutes
583
+ * @param maxDuration - Maximum slot duration in minutes
584
+ * @returns Array of available slots for this day
585
+ */
586
+ findDaySlots(date, events, workingHours, minDuration, maxDuration) {
587
+ const slots = [];
588
+ // Parse working hours
589
+ const [startHour, startMin] = workingHours.start.split(':').map(Number);
590
+ const [endHour, endMin] = workingHours.end.split(':').map(Number);
591
+ // Create working hours boundaries for this day
592
+ const workStart = new Date(date);
593
+ workStart.setHours(startHour, startMin, 0, 0);
594
+ const workEnd = new Date(date);
595
+ workEnd.setHours(endHour, endMin, 0, 0);
596
+ // Filter events for this day (events that overlap with working hours)
597
+ const dayEvents = events
598
+ .filter((e) => {
599
+ const eventStart = new Date(e.start);
600
+ const eventEnd = new Date(e.end);
601
+ // Event must overlap with this day's working hours
602
+ return eventStart < workEnd && eventEnd > workStart;
603
+ })
604
+ .filter((e) => !e.isAllDay) // Exclude all-day events
605
+ .map((e) => ({
606
+ start: new Date(e.start),
607
+ end: new Date(e.end),
608
+ title: e.title,
609
+ }))
610
+ .sort((a, b) => a.start.getTime() - b.start.getTime());
611
+ // If all-day event exists, no slots available
612
+ const hasAllDayEvent = events.some((e) => {
613
+ const eventStart = new Date(e.start);
614
+ const eventEnd = new Date(e.end);
615
+ return e.isAllDay && eventStart <= workStart && eventEnd >= workEnd;
616
+ });
617
+ if (hasAllDayEvent) {
618
+ return [];
619
+ }
620
+ // Find gaps between events
621
+ let currentTime = workStart;
622
+ for (const event of dayEvents) {
623
+ // Calculate gap before this event
624
+ const gapStart = currentTime;
625
+ const gapEnd = event.start < workStart
626
+ ? workStart
627
+ : event.start > workEnd
628
+ ? workEnd
629
+ : event.start;
630
+ const gapMinutes = (gapEnd.getTime() - gapStart.getTime()) / (1000 * 60);
631
+ // Add slot if gap meets duration requirements
632
+ if (gapMinutes >= minDuration && gapMinutes <= maxDuration) {
633
+ slots.push({
634
+ start: gapStart.toISOString(),
635
+ end: gapEnd.toISOString(),
636
+ durationMinutes: Math.floor(gapMinutes),
637
+ // Note: suitability will be calculated in Task 20b
638
+ suitability: 'good',
639
+ reason: `${Math.floor(gapMinutes)}分の空き時間`,
640
+ conflicts: [],
641
+ dayType: 'normal',
642
+ source: 'eventkit', // Source doesn't matter for merged slots
643
+ });
644
+ }
645
+ // Move current time to end of event
646
+ const eventEnd = event.end > workEnd ? workEnd : event.end;
647
+ currentTime = eventEnd > currentTime ? eventEnd : currentTime;
648
+ }
649
+ // Check remaining time after last event
650
+ const remainingMinutes = (workEnd.getTime() - currentTime.getTime()) / (1000 * 60);
651
+ if (remainingMinutes >= minDuration && remainingMinutes <= maxDuration) {
652
+ slots.push({
653
+ start: currentTime.toISOString(),
654
+ end: workEnd.toISOString(),
655
+ durationMinutes: Math.floor(remainingMinutes),
656
+ suitability: 'good',
657
+ reason: `${Math.floor(remainingMinutes)}分の空き時間`,
658
+ conflicts: [],
659
+ dayType: 'normal',
660
+ source: 'eventkit',
661
+ });
662
+ }
663
+ return slots;
664
+ }
665
+ /**
666
+ * Sync calendars between sources
667
+ * Requirement: 8 (Calendar synchronization)
668
+ *
669
+ * Synchronizes events between EventKit and Google Calendar when both
670
+ * sources are enabled. Handles conflicts and provides sync results.
671
+ *
672
+ * Note: This is a stub implementation. Full sync logic can be added later.
673
+ * Currently, it validates that both sources are enabled and returns empty results.
674
+ *
675
+ * @returns Sync result with statistics and errors
676
+ * @throws Error if both sources are not enabled
677
+ */
678
+ async syncCalendars() {
679
+ const enabledSources = this.getEnabledSources();
680
+ // Check if both sources are enabled
681
+ if (enabledSources.length < 2) {
682
+ throw new Error('Both EventKit and Google Calendar must be enabled for sync');
683
+ }
684
+ // Verify that both specific sources are enabled
685
+ const hasEventKit = enabledSources.includes('eventkit');
686
+ const hasGoogle = enabledSources.includes('google');
687
+ if (!hasEventKit || !hasGoogle) {
688
+ throw new Error('Both EventKit and Google Calendar must be enabled for sync');
689
+ }
690
+ // For now, return empty result
691
+ // Full sync implementation to be added in future
692
+ return {
693
+ success: true,
694
+ eventsAdded: 0,
695
+ eventsUpdated: 0,
696
+ eventsDeleted: 0,
697
+ conflicts: [],
698
+ errors: [],
699
+ timestamp: new Date().toISOString(),
700
+ };
701
+ }
702
+ /**
703
+ * Get sync status between calendar sources
704
+ * Requirement: 8 (Calendar synchronization)
705
+ *
706
+ * Returns the current sync status including last sync time,
707
+ * next scheduled sync, and source availability.
708
+ *
709
+ * Note: This is a stub implementation. lastSyncTime tracking will be added
710
+ * when full sync implementation is completed.
711
+ *
712
+ * @returns Sync status information
713
+ */
714
+ async getSyncStatus() {
715
+ const enabledSources = this.getEnabledSources();
716
+ const isEnabled = enabledSources.length >= 2
717
+ && enabledSources.includes('eventkit')
718
+ && enabledSources.includes('google');
719
+ // Check EventKit availability
720
+ const eventkitAvailable = enabledSources.includes('eventkit') && !!this.calendarService;
721
+ // Check Google Calendar availability
722
+ const googleAvailable = enabledSources.includes('google') && !!this.googleCalendarService;
723
+ // Calculate next sync time based on config sync interval (default: 300 seconds = 5 minutes)
724
+ const syncInterval = this.config?.calendar?.sources?.google?.syncInterval || 300;
725
+ const nextSyncTime = isEnabled
726
+ ? new Date(Date.now() + syncInterval * 1000).toISOString()
727
+ : undefined;
728
+ return {
729
+ isEnabled,
730
+ lastSyncTime: undefined, // TODO: Store last sync time when full sync is implemented
731
+ nextSyncTime,
732
+ sources: {
733
+ eventkit: { available: eventkitAvailable },
734
+ google: { available: googleAvailable },
735
+ },
736
+ };
737
+ }
738
+ /**
739
+ * Health check for calendar sources
740
+ * Requirement: 10, 11 (Health check for both sources)
741
+ * Task 22: Implementation
742
+ *
743
+ * Checks availability and health of all calendar sources by calling
744
+ * their respective isAvailable() methods. Returns a status object
745
+ * indicating which sources are currently healthy and available.
746
+ *
747
+ * This method never throws errors - it catches all failures and returns
748
+ * false for sources that are unavailable or unhealthy.
749
+ *
750
+ * @returns Object indicating health status of each source
751
+ */
752
+ async healthCheck() {
753
+ const checks = await Promise.all([
754
+ // EventKit health check
755
+ (async () => {
756
+ if (!this.calendarService) {
757
+ return false;
758
+ }
759
+ try {
760
+ return await this.calendarService.isAvailable();
761
+ }
762
+ catch (error) {
763
+ console.error('EventKit health check failed:', error);
764
+ return false;
765
+ }
766
+ })(),
767
+ // Google Calendar health check
768
+ (async () => {
769
+ if (!this.googleCalendarService) {
770
+ return false;
771
+ }
772
+ try {
773
+ return await this.googleCalendarService.isAvailable();
774
+ }
775
+ catch (error) {
776
+ console.error('Google Calendar health check failed:', error);
777
+ return false;
778
+ }
779
+ })(),
780
+ ]);
781
+ return {
782
+ eventkit: checks[0],
783
+ google: checks[1],
784
+ };
785
+ }
786
+ /**
787
+ * Respond to a calendar event
788
+ * Requirement: 6 (Event response/RSVP)
789
+ *
790
+ * Routes event response to the appropriate calendar source.
791
+ * Supports EventKit (via CalendarEventResponseService) and Google Calendar.
792
+ * If source is not specified, attempts to determine source by fetching
793
+ * the event from all enabled sources.
794
+ *
795
+ * @param eventId - Event ID
796
+ * @param response - Response type: 'accept', 'decline', or 'tentative'
797
+ * @param source - Optional source routing ('eventkit' or 'google')
798
+ * @param calendarId - Optional calendar ID (for Google Calendar)
799
+ * @returns Success status and message
800
+ * @throws Error if event not found or response fails
801
+ */
802
+ async respondToEvent(eventId, response, source, calendarId) {
803
+ const enabledSources = this.getEnabledSources();
804
+ // If source specified, route to that source
805
+ if (source) {
806
+ if (!enabledSources.includes(source)) {
807
+ throw new Error(`Calendar source '${source}' is not enabled`);
808
+ }
809
+ if (source === 'google') {
810
+ if (!this.googleCalendarService) {
811
+ throw new Error('Google Calendar service not initialized');
812
+ }
813
+ try {
814
+ // Convert response type: 'accept' -> 'accepted', etc.
815
+ const googleResponse = response === 'accept' ? 'accepted' :
816
+ response === 'decline' ? 'declined' : 'tentative';
817
+ await this.googleCalendarService.respondToEvent(eventId, googleResponse, calendarId);
818
+ return {
819
+ success: true,
820
+ message: `Successfully responded '${response}' to Google Calendar event`,
821
+ source: 'google',
822
+ };
823
+ }
824
+ catch (error) {
825
+ throw new Error(`Failed to respond to Google Calendar event: ${error instanceof Error ? error.message : 'Unknown error'}`);
826
+ }
827
+ }
828
+ else if (source === 'eventkit') {
829
+ // EventKit response is handled via CalendarEventResponseService
830
+ throw new Error('EventKit event responses should be handled via CalendarEventResponseService directly');
831
+ }
832
+ }
833
+ // No source specified - try to find event in enabled sources
834
+ // For now, prefer Google Calendar if enabled, as EventKit is typically
835
+ // handled via CalendarEventResponseService
836
+ if (enabledSources.includes('google') && this.googleCalendarService) {
837
+ try {
838
+ const googleResponse = response === 'accept' ? 'accepted' :
839
+ response === 'decline' ? 'declined' : 'tentative';
840
+ await this.googleCalendarService.respondToEvent(eventId, googleResponse, calendarId);
841
+ return {
842
+ success: true,
843
+ message: `Successfully responded '${response}' to Google Calendar event`,
844
+ source: 'google',
845
+ };
846
+ }
847
+ catch (error) {
848
+ // If Google Calendar fails and EventKit is enabled, suggest trying EventKit
849
+ if (enabledSources.includes('eventkit')) {
850
+ throw new Error(`Event not found in Google Calendar. If this is an EventKit event, please use CalendarEventResponseService. Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
851
+ }
852
+ throw error;
853
+ }
854
+ }
855
+ // If only EventKit is enabled
856
+ if (enabledSources.includes('eventkit')) {
857
+ throw new Error('EventKit event responses should be handled via CalendarEventResponseService directly');
858
+ }
859
+ throw new Error('No calendar sources enabled');
860
+ }
861
+ }
862
+ //# sourceMappingURL=calendar-source-manager.js.map