@luckydye/calendar 1.1.0 → 1.1.2

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/src/app.ts ADDED
@@ -0,0 +1,932 @@
1
+ import { CalendarViewElement } from "./lib.ts";
2
+ import "./CalDAVConfig.ts";
3
+ import type { CalDAVConfigElement } from "./CalDAVConfig.ts";
4
+ import {
5
+ CalendarIntegration,
6
+ type Calendar,
7
+ type CalendarSource,
8
+ } from "./CalendarIntegration.js";
9
+ import { CalDAVSource } from "./CalDAVSource.js";
10
+ import { fetchICalEventsWithNotifications, parseICalEvents, parseICalEventsWithNotifications } from "./ICal.js";
11
+ import { initializeTheme } from "./Theme.js";
12
+ import { InMemorySource } from "./InMemorySource.js";
13
+ import { GoogleCalendarSource } from "./GoogleCalendarSource.js";
14
+ import { InhouseBookingSource } from "./InhouseBookingSource.js";
15
+ import { queueStatus } from "./StatusMessage.js";
16
+ import { activeCalendarStore } from "./ActiveCalendarStore.js";
17
+ import type { CalendarEvent } from "./CalendarInternal.js";
18
+ import "./StatusBar.js";
19
+ import { NotificationScheduler } from "./NotificationScheduler.js";
20
+ import serviceWorkerUrl from "./service-worker.js?url";
21
+ import type { StatusBarElement } from "./StatusBar.js";
22
+
23
+ try {
24
+ customElements.define("calendar-view", CalendarViewElement);
25
+ } catch(err) {
26
+ console.error(err);
27
+ }
28
+
29
+ // Initialize theme on app start
30
+ initializeTheme();
31
+
32
+ const calendarElement = document.querySelector(
33
+ "calendar-view",
34
+ ) as CalendarViewElement;
35
+ const calendar = calendarElement.internal;
36
+
37
+ let workerPromise;
38
+
39
+ // Register service worker for PWA
40
+ if ("serviceWorker" in navigator) {
41
+ workerPromise = navigator.serviceWorker
42
+ .register(serviceWorkerUrl)
43
+ .catch((error) => {
44
+ console.error("Service Worker registration failed:", error);
45
+ });
46
+ }
47
+
48
+ // Initialize notification scheduler
49
+ const notificationScheduler = new NotificationScheduler(() => workerPromise);
50
+
51
+ async function scheduleNotificationsForEvents(events: CalendarEvent[]): Promise<void> {
52
+ const now = new Date();
53
+ const next24Hours = new Date(now.getTime() + 24 * 60 * 60 * 1000);
54
+ const upcoming = events.filter(e => e.start >= now && e.start <= next24Hours && e.reminders?.length);
55
+ for (const event of upcoming) {
56
+ await notificationScheduler.scheduleEventNotifications(event);
57
+ }
58
+ }
59
+
60
+ // Create and add statusbar to app layer
61
+ const statusBar = document.querySelector<StatusBarElement>("status-bar");
62
+
63
+ // Update statusbar data
64
+ function updateStatusBar() {
65
+ statusBar.data = calendarElement.getStatusBarData();
66
+ }
67
+
68
+ // Update statusbar on relevant events
69
+ calendarElement.addEventListener("selection-change", updateStatusBar);
70
+
71
+ // Update statusbar periodically for current time
72
+ setInterval(updateStatusBar, 10000); // Every 10 seconds
73
+
74
+ // Initial update after calendar is ready
75
+ calendar.initPromise.then(async (events) => {
76
+ await scheduleNotificationsForEvents(events);
77
+ updateStatusBar();
78
+ // Also update on any calendar interaction
79
+ calendarElement.addEventListener("event-click", updateStatusBar);
80
+ calendarElement.addEventListener("selection", updateStatusBar);
81
+ });
82
+
83
+ // Request notification permission on first interaction
84
+ async function requestNotificationPermission() {
85
+ if (!("Notification" in window)) return;
86
+ if (Notification.permission === "default") {
87
+ const permission = await Notification.requestPermission();
88
+ if (permission === "granted") {
89
+ queueStatus("Notifications enabled");
90
+ }
91
+ }
92
+ }
93
+
94
+ calendarElement.addEventListener("event-click", () => {
95
+ requestNotificationPermission();
96
+ }, { once: true });
97
+
98
+ // Load iCal files
99
+ const integration = new CalendarIntegration(calendar);
100
+
101
+ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void> {
102
+ const events = await integration.sync(cal, options);
103
+ await scheduleNotificationsForEvents(events);
104
+ }
105
+
106
+ // Initialize calendar with storage first, then sync sources
107
+ (async () => {
108
+ await calendar.initPromise;
109
+
110
+ // Sync CalDAV sources
111
+ await syncCalDAV();
112
+ })();
113
+
114
+ // CalDAV configuration UI
115
+ calendarElement.addEventListener("caldav-config", () => {
116
+ showCalDAVConfig();
117
+ });
118
+
119
+ function showCalDAVConfig() {
120
+ // Remove existing modal if present
121
+ const existing = document.querySelector("caldav-config-modal");
122
+ if (existing) {
123
+ existing.remove();
124
+ return;
125
+ }
126
+
127
+ // Create modal overlay
128
+ const modal = document.createElement("div");
129
+ modal.className = "caldav-config-modal";
130
+ modal.style.cssText = `
131
+ position: fixed;
132
+ top: 6.5rem;
133
+ left: 0.5rem;
134
+ bottom: 7rem;
135
+ width: 400px;
136
+ z-index: 1000;
137
+ `;
138
+
139
+ // Create CalDAV config component
140
+ const config = document.createElement("caldav-config") as CalDAVConfigElement;
141
+
142
+ config.addEventListener("close", () => {
143
+ modal.remove();
144
+ });
145
+
146
+ config.addEventListener("sources-changed", async () => {
147
+ await syncCalDAV();
148
+ });
149
+
150
+ modal.appendChild(config);
151
+ document.body.appendChild(modal);
152
+
153
+ // Close on backdrop click
154
+ modal.addEventListener("click", (e) => {
155
+ if (e.target === modal) {
156
+ modal.remove();
157
+ }
158
+ });
159
+ }
160
+
161
+ // Store for loaded calendars
162
+ const loadedCalendars: Map<string, Calendar> = new Map();
163
+
164
+ function findCalendarForEvent(event: CalendarEvent): Calendar | undefined {
165
+ if (event.calendarId) {
166
+ // Direct ID match (most sources)
167
+ const byId = loadedCalendars.get(event.calendarId);
168
+ if (byId) return byId;
169
+ // CalDAV: calendar.id is "sourceId::url" but event.calendarId is just the URL
170
+ for (const cal of loadedCalendars.values()) {
171
+ if (cal.calendarUrl === event.calendarId) return cal;
172
+ }
173
+ }
174
+ for (const cal of loadedCalendars.values()) {
175
+ if (cal.name === event.calendar) return cal;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ function getActiveCalendar(): Calendar | null {
181
+ return activeCalendarStore.getActiveCalendar();
182
+ }
183
+
184
+ // Create a CalDAV calendar wrapper
185
+ async function createCalDAVCalendars(
186
+ source: CalendarSource,
187
+ ): Promise<{ calendars: Calendar[]; userEmail: string }> {
188
+ const serverUrl = source.credentials.serverUrl;
189
+ const username = source.credentials.username;
190
+ const password = source.credentials.password;
191
+ if (!serverUrl || !username || !password) {
192
+ throw new Error("Missing CalDAV credentials");
193
+ }
194
+
195
+ const caldavSource = new CalDAVSource(
196
+ source.id,
197
+ source.name,
198
+ source.color,
199
+ {
200
+ serverUrl,
201
+ username,
202
+ password,
203
+ },
204
+ source.enabled
205
+ );
206
+
207
+ // Fetch all calendars and the current user's email from the CalDAV server
208
+ const [calendarInfos, userEmail] = await Promise.all([
209
+ caldavSource.fetchCalendars(),
210
+ caldavSource.fetchCurrentUserEmail(),
211
+ ]);
212
+ const calendars: Calendar[] = [];
213
+
214
+ for (const calInfo of calendarInfos) {
215
+ const calendarId = `${source.id}::${calInfo.url}`;
216
+
217
+ calendars.push({
218
+ id: calendarId,
219
+ name: calInfo.displayName,
220
+ color: calInfo.color || source.color,
221
+ enabled: source.enabled,
222
+ sourceId: source.id,
223
+ sourceType: "caldav",
224
+ calendarUrl: calInfo.url,
225
+
226
+ async fetchEvents(): Promise<CalendarEvent[]> {
227
+ return caldavSource.fetchEventsForCalendar(calInfo.url, calInfo.displayName, calInfo.color);
228
+ },
229
+
230
+ async createEvent(
231
+ event: Omit<CalendarEvent, "calendar" | "color">,
232
+ ): Promise<CalendarEvent> {
233
+ return caldavSource.createEvent({ ...event, calendarId: calInfo.url });
234
+ },
235
+
236
+ async updateEvent(
237
+ id: string,
238
+ updates: Partial<CalendarEvent>,
239
+ ): Promise<CalendarEvent> {
240
+ return caldavSource.updateEvent(id, { ...updates, calendarId: calInfo.url });
241
+ },
242
+
243
+ async moveEvent(
244
+ id: string,
245
+ newStart: Date,
246
+ newEnd: Date,
247
+ ): Promise<CalendarEvent> {
248
+ return caldavSource.updateEvent(id, { start: newStart, end: newEnd, calendarId: calInfo.url });
249
+ },
250
+
251
+ async deleteEvent(id: string): Promise<void> {
252
+ const eventUrl = `${calInfo.url.replace(/\/$/, '')}/${id}.ics`;
253
+ await caldavSource.request(eventUrl, 'DELETE', undefined, {});
254
+ },
255
+ });
256
+ }
257
+
258
+ return { calendars, userEmail };
259
+ }
260
+
261
+ // Create an iCal calendar wrapper
262
+ function createICalCalendar(source: CalendarSource): Calendar {
263
+ return {
264
+ id: source.id,
265
+ name: source.name,
266
+ color: source.color,
267
+ enabled: source.enabled,
268
+ sourceId: source.id,
269
+ sourceType: "ical",
270
+
271
+ async fetchEvents(): Promise<CalendarEvent[]> {
272
+ const result = await fetchICalEventsWithNotifications(source.credentials, source.color, source.name);
273
+ return result.events;
274
+ },
275
+ // iCal calendars are read-only
276
+ };
277
+ }
278
+
279
+ // Create a Google Calendar wrapper
280
+ function createGoogleCalendar(source: CalendarSource): Calendar {
281
+ const calendarId = source.credentials.calendarId || "primary";
282
+ const googleSource = new GoogleCalendarSource(
283
+ source.id,
284
+ source.name,
285
+ source.color,
286
+ {
287
+ accessToken: source.credentials.accessToken,
288
+ refreshToken: source.credentials.refreshToken,
289
+ tokenExpiry: source.credentials.tokenExpiry,
290
+ calendarId: calendarId,
291
+ },
292
+ source.enabled
293
+ );
294
+
295
+ return {
296
+ id: source.id,
297
+ name: source.name,
298
+ color: source.color,
299
+ enabled: source.enabled,
300
+ sourceId: source.id,
301
+ sourceType: "google",
302
+ calendarUrl: "https://calendar.google.com/calendar/u/0/r?tab=mc",
303
+
304
+ async fetchEvents(): Promise<CalendarEvent[]> {
305
+ return googleSource.fetchEvents();
306
+ },
307
+
308
+ async createEvent(
309
+ event: Omit<CalendarEvent, "calendar" | "color">,
310
+ ): Promise<CalendarEvent> {
311
+ return googleSource.createEvent(event);
312
+ },
313
+
314
+ async updateEvent(
315
+ id: string,
316
+ updates: Partial<CalendarEvent>,
317
+ ): Promise<CalendarEvent> {
318
+ return googleSource.updateEvent(id, updates);
319
+ },
320
+
321
+ async moveEvent(
322
+ id: string,
323
+ newStart: Date,
324
+ newEnd: Date,
325
+ ): Promise<CalendarEvent> {
326
+ return googleSource.moveEvent(id, newStart, newEnd);
327
+ },
328
+
329
+ async deleteEvent(id: string): Promise<void> {
330
+ return googleSource.deleteEvent(id);
331
+ },
332
+ };
333
+ }
334
+
335
+ // Create an Inhouse Booking calendar wrapper
336
+ function createInhouseCalendar(source: CalendarSource): Calendar {
337
+ const inhouseSource = new InhouseBookingSource(
338
+ source.id,
339
+ source.name,
340
+ source.color,
341
+ {
342
+ sessionCookie: source.credentials.sessionCookie,
343
+ employeeId: source.credentials.employeeId,
344
+ unitId: source.credentials.unitId,
345
+ },
346
+ source.enabled
347
+ );
348
+
349
+ return {
350
+ id: source.id,
351
+ name: source.name,
352
+ color: source.color,
353
+ enabled: source.enabled,
354
+ sourceId: source.id,
355
+ sourceType: "inhouse",
356
+
357
+ async fetchEvents(): Promise<CalendarEvent[]> {
358
+ return inhouseSource.fetchEvents();
359
+ },
360
+ };
361
+ }
362
+
363
+ // Create an in-memory calendar wrapper
364
+ function createInMemoryCalendar(source: InMemorySource): Calendar {
365
+ return {
366
+ id: source.id,
367
+ name: source.name,
368
+ color: source.color,
369
+ enabled: source.enabled,
370
+ sourceId: source.id,
371
+ sourceType: "in-memory",
372
+
373
+ async fetchEvents(): Promise<CalendarEvent[]> {
374
+ return source.fetchEvents();
375
+ },
376
+
377
+ async createEvent(
378
+ event: Omit<CalendarEvent, "calendar" | "color">,
379
+ ): Promise<CalendarEvent> {
380
+ if (!source.createEvent)
381
+ throw new Error("Source does not support createEvent");
382
+ return source.createEvent(event);
383
+ },
384
+
385
+ async updateEvent(
386
+ id: string,
387
+ updates: Partial<CalendarEvent>,
388
+ ): Promise<CalendarEvent> {
389
+ if (!source.updateEvent)
390
+ throw new Error("Source does not support updateEvent");
391
+ return source.updateEvent(id, updates);
392
+ },
393
+
394
+ async moveEvent(
395
+ id: string,
396
+ newStart: Date,
397
+ newEnd: Date,
398
+ ): Promise<CalendarEvent> {
399
+ if (!source.moveEvent)
400
+ throw new Error("Source does not support moveEvent");
401
+ return source.moveEvent(id, newStart, newEnd);
402
+ },
403
+
404
+ async deleteEvent(id: string): Promise<void> {
405
+ if (!source.deleteEvent)
406
+ throw new Error("Source does not support deleteEvent");
407
+ return source.deleteEvent(id);
408
+ },
409
+ };
410
+ }
411
+
412
+ // Keep track of configured source IDs to detect removals
413
+ let configuredSourceIds: Set<string> = new Set();
414
+
415
+ // Initial CalDAV sync - sync all configured sources
416
+ async function syncCalDAV(force = false) {
417
+ const saved = localStorage.getItem("caldav-sources");
418
+ if (!saved) return;
419
+
420
+ let sources: CalendarSource[];
421
+ try {
422
+ sources = JSON.parse(saved);
423
+ } catch {
424
+ return;
425
+ }
426
+
427
+ const newSourceIds = new Set(sources.map((s) => s.id));
428
+
429
+ // Remove calendars from sources that are no longer configured
430
+ for (const sourceId of configuredSourceIds) {
431
+ if (!newSourceIds.has(sourceId)) {
432
+ // Unregister all calendars from this source and clean up their events
433
+ for (const [id, calendarObj] of loadedCalendars) {
434
+ if (calendarObj.sourceId === sourceId) {
435
+ // Sync with empty events to trigger stale event deletion
436
+ calendar.sync(calendarObj.name, new Date(), []);
437
+ loadedCalendars.delete(id);
438
+ activeCalendarStore.unregisterCalendar(id);
439
+ }
440
+ }
441
+ }
442
+ }
443
+ configuredSourceIds = newSourceIds;
444
+
445
+ for (const source of sources) {
446
+ if (source.type === "caldav") {
447
+ if (
448
+ !source.credentials?.serverUrl ||
449
+ !source.credentials?.username ||
450
+ !source.credentials?.password
451
+ ) {
452
+ console.warn(
453
+ `Skipping CalDAV source ${source.name}: missing credentials`,
454
+ );
455
+ continue;
456
+ }
457
+
458
+ try {
459
+ const { calendars, userEmail } = await createCalDAVCalendars(source);
460
+
461
+ calendarElement.currentUserEmails.add(userEmail);
462
+
463
+ for (const calendar of calendars) {
464
+ loadedCalendars.set(calendar.id, calendar);
465
+ activeCalendarStore.registerCalendar(calendar);
466
+
467
+ // Sync this calendar
468
+ await sync(calendar, { force });
469
+ }
470
+ } catch (error) {
471
+ console.error(`Sync error for CalDAV source ${source.name}:`, error);
472
+ }
473
+ } else if (source.type === "ical") {
474
+ if (!source.credentials?.url) {
475
+ console.warn(`Skipping iCal source ${source.name}: missing URL`);
476
+ continue;
477
+ }
478
+
479
+ // iCal sources have one calendar
480
+ const calendar = createICalCalendar(source);
481
+ loadedCalendars.set(calendar.id, calendar);
482
+ activeCalendarStore.registerCalendar(calendar);
483
+
484
+ try {
485
+ await sync(calendar, { force });
486
+ } catch (error) {
487
+ console.error(`Sync error for iCal source ${source.name}:`, error);
488
+ }
489
+ } else if (source.type === "google") {
490
+ if (!source.credentials?.accessToken) {
491
+ console.warn(`Skipping Google Calendar source ${source.name}: missing access token`);
492
+ continue;
493
+ }
494
+
495
+ // Google Calendar sources have one calendar
496
+ const calendar = createGoogleCalendar(source);
497
+ loadedCalendars.set(calendar.id, calendar);
498
+ activeCalendarStore.registerCalendar(calendar);
499
+
500
+ try {
501
+ await sync(calendar, { force });
502
+ } catch (error) {
503
+ console.error(`Sync error for Google Calendar source ${source.name}:`, error);
504
+ }
505
+ } else if (source.type === "inhouse") {
506
+ if (!source.credentials?.sessionCookie || !source.credentials?.employeeId) {
507
+ console.warn(`Skipping Inhouse Booking source ${source.name}: missing session cookie or employee ID`);
508
+ continue;
509
+ }
510
+
511
+ // Inhouse Booking sources have one calendar
512
+ const calendar = createInhouseCalendar(source);
513
+ loadedCalendars.set(calendar.id, calendar);
514
+ activeCalendarStore.registerCalendar(calendar);
515
+
516
+ try {
517
+ await sync(calendar, { force });
518
+ } catch (error) {
519
+ console.error(`Sync error for Inhouse Booking source ${source.name}:`, error);
520
+ }
521
+ } else {
522
+ console.warn(`Unknown source type for ${source.name}: ${source.type}`);
523
+ }
524
+ }
525
+
526
+ // Update enabled calendars filter
527
+ updateEnabledCalendars();
528
+
529
+ // Update the UI selector and color after sync
530
+ updateActiveCalendarSelector();
531
+ updateActiveCalendarColor();
532
+ }
533
+
534
+ // Refresh CalDAV every 5 minutes
535
+ setInterval(syncCalDAV, 5 * 60 * 1000);
536
+
537
+ calendarElement.addEventListener("selection", (e) => {
538
+ console.log("Box selection time ranges:", e.detail.timeRanges);
539
+ console.log("Overall range:", e.detail.start, "to", e.detail.end);
540
+ });
541
+ calendarElement.addEventListener("event-click", (e) => {
542
+ console.log("Event clicked:", e.detail);
543
+ });
544
+ calendarElement.addEventListener("selection-change", (e) => {
545
+ console.log("Selection changed:", e.detail.selectedEvents);
546
+ });
547
+
548
+ calendarElement.addEventListener("create-event", async (e) => {
549
+ const { start, end } = e.detail;
550
+ const calendar = getActiveCalendar();
551
+ if (!calendar) {
552
+ queueStatus("No calendar selected. Please configure a calendar first.");
553
+ return;
554
+ }
555
+
556
+ if (!calendar.createEvent) {
557
+ queueStatus(`Calendar "${calendar.name}" is read-only.`);
558
+ return;
559
+ }
560
+
561
+ const id = `event-${Date.now()}`;
562
+ console.log("Creating event:", id, start, end, "on calendar:", calendar.name);
563
+ await calendar.createEvent({
564
+ id,
565
+ title: "New Event",
566
+ start,
567
+ end,
568
+ });
569
+ await sync(calendar, { force: true });
570
+ updateStatusBar();
571
+ });
572
+
573
+ calendarElement.addEventListener("move-event", async (e) => {
574
+ const { event, start, end } = e.detail;
575
+
576
+ const calendar = findCalendarForEvent(event);
577
+
578
+ if (!calendar) {
579
+ queueStatus("Cannot move event: calendar not found.");
580
+ return;
581
+ }
582
+
583
+ if (!calendar.moveEvent) {
584
+ queueStatus(`Calendar "${calendar.name}" does not support moving events.`);
585
+ return;
586
+ }
587
+
588
+ console.log(
589
+ "Moving event:",
590
+ event.id,
591
+ "from",
592
+ event.start,
593
+ "to",
594
+ start,
595
+ "on calendar:",
596
+ calendar.name,
597
+ );
598
+ try {
599
+ await calendar.moveEvent(event.id, start, end);
600
+ await sync(calendar, { force: true });
601
+ console.log("Move completed on calendar:", calendar.name);
602
+ updateStatusBar();
603
+ } catch (error) {
604
+ console.error("Failed to move event:", error);
605
+ queueStatus(`Failed to move event: ${error instanceof Error ? error.message : String(error)}`);
606
+ }
607
+ });
608
+
609
+ calendarElement.addEventListener("update-event", async (e) => {
610
+ const { event, updates } = e.detail;
611
+
612
+ const calendar = findCalendarForEvent(event);
613
+
614
+ if (!calendar) {
615
+ queueStatus("Cannot update event: calendar not found.");
616
+ return;
617
+ }
618
+
619
+ if (!calendar.updateEvent) {
620
+ queueStatus(
621
+ `Calendar "${calendar.name}" does not support updating events.`,
622
+ );
623
+ return;
624
+ }
625
+
626
+ console.log(
627
+ "Updating event:",
628
+ event
629
+ );
630
+ try {
631
+ await calendar.updateEvent(event.id, updates);
632
+ await sync(calendar, { force: true });
633
+
634
+ if ("reminders" in updates) {
635
+ const eventWithReminders = { ...event, ...updates };
636
+ await notificationScheduler.scheduleEventNotifications(eventWithReminders);
637
+
638
+ if (calendarElement.selectedEventForDetail?.id === event.id) {
639
+ calendarElement.selectedEventForDetail = eventWithReminders;
640
+ }
641
+ }
642
+
643
+ console.log("Update completed on calendar:", calendar.name);
644
+ updateStatusBar();
645
+ } catch (error) {
646
+ console.error("Failed to update event:", error);
647
+ queueStatus(`Failed to update event: ${error instanceof Error ? error.message : String(error)}`);
648
+ }
649
+ });
650
+
651
+ calendarElement.addEventListener("delete-events", async (e) => {
652
+ const { events } = e.detail;
653
+
654
+ // Group events by their calendarId
655
+ const eventsByCalendar = new Map<string, typeof events>();
656
+ for (const event of events) {
657
+ const calendarId = event.calendarId ?? "";
658
+ if (!eventsByCalendar.has(calendarId)) {
659
+ eventsByCalendar.set(calendarId, []);
660
+ }
661
+ const calendarEvents = eventsByCalendar.get(calendarId);
662
+ if (calendarEvents) {
663
+ calendarEvents.push(event);
664
+ }
665
+ }
666
+
667
+ // Delete events from each calendar
668
+ for (const [, calendarEvents] of eventsByCalendar) {
669
+ const calendar = findCalendarForEvent(calendarEvents[0]);
670
+
671
+ if (!calendar) {
672
+ queueStatus(
673
+ `Cannot delete events: calendar "${calendarId}" not found.`,
674
+ );
675
+ continue;
676
+ }
677
+
678
+ if (!calendar.deleteEvent) {
679
+ queueStatus(
680
+ `Calendar "${calendar.name}" does not support deleting events.`,
681
+ );
682
+ continue;
683
+ }
684
+
685
+ console.log(
686
+ "Deleting events from",
687
+ calendar.name,
688
+ ":",
689
+ calendarEvents.map((ev) => ev.id),
690
+ );
691
+ for (const event of calendarEvents) {
692
+ await calendar.deleteEvent(event.id);
693
+ }
694
+ await sync(calendar, { force: true });
695
+ }
696
+ console.log("Delete completed");
697
+ updateStatusBar();
698
+ });
699
+
700
+ calendarElement.addEventListener("force-sync", async () => {
701
+ queueStatus("Force syncing all calendars...");
702
+ console.log("Force sync triggered");
703
+
704
+ await syncCalDAV(true);
705
+
706
+ queueStatus("Sync completed");
707
+ console.log("Force sync completed");
708
+ updateStatusBar();
709
+ });
710
+
711
+ calendarElement.addEventListener("load-notifications", async () => {
712
+ try {
713
+ const notifications = await notificationScheduler.getScheduledNotifications();
714
+ calendarElement.setScheduledNotifications(notifications);
715
+ } catch (error) {
716
+ console.error("Failed to load scheduled notifications:", error);
717
+ }
718
+ });
719
+
720
+ calendarElement.addEventListener("import-ical", async (e) => {
721
+ const { icalText, fileName } = e.detail;
722
+ const calendar = getActiveCalendar();
723
+
724
+ if (!calendar) {
725
+ queueStatus("No calendar selected. Please configure a calendar first.");
726
+ return;
727
+ }
728
+
729
+ if (!calendar.createEvent) {
730
+ queueStatus(
731
+ `Calendar "${calendar.name}" is read-only and cannot import events.`,
732
+ );
733
+ return;
734
+ }
735
+
736
+ try {
737
+ const parsedEvents = parseICalEvents(
738
+ icalText,
739
+ calendar.color,
740
+ calendar.name,
741
+ );
742
+
743
+ if (parsedEvents.length === 0) {
744
+ queueStatus(`No events found in ${fileName}`);
745
+ return;
746
+ }
747
+
748
+ queueStatus(
749
+ `Importing ${parsedEvents.length} event${
750
+ parsedEvents.length === 1 ? "" : "s"
751
+ } from ${fileName} into "${calendar.name}"...`,
752
+ );
753
+
754
+ const firstEventStart = parsedEvents[0].start;
755
+
756
+ for (const event of parsedEvents) {
757
+ await calendar.createEvent({
758
+ id: `imported-${Date.now()}-${Math.random()}`,
759
+ title: event.title,
760
+ start: event.start,
761
+ end: event.end,
762
+ description: event.description,
763
+ location: event.location,
764
+ url: event.url,
765
+ readOnly: false,
766
+ });
767
+ }
768
+
769
+ await sync(calendar, { force: true });
770
+ queueStatus(
771
+ `Successfully imported ${parsedEvents.length} event${
772
+ parsedEvents.length === 1 ? "" : "s"
773
+ } into "${calendar.name}"`,
774
+ );
775
+
776
+ calendarElement.scrollToDate(firstEventStart, 0.5, true, true);
777
+ updateStatusBar();
778
+ } catch (error) {
779
+ console.error("Import error:", error);
780
+ queueStatus(`Failed to import ${fileName}: ${error.message}`);
781
+ }
782
+ });
783
+
784
+ // --- In-Memory Source for Testing ---
785
+ let sampleSource: InMemorySource | null = null;
786
+
787
+ (async () => {
788
+ sampleSource = new InMemorySource('sample', "Sample Events", '#10b981');
789
+
790
+ // Register the sample source as a calendar
791
+ const sampleCalendar = createInMemoryCalendar(sampleSource);
792
+ loadedCalendars.set(sampleCalendar.id, sampleCalendar);
793
+ activeCalendarStore.registerCalendar(sampleCalendar);
794
+
795
+ // Create the active calendar selector UI
796
+ createActiveCalendarSelector();
797
+
798
+ // Update enabled calendars and active calendar color
799
+ updateEnabledCalendars();
800
+ updateActiveCalendarColor();
801
+ })();
802
+
803
+ // --- Active Calendar Selector UI ---
804
+ function createActiveCalendarSelector() {
805
+ // Check if selector already exists in the slot
806
+ if (document.getElementById("active-calendar-selector")) {
807
+ return;
808
+ }
809
+
810
+ const selector = document.createElement("div");
811
+ selector.id = "active-calendar-selector";
812
+ selector.setAttribute("slot", "toolbar-center");
813
+ selector.style.cssText = `
814
+ display: flex;
815
+ align-items: center;
816
+ gap: 8px;
817
+ font-size: 13px;
818
+ margin-right: auto;
819
+ `;
820
+
821
+ const label = document.createElement("span");
822
+ label.textContent = "Active:";
823
+ label.style.cssText = `
824
+ color: var(--text-muted, rgba(255, 255, 255, 0.5));
825
+ white-space: nowrap;
826
+ `;
827
+
828
+ const select = document.createElement("select");
829
+ select.id = "active-calendar-select";
830
+ select.style.cssText = `
831
+ background: var(--bg-input, rgba(0, 0, 0, 0.3));
832
+ border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
833
+ border-radius: var(--border-radius-sm, 4px);
834
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
835
+ padding: 4px 8px;
836
+ font-size: 13px;
837
+ cursor: pointer;
838
+ min-width: 150px;
839
+ `;
840
+
841
+ select.addEventListener("change", (e) => {
842
+ const target = e.target as HTMLSelectElement;
843
+ try {
844
+ activeCalendarStore.setActive(target.value || null);
845
+ updateActiveCalendarColor();
846
+ const calendar = activeCalendarStore.getActiveCalendar();
847
+ if (calendar) {
848
+ queueStatus(`Active calendar: ${calendar.name}`);
849
+ }
850
+ } catch (err) {
851
+ console.error("Failed to set active calendar:", err);
852
+ queueStatus(`Error: ${err.message}`);
853
+ }
854
+ });
855
+
856
+ selector.appendChild(label);
857
+ selector.appendChild(select);
858
+ calendarElement.appendChild(selector);
859
+
860
+ // Subscribe to changes and update the selector and color
861
+ activeCalendarStore.subscribe(() => {
862
+ updateActiveCalendarSelector();
863
+ updateActiveCalendarColor();
864
+ });
865
+
866
+ // Initial update
867
+ updateActiveCalendarSelector();
868
+ }
869
+
870
+ function updateActiveCalendarColor() {
871
+ const calendar = activeCalendarStore.getActiveCalendar();
872
+ calendarElement.activeCalendarColor = calendar?.color ?? null;
873
+ }
874
+
875
+ function updateEnabledCalendars() {
876
+ const enabledCalendarIdentifiers = Array.from(loadedCalendars.values())
877
+ .filter(cal => cal.enabled)
878
+ .flatMap(cal => {
879
+ // For CalDAV, use the calendar URL; for others, use the source ID
880
+ const identifiers = [];
881
+ if (cal.calendarUrl) {
882
+ identifiers.push(cal.calendarUrl);
883
+ }
884
+ if (cal.sourceId) {
885
+ identifiers.push(cal.sourceId);
886
+ }
887
+ return identifiers;
888
+ });
889
+ calendar.setEnabledCalendars(enabledCalendarIdentifiers);
890
+ }
891
+
892
+ function getActiveCalendarSelect(): HTMLSelectElement | null {
893
+ // Look in the light DOM first (where slotted content lives)
894
+ const selector = document.getElementById("active-calendar-selector");
895
+ if (selector) {
896
+ return selector.querySelector(
897
+ "#active-calendar-select",
898
+ ) as HTMLSelectElement | null;
899
+ }
900
+ return null;
901
+ }
902
+
903
+ function updateActiveCalendarSelector() {
904
+ const select = getActiveCalendarSelect();
905
+ if (!select) return;
906
+
907
+ const calendars = activeCalendarStore.getAvailableCalendars();
908
+ const activeId = activeCalendarStore.getActiveId();
909
+
910
+ // Clear current options
911
+ select.innerHTML = "";
912
+
913
+ if (calendars.length === 0) {
914
+ const option = document.createElement("option");
915
+ option.value = "";
916
+ option.textContent = "No calendars";
917
+ option.disabled = true;
918
+ option.selected = true;
919
+ select.appendChild(option);
920
+ return;
921
+ }
922
+
923
+ for (const calendar of calendars) {
924
+ const option = document.createElement("option");
925
+ option.value = calendar.id;
926
+ option.textContent = calendar.name;
927
+ if (calendar.id === activeId) {
928
+ option.selected = true;
929
+ }
930
+ select.appendChild(option);
931
+ }
932
+ }