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 +1 -1
- package/src/board/assets/app.js +11 -0
- package/src/board/assets/components/Notice.js +18 -4
- package/src/board/assets/state/actions.js +6 -6
- package/src/board/assets/state/api.js +151 -26
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +73 -13
- package/src/board/routes.ts +392 -52
- package/src/board/snapshot.ts +151 -168
- package/src/commands/events.ts +17 -11
- package/src/commands/init.ts +36 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +310 -26
- package/src/domain/tracker-domain.ts +169 -5
- package/src/storage/migrations.ts +98 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +17 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +43 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +578 -149
package/package.json
CHANGED
package/src/board/assets/app.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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: () =>
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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: () =>
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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:
|
|
67
|
-
description:
|
|
77
|
+
title: normalizeText(task.title, "Untitled task"),
|
|
78
|
+
description: normalizeText(task.description),
|
|
68
79
|
status: normalizeStatus(task.status),
|
|
69
|
-
createdAt
|
|
70
|
-
updatedAt:
|
|
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:
|
|
89
|
-
description:
|
|
100
|
+
title: normalizeText(subtask.title, "Untitled subtask"),
|
|
101
|
+
description: normalizeText(subtask.description),
|
|
90
102
|
status: normalizeStatus(subtask.status),
|
|
91
|
-
createdAt
|
|
92
|
-
updatedAt:
|
|
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:
|
|
158
|
+
description: normalizeText(epic.description),
|
|
146
159
|
status: normalizeStatus(String(epic.status ?? "todo")),
|
|
147
|
-
createdAt
|
|
148
|
-
updatedAt:
|
|
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
|
-
|
|
208
|
-
|
|
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
|
/**
|