trekoon 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -14
- package/docs/commands.md +9 -11
- package/docs/quickstart.md +10 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +469 -1377
- package/src/board/assets/components/ClampedText.js +1 -1
- package/src/board/assets/components/Component.js +271 -0
- package/src/board/assets/components/ConfirmDialog.js +81 -0
- package/src/board/assets/components/EpicRow.js +43 -26
- package/src/board/assets/components/EpicsOverview.js +52 -11
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +87 -0
- package/src/board/assets/components/SubtaskModal.js +100 -0
- package/src/board/assets/components/TaskCard.js +82 -0
- package/src/board/assets/components/TaskModal.js +99 -0
- package/src/board/assets/components/TopBar.js +167 -0
- package/src/board/assets/components/Workspace.js +319 -0
- package/src/board/assets/components/assetMap.js +29 -14
- package/src/board/assets/components/helpers.js +261 -0
- package/src/board/assets/fonts/inter-latin.woff2 +0 -0
- package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
- package/src/board/assets/index.html +20 -57
- package/src/board/assets/main.js +2 -18
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +342 -0
- package/src/board/assets/state/actions.js +204 -16
- package/src/board/assets/state/api.js +201 -46
- package/src/board/assets/state/store.js +418 -117
- package/src/board/assets/state/url.js +184 -0
- package/src/board/assets/state/utils.js +222 -0
- package/src/board/assets/styles/board.css +933 -129
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/routes.ts +15 -6
- package/src/board/server.ts +1 -0
- package/src/board/assets/components/AppShell.js +0 -17
- package/src/board/assets/components/BoardTopbar.js +0 -78
- package/src/board/assets/components/WorkspaceHeader.js +0 -70
- package/src/board/assets/utils/dom.js +0 -308
|
@@ -1,15 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API layer with serial mutation queue.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the boolean isMutating gate with a proper queue that processes
|
|
5
|
+
* mutations sequentially, applies optimistic updates immediately, and
|
|
6
|
+
* reverts on error.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
function cloneSnapshot(snapshot) {
|
|
2
10
|
if (typeof structuredClone === "function") {
|
|
3
11
|
return structuredClone(snapshot);
|
|
4
12
|
}
|
|
5
|
-
|
|
6
13
|
return JSON.parse(JSON.stringify(snapshot));
|
|
7
14
|
}
|
|
8
15
|
|
|
16
|
+
async function readJsonPayload(response) {
|
|
17
|
+
const text = await response.text();
|
|
18
|
+
if (text.length === 0) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(text);
|
|
24
|
+
} catch {
|
|
25
|
+
const error = new Error(`Board API returned malformed JSON (${response.status} ${response.statusText || "unknown"})`);
|
|
26
|
+
error.code = "invalid_response";
|
|
27
|
+
error.status = response.status;
|
|
28
|
+
error.statusText = response.statusText;
|
|
29
|
+
error.details = {
|
|
30
|
+
responseText: text.slice(0, 240),
|
|
31
|
+
};
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildRequestError(method, path, response, payload) {
|
|
37
|
+
const code = payload?.error?.code;
|
|
38
|
+
const routeMessage = payload?.error?.message;
|
|
39
|
+
const message = routeMessage
|
|
40
|
+
? `${method} ${path} failed (${response.status}${code ? ` ${code}` : ""}): ${routeMessage}`
|
|
41
|
+
: `${method} ${path} failed with ${response.status} ${response.statusText || "unknown error"}`;
|
|
42
|
+
const error = new Error(message);
|
|
43
|
+
error.code = code;
|
|
44
|
+
error.status = response.status;
|
|
45
|
+
error.statusText = response.statusText;
|
|
46
|
+
error.details = payload?.error?.details;
|
|
47
|
+
return error;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a serial mutation queue.
|
|
52
|
+
*
|
|
53
|
+
* Mutations are enqueued and processed one at a time in FIFO order.
|
|
54
|
+
* Each mutation can apply an optimistic update, make an async request,
|
|
55
|
+
* and handle success or error.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{
|
|
58
|
+
* enqueue: (mutation: object) => void,
|
|
59
|
+
* isPending: boolean,
|
|
60
|
+
* flush: () => Promise<void>,
|
|
61
|
+
* }}
|
|
62
|
+
*/
|
|
63
|
+
export function createMutationQueue(model, rerender) {
|
|
64
|
+
/** @type {Array<{ optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
|
|
65
|
+
const queue = [];
|
|
66
|
+
let processing = false;
|
|
67
|
+
/** @type {Array<() => void>} */
|
|
68
|
+
let flushResolvers = [];
|
|
69
|
+
|
|
70
|
+
function resolveFlushes() {
|
|
71
|
+
if (processing || queue.length > 0 || flushResolvers.length === 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const pendingResolvers = flushResolvers;
|
|
76
|
+
flushResolvers = [];
|
|
77
|
+
pendingResolvers.forEach((resolve) => resolve());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function processNext() {
|
|
81
|
+
if (processing || queue.length === 0) return;
|
|
82
|
+
processing = true;
|
|
83
|
+
model.store.isMutating = true;
|
|
84
|
+
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
const mutation = queue.shift();
|
|
87
|
+
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();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const data = await mutation.request();
|
|
98
|
+
|
|
99
|
+
if (data?.snapshot) {
|
|
100
|
+
model.replaceSnapshot(data.snapshot);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (typeof mutation.onSuccess === "function") {
|
|
104
|
+
mutation.onSuccess(data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
model.store.notice = mutation.successMessage
|
|
108
|
+
? { type: "success", message: mutation.successMessage }
|
|
109
|
+
: null;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Revert to pre-optimistic snapshot
|
|
112
|
+
model.replaceSnapshot(previousSnapshot);
|
|
113
|
+
|
|
114
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
115
|
+
model.store.notice = { type: "error", message };
|
|
116
|
+
|
|
117
|
+
if (typeof mutation.onError === "function") {
|
|
118
|
+
mutation.onError(error);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Clear remaining queue on error to prevent cascading failures
|
|
122
|
+
queue.length = 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
processing = false;
|
|
127
|
+
model.store.isMutating = false;
|
|
128
|
+
rerender();
|
|
129
|
+
resolveFlushes();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
enqueue(mutation) {
|
|
134
|
+
queue.push(mutation);
|
|
135
|
+
processNext();
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
get isPending() {
|
|
139
|
+
return processing || queue.length > 0;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
flush() {
|
|
143
|
+
if (!processing && queue.length === 0) return Promise.resolve();
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
flushResolvers.push(resolve);
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create the API layer with mutation queue.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} model - Store model from createStore
|
|
155
|
+
* @param {object} options
|
|
156
|
+
* @param {string} options.sessionToken - Auth token for API requests
|
|
157
|
+
* @param {function} options.rerender - Trigger a UI rerender
|
|
158
|
+
* @returns {object} API methods: patchTask, patchSubtask, createSubtask, deleteSubtask, addDependency, removeDependency
|
|
159
|
+
*/
|
|
9
160
|
export function createApi(model, options) {
|
|
10
161
|
const { sessionToken, rerender } = options;
|
|
11
162
|
|
|
12
163
|
async function request(path, requestOptions = {}) {
|
|
164
|
+
const method = typeof requestOptions.method === "string" ? requestOptions.method.toUpperCase() : "GET";
|
|
13
165
|
const headers = new Headers(requestOptions.headers || {});
|
|
14
166
|
if (sessionToken.length > 0) {
|
|
15
167
|
headers.set("authorization", `Bearer ${sessionToken}`);
|
|
@@ -18,54 +170,41 @@ export function createApi(model, options) {
|
|
|
18
170
|
headers.set("content-type", "application/json");
|
|
19
171
|
}
|
|
20
172
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return payload.data;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async function runMutation({ optimistic, request: mutationRequest, successMessage }) {
|
|
35
|
-
if (model.store.isMutating) {
|
|
36
|
-
return;
|
|
173
|
+
let response;
|
|
174
|
+
try {
|
|
175
|
+
response = await fetch(path, { ...requestOptions, headers });
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
178
|
+
const requestError = new Error(`${method} ${path} failed before a response was received: ${message}`);
|
|
179
|
+
requestError.code = "network_error";
|
|
180
|
+
requestError.cause = error;
|
|
181
|
+
throw requestError;
|
|
37
182
|
}
|
|
38
183
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (typeof optimistic === "function") {
|
|
44
|
-
model.store.snapshot = optimistic(cloneSnapshot(model.store.snapshot));
|
|
45
|
-
rerender();
|
|
184
|
+
const payload = await readJsonPayload(response);
|
|
185
|
+
if (!response.ok || !payload?.ok) {
|
|
186
|
+
throw buildRequestError(method, path, response, payload);
|
|
46
187
|
}
|
|
47
188
|
|
|
48
|
-
|
|
49
|
-
const data = await mutationRequest();
|
|
50
|
-
if (data?.snapshot) {
|
|
51
|
-
model.replaceSnapshot(data.snapshot);
|
|
52
|
-
}
|
|
53
|
-
model.store.notice = successMessage ? { type: "success", message: successMessage } : null;
|
|
54
|
-
} catch (error) {
|
|
55
|
-
model.replaceSnapshot(previousSnapshot);
|
|
56
|
-
model.store.notice = {
|
|
57
|
-
type: "error",
|
|
58
|
-
message: error instanceof Error ? error.message : String(error),
|
|
59
|
-
};
|
|
60
|
-
} finally {
|
|
61
|
-
model.store.isMutating = false;
|
|
62
|
-
rerender();
|
|
63
|
-
}
|
|
189
|
+
return payload.data;
|
|
64
190
|
}
|
|
65
191
|
|
|
192
|
+
const queue = createMutationQueue(model, rerender);
|
|
193
|
+
|
|
66
194
|
return {
|
|
195
|
+
patchEpic(epicId, updates, optimistic) {
|
|
196
|
+
queue.enqueue({
|
|
197
|
+
optimistic,
|
|
198
|
+
successMessage: "Epic saved.",
|
|
199
|
+
request: () => request(`/api/epics/${encodeURIComponent(epicId)}`, {
|
|
200
|
+
method: "PATCH",
|
|
201
|
+
body: JSON.stringify(updates),
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
|
|
67
206
|
patchTask(taskId, updates, optimistic) {
|
|
68
|
-
|
|
207
|
+
queue.enqueue({
|
|
69
208
|
optimistic,
|
|
70
209
|
successMessage: "Task saved.",
|
|
71
210
|
request: () => request(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
@@ -74,8 +213,9 @@ export function createApi(model, options) {
|
|
|
74
213
|
}),
|
|
75
214
|
});
|
|
76
215
|
},
|
|
216
|
+
|
|
77
217
|
patchSubtask(subtaskId, updates, optimistic) {
|
|
78
|
-
|
|
218
|
+
queue.enqueue({
|
|
79
219
|
optimistic,
|
|
80
220
|
successMessage: "Subtask saved.",
|
|
81
221
|
request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
|
|
@@ -84,8 +224,20 @@ export function createApi(model, options) {
|
|
|
84
224
|
}),
|
|
85
225
|
});
|
|
86
226
|
},
|
|
227
|
+
|
|
228
|
+
cascadeEpicStatus(epicId, status, optimistic) {
|
|
229
|
+
queue.enqueue({
|
|
230
|
+
optimistic,
|
|
231
|
+
successMessage: "Epic cascade status updated.",
|
|
232
|
+
request: () => request(`/api/epics/${encodeURIComponent(epicId)}/cascade`, {
|
|
233
|
+
method: "PATCH",
|
|
234
|
+
body: JSON.stringify({ status }),
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
},
|
|
238
|
+
|
|
87
239
|
createSubtask(input, optimistic) {
|
|
88
|
-
|
|
240
|
+
queue.enqueue({
|
|
89
241
|
optimistic,
|
|
90
242
|
successMessage: "Subtask added.",
|
|
91
243
|
request: () => request("/api/subtasks", {
|
|
@@ -94,8 +246,9 @@ export function createApi(model, options) {
|
|
|
94
246
|
}),
|
|
95
247
|
});
|
|
96
248
|
},
|
|
249
|
+
|
|
97
250
|
deleteSubtask(subtaskId, optimistic) {
|
|
98
|
-
|
|
251
|
+
queue.enqueue({
|
|
99
252
|
optimistic,
|
|
100
253
|
successMessage: "Subtask removed.",
|
|
101
254
|
request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
|
|
@@ -103,8 +256,9 @@ export function createApi(model, options) {
|
|
|
103
256
|
}),
|
|
104
257
|
});
|
|
105
258
|
},
|
|
259
|
+
|
|
106
260
|
addDependency(sourceId, dependsOnId, optimistic) {
|
|
107
|
-
|
|
261
|
+
queue.enqueue({
|
|
108
262
|
optimistic,
|
|
109
263
|
successMessage: "Dependency added.",
|
|
110
264
|
request: () => request("/api/dependencies", {
|
|
@@ -113,8 +267,9 @@ export function createApi(model, options) {
|
|
|
113
267
|
}),
|
|
114
268
|
});
|
|
115
269
|
},
|
|
270
|
+
|
|
116
271
|
removeDependency(sourceId, dependsOnId, optimistic) {
|
|
117
|
-
|
|
272
|
+
queue.enqueue({
|
|
118
273
|
optimistic,
|
|
119
274
|
successMessage: "Dependency removed.",
|
|
120
275
|
request: () => request(`/api/dependencies?sourceId=${encodeURIComponent(sourceId)}&dependsOnId=${encodeURIComponent(dependsOnId)}`, {
|