@luckydye/calendar 1.2.3 → 1.3.1

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
@@ -12,6 +12,7 @@ import { initializeTheme } from "./Theme.js";
12
12
  import { InMemorySource } from "./InMemorySource.js";
13
13
  import { GoogleCalendarSource } from "./GoogleCalendarSource.js";
14
14
  import { InhouseBookingSource } from "./InhouseBookingSource.js";
15
+ import { fetchTimeseriesJsonEvents } from "./TimeseriesJson.js";
15
16
  import { queueStatus } from "./StatusMessage.js";
16
17
  import { activeCalendarStore } from "./ActiveCalendarStore.js";
17
18
  import type { CalendarEvent } from "./CalendarInternal.js";
@@ -44,7 +45,6 @@ registerKeybinds(
44
45
  { key: "Escape", action: () => calendarElement.escape() },
45
46
  { key: "t", action: () => calendarElement.scrollToToday() },
46
47
  ],
47
- calendarElement,
48
48
  );
49
49
 
50
50
  let workerPromise: Promise<ServiceWorkerRegistration> | undefined;
@@ -80,6 +80,7 @@ function updateStatusBar() {
80
80
 
81
81
  // Update statusbar on relevant events
82
82
  calendarElement.addEventListener("selection-change", updateStatusBar);
83
+ calendarElement.addEventListener("meta-key-change", updateStatusBar);
83
84
 
84
85
  // Update statusbar periodically for current time
85
86
  setInterval(updateStatusBar, 10000); // Every 10 seconds
@@ -116,6 +117,42 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
116
117
  await scheduleNotificationsForEvents(events);
117
118
  }
118
119
 
120
+ // Mutation coordination: batches post-mutation syncs so concurrent
121
+ // mutations don't each trigger their own reconciliation round-trip.
122
+ let pendingMutations = 0;
123
+ let mutationsSettledResolve: (() => void) | null = null;
124
+ let mutationsSettledPromise: Promise<void> | null = null;
125
+ const dirtyCalendars = new Set<Calendar>();
126
+
127
+ async function withMutation(cal: Calendar, fn: () => Promise<void>): Promise<void> {
128
+ if (pendingMutations === 0) {
129
+ mutationsSettledPromise = new Promise(r => { mutationsSettledResolve = r; });
130
+ }
131
+ pendingMutations++;
132
+ try {
133
+ await fn();
134
+ } finally {
135
+ dirtyCalendars.add(cal);
136
+ pendingMutations--;
137
+ if (pendingMutations === 0) {
138
+ const resolve = mutationsSettledResolve;
139
+ mutationsSettledResolve = null;
140
+ mutationsSettledPromise = null;
141
+ resolve?.();
142
+ const toSync = [...dirtyCalendars];
143
+ dirtyCalendars.clear();
144
+ for (const c of toSync) {
145
+ await sync(c, { force: true });
146
+ }
147
+ updateStatusBar();
148
+ }
149
+ }
150
+ }
151
+
152
+ async function waitForMutations(): Promise<void> {
153
+ if (mutationsSettledPromise) await mutationsSettledPromise;
154
+ }
155
+
119
156
  // Initialize calendar with storage first, then sync sources
120
157
  (async () => {
121
158
  await calendar.initPromise;
@@ -126,18 +163,33 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
126
163
  loadedCalendars.set(sampleCalendar.id, sampleCalendar);
127
164
  activeCalendarStore.registerCalendar(sampleCalendar);
128
165
 
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();
166
+ // Create sidebar with CalDAV config
167
+ const sidebar = document.createElement("div");
168
+ sidebar.slot = "sidebar";
169
+ sidebar.className = "caldav-sidebar";
170
+
171
+ config = document.createElement("caldav-config") as CalDAVConfigElement;
172
+ config.addEventListener("sources-changed", async () => {
173
+ await syncCalDAV();
135
174
  });
136
- button.slot = "toolbar-center";
137
- calendarElement.appendChild(button);
175
+ config.addEventListener("collapsed-changed", (e: Event) => {
176
+ const { collapsed } = (e as CustomEvent).detail;
177
+ sidebar.classList.toggle("collapsed", collapsed);
178
+ });
179
+ config.addEventListener("active-calendar-changed", (e: Event) => {
180
+ const { calendarId } = (e as CustomEvent).detail;
181
+ activeCalendarStore.setActive(calendarId);
182
+ updateActiveCalendarColor();
183
+ });
184
+ sidebar.classList.toggle("collapsed", config.collapsed);
185
+ sidebar.appendChild(config);
186
+ calendarElement.appendChild(sidebar);
138
187
 
139
- // Create the active calendar selector UI
140
- createActiveCalendarSelector();
188
+ // Subscribe to active calendar store changes → update sidebar
189
+ activeCalendarStore.subscribe(() => {
190
+ config.activeCalendarId = activeCalendarStore.getActiveId();
191
+ updateActiveCalendarColor();
192
+ });
141
193
 
142
194
  // Sync CalDAV sources (this will call updateEnabledCalendars after calendars are registered)
143
195
  await syncCalDAV();
@@ -146,55 +198,10 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
146
198
  updateActiveCalendarColor();
147
199
  })();
