@luckydye/calendar 1.1.2 → 1.2.0

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 CHANGED
@@ -15,6 +15,7 @@ import { InhouseBookingSource } from "./InhouseBookingSource.js";
15
15
  import { queueStatus } from "./StatusMessage.js";
16
16
  import { activeCalendarStore } from "./ActiveCalendarStore.js";
17
17
  import type { CalendarEvent } from "./CalendarInternal.js";
18
+ import { registerKeybinds } from "./Keybinds.js";
18
19
  import "./StatusBar.js";
19
20
  import { NotificationScheduler } from "./NotificationScheduler.js";
20
21
  import serviceWorkerUrl from "./service-worker.js?url";
@@ -34,7 +35,19 @@ const calendarElement = document.querySelector(
34
35
  ) as CalendarViewElement;
35
36
  const calendar = calendarElement.internal;
36
37
 
37
- let workerPromise;
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;
38
51
 
39
52
  // Register service worker for PWA
40
53
  if ("serviceWorker" in navigator) {
@@ -107,8 +120,30 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
107
120
  (async () => {
108
121
  await calendar.initPromise;
109
122
 
110
- // Sync CalDAV sources
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)
111
143
  await syncCalDAV();
144
+
145
+ // Update active calendar color after sync
146
+ updateActiveCalendarColor();
112
147
  })();
113
148
 
114
149
  // CalDAV configuration UI
