@luckydye/calendar 1.2.2 → 1.3.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
@@ -116,6 +116,42 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
116
116
  await scheduleNotificationsForEvents(events);
117
117
  }
118
118
 
119
+ // Mutation coordination: batches post-mutation syncs so concurrent
120
+ // mutations don't each trigger their own reconciliation round-trip.
121
+ let pendingMutations = 0;
122
+ let mutationsSettledResolve: (() => void) | null = null;
123
+ let mutationsSettledPromise: Promise<void> | null = null;
124
+ const dirtyCalendars = new Set<Calendar>();
125
+
126
+ async function withMutation(cal: Calendar, fn: () => Promise<void>): Promise<void> {
127
+ if (pendingMutations === 0) {
128
+ mutationsSettledPromise = new Promise(r => { mutationsSettledResolve = r; });
129
+ }
130
+ pendingMutations++;
131
+ try {
132
+ await fn();
133
+ } finally {
134
+ dirtyCalendars.add(cal);
135
+ pendingMutations--;
136
+ if (pendingMutations === 0) {
137
+ const resolve = mutationsSettledResolve;
138
+ mutationsSettledResolve = null;
139
+ mutationsSettledPromise = null;
140
+ resolve?.();
141
+ const toSync = [...dirtyCalendars];
142
+ dirtyCalendars.clear();
143
+ for (const c of toSync) {
144
+ await sync(c, { force: true });
145
+ }
146
+ updateStatusBar();
147
+ }
148
+ }
149
+ }
150
+
151
+ async function waitForMutations(): Promise<void> {
152
+ if (mutationsSettledPromise) await mutationsSettledPromise;
153
+ }
154
+
119
155
  // Initialize calendar with storage first, then sync sources
120
156
  (async () => {
121
157
  await calendar.initPromise;
@@ -126,18 +162,33 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
126
162
  loadedCalendars.set(sampleCalendar.id, sampleCalendar);
127
163
  activeCalendarStore.registerCalendar(sampleCalendar);
128
164
 
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();
165
+ // Create sidebar with CalDAV config
166
+ const sidebar = document.createElement("div");
167
+ sidebar.slot = "sidebar";
168
+ sidebar.className = "caldav-sidebar";
169
+
170
+ config = document.createElement("caldav-config") as CalDAVConfigElement;
171
+ config.addEventListener("sources-changed", async () => {
172
+ await syncCalDAV();
173
+ });
174
+ config.addEventListener("collapsed-changed", (e: Event) => {
175
+ const { collapsed } = (e as CustomEvent).detail;
176
+ sidebar.classList.toggle("collapsed", collapsed);
135
177
  });
136
- button.slot = "toolbar-center";
137
- calendarElement.appendChild(button);
178
+ config.addEventListener("active-calendar-changed", (e: Event) => {
179
+ const { calendarId } = (e as CustomEvent).detail;
180
+ activeCalendarStore.setActive(calendarId);
181
+ updateActiveCalendarColor();
182
+ });
183
+ sidebar.classList.toggle("collapsed", config.collapsed);
184
+ sidebar.appendChild(config);
185
+ calendarElement.appendChild(sidebar);
138
186
 
139
- // Create the active calendar selector UI
140
- createActiveCalendarSelector();
187
+ // Subscribe to active calendar store changes → update sidebar
188
+ activeCalendarStore.subscribe(() => {
189
+ config.activeCalendarId = activeCalendarStore.getActiveId();
190
+ updateActiveCalendarColor();
191
+ });
141
192
 
142
193
  // Sync CalDAV sources (this will call updateEnabledCalendars after calendars are registered)
143
194
  await syncCalDAV();
@@ -146,55 +197,10 @@ async function sync(cal: Calendar, options?: { force?: boolean }): Promise<void>
146
197
  updateActiveCalendarColor();
147
198
  })();
148
199
 
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
200
 
196
201
  // Store for loaded calendars
197
202
  const loadedCalendars: Map<string, Calendar> = new Map();
203
+ let config: CalDAVConfigElement;
198
204
 