148
200
 
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
201
 
196
202
  // Store for loaded calendars
197
203
  const loadedCalendars: Map<string, Calendar> = new Map();
204
+ let config: CalDAVConfigElement;
198
205
 
199
206
  function findCalendarForEvent(event: CalendarEvent): Calendar | undefined {
200
207
  if (event.calendarId) {
@@ -305,6 +312,29 @@ function createICalCalendar(source: CalendarSource): Calendar {
305
312
  };
306
313
  }
307
314
 
315
+ // Create a Timeseries JSON calendar wrapper
316
+ function createTimeseriesJsonCalendar(source: CalendarSource): Calendar {
317
+ return {
318
+ id: source.id,
319
+ name: source.name,
320
+ color: source.color,
321
+ enabled: source.enabled,
322
+ locked: (source as { locked?: boolean }).locked,
323
+ sourceId: source.id,
324
+ sourceType: "timeseries-json",
325
+
326
+ async fetchEvents(): Promise<CalendarEvent[]> {
327
+ return fetchTimeseriesJsonEvents(
328
+ source.credentials as { url: string; timestampField?: string; titleField?: string },
329
+ source.color,
330
+ source.name,
331
+ source.id,
332
+ );
333
+ },
334
+ // Timeseries JSON calendars are read-only by default
335
+ };
336
+ }
337
+
308
338
  // Create a Google Calendar wrapper
309
339
  function createGoogleCalendar(source: CalendarSource): Calendar {
310
340
  const calendarId = source.credentials.calendarId || "primary";
@@ -449,6 +479,7 @@ let configuredSourceIds: Set<string> = new Set();
449
479
 
450
480
  // Initial CalDAV sync - sync all configured sources
451
481
  async function syncCalDAV(force = false) {
482
+ await waitForMutations();
452
483
  const saved = localStorage.getItem("caldav-sources");
453
484
  if (!saved) return;
454
485
 
@@ -478,7 +509,15 @@ async function syncCalDAV(force = false) {
478
509
  configuredSourceIds = newSourceIds;
479
510
 
480
511
  for (const source of sources) {
481
- if (!source.enabled) continue;
512
+ if (!source.enabled) {
513
+ // Mark existing calendars from this source as disabled
514
+ for (const cal of loadedCalendars.values()) {
515
+ if (cal.sourceId === source.id) {
516
+ cal.enabled = false;
517
+ }
518
+ }
519
+ continue;
520
+ }
482
521
 
483
522
  if (source.type === "caldav") {
484
523
  if (
@@ -523,6 +562,21 @@ async function syncCalDAV(force = false) {
523
562
  } catch (error) {
524
563
  console.error(`Sync error for iCal source ${source.name}:`, error);
525
564
  }
565
+ } else if (source.type === "timeseries-json") {
566
+ if (!source.credentials?.url) {
567
+ console.warn(`Skipping Timeseries JSON source ${source.name}: missing URL`);
568
+ continue;
569
+ }
570
+
571
+ const calendar = createTimeseriesJsonCalendar(source);
572
+ loadedCalendars.set(calendar.id, calendar);
573
+ activeCalendarStore.registerCalendar(calendar);
574
+
575
+ try {
576
+ await sync(calendar, { force });
577
+ } catch (error) {
578
+ console.error(`Sync error for Timeseries JSON source ${source.name}:`, error);
579
+ }
526
580
  } else if (source.type === "google") {
527
581
  if (!source.credentials?.accessToken) {
528
582
  console.warn(`Skipping Google Calendar source ${source.name}: missing access token`);
@@ -563,8 +617,14 @@ async function syncCalDAV(force = false) {
563
617
  updateEnabledCalendars();
564
618
  updateLockedCalendars();
565
619
 
566
- // Update the UI selector and color after sync
567
- updateActiveCalendarSelector();
620
+ // Push calendar list to sidebar
621
+ config.calendars = Array.from(loadedCalendars.values()).map(cal => ({
622
+ id: cal.id,
623
+ name: cal.name,
624
+ color: cal.color,
625
+ sourceId: cal.sourceId,
626
+ }));
627
+ config.activeCalendarId = activeCalendarStore.getActiveId();
568
628
  updateActiveCalendarColor();
569
629
  }
570
630
 
@@ -582,6 +642,55 @@ calendarElement.addEventListener("selection-change", (e) => {
582
642
  console.log("Selection changed:", e.detail.selectedEvents);
583
643
  });
584
644
 
645
+ function showDeleteConfirmDialog(count: number): Promise<boolean> {
646
+ return new Promise((resolve) => {
647
+ const dialog = document.createElement("dialog");
648
+ dialog.style.cssText =
649
+ "margin:auto;color:var(--text-primary);border:1px solid var(--grid-color-strong);border-radius:var(--border-radius);padding:16px;min-width:280px;";
650
+
651
+ const message = document.createElement("p");
652
+ message.style.cssText = "margin:0 0 16px 0;";
653
+ message.textContent = `Delete ${count} event${count === 1 ? "" : "s"}?`;
654
+
655
+ const buttons = document.createElement("div");
656
+ buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
657
+
658
+ const cancel = document.createElement("button");
659
+ cancel.textContent = "Cancel";
660
+ cancel.style.cssText =
661
+ "padding:6px 12px;background:var(--bg-item);color:var(--text-primary);border:1px solid var(--grid-color-strong);border-radius:var(--border-radius-sm);cursor:pointer;";
662
+ cancel.addEventListener("click", () => {
663
+ dialog.close();
664
+ resolve(false);
665
+ });
666
+
667
+ const confirm = document.createElement("button");
668
+ confirm.textContent = "Delete";
669
+ confirm.style.cssText =
670
+ "padding:6px 12px;background:var(--color-danger, #e53e3e);color:#fff;border:none;border-radius:var(--border-radius-sm);cursor:pointer;";
671
+ confirm.addEventListener("click", () => {
672
+ dialog.close();
673
+ resolve(true);
674
+ });
675
+
676
+ dialog.addEventListener("close", () => {
677
+ dialog.remove();
678
+ });
679
+ dialog.addEventListener("click", (ev) => {
680
+ if (ev.target === dialog) {
681
+ dialog.close();
682
+ resolve(false);
683
+ }
684
+ });
685
+
686
+ buttons.append(cancel, confirm);
687
+ dialog.append(message, buttons);
688
+ document.body.appendChild(dialog);
689
+ dialog.showModal();
690
+ confirm.focus();
691
+ });
692
+ }
693
+
585
694
  function showProjectPickerDialog(
586
695
  projects: Array<{ id: number; name: string }>,
587
696
  ): Promise<number | null> {
@@ -595,7 +704,7 @@ function showProjectPickerDialog(
595
704
 
596
705
  const dialog = document.createElement("dialog");
597
706
  dialog.style.cssText =
598
- "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;";
707
+ "margin:auto;color:var(--text-primary);border:1px solid var(--grid-color-strong);border-radius:var(--border-radius);padding:16px;min-width:300px;max-width:480px;";
599
708
 
600
709
  const input = document.createElement("input");
601
710
  input.type = "text";
@@ -652,7 +761,7 @@ function showProjectPickerDialog(
652
761
  }
653
762
 
654
763
  calendarElement.addEventListener("create-event", async (e) => {
655
- const { start, end } = e.detail;
764
+ const { start, end, isAllDay } = e.detail;
656
765
  const calendar = getActiveCalendar();
657
766
  if (!calendar) {
658
767
  queueStatus("No calendar selected. Please configure a calendar first.");
@@ -676,9 +785,25 @@ calendarElement.addEventListener("create-event", async (e) => {
676
785
  id = `0:0:${projectId}`;
677
786
  }
678
787
 
679
- await calendar.createEvent({ id, title: "New Event", start, end });
680
- await sync(calendar, { force: true });
681
- updateStatusBar();
788
+ calendarElement.internal.applyEventOptimistically({
789
+ id,
790
+ title: "New Event",
791
+ start,
792
+ end,
793
+ isAllDay,
794
+ calendar: calendar.name,
795
+ calendarId: calendar.calendarUrl ?? calendar.id,
796
+ sourceId: calendar.sourceId,
797
+ color: calendar.color,
798
+ lastSynced: new Date(),
799
+ });
800
+ try {
801
+ await withMutation(calendar, () => calendar.createEvent({ id, title: "New Event", start, end, isAllDay }));
802
+ } catch (error) {
803
+ calendarElement.internal.removeEventOptimistically(id);
804
+ console.error("Failed to create event:", error);
805
+ queueStatus(`Failed to create event: ${error instanceof Error ? error.message : String(error)}`);
806
+ }
682
807
  });
683
808
 
684
809
  calendarElement.addEventListener("move-event", async (e) => {
@@ -707,11 +832,10 @@ calendarElement.addEventListener("move-event", async (e) => {
707
832
  calendar.name,
708
833
  );
709
834
  try {
710
- await calendar.updateEvent(event.id, { start, end });
711
- await sync(calendar, { force: true });
712
- console.log("Move completed on calendar:", calendar.name);
713
- updateStatusBar();
835
+ calendarElement.internal.applyEventOptimistically({ ...event, start, end });
836
+ await withMutation(calendar, () => calendar.updateEvent(event.id, { start, end }));
714
837
  } catch (error) {
838
+ calendarElement.internal.applyEventOptimistically(event);
715
839
  console.error("Failed to move event:", error);
716
840
  queueStatus(`Failed to move event: ${error instanceof Error ? error.message : String(error)}`);
717
841
  }
@@ -739,8 +863,8 @@ calendarElement.addEventListener("update-event", async (e) => {
739
863
  event
740
864
  );
741
865
  try {
742
- await calendar.updateEvent(event.id, updates);
743
- await sync(calendar, { force: true });
866
+ calendarElement.internal.applyEventOptimistically({ ...event, ...updates });
867
+ await withMutation(calendar, () => calendar.updateEvent(event.id, updates));
744
868
 
745
869
  if ("reminders" in updates) {
746
870
  const eventWithReminders = { ...event, ...updates };
@@ -750,10 +874,8 @@ calendarElement.addEventListener("update-event", async (e) => {
750
874
  calendarElement.selectedEventForDetail = eventWithReminders;
751
875
  }
752
876
  }
753
-
754
- console.log("Update completed on calendar:", calendar.name);
755
- updateStatusBar();
756
877
  } catch (error) {
878
+ calendarElement.internal.applyEventOptimistically(event);
757
879
  console.error("Failed to update event:", error);
758
880
  queueStatus(`Failed to update event: ${error instanceof Error ? error.message : String(error)}`);
759
881
  }
@@ -762,6 +884,13 @@ calendarElement.addEventListener("update-event", async (e) => {
762
884
  calendarElement.addEventListener("delete-events", async (e) => {
763
885
  const { events } = e.detail;
764
886
 
887
+ const confirmed = await showDeleteConfirmDialog(events.length);
888
+ if (!confirmed) return;
889
+
890
+ for (const event of events) {
891
+ calendarElement.internal.removeEventOptimistically(event.id);
892
+ }
893
+
765
894
  // Group events by their calendarId
766
895
  const eventsByCalendar = new Map<string, typeof events>();
767
896
  for (const event of events) {
@@ -800,17 +929,19 @@ calendarElement.addEventListener("delete-events", async (e) => {
800
929
  calendarEvents.map((ev) => ev.id),
801
930
  );
802
931
  try {
932
+ await withMutation(calendar, async () => {
933
+ for (const event of calendarEvents) {
934
+ await calendar.deleteEvent(event.id);
935
+ }
936
+ });
937
+ } catch (error) {
803
938
  for (const event of calendarEvents) {
804
- await calendar.deleteEvent(event.id);
939
+ calendarElement.internal.applyEventOptimistically(event);
805
940
  }
806
- await sync(calendar, { force: true });
807
- } catch (error) {
808
941
  console.error("Failed to delete event:", error);
809
942
  queueStatus(`Failed to delete event: ${error instanceof Error ? error.message : String(error)}`);
810
943
  }
811
944
  }
812
- console.log("Delete completed");
813
- updateStatusBar();
814
945
  });
815
946
 
816
947
  calendarElement.addEventListener("force-sync", async () => {
@@ -897,75 +1028,11 @@ calendarElement.addEventListener("import-ical", async (e) => {
897
1028
  }
898
1029
  });
899
1030
 
900
- // --- Active Calendar Selector UI ---
901
- function createActiveCalendarSelector() {
902
- // Check if selector already exists in the slot
903
- if (document.getElementById("active-calendar-selector")) {
904
- return;
905
- }
906
-
907
- const selector = document.createElement("div");
908
- selector.id = "active-calendar-selector";
909
- selector.setAttribute("slot", "toolbar-center");
910
- selector.style.cssText = `
911
- display: flex;
912
- align-items: center;
913
- gap: 8px;
914
- font-size: 13px;
915
- margin-right: auto;
916
- `;
917
-
918
- const label = document.createElement("span");
919
- label.style.cssText = `
920
- color: var(--text-muted, rgba(255, 255, 255, 0.5));
921
- white-space: nowrap;
922
- `;
923
-
924
- const select = document.createElement("select");
925
- select.id = "active-calendar-select";
926
- select.style.cssText = `
927
- background: var(--bg-input, rgba(0, 0, 0, 0.3));
928
- border: 1px solid var(--grid-color, rgba(255, 255, 255, 0.1));
929
- border-radius: var(--border-radius-sm, 4px);
930
- color: var(--text-primary, rgba(255, 255, 255, 0.9));
931
- padding: 4px 8px;
932
- font-size: 13px;
933
- cursor: pointer;
934
- min-width: 150px;
935
- `;
936
-
937
- select.addEventListener("change", (e) => {
938
- const target = e.target as HTMLSelectElement;
939
- try {
940
- activeCalendarStore.setActive(target.value || null);
941
- updateActiveCalendarColor();
942
- const calendar = activeCalendarStore.getActiveCalendar();
943
- if (calendar) {
944
- queueStatus(`Active calendar: ${calendar.name}`);
945
- }
946
- } catch (err) {
947
- console.error("Failed to set active calendar:", err);
948
- queueStatus(`Error: ${err.message}`);
949
- }
950
- });
951
-
952
- selector.appendChild(label);
953
- selector.appendChild(select);
954
- calendarElement.appendChild(selector);
955
-
956
- // Subscribe to changes and update the selector and color
957
- activeCalendarStore.subscribe(() => {
958
- updateActiveCalendarSelector();
959
- updateActiveCalendarColor();
960
- });
961
-
962
- // Initial update
963
- updateActiveCalendarSelector();
964
- }
965
1031
 
966
1032
  function updateActiveCalendarColor() {
967
1033
  const calendar = activeCalendarStore.getActiveCalendar();
968
1034
  calendarElement.activeCalendarColor = calendar?.color ?? null;
1035
+ calendarElement.activeCalendarId = activeCalendarStore.getActiveId();
969
1036
  }
970
1037
 
971
1038
  function updateEnabledCalendars() {
@@ -1001,45 +1068,3 @@ function updateLockedCalendars() {
1001
1068
  });
1002
1069
  calendar.setLockedCalendars(lockedCalendarIdentifiers);
1003
1070
  }
1004
-
1005
- function getActiveCalendarSelect(): HTMLSelectElement | null {
1006
- // Look in the light DOM first (where slotted content lives)
1007
- const selector = document.getElementById("active-calendar-selector");
1008
- if (selector) {
1009
- return selector.querySelector(
1010
- "#active-calendar-select",
1011
- ) as HTMLSelectElement | null;
1012
- }
1013
- return null;
1014
- }
1015
-
1016
- function updateActiveCalendarSelector() {
1017
- const select = getActiveCalendarSelect();
1018
- if (!select) return;
1019
-
1020
- const calendars = activeCalendarStore.getAvailableCalendars();
1021
- const activeId = activeCalendarStore.getActiveId();
1022
-
1023
- // Clear current options
1024
- select.innerHTML = "";
1025
-
1026
- if (calendars.length === 0) {
1027
- const option = document.createElement("option");
1028
- option.value = "";
1029
- option.textContent = "No calendars";
1030
- option.disabled = true;
1031
- option.selected = true;
1032
- select.appendChild(option);
1033
- return;
1034
- }
1035
-
1036
- for (const calendar of calendars) {
1037
- const option = document.createElement("option");
1038
- option.value = calendar.id;
1039
- option.textContent = calendar.name;
1040
- if (calendar.id === activeId) {
1041
- option.selected = true;
1042
- }
1043
- select.appendChild(option);
1044
- }
1045
- }