trekoon 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trekoon",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "AI-first local issue tracker CLI.",
5
5
  "keywords": [
6
6
  "ai",
@@ -422,6 +422,17 @@ export async function bootLegacyBoard(options = {}) {
422
422
  notice.update({
423
423
  notice: store.notice,
424
424
  onDismiss() { store.notice = null; rerender(); },
425
+ onRetry() {
426
+ const didRetry = api.retryLastFailedMutation();
427
+ if (!didRetry) {
428
+ store.notice = {
429
+ type: "error",
430
+ title: "Retry unavailable",
431
+ message: "The failed action is no longer available. Repeat the change from the board.",
432
+ };
433
+ }
434
+ rerender();
435
+ },
425
436
  });
426
437
 
427
438
  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,7 +120,9 @@ 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;
123
+ if (model.store.notice?.retryMutationId !== mutation.id) {
124
+ model.store.notice = null;
125
+ }
89
126
 
90
127
  // Apply optimistic update
91
128
  if (typeof mutation.optimistic === "function") {
@@ -98,6 +135,8 @@ export function createMutationQueue(model, rerender) {
98
135
 
99
136
  if (data?.snapshot) {
100
137
  model.replaceSnapshot(data.snapshot);
138
+ } else if (data?.snapshotDelta) {
139
+ model.applySnapshotDelta(data.snapshotDelta);
101
140
  }
102
141
 
103
142
  if (typeof mutation.onSuccess === "function") {
@@ -112,14 +151,17 @@ export function createMutationQueue(model, rerender) {
112
151
  model.replaceSnapshot(previousSnapshot);
113
152
 
114
153
  const message = error instanceof Error ? error.message : String(error);
115
- model.store.notice = { type: "error", message };
154
+ model.store.notice = {
155
+ type: "error",
156
+ title: "Action failed",
157
+ message,
158
+ retryLabel: "Retry",
159
+ retryMutationId: mutation.id,
160
+ };
116
161
 
117
162
  if (typeof mutation.onError === "function") {
118
163
  mutation.onError(error);
119
164
  }
120
-
121
- // Clear remaining queue on error to prevent cascading failures
122
- queue.length = 0;
123
165
  }
124
166
  }
125
167
 
@@ -131,7 +173,8 @@ export function createMutationQueue(model, rerender) {
131
173
 
132
174
  return {
133
175
  enqueue(mutation) {
134
- queue.push(mutation);
176
+ queue.push({ ...mutation, id: nextMutationId });
177
+ nextMutationId += 1;
135
178
  processNext();
136
179
  },
137
180
 
@@ -158,11 +201,35 @@ export function createMutationQueue(model, rerender) {
158
201
  * @returns {object} API methods: patchTask, patchSubtask, createSubtask, deleteSubtask, addDependency, removeDependency
159
202
  */
160
203
  export function createApi(model, options) {
161
- const { sessionToken, rerender } = options;
204
+ const { sessionToken, rerender, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = options;
205
+ let lastFailedMutation = null;
206
+
207
+ function enqueueMutation(definition) {
208
+ queue.enqueue({
209
+ ...definition,
210
+ onSuccess(data) {
211
+ if (lastFailedMutation?.request === definition.request) {
212
+ lastFailedMutation = null;
213
+ }
214
+ if (typeof definition.onSuccess === "function") {
215
+ definition.onSuccess(data);
216
+ }
217
+ },
218
+ onError(error) {
219
+ lastFailedMutation = definition;
220
+ if (typeof definition.onError === "function") {
221
+ definition.onError(error);
222
+ }
223
+ },
224
+ });
225
+ }
162
226
 
163
227
  async function request(path, requestOptions = {}) {
164
228
  const method = typeof requestOptions.method === "string" ? requestOptions.method.toUpperCase() : "GET";
165
229
  const headers = new Headers(requestOptions.headers || {});
230
+ const timeoutMs = Number.isFinite(requestOptions.timeoutMs) && requestOptions.timeoutMs > 0
231
+ ? requestOptions.timeoutMs
232
+ : requestTimeoutMs;
166
233
  if (sessionToken.length > 0) {
167
234
  headers.set("authorization", `Bearer ${sessionToken}`);
168
235
  }
@@ -171,14 +238,26 @@ export function createApi(model, options) {
171
238
  }
172
239
 
173
240
  let response;
241
+ const controller = new AbortController();
242
+ const timeoutId = setTimeout(() => {
243
+ controller.abort(createTimeoutError(method, path, timeoutMs));
244
+ }, timeoutMs);
245
+
174
246
  try {
175
- response = await fetch(path, { ...requestOptions, headers });
247
+ response = await fetch(path, { ...requestOptions, headers, signal: controller.signal });
176
248
  } catch (error) {
249
+ if (controller.signal.aborted) {
250
+ throw controller.signal.reason instanceof Error
251
+ ? controller.signal.reason
252
+ : createTimeoutError(method, path, timeoutMs);
253
+ }
177
254
  const message = error instanceof Error ? error.message : String(error);
178
255
  const requestError = new Error(`${method} ${path} failed before a response was received: ${message}`);
179
256
  requestError.code = "network_error";
180
257
  requestError.cause = error;
181
258
  throw requestError;
259
+ } finally {
260
+ clearTimeout(timeoutId);
182
261
  }
183
262
 
184
263
  const payload = await readJsonPayload(response);
@@ -192,8 +271,16 @@ export function createApi(model, options) {
192
271
  const queue = createMutationQueue(model, rerender);
193
272
 
194
273
  return {
274
+ retryLastFailedMutation() {
275
+ if (!lastFailedMutation) {
276
+ return false;
277
+ }
278
+ enqueueMutation(lastFailedMutation);
279
+ return true;
280
+ },
281
+
195
282
  patchEpic(epicId, updates, optimistic) {
196
- queue.enqueue({
283
+ enqueueMutation({
197
284
  optimistic,
198
285
  successMessage: "Epic saved.",
199
286
  request: () => request(`/api/epics/${encodeURIComponent(epicId)}`, {
@@ -204,7 +291,7 @@ export function createApi(model, options) {
204
291
  },
205
292
 
206
293
  patchTask(taskId, updates, optimistic) {
207
- queue.enqueue({
294
+ enqueueMutation({
208
295
  optimistic,
209
296
  successMessage: "Task saved.",
210
297
  request: () => request(`/api/tasks/${encodeURIComponent(taskId)}`, {
@@ -215,7 +302,7 @@ export function createApi(model, options) {
215
302
  },
216
303
 
217
304
  patchSubtask(subtaskId, updates, optimistic) {
218
- queue.enqueue({
305
+ enqueueMutation({
219
306
  optimistic,
220
307
  successMessage: "Subtask saved.",
221
308
  request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
@@ -226,7 +313,7 @@ export function createApi(model, options) {
226
313
  },
227
314
 
228
315
  cascadeEpicStatus(epicId, status, optimistic) {
229
- queue.enqueue({
316
+ enqueueMutation({
230
317
  optimistic,
231
318
  successMessage: "Epic cascade status updated.",
232
319
  request: () => request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
@@ -237,43 +324,81 @@ export function createApi(model, options) {
237
324
  },
238
325
 
239
326
  createSubtask(input, optimistic) {
240
- queue.enqueue({
241
- optimistic,
327
+ const clientRequestId = createClientRequestId();
328
+ const optimisticId = createOptimisticId("subtask", clientRequestId);
329
+ enqueueMutation({
330
+ optimistic: typeof optimistic === "function"
331
+ ? (snapshot) => optimistic(snapshot, optimisticId)
332
+ : optimistic,
242
333
  successMessage: "Subtask added.",
243
- request: () => request("/api/subtasks", {
244
- method: "POST",
245
- body: JSON.stringify(input),
246
- }),
334
+ request: async () => {
335
+ const data = await request("/api/subtasks", {
336
+ method: "POST",
337
+ headers: {
338
+ "x-trekoon-idempotency-key": clientRequestId,
339
+ },
340
+ body: JSON.stringify({ ...input, clientRequestId }),
341
+ });
342
+ return data?.snapshotDelta
343
+ ? {
344
+ ...data,
345
+ snapshotDelta: augmentSnapshotDeltaWithOptimisticDelete(data.snapshotDelta, "subtasks", optimisticId),
346
+ }
347
+ : data;
348
+ },
247
349
  });
248
350
  },
249
351
 
250
352
  deleteSubtask(subtaskId, optimistic) {
251
- queue.enqueue({
353
+ const clientRequestId = createClientRequestId();
354
+ enqueueMutation({
252
355
  optimistic,
253
356
  successMessage: "Subtask removed.",
254
357
  request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
255
358
  method: "DELETE",
359
+ headers: {
360
+ "x-trekoon-idempotency-key": clientRequestId,
361
+ },
256
362
  }),
257
363
  });
258
364
  },
259
365
 
260
366
  addDependency(sourceId, dependsOnId, optimistic) {
261
- queue.enqueue({
262
- optimistic,
367
+ const clientRequestId = createClientRequestId();
368
+ const optimisticId = createOptimisticId("dependency", clientRequestId);
369
+ enqueueMutation({
370
+ optimistic: typeof optimistic === "function"
371
+ ? (snapshot) => optimistic(snapshot, optimisticId)
372
+ : optimistic,
263
373
  successMessage: "Dependency added.",
264
- request: () => request("/api/dependencies", {
265
- method: "POST",
266
- body: JSON.stringify({ sourceId, dependsOnId }),
267
- }),
374
+ request: async () => {
375
+ const data = await request("/api/dependencies", {
376
+ method: "POST",
377
+ headers: {
378
+ "x-trekoon-idempotency-key": clientRequestId,
379
+ },
380
+ body: JSON.stringify({ sourceId, dependsOnId, clientRequestId }),
381
+ });
382
+ return data?.snapshotDelta
383
+ ? {
384
+ ...data,
385
+ snapshotDelta: augmentSnapshotDeltaWithOptimisticDelete(data.snapshotDelta, "dependencies", optimisticId),
386
+ }
387
+ : data;
388
+ },
268
389
  });
269
390
  },
270
391
 
271
392
  removeDependency(sourceId, dependsOnId, optimistic) {
272
- queue.enqueue({
393
+ const clientRequestId = createClientRequestId();
394
+ enqueueMutation({
273
395
  optimistic,
274
396
  successMessage: "Dependency removed.",
275
397
  request: () => request(`/api/dependencies?sourceId=${encodeURIComponent(sourceId)}&dependsOnId=${encodeURIComponent(dependsOnId)}`, {
276
398
  method: "DELETE",
399
+ headers: {
400
+ "x-trekoon-idempotency-key": clientRequestId,
401
+ },
277
402
  }),
278
403
  });
279
404
  },
@@ -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
 
@@ -33,6 +33,15 @@ function getId(record) {
33
33
  return typeof record?.id === "string" && record.id.length > 0 ? record.id : crypto.randomUUID();
34
34
  }
35
35
 
36
+ function normalizeTimestamp(value, fallback) {
37
+ const normalized = Number(value);
38
+ return Number.isFinite(normalized) && normalized > 0 ? normalized : fallback;
39
+ }
40
+
41
+ function normalizeText(value, fallback = "") {
42
+ return String(value ?? fallback).replace(/\\n/g, "\n");
43
+ }
44
+
36
45
  /**
37
46
  * @param {any[]} tasks
38
47
  * @returns {Record<string, number>}
@@ -55,19 +64,21 @@ export function normalizeSnapshot(rawSnapshot) {
55
64
  const rawTasks = normalizeArray(rawSnapshot?.tasks);
56
65
  const rawSubtasks = normalizeArray(rawSnapshot?.subtasks);
57
66
  const rawDependencies = normalizeArray(rawSnapshot?.dependencies);
67
+
58
68
  const taskIndex = new Map();
59
69
  const subtaskIndex = new Map();
60
70
 
61
71
  const tasks = rawTasks.map((task) => {
72
+ const createdAt = normalizeTimestamp(task.createdAt, Date.now());
62
73
  const normalizedTask = {
63
74
  id: getId(task),
64
75
  kind: "task",
65
76
  epicId: task.epicId ?? task.epic?.id ?? null,
66
- title: String(task.title ?? "Untitled task"),
67
- description: String(task.description ?? "").replace(/\\n/g, "\n"),
77
+ title: normalizeText(task.title, "Untitled task"),
78
+ description: normalizeText(task.description),
68
79
  status: normalizeStatus(task.status),
69
- createdAt: Number(task.createdAt ?? Date.now()),
70
- updatedAt: Number(task.updatedAt ?? task.createdAt ?? Date.now()),
80
+ createdAt,
81
+ updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
71
82
  blockedBy: [],
72
83
  blocks: [],
73
84
  dependencyIds: [],
@@ -81,15 +92,16 @@ export function normalizeSnapshot(rawSnapshot) {
81
92
  });
82
93
 
83
94
  const subtasks = rawSubtasks.map((subtask) => {
95
+ const createdAt = normalizeTimestamp(subtask.createdAt, Date.now());
84
96
  const normalizedSubtask = {
85
97
  id: getId(subtask),
86
98
  kind: "subtask",
87
99
  taskId: subtask.taskId ?? subtask.task?.id ?? null,
88
- title: String(subtask.title ?? "Untitled subtask"),
89
- description: String(subtask.description ?? "").replace(/\\n/g, "\n"),
100
+ title: normalizeText(subtask.title, "Untitled subtask"),
101
+ description: normalizeText(subtask.description),
90
102
  status: normalizeStatus(subtask.status),
91
- createdAt: Number(subtask.createdAt ?? Date.now()),
92
- updatedAt: Number(subtask.updatedAt ?? subtask.createdAt ?? Date.now()),
103
+ createdAt,
104
+ updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
93
105
  blockedBy: [],
94
106
  blocks: [],
95
107
  dependencyIds: [],
@@ -139,13 +151,14 @@ export function normalizeSnapshot(rawSnapshot) {
139
151
  const epics = rawEpics.map((epic) => {
140
152
  const epicId = getId(epic);
141
153
  const epicTasks = tasks.filter((task) => task.epicId === epicId);
154
+ const createdAt = normalizeTimestamp(epic.createdAt, Date.now());
142
155
  const normalizedEpic = {
143
156
  id: epicId,
144
157
  title: String(epic.title ?? "Untitled epic"),
145
- description: String(epic.description ?? "").replace(/\\n/g, "\n"),
158
+ description: normalizeText(epic.description),
146
159
  status: normalizeStatus(String(epic.status ?? "todo")),
147
- createdAt: Number(epic.createdAt ?? Date.now()),
148
- updatedAt: Number(epic.updatedAt ?? epic.createdAt ?? Date.now()),
160
+ createdAt,
161
+ updatedAt: normalizeTimestamp(epic.updatedAt, createdAt),
149
162
  taskIds: epicTasks.map((task) => task.id),
150
163
  counts: deriveCounts(epicTasks),
151
164
  searchText: "",
@@ -195,6 +208,47 @@ export function normalizeSnapshot(rawSnapshot) {
195
208
  };
196
209
  }
197
210
 
211
+ function mergeRecordsById(existingRecords, incomingRecords, deletedIds = []) {
212
+ const deletedIdSet = new Set(deletedIds);
213
+ const nextRecords = existingRecords.filter((record) => !deletedIdSet.has(record.id));
214
+ const indexById = new Map(nextRecords.map((record, index) => [record.id, index]));
215
+
216
+ for (const record of incomingRecords) {
217
+ const existingIndex = indexById.get(record.id);
218
+ if (existingIndex === undefined) {
219
+ indexById.set(record.id, nextRecords.length);
220
+ nextRecords.push(record);
221
+ continue;
222
+ }
223
+
224
+ nextRecords[existingIndex] = record;
225
+ }
226
+
227
+ return nextRecords;
228
+ }
229
+
230
+ export function applySnapshotDelta(snapshot, delta) {
231
+ const baseSnapshot = snapshot && typeof snapshot === "object"
232
+ ? snapshot
233
+ : { generatedAt: null, epics: [], tasks: [], subtasks: [], dependencies: [] };
234
+
235
+ if (!delta || typeof delta !== "object") {
236
+ return baseSnapshot;
237
+ }
238
+
239
+ return {
240
+ generatedAt: delta.generatedAt ?? baseSnapshot.generatedAt ?? null,
241
+ epics: mergeRecordsById(baseSnapshot.epics ?? [], normalizeArray(delta.epics), normalizeArray(delta.deletedEpicIds)),
242
+ tasks: mergeRecordsById(baseSnapshot.tasks ?? [], normalizeArray(delta.tasks), normalizeArray(delta.deletedTaskIds)),
243
+ subtasks: mergeRecordsById(baseSnapshot.subtasks ?? [], normalizeArray(delta.subtasks), normalizeArray(delta.deletedSubtaskIds)),
244
+ dependencies: mergeRecordsById(
245
+ baseSnapshot.dependencies ?? [],
246
+ normalizeArray(delta.dependencies),
247
+ normalizeArray(delta.deletedDependencyIds),
248
+ ),
249
+ };
250
+ }
251
+
198
252
  const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
199
253
 
200
254
  /**
@@ -204,8 +258,14 @@ const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium",
204
258
  * @returns {string}
205
259
  */
206
260
  export function formatDate(timestamp) {
207
- if (!timestamp) return "Unknown";
208
- return dateFormatter.format(timestamp);
261
+ const normalized = Number(timestamp);
262
+ if (!Number.isFinite(normalized) || normalized <= 0) return "Unknown";
263
+
264
+ try {
265
+ return dateFormatter.format(normalized);
266
+ } catch {
267
+ return "Unknown";
268
+ }
209
269
  }
210
270
 
211
271
  /**