@luckydye/calendar 1.1.2 → 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 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");
@@ -508,7 +542,6 @@ async function syncCalDAV(force = false) {
508
542
  continue;
509
543
  }
510
544
 
511
- // Inhouse Booking sources have one calendar
512
545
  const calendar = createInhouseCalendar(source);
513
546
  loadedCalendars.set(calendar.id, calendar);
514
547
  activeCalendarStore.registerCalendar(calendar);
@@ -525,6 +558,7 @@ async function syncCalDAV(force = false) {
525
558
 
526
559
  // Update enabled calendars filter
527
560
  updateEnabledCalendars();
561
+ updateLockedCalendars();
528
562
 
529
563
  // Update the UI selector and color after sync
530
564
  updateActiveCalendarSelector();
@@ -545,6 +579,75 @@ calendarElement.addEventListener("selection-change", (e) => {
545
579
  console.log("Selection changed:", e.detail.selectedEvents);
546
580
  });
547
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
+
548
651
  calendarElement.addEventListener("create-event", async (e) => {
549
652
  const { start, end } = e.detail;
550
653
  const calendar = getActiveCalendar();
@@ -558,14 +661,19 @@ calendarElement.addEventListener("create-event", async (e) => {
558
661
  return;
559
662
  }
560
663
 
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
- });
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 });
569
677
  await sync(calendar, { force: true });
570
678
  updateStatusBar();
571
679
  });
@@ -580,7 +688,7 @@ calendarElement.addEventListener("move-event", async (e) => {
580
688
  return;
581
689
  }
582
690
 
583
- if (!calendar.moveEvent) {
691
+ if (!calendar.updateEvent) {
584
692
  queueStatus(`Calendar "${calendar.name}" does not support moving events.`);
585
693
  return;
586
694
  }
@@ -596,7 +704,7 @@ calendarElement.addEventListener("move-event", async (e) => {
596
704
  calendar.name,
597
705
  );
598
706
  try {
599
- await calendar.moveEvent(event.id, start, end);
707
+ await calendar.updateEvent(event.id, { start, end });
600
708
  await sync(calendar, { force: true });
601
709
  console.log("Move completed on calendar:", calendar.name);
602
710
  updateStatusBar();
@@ -688,10 +796,15 @@ calendarElement.addEventListener("delete-events", async (e) => {
688
796
  ":",
689
797
  calendarEvents.map((ev) => ev.id),
690
798
  );
691
- for (const event of calendarEvents) {
692
- await calendar.deleteEvent(event.id);
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)}`);
693
807
  }
694
- await sync(calendar, { force: true });
695
808
  }
696
809
  console.log("Delete completed");
697
810
  updateStatusBar();
@@ -781,25 +894,6 @@ calendarElement.addEventListener("import-ical", async (e) => {
781
894
  }
782
895
  });
783
896
 
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
897
  // --- Active Calendar Selector UI ---
804
898
  function createActiveCalendarSelector() {
805
899
  // Check if selector already exists in the slot
@@ -819,7 +913,6 @@ function createActiveCalendarSelector() {
819
913
  `;
820
914
 
821
915
  const label = document.createElement("span");
822
- label.textContent = "Active:";
823
916
  label.style.cssText = `
824
917
  color: var(--text-muted, rgba(255, 255, 255, 0.5));
825
918
  white-space: nowrap;
@@ -889,6 +982,23 @@ function updateEnabledCalendars() {
889
982
  calendar.setEnabledCalendars(enabledCalendarIdentifiers);
890
983
  }
891
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
+
892
1002
  function getActiveCalendarSelect(): HTMLSelectElement | null {
893
1003
  // Look in the light DOM first (where slotted content lives)
894
1004
  const selector = document.getElementById("active-calendar-selector");