@@ -219,6 +254,7 @@ async function createCalDAVCalendars(
219
254
  name: calInfo.displayName,
220
255
  color: calInfo.color || source.color,
221
256
  enabled: source.enabled,
257
+ locked: (source as { locked?: boolean }).locked,
222
258
  sourceId: source.id,
223
259
  sourceType: "caldav",
224
260
  calendarUrl: calInfo.url,
@@ -240,14 +276,6 @@ async function createCalDAVCalendars(
240
276
  return caldavSource.updateEvent(id, { ...updates, calendarId: calInfo.url });
241
277
  },
242
278
 
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
279
  async deleteEvent(id: string): Promise<void> {
252
280
  const eventUrl = `${calInfo.url.replace(/\/$/, '')}/${id}.ics`;
253
281
  await caldavSource.request(eventUrl, 'DELETE', undefined, {});
@@ -265,6 +293,7 @@ function createICalCalendar(source: CalendarSource): Calendar {
265
293
  name: source.name,
266
294
  color: source.color,
267
295
  enabled: source.enabled,
296
+ locked: (source as { locked?: boolean }).locked,
268
297
  sourceId: source.id,
269
298
  sourceType: "ical",
270
299
 
@@ -272,7 +301,7 @@ function createICalCalendar(source: CalendarSource): Calendar {
272
301
  const result = await fetchICalEventsWithNotifications(source.credentials, source.color, source.name);
273
302
  return result.events;
274
303
  },
275
- // iCal calendars are read-only
304
+ // iCal calendars are read-only by default
276
305
  };
277
306
  }
278
307
 
@@ -297,6 +326,7 @@ function createGoogleCalendar(source: CalendarSource): Calendar {
297
326
  name: source.name,
298
327
  color: source.color,
299
328
  enabled: source.enabled,
329
+ locked: (source as { locked?: boolean }).locked,
300
330
  sourceId: source.id,
301
331
  sourceType: "google",
302
332
  calendarUrl: "https://calendar.google.com/calendar/u/0/r?tab=mc",
@@ -318,14 +348,6 @@ function createGoogleCalendar(source: CalendarSource): Calendar {
318
348
  return googleSource.updateEvent(id, updates);
319
349
  },
320
350
 
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
351
  async deleteEvent(id: string): Promise<void> {
330
352
  return googleSource.deleteEvent(id);
331
353
  },
@@ -351,12 +373,34 @@ function createInhouseCalendar(source: CalendarSource): Calendar {
351
373
  name: source.name,
352
374
  color: source.color,
353
375
  enabled: source.enabled,
376
+ locked: (source as { locked?: boolean }).locked,
354
377
  sourceId: source.id,
355
378
  sourceType: "inhouse",
356
379
 
357
380
  async fetchEvents(): Promise<CalendarEvent[]> {
358
381
  return inhouseSource.fetchEvents();
359
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
+ },
360
404
  };
361
405
  }
362
406
 
@@ -391,16 +435,6 @@ function createInMemoryCalendar(source: InMemorySource): Calendar {
391
435
  return source.updateEvent(id, updates);
392
436
  },
393
437
 
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
438
  async deleteEvent(id: string): Promise<void> {
405
439
  if (!source.deleteEvent)
406
440
  throw new Error("Source does not support deleteEvent");
@@ -443,6 +477,8 @@ async function syncCalDAV(force = false) {
443
477
  configuredSourceIds = newSourceIds;
444
478
 
445
479
  for (const source of sources) {
480
+ if (!source.enabled) continue;
481
+
446
482
  if (source.type === "caldav") {
447
483
  if (
448
484
  !source.credentials?.serverUrl ||
@@ -508,7 +544,6 @@ async function syncCalDAV(force = false) {
508
544
  continue;
509
545
  }
510
546
 
511
- // Inhouse Booking sources have one calendar
512
547
  const calendar = createInhouseCalendar(source);
513
548
  loadedCalendars.set(calendar.id, calendar);
514
549
  activeCalendarStore.registerCalendar(calendar);
@@ -525,6 +560,7 @@ async function syncCalDAV(force = false) {
525
560
 
526
561
  // Update enabled calendars filter
527
562
  updateEnabledCalendars();
563
+ updateLockedCalendars();
528
564
 
529
565
  // Update the UI selector and color after sync
530
566
  updateActiveCalendarSelector();
@@ -545,6 +581,75 @@ calendarElement.addEventListener("selection-change", (e) => {
545
581
  console.log("Selection changed:", e.detail.selectedEvents);
546
582
  });
547
583
 
584
+ function showProjectPickerDialog(
585
+ projects: Array<{ id: number; name: string }>,
586
+ ): Promise<number | null> {
587
+ return new Promise((resolve) => {
588
+ let resolved = false;
589
+ const finish = (value: number | null) => {
590
+ if (resolved) return;
591
+ resolved = true;
592
+ resolve(value);
593
+ };
594
+
595
+ const dialog = document.createElement("dialog");
596
+ dialog.style.cssText =
597
+ "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;";
598
+
599
+ const input = document.createElement("input");
600
+ input.type = "text";
601
+ input.placeholder = "Search projects...";
602
+ input.style.cssText =
603
+ "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;";
604
+
605
+ const list = document.createElement("div");
606
+ list.style.cssText =
607
+ "max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;";
608
+
609
+ const renderList = (filter: string) => {
610
+ list.innerHTML = "";
611
+ const filtered = filter
612
+ ? projects.filter((p) =>
613
+ p.name.toLowerCase().includes(filter.toLowerCase()),
614
+ )
615
+ : projects;
616
+ for (const project of filtered) {
617
+ const item = document.createElement("button");
618
+ item.textContent = project.name;
619
+ item.style.cssText =
620
+ "text-align:left;padding:6px 8px;background:var(--bg-item);color:var(--text-primary);border:none;border-radius:var(--border-radius-sm);cursor:pointer;";
621
+ item.addEventListener("mouseenter", () => {
622
+ item.style.background = "var(--bg-item-hover)";
623
+ });
624
+ item.addEventListener("mouseleave", () => {
625
+ item.style.background = "var(--bg-item)";
626
+ });
627
+ item.addEventListener("click", () => {
628
+ finish(project.id);
629
+ dialog.close();
630
+ });
631
+ list.appendChild(item);
632
+ }
633
+ };
634
+
635
+ renderList("");
636
+ input.addEventListener("input", () => renderList(input.value));
637
+
638
+ dialog.addEventListener("close", () => {
639
+ dialog.remove();
640
+ finish(null);
641
+ });
642
+ dialog.addEventListener("click", (ev) => {
643
+ if (ev.target === dialog) dialog.close();
644
+ });
645
+
646
+ dialog.append(input, list);
647
+ document.body.appendChild(dialog);
648
+ dialog.showModal();
649
+ input.focus();
650
+ });
651
+ }
652
+
548
653
  calendarElement.addEventListener("create-event", async (e) => {
549
654
  const { start, end } = e.detail;
550
655
  const calendar = getActiveCalendar();
@@ -558,14 +663,19 @@ calendarElement.addEventListener("create-event", async (e) => {
558
663
  return;
559
664
  }
560
665
 
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
- });
666
+ let id = `event-${Date.now()}`;
667
+
668
+ if (calendar.sourceType === "inhouse") {
669
+ const inhouseCalendar = calendar as typeof calendar & {
670
+ fetchProjects(): Promise<Array<{ id: number; name: string }>>;
671
+ };
672
+ const projects = await inhouseCalendar.fetchProjects();
673
+ const projectId = await showProjectPickerDialog(projects);
674
+ if (projectId === null) return; // user cancelled
675
+ id = `0:0:${projectId}`;
676
+ }
677
+
678
+ await calendar.createEvent({ id, title: "New Event", start, end });
569
679
  await sync(calendar, { force: true });
570
680
  updateStatusBar();
571
681
  });
@@ -580,7 +690,7 @@ calendarElement.addEventListener("move-event", async (e) => {
580
690
  return;
581
691
  }
582
692
 
583
- if (!calendar.moveEvent) {
693
+ if (!calendar.updateEvent) {
584
694
  queueStatus(`Calendar "${calendar.name}" does not support moving events.`);
585
695
  return;
586
696
  }
@@ -596,7 +706,7 @@ calendarElement.addEventListener("move-event", async (e) => {
596
706
  calendar.name,
597
707
  );
598
708
  try {
599
- await calendar.moveEvent(event.id, start, end);
709
+ await calendar.updateEvent(event.id, { start, end });
600
710
  await sync(calendar, { force: true });
601
711
  console.log("Move completed on calendar:", calendar.name);
602
712
  updateStatusBar();
@@ -688,10 +798,15 @@ calendarElement.addEventListener("delete-events", async (e) => {
688
798
  ":",
689
799
  calendarEvents.map((ev) => ev.id),
690
800
  );
691
- for (const event of calendarEvents) {
692
- await calendar.deleteEvent(event.id);
801
+ try {
802
+ for (const event of calendarEvents) {
803
+ await calendar.deleteEvent(event.id);
804
+ }
805
+ await sync(calendar, { force: true });
806
+ } catch (error) {
807
+ console.error("Failed to delete event:", error);
808
+ queueStatus(`Failed to delete event: ${error instanceof Error ? error.message : String(error)}`);
693
809
  }
694
- await sync(calendar, { force: true });
695
810
  }
696
811
  console.log("Delete completed");
697
812
  updateStatusBar();
@@ -781,25 +896,6 @@ calendarElement.addEventListener("import-ical", async (e) => {
781
896
  }
782
897
  });
783
898
 
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
899
  // --- Active Calendar Selector UI ---
804
900
  function createActiveCalendarSelector() {
805
901
  // Check if selector already exists in the slot
@@ -819,7 +915,6 @@ function createActiveCalendarSelector() {
819
915
  `;
820
916
 
821
917
  const label = document.createElement("span");
822
- label.textContent = "Active:";
823
918
  label.style.cssText = `
824
919
  color: var(--text-muted, rgba(255, 255, 255, 0.5));
825
920
  white-space: nowrap;
@@ -889,6 +984,23 @@ function updateEnabledCalendars() {
889
984
  calendar.setEnabledCalendars(enabledCalendarIdentifiers);
890
985
  }
891
986
 
987
+ function updateLockedCalendars() {
988
+ const lockedCalendarIdentifiers = Array.from(loadedCalendars.values())
989
+ .filter(cal => (cal as { locked?: boolean }).locked)
990
+ .flatMap(cal => {
991
+ // For CalDAV, use the calendar URL; for others, use the source ID
992
+ const identifiers = [];
993
+ if (cal.calendarUrl) {
994
+ identifiers.push(cal.calendarUrl);
995
+ }
996
+ if (cal.sourceId) {
997
+ identifiers.push(cal.sourceId);
998
+ }
999
+ return identifiers;
1000
+ });
1001
+ calendar.setLockedCalendars(lockedCalendarIdentifiers);
1002
+ }
1003
+
892
1004
  function getActiveCalendarSelect(): HTMLSelectElement | null {
893
1005
  // Look in the light DOM first (where slotted content lives)
894
1006
  const selector = document.getElementById("active-calendar-selector");