@luckydye/calendar 1.1.1 → 1.1.3

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