199
205
  function findCalendarForEvent(event: CalendarEvent): Calendar | undefined {
200
206
  if (event.calendarId) {
@@ -449,6 +455,7 @@ let configuredSourceIds: Set<string> = new Set();
449
455
 
450
456
  // Initial CalDAV sync - sync all configured sources
451
457
  async function syncCalDAV(force = false) {
458
+ await waitForMutations();
452
459
  const saved = localStorage.getItem("caldav-sources");
453
460
  if (!saved) return;
454
461
 
@@ -478,7 +485,15 @@ async function syncCalDAV(force = false) {
478
485
  configuredSourceIds = newSourceIds;
479
486
 
480
487
  for (const source of sources) {
481
- if (!source.enabled) continue;
488
+ if (!source.enabled) {
489
+ // Mark existing calendars from this source as disabled
490
+ for (const cal of loadedCalendars.values()) {
491
+ if (cal.sourceId === source.id) {
492
+ cal.enabled = false;
493
+ }
494
+ }
495
+ continue;
496
+ }
482
497
 
483
498
  if (source.type === "caldav") {
484
499
  if (
@@ -563,8 +578,14 @@ async function syncCalDAV(force = false) {
563
578
  updateEnabledCalendars();
564
579
  updateLockedCalendars();
565
580
 
566
- // Update the UI selector and color after sync
567
- updateActiveCalendarSelector();
581
+ // Push calendar list to sidebar
582
+ config.calendars = Array.from(loadedCalendars.values()).map(cal => ({
583
+ id: cal.id,
584
+ name: cal.name,
585
+ color: cal.color,
586
+ sourceId: cal.sourceId,
587
+ }));
588
+ config.activeCalendarId = activeCalendarStore.getActiveId();
568
589
  updateActiveCalendarColor();
569
590
  }
570
591
 
@@ -582,6 +603,55 @@ calendarElement.addEventListener("selection-change", (e) => {
582
603
  console.log("Selection changed:", e.detail.selectedEvents);
583
604
  });
584
605
 
606
+ function showDeleteConfirmDialog(count: number): Promise<boolean> {
607
+ return new Promise((resolve) => {
608
+ const dialog = document.createElement("dialog");
609
+ dialog.style.cssText =
610
+ "margin:auto;color:var(--text-primary);border:1px solid var(--grid-color-strong);border-radius:var(--border-radius);padding:16px;min-width:280px;";
611
+
612
+ const message = document.createElement("p");
613
+ message.style.cssText = "margin:0 0 16px 0;";
614
+ message.textContent = `Delete ${count} event${count === 1 ? "" : "s"}?`;
615
+
616
+ const buttons = document.createElement("div");
617
+ buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
618
+
619
+ const cancel = document.createElement("button");
620
+ cancel.textContent = "Cancel";
621
+ cancel.style.cssText =
622
+ "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;";
623
+ cancel.addEventListener("click", () => {
624
+ dialog.close();
625
+ resolve(false);
626
+ });
627
+
628
+ const confirm = document.createElement("button");
629
+ confirm.textContent = "Delete";
630
+ confirm.style.cssText =
631
+ "padding:6px 12px;background:var(--color-danger, #e53e3e);color:#fff;border:none;border-radius:var(--border-radius-sm);cursor:pointer;";
632
+ confirm.addEventListener("click", () => {
633
+ dialog.close();
634
+ resolve(true);
635
+ });
636
+
637
+ dialog.addEventListener("close", () => {
638
+ dialog.remove();
639
+ });
640
+ dialog.addEventListener("click", (ev) => {
641
+ if (ev.target === dialog) {
642
+ dialog.close();
643
+ resolve(false);
644
+ }
645
+ });
646
+
647
+ buttons.append(cancel, confirm);
648
+ dialog.append(message, buttons);
649
+ document.body.appendChild(dialog);
650
+ dialog.showModal();
651
+ confirm.focus();
652
+ });
653
+ }
654
+
585
655
  function showProjectPickerDialog(
586
656
  projects: Array<{ id: number; name: string }>,
587
657
  ): Promise<number | null> {
@@ -595,7 +665,7 @@ function showProjectPickerDialog(
595
665
 
596
666
  const dialog = document.createElement("dialog");
597
667
  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;";
668
+ "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
669
 
600
670
  const input = document.createElement("input");
601
671
  input.type = "text";
@@ -676,9 +746,24 @@ calendarElement.addEventListener("create-event", async (e) => {
676
746
  id = `0:0:${projectId}`;
677
747
  }
678
748
 
679
- await calendar.createEvent({ id, title: "New Event", start, end });
680
- await sync(calendar, { force: true });
681
- updateStatusBar();
749
+ calendarElement.internal.applyEventOptimistically({
750
+ id,
751
+ title: "New Event",
752
+ start,
753
+ end,
754
+ calendar: calendar.name,
755
+ calendarId: calendar.calendarUrl ?? calendar.id,
756
+ sourceId: calendar.sourceId,
757
+ color: calendar.color,
758
+ lastSynced: new Date(),
759
+ });
760
+ try {
761
+ await withMutation(calendar, () => calendar.createEvent({ id, title: "New Event", start, end }));
762
+ } catch (error) {
763
+ calendarElement.internal.removeEventOptimistically(id);
764
+ console.error("Failed to create event:", error);
765
+ queueStatus(`Failed to create event: ${error instanceof Error ? error.message : String(error)}`);
766
+ }
682
767
  });
683
768
 
684
769
  calendarElement.addEventListener("move-event", async (e) => {
@@ -707,11 +792,10 @@ calendarElement.addEventListener("move-event", async (e) => {
707
792
  calendar.name,
708
793
  );
709
794
  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();
795
+ calendarElement.internal.applyEventOptimistically({ ...event, start, end });
796
+ await withMutation(calendar, () => calendar.updateEvent(event.id, { start, end }));
714
797
  } catch (error) {
798
+ calendarElement.internal.applyEventOptimistically(event);
715
799
  console.error("Failed to move event:", error);
716
800
  queueStatus(`Failed to move event: ${error instanceof Error ? error.message : String(error)}`);
717
801
  }
@@ -739,8 +823,8 @@ calendarElement.addEventListener("update-event", async (e) => {
739
823
  event
740
824
  );
741
825
  try {
742
- await calendar.updateEvent(event.id, updates);
743
- await sync(calendar, { force: true });
826
+ calendarElement.internal.applyEventOptimistically({ ...event, ...updates });
827
+ await withMutation(calendar, () => calendar.updateEvent(event.id, updates));
744
828
 
745
829
  if ("reminders" in updates) {
746
830
  const eventWithReminders = { ...event, ...updates };
@@ -750,10 +834,8 @@ calendarElement.addEventListener("update-event", async (e) => {
750
834
  calendarElement.selectedEventForDetail = eventWithReminders;
751
835
  }
752
836
  }
753
-
754
- console.log("Update completed on calendar:", calendar.name);
755
- updateStatusBar();
756
837
  } catch (error) {
838
+ calendarElement.internal.applyEventOptimistically(event);
757
839
  console.error("Failed to update event:", error);
758
840
  queueStatus(`Failed to update event: ${error instanceof Error ? error.message : String(error)}`);
759
841
  }
@@ -762,6 +844,13 @@ calendarElement.addEventListener("update-event", async (e) => {
762
844
  calendarElement.addEventListener("delete-events", async (e) => {
763
845
  const { events } = e.detail;
764
846
 
847
+ const confirmed = await showDeleteConfirmDialog(events.length);
848
+ if (!confirmed) return;
849
+
850
+ for (const event of events) {
851
+ calendarElement.internal.removeEventOptimistically(event.id);
852
+ }
853
+
765
854
  // Group events by their calendarId
766
855
  const eventsByCalendar = new Map<string, typeof events>();
767
856
  for (const event of events) {
@@ -800,17 +889,19 @@ calendarElement.addEventListener("delete-events", async (e) => {
800
889
  calendarEvents.map((ev) => ev.id),
801
890
  );
802
891
  try {
892
+ await withMutation(calendar, async () => {
893
+ for (const event of calendarEvents) {
894
+ await calendar.deleteEvent(event.id);
895
+ }
896
+ });
897
+ } catch (error) {
803
898
  for (const event of calendarEvents) {
804
- await calendar.deleteEvent(event.id);
899
+ calendarElement.internal.applyEventOptimistically(event);
805
900
  }
806
- await sync(calendar, { force: true });
807
- } catch (error) {
808
901
  console.error("Failed to delete event:", error);
809
902
  queueStatus(`Failed to delete event: ${error instanceof Error ? error.message : String(error)}`);
810
903
  }
811
904
  }
812
- console.log("Delete completed");
813
- updateStatusBar();
814
905
  });
815
906
 
816
907
  calendarElement.addEventListener("force-sync", async () => {
@@ -897,71 +988,6 @@ calendarElement.addEventListener("import-ical", async (e) => {
897
988
  }
898
989
  });
899
990
 
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
991
 
966
992
  function updateActiveCalendarColor() {
967
993
  const calendar = activeCalendarStore.getActiveCalendar();
@@ -1002,44 +1028,3 @@ function updateLockedCalendars() {
1002
1028
  calendar.setLockedCalendars(lockedCalendarIdentifiers);
1003
1029
  }
1004
1030
 
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
- }