trekoon 0.2.7 → 0.2.9

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.
Files changed (45) hide show
  1. package/README.md +60 -0
  2. package/docs/commands.md +100 -0
  3. package/docs/quickstart.md +74 -1
  4. package/package.json +2 -1
  5. package/src/board/assets/app.js +589 -0
  6. package/src/board/assets/components/ClampedText.js +31 -0
  7. package/src/board/assets/components/Component.js +271 -0
  8. package/src/board/assets/components/ConfirmDialog.js +81 -0
  9. package/src/board/assets/components/EpicRow.js +64 -0
  10. package/src/board/assets/components/EpicsOverview.js +80 -0
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +80 -0
  13. package/src/board/assets/components/SubtaskModal.js +100 -0
  14. package/src/board/assets/components/TaskCard.js +82 -0
  15. package/src/board/assets/components/TaskModal.js +99 -0
  16. package/src/board/assets/components/TopBar.js +167 -0
  17. package/src/board/assets/components/Workspace.js +308 -0
  18. package/src/board/assets/components/assetMap.js +80 -0
  19. package/src/board/assets/components/helpers.js +244 -0
  20. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  21. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  22. package/src/board/assets/index.html +39 -0
  23. package/src/board/assets/main.js +11 -0
  24. package/src/board/assets/manifest.json +12 -0
  25. package/src/board/assets/runtime/delegation.js +309 -0
  26. package/src/board/assets/state/actions.js +454 -0
  27. package/src/board/assets/state/api.js +281 -0
  28. package/src/board/assets/state/store.js +472 -0
  29. package/src/board/assets/state/url.js +184 -0
  30. package/src/board/assets/state/utils.js +222 -0
  31. package/src/board/assets/styles/board.css +1811 -0
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/install.ts +196 -0
  34. package/src/board/open-browser.ts +131 -0
  35. package/src/board/routes.ts +308 -0
  36. package/src/board/server.ts +185 -0
  37. package/src/board/snapshot.ts +277 -0
  38. package/src/board/types.ts +43 -0
  39. package/src/commands/board.ts +158 -0
  40. package/src/commands/help.ts +21 -0
  41. package/src/commands/init.ts +29 -0
  42. package/src/domain/mutation-service.ts +40 -0
  43. package/src/domain/tracker-domain.ts +11 -3
  44. package/src/runtime/cli-shell.ts +5 -0
  45. package/src/storage/path.ts +36 -0
@@ -0,0 +1,281 @@
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
+
9
+ function cloneSnapshot(snapshot) {
10
+ if (typeof structuredClone === "function") {
11
+ return structuredClone(snapshot);
12
+ }
13
+ return JSON.parse(JSON.stringify(snapshot));
14
+ }
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
+ */
160
+ export function createApi(model, options) {
161
+ const { sessionToken, rerender } = options;
162
+
163
+ async function request(path, requestOptions = {}) {
164
+ const method = typeof requestOptions.method === "string" ? requestOptions.method.toUpperCase() : "GET";
165
+ const headers = new Headers(requestOptions.headers || {});
166
+ if (sessionToken.length > 0) {
167
+ headers.set("authorization", `Bearer ${sessionToken}`);
168
+ }
169
+ if (requestOptions.body && !headers.has("content-type")) {
170
+ headers.set("content-type", "application/json");
171
+ }
172
+
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;
182
+ }
183
+
184
+ const payload = await readJsonPayload(response);
185
+ if (!response.ok || !payload?.ok) {
186
+ throw buildRequestError(method, path, response, payload);
187
+ }
188
+
189
+ return payload.data;
190
+ }
191
+
192
+ const queue = createMutationQueue(model, rerender);
193
+
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
+
206
+ patchTask(taskId, updates, optimistic) {
207
+ queue.enqueue({
208
+ optimistic,
209
+ successMessage: "Task saved.",
210
+ request: () => request(`/api/tasks/${encodeURIComponent(taskId)}`, {
211
+ method: "PATCH",
212
+ body: JSON.stringify(updates),
213
+ }),
214
+ });
215
+ },
216
+
217
+ patchSubtask(subtaskId, updates, optimistic) {
218
+ queue.enqueue({
219
+ optimistic,
220
+ successMessage: "Subtask saved.",
221
+ request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
222
+ method: "PATCH",
223
+ body: JSON.stringify(updates),
224
+ }),
225
+ });
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
+
239
+ createSubtask(input, optimistic) {
240
+ queue.enqueue({
241
+ optimistic,
242
+ successMessage: "Subtask added.",
243
+ request: () => request("/api/subtasks", {
244
+ method: "POST",
245
+ body: JSON.stringify(input),
246
+ }),
247
+ });
248
+ },
249
+
250
+ deleteSubtask(subtaskId, optimistic) {
251
+ queue.enqueue({
252
+ optimistic,
253
+ successMessage: "Subtask removed.",
254
+ request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
255
+ method: "DELETE",
256
+ }),
257
+ });
258
+ },
259
+
260
+ addDependency(sourceId, dependsOnId, optimistic) {
261
+ queue.enqueue({
262
+ optimistic,
263
+ successMessage: "Dependency added.",
264
+ request: () => request("/api/dependencies", {
265
+ method: "POST",
266
+ body: JSON.stringify({ sourceId, dependsOnId }),
267
+ }),
268
+ });
269
+ },
270
+
271
+ removeDependency(sourceId, dependsOnId, optimistic) {
272
+ queue.enqueue({
273
+ optimistic,
274
+ successMessage: "Dependency removed.",
275
+ request: () => request(`/api/dependencies?sourceId=${encodeURIComponent(sourceId)}&dependsOnId=${encodeURIComponent(dependsOnId)}`, {
276
+ method: "DELETE",
277
+ }),
278
+ });
279
+ },
280
+ };
281
+ }