trekoon 0.3.6 → 0.3.8

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.
@@ -13,7 +13,6 @@ import { createConfirmDialog } from "./components/ConfirmDialog.js";
13
13
  import { createEpicsOverview } from "./components/EpicsOverview.js";
14
14
  import { panelClasses, renderIcon, sectionLabelClasses, escapeHtml } from "./components/helpers.js";
15
15
 
16
- const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
17
16
  const SEARCH_FOCUS_KEYS = new Set(["/", "s"]);
18
17
  const FOCUSABLE_SELECTOR = [
19
18
  "a[href]",
@@ -33,30 +32,15 @@ const FOCUSABLE_SELECTOR = [
33
32
  // Session token management
34
33
  // ---------------------------------------------------------------------------
35
34
 
36
- function readSessionTokenFromStorage() {
37
- try {
38
- return (sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "").trim();
39
- } catch {
40
- return "";
41
- }
42
- }
43
-
44
- function persistSessionToken(token) {
45
- try {
46
- sessionStorage.setItem(SESSION_TOKEN_STORAGE_KEY, token);
47
- return true;
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
35
  function resolveRuntimeSession() {
54
36
  const url = new URL(window.location.href);
55
37
  const queryToken = (url.searchParams.get("token") || "").trim();
56
38
  if (queryToken.length > 0) {
57
- return { token: queryToken, shouldScrubAddressBar: persistSessionToken(queryToken) };
39
+ return { token: queryToken, shouldScrubAddressBar: true };
58
40
  }
59
- return { token: readSessionTokenFromStorage(), shouldScrubAddressBar: false };
41
+ const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
42
+ const bootstrapToken = typeof bootstrap?.token === "string" ? bootstrap.token.trim() : "";
43
+ return { token: bootstrapToken, shouldScrubAddressBar: false };
60
44
  }
61
45
 
62
46
  function scrubTokenFromAddressBar() {
@@ -149,11 +133,10 @@ export async function bootLegacyBoard(options = {}) {
149
133
  if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
150
134
 
151
135
  // Fetch snapshot
152
- let snapshotPayload = readJsonScript("trekoon-board-snapshot") ?? {};
153
- if (runtimeSession.token.length > 0) {
154
- const headers = new Headers();
155
- headers.set("authorization", `Bearer ${runtimeSession.token}`);
156
- const response = await fetch("/api/snapshot", { headers });
136
+ const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
137
+ let snapshotPayload = bootstrap?.snapshot ?? readJsonScript("trekoon-board-snapshot") ?? {};
138
+ if ((!snapshotPayload || typeof snapshotPayload !== "object") && runtimeSession.token.length > 0) {
139
+ const response = await fetch("/api/snapshot");
157
140
  const payload = await response.json();
158
141
  if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
159
142
  snapshotPayload = payload?.data?.snapshot ?? {};
@@ -422,6 +405,17 @@ export async function bootLegacyBoard(options = {}) {
422
405
  notice.update({
423
406
  notice: store.notice,
424
407
  onDismiss() { store.notice = null; rerender(); },
408
+ onRetry() {
409
+ const didRetry = api.retryLastFailedMutation();
410
+ if (!didRetry) {
411
+ store.notice = {
412
+ type: "error",
413
+ title: "Retry unavailable",
414
+ message: "The failed action is no longer available. Repeat the change from the board.",
415
+ };
416
+ }
417
+ rerender();
418
+ },
425
419
  });
426
420
 
427
421
  if (showTasks) {
@@ -26,11 +26,11 @@ export function createNotice() {
26
26
  },
27
27
 
28
28
  /**
29
- * @param {{ notice: { type: string, message: string, title?: string } | null, onDismiss?: () => void }} props
29
+ * @param {{ notice: { type: string, message: string, title?: string, retryLabel?: string } | null, onDismiss?: () => void, onRetry?: () => void }} props
30
30
  */
31
31
  update(props) {
32
32
  if (!container) return;
33
- const { notice, onDismiss } = props;
33
+ const { notice, onDismiss, onRetry } = props;
34
34
 
35
35
  if (!notice) {
36
36
  if (lastNotice) {
@@ -42,7 +42,12 @@ export function createNotice() {
42
42
  }
43
43
 
44
44
  // Same notice — skip
45
- if (lastNotice && lastNotice.type === notice.type && lastNotice.message === notice.message) {
45
+ if (
46
+ lastNotice
47
+ && lastNotice.type === notice.type
48
+ && lastNotice.message === notice.message
49
+ && lastNotice.retryMutationId === notice.retryMutationId
50
+ ) {
46
51
  return;
47
52
  }
48
53
 
@@ -61,14 +66,23 @@ export function createNotice() {
61
66
  <div class="board-toast__content">
62
67
  <p class="board-toast__title" id="board-notice-title">${escapeHtml(noticeTitle)}</p>
63
68
  <p class="board-toast__message">${escapeHtml(notice.message)}</p>
69
+ ${typeof notice.retryLabel === "string" && notice.retryLabel.trim().length > 0
70
+ ? `<button type="button" class="mt-3 inline-flex items-center gap-2 rounded-lg border border-[var(--board-border-strong)] bg-[var(--board-surface-2)] px-3 py-2 text-sm font-medium text-[var(--board-text)] transition hover:border-[var(--board-accent)] hover:text-[var(--board-accent)]" data-board-notice-retry>${escapeHtml(notice.retryLabel.trim())}</button>`
71
+ : ""}
64
72
  </div>
65
73
  </section>
66
74
  </div>
67
75
  `;
68
- lastNotice = { type: notice.type, message: notice.message };
76
+ lastNotice = { type: notice.type, message: notice.message, retryMutationId: notice.retryMutationId };
69
77
 
70
78
  // Auto-dismiss after 4 s
71
79
  clearTimer();
80
+ const retryButton = typeof container.querySelector === "function"
81
+ ? container.querySelector("[data-board-notice-retry]")
82
+ : null;
83
+ if (retryButton && typeof retryButton.addEventListener === "function" && typeof onRetry === "function") {
84
+ retryButton.addEventListener("click", onRetry);
85
+ }
72
86
  if (typeof onDismiss === "function") {
73
87
  dismissTimer = setTimeout(() => {
74
88
  onDismiss();
@@ -75,14 +75,14 @@ export function cascadeEpicStatusInSnapshot(snapshot, epicId, status, normalizeS
75
75
  return normalizeSnapshot(nextSnapshot);
76
76
  }
77
77
 
78
- export function addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot) {
78
+ export function addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot, optimisticId = null) {
79
79
  const nextSnapshot = cloneSnapshot(snapshot);
80
80
  const duplicate = normalizeArray(nextSnapshot.dependencies).some(
81
81
  (dependency) => dependency.sourceId === sourceId && dependency.dependsOnId === dependsOnId,
82
82
  );
83
83
  if (!duplicate) {
84
84
  normalizeArray(nextSnapshot.dependencies).push({
85
- id: crypto.randomUUID(),
85
+ id: optimisticId ?? crypto.randomUUID(),
86
86
  sourceId,
87
87
  sourceKind: nextSnapshot.subtasks.some((subtask) => subtask.id === sourceId) ? "subtask" : "task",
88
88
  dependsOnId,
@@ -102,10 +102,10 @@ export function removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, norm
102
102
  return normalizeSnapshot(nextSnapshot);
103
103
  }
104
104
 
105
- export function createSubtaskInSnapshot(snapshot, input, normalizeSnapshot) {
105
+ export function createSubtaskInSnapshot(snapshot, input, normalizeSnapshot, optimisticId = null) {
106
106
  const nextSnapshot = cloneSnapshot(snapshot);
107
107
  normalizeArray(nextSnapshot.subtasks).push({
108
- id: crypto.randomUUID(),
108
+ id: optimisticId ?? crypto.randomUUID(),
109
109
  taskId: input.taskId,
110
110
  title: input.title,
111
111
  description: input.description ?? "",
@@ -382,7 +382,7 @@ export function createBoardActions(options) {
382
382
  return;
383
383
  }
384
384
 
385
- api.createSubtask(input, (snapshot) => createSubtaskInSnapshot(snapshot, input, normalizeSnapshot));
385
+ api.createSubtask(input, (snapshot, optimisticId) => createSubtaskInSnapshot(snapshot, input, normalizeSnapshot, optimisticId));
386
386
  },
387
387
  deleteSubtask(subtaskId) {
388
388
  if (!subtaskId) {
@@ -399,7 +399,7 @@ export function createBoardActions(options) {
399
399
  return;
400
400
  }
401
401
 
402
- api.addDependency(sourceId, dependsOnId, (snapshot) => addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
402
+ api.addDependency(sourceId, dependsOnId, (snapshot, optimisticId) => addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot, optimisticId));
403
403
  },
404
404
  removeDependency(sourceId, dependsOnId) {
405
405
  api.removeDependency(sourceId, dependsOnId, (snapshot) => removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
@@ -47,6 +47,40 @@ function buildRequestError(method, path, response, payload) {
47
47
  return error;
48
48
  }
49
49
 
50
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
51
+
52
+ function createClientRequestId() {
53
+ return crypto.randomUUID();
54
+ }
55
+
56
+ function createOptimisticId(prefix, clientRequestId) {
57
+ return `optimistic:${prefix}:${clientRequestId}`;
58
+ }
59
+
60
+ function augmentSnapshotDeltaWithOptimisticDelete(snapshotDelta, key, optimisticId) {
61
+ if (!snapshotDelta || typeof snapshotDelta !== "object" || typeof optimisticId !== "string" || optimisticId.length === 0) {
62
+ return snapshotDelta;
63
+ }
64
+
65
+ const deletedKey = key === "subtasks" ? "deletedSubtaskIds" : "deletedDependencyIds";
66
+ const deletedIds = Array.isArray(snapshotDelta[deletedKey]) ? snapshotDelta[deletedKey] : [];
67
+ if (deletedIds.includes(optimisticId)) {
68
+ return snapshotDelta;
69
+ }
70
+
71
+ return {
72
+ ...snapshotDelta,
73
+ [deletedKey]: [...deletedIds, optimisticId],
74
+ };
75
+ }
76
+
77
+ function createTimeoutError(method, path, timeoutMs) {
78
+ const error = new Error(`${method} ${path} timed out after ${timeoutMs}ms. Retry your change.`);
79
+ error.code = "request_timeout";
80
+ error.timeoutMs = timeoutMs;
81
+ return error;
82
+ }
83
+
50
84
  /**
51
85
  * Create a serial mutation queue.
52
86
  *
@@ -64,6 +98,7 @@ export function createMutationQueue(model, rerender) {
64
98
  /** @type {Array<{ optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
65
99
  const queue = [];
66
100
  let processing = false;
101
+ let nextMutationId = 1;
67
102
  /** @type {Array<() => void>} */
68
103
  let flushResolvers = [];
69
104
 
@@ -85,19 +120,22 @@ export function createMutationQueue(model, rerender) {
85
120
  while (queue.length > 0) {
86
121
  const mutation = queue.shift();
87
122
  const previousSnapshot = cloneSnapshot(model.store.snapshot);
88
- model.store.notice = null;
89
-
90
- // Apply optimistic update
91
- if (typeof mutation.optimistic === "function") {
92
- model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
93
- rerender();
123
+ if (model.store.notice?.retryMutationId !== mutation.id) {
124
+ model.store.notice = null;
94
125
  }
95
126
 
96
127
  try {
128
+ if (typeof mutation.optimistic === "function") {
129
+ model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
130
+ rerender();
131
+ }
132
+
97
133
  const data = await mutation.request();
98
134
 
99
135
  if (data?.snapshot) {
100
136
  model.replaceSnapshot(data.snapshot);
137
+ } else if (data?.snapshotDelta) {
138
+ model.applySnapshotDelta(data.snapshotDelta);
101
139
  }
102
140
 
103
141
  if (typeof mutation.onSuccess === "function") {
@@ -112,14 +150,17 @@ export function createMutationQueue(model, rerender) {
112
150
  model.replaceSnapshot(previousSnapshot);
113
151
 
114
152
  const message = error instanceof Error ? error.message : String(error);
115
- model.store.notice = { type: "error", message };
153
+ model.store.notice = {
154
+ type: "error",
155
+ title: "Action failed",
156
+ message,
157
+ retryLabel: "Retry",
158
+ retryMutationId: mutation.id,
159
+ };
116
160
 
117
161
  if (typeof mutation.onError === "function") {
118
162
  mutation.onError(error);
119
163
  }
120
-
121
- // Clear remaining queue on error to prevent cascading failures
122
- queue.length = 0;
123
164
  }
124
165
  }
125
166
 
@@ -131,7 +172,8 @@ export function createMutationQueue(model, rerender) {
131
172
 
132
173
  return {
133
174
  enqueue(mutation) {
134
- queue.push(mutation);
175
+ queue.push({ ...mutation, id: nextMutationId });
176
+ nextMutationId += 1;
135
177
  processNext();
136
178
  },
137
179
 
@@ -158,11 +200,35 @@ export function createMutationQueue(model, rerender) {
158
200
  * @returns {object} API methods: patchTask, patchSubtask, createSubtask, deleteSubtask, addDependency, removeDependency
159
201
  */
160
202
  export function createApi(model, options) {
161
- const { sessionToken, rerender } = options;
203
+ const { sessionToken, rerender, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = options;
204
+ let lastFailedMutation = null;
205
+
206
+ function enqueueMutation(definition) {
207
+ queue.enqueue({
208
+ ...definition,
209
+ onSuccess(data) {
210
+ if (lastFailedMutation?.request === definition.request) {
211
+ lastFailedMutation = null;
212
+ }
213
+ if (typeof definition.onSuccess === "function") {
214
+ definition.onSuccess(data);
215
+ }
216
+ },
217
+ onError(error) {
218
+ lastFailedMutation = definition;
219
+ if (typeof definition.onError === "function") {
220
+ definition.onError(error);
221
+ }
222
+ },
223
+ });
224
+ }
162
225
 
163
226
  async function request(path, requestOptions = {}) {
164
227
  const method = typeof requestOptions.method === "string" ? requestOptions.method.toUpperCase() : "GET";
165
228
  const headers = new Headers(requestOptions.headers || {});
229
+ const timeoutMs = Number.isFinite(requestOptions.timeoutMs) && requestOptions.timeoutMs > 0
230
+ ? requestOptions.timeoutMs
231
+ : requestTimeoutMs;
166
232
  if (sessionToken.length > 0) {
167
233
  headers.set("authorization", `Bearer ${sessionToken}`);
168
234
  }
@@ -171,14 +237,26 @@ export function createApi(model, options) {
171
237
  }
172
238
 
173
239
  let response;
240
+ const controller = new AbortController();
241
+ const timeoutId = setTimeout(() => {
242
+ controller.abort(createTimeoutError(method, path, timeoutMs));
243
+ }, timeoutMs);
244
+
174
245
  try {
175
- response = await fetch(path, { ...requestOptions, headers });
246
+ response = await fetch(path, { ...requestOptions, headers, signal: controller.signal });
176
247
  } catch (error) {
248
+ if (controller.signal.aborted) {
249
+ throw controller.signal.reason instanceof Error
250
+ ? controller.signal.reason
251
+ : createTimeoutError(method, path, timeoutMs);
252
+ }
177
253
  const message = error instanceof Error ? error.message : String(error);
178
254
  const requestError = new Error(`${method} ${path} failed before a response was received: ${message}`);
179
255
  requestError.code = "network_error";
180
256
  requestError.cause = error;
181
257
  throw requestError;
258
+ } finally {
259
+ clearTimeout(timeoutId);
182
260
  }
183
261
 
184
262
  const payload = await readJsonPayload(response);
@@ -192,8 +270,16 @@ export function createApi(model, options) {
192
270
  const queue = createMutationQueue(model, rerender);
193
271
 
194
272
  return {
273
+ retryLastFailedMutation() {
274
+ if (!lastFailedMutation) {
275
+ return false;
276
+ }
277
+ enqueueMutation(lastFailedMutation);
278
+ return true;
279
+ },
280
+
195
281
  patchEpic(epicId, updates, optimistic) {
196
- queue.enqueue({
282
+ enqueueMutation({
197
283
  optimistic,
198
284
  successMessage: "Epic saved.",
199
285
  request: () => request(`/api/epics/${encodeURIComponent(epicId)}`, {
@@ -204,7 +290,7 @@ export function createApi(model, options) {
204
290
  },
205
291
 
206
292
  patchTask(taskId, updates, optimistic) {
207
- queue.enqueue({
293
+ enqueueMutation({
208
294
  optimistic,
209
295
  successMessage: "Task saved.",
210
296
  request: () => request(`/api/tasks/${encodeURIComponent(taskId)}`, {
@@ -215,7 +301,7 @@ export function createApi(model, options) {
215
301
  },
216
302
 
217
303
  patchSubtask(subtaskId, updates, optimistic) {
218
- queue.enqueue({
304
+ enqueueMutation({
219
305
  optimistic,
220
306
  successMessage: "Subtask saved.",
221
307
  request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
@@ -226,7 +312,7 @@ export function createApi(model, options) {
226
312
  },
227
313
 
228
314
  cascadeEpicStatus(epicId, status, optimistic) {
229
- queue.enqueue({
315
+ enqueueMutation({
230
316
  optimistic,
231
317
  successMessage: "Epic cascade status updated.",
232
318
  request: () => request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
@@ -237,43 +323,81 @@ export function createApi(model, options) {
237
323
  },
238
324
 
239
325
  createSubtask(input, optimistic) {
240
- queue.enqueue({
241
- optimistic,
326
+ const clientRequestId = createClientRequestId();
327
+ const optimisticId = createOptimisticId("subtask", clientRequestId);
328
+ enqueueMutation({
329
+ optimistic: typeof optimistic === "function"
330
+ ? (snapshot) => optimistic(snapshot, optimisticId)
331
+ : optimistic,
242
332
  successMessage: "Subtask added.",
243
- request: () => request("/api/subtasks", {
244
- method: "POST",
245
- body: JSON.stringify(input),
246
- }),
333
+ request: async () => {
334
+ const data = await request("/api/subtasks", {
335
+ method: "POST",
336
+ headers: {
337
+ "x-trekoon-idempotency-key": clientRequestId,
338
+ },
339
+ body: JSON.stringify({ ...input, clientRequestId }),
340
+ });
341
+ return data?.snapshotDelta
342
+ ? {
343
+ ...data,
344
+ snapshotDelta: augmentSnapshotDeltaWithOptimisticDelete(data.snapshotDelta, "subtasks", optimisticId),
345
+ }
346
+ : data;
347
+ },
247
348
  });
248
349
  },
249
350
 
250
351
  deleteSubtask(subtaskId, optimistic) {
251
- queue.enqueue({
352
+ const clientRequestId = createClientRequestId();
353
+ enqueueMutation({
252
354
  optimistic,
253
355
  successMessage: "Subtask removed.",
254
356
  request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
255
357
  method: "DELETE",
358
+ headers: {
359
+ "x-trekoon-idempotency-key": clientRequestId,
360
+ },
256
361
  }),
257
362
  });
258
363
  },
259
364
 
260
365
  addDependency(sourceId, dependsOnId, optimistic) {
261
- queue.enqueue({
262
- optimistic,
366
+ const clientRequestId = createClientRequestId();
367
+ const optimisticId = createOptimisticId("dependency", clientRequestId);
368
+ enqueueMutation({
369
+ optimistic: typeof optimistic === "function"
370
+ ? (snapshot) => optimistic(snapshot, optimisticId)
371
+ : optimistic,
263
372
  successMessage: "Dependency added.",
264
- request: () => request("/api/dependencies", {
265
- method: "POST",
266
- body: JSON.stringify({ sourceId, dependsOnId }),
267
- }),
373
+ request: async () => {
374
+ const data = await request("/api/dependencies", {
375
+ method: "POST",
376
+ headers: {
377
+ "x-trekoon-idempotency-key": clientRequestId,
378
+ },
379
+ body: JSON.stringify({ sourceId, dependsOnId, clientRequestId }),
380
+ });
381
+ return data?.snapshotDelta
382
+ ? {
383
+ ...data,
384
+ snapshotDelta: augmentSnapshotDeltaWithOptimisticDelete(data.snapshotDelta, "dependencies", optimisticId),
385
+ }
386
+ : data;
387
+ },
268
388
  });
269
389
  },
270
390
 
271
391
  removeDependency(sourceId, dependsOnId, optimistic) {
272
- queue.enqueue({
392
+ const clientRequestId = createClientRequestId();
393
+ enqueueMutation({
273
394
  optimistic,
274
395
  successMessage: "Dependency removed.",
275
396
  request: () => request(`/api/dependencies?sourceId=${encodeURIComponent(sourceId)}&dependsOnId=${encodeURIComponent(dependsOnId)}`, {
276
397
  method: "DELETE",
398
+ headers: {
399
+ "x-trekoon-idempotency-key": clientRequestId,
400
+ },
277
401
  }),
278
402
  });
279
403
  },
@@ -1,4 +1,4 @@
1
- import { normalizeSnapshot, VIEW_MODES } from "./utils.js";
1
+ import { applySnapshotDelta, normalizeSnapshot, VIEW_MODES } from "./utils.js";
2
2
 
3
3
  export const THEME_STORAGE_KEY = "trekoon-board-theme";
4
4
  export const STATE_STORAGE_KEY = "trekoon-board-state";
@@ -23,6 +23,10 @@ function readStatusFilter(raw) {
23
23
 
24
24
  export function readStoredState() {
25
25
  try {
26
+ if (typeof localStorage?.getItem !== "function") {
27
+ return {};
28
+ }
29
+
26
30
  return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}");
27
31
  } catch {
28
32
  return {};
@@ -30,17 +34,38 @@ export function readStoredState() {
30
34
  }
31
35
 
32
36
  export function writeStoredState(nextState) {
33
- localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(nextState));
37
+ try {
38
+ if (typeof localStorage?.setItem !== "function") {
39
+ return false;
40
+ }
41
+
42
+ localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(nextState));
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
34
47
  }
35
48
 
36
49
  export function readThemePreference() {
37
- const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
38
- return storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
50
+ try {
51
+ if (typeof localStorage?.getItem !== "function") {
52
+ return "dark";
53
+ }
54
+
55
+ const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
56
+ return storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
57
+ } catch {
58
+ return "dark";
59
+ }
39
60
  }
40
61
 
41
62
  export function applyTheme(theme) {
42
63
  document.documentElement.dataset.theme = theme;
43
- localStorage.setItem(THEME_STORAGE_KEY, theme);
64
+ try {
65
+ localStorage?.setItem?.(THEME_STORAGE_KEY, theme);
66
+ } catch {
67
+ // Ignore storage failures so board rendering remains usable.
68
+ }
44
69
 
45
70
  const themeColor = theme === "light" ? "#f4f6fb" : "#0b0d12";
46
71
  const themeColorMeta = document.querySelector('meta[name="theme-color"][data-board-theme-color="active"]');
@@ -326,7 +351,7 @@ export function createStore(initialSnapshot, options = {}) {
326
351
  }
327
352
 
328
353
  function persist() {
329
- writeStoredState({
354
+ return writeStoredState({
330
355
  screen: state.screen,
331
356
  selectedEpicId: state.selectedEpicId,
332
357
  search: state.search,
@@ -457,6 +482,13 @@ export function createStore(initialSnapshot, options = {}) {
457
482
  notify();
458
483
  },
459
484
 
485
+ applySnapshotDelta(delta) {
486
+ state.snapshot = normalize(applySnapshotDelta(state.snapshot, delta));
487
+ syncState();
488
+ persist();
489
+ notify();
490
+ },
491
+
460
492
  /** Persist navigational state to localStorage. */
461
493
  persist,
462
494