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.
- package/.agents/skills/trekoon/SKILL.md +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +19 -25
- 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 +155 -31
- package/src/board/assets/state/store.js +38 -6
- package/src/board/assets/state/utils.js +123 -30
- package/src/board/routes.ts +397 -54
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +205 -173
- package/src/commands/board.ts +1 -1
- package/src/commands/events.ts +17 -11
- package/src/commands/quickstart.ts +10 -0
- package/src/commands/subtask.ts +2 -2
- package/src/domain/mutation-service.ts +452 -54
- package/src/domain/tracker-domain.ts +185 -7
- package/src/storage/migrations.ts +123 -0
- package/src/storage/path.ts +12 -1
- package/src/storage/schema.ts +18 -1
- package/src/storage/worktree-recovery.ts +12 -6
- package/src/sync/branch-db.ts +12 -1
- package/src/sync/event-writes.ts +47 -7
- package/src/sync/git-context.ts +10 -6
- package/src/sync/service.ts +759 -151
package/src/board/assets/app.js
CHANGED
|
@@ -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:
|
|
39
|
+
return { token: queryToken, shouldScrubAddressBar: true };
|
|
58
40
|
}
|
|
59
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 (
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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: () =>
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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: () =>
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|