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.
Files changed (38) hide show
  1. package/README.md +14 -14
  2. package/docs/commands.md +9 -11
  3. package/docs/quickstart.md +10 -12
  4. package/package.json +23 -1
  5. package/src/board/assets/app.js +469 -1377
  6. package/src/board/assets/components/ClampedText.js +1 -1
  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 +43 -26
  10. package/src/board/assets/components/EpicsOverview.js +52 -11
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +87 -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 +319 -0
  18. package/src/board/assets/components/assetMap.js +29 -14
  19. package/src/board/assets/components/helpers.js +261 -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 +20 -57
  23. package/src/board/assets/main.js +2 -18
  24. package/src/board/assets/runtime/clipboard.js +34 -0
  25. package/src/board/assets/runtime/delegation.js +342 -0
  26. package/src/board/assets/state/actions.js +204 -16
  27. package/src/board/assets/state/api.js +201 -46
  28. package/src/board/assets/state/store.js +418 -117
  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 +933 -129
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/routes.ts +15 -6
  34. package/src/board/server.ts +1 -0
  35. package/src/board/assets/components/AppShell.js +0 -17
  36. package/src/board/assets/components/BoardTopbar.js +0 -78
  37. package/src/board/assets/components/WorkspaceHeader.js +0 -70
  38. 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
- const response = await fetch(path, { ...requestOptions, headers });
22
- const payload = await response.json();
23
- if (!payload?.ok) {
24
- const message = payload?.error?.message || "Board request failed";
25
- const error = new Error(message);
26
- error.code = payload?.error?.code;
27
- error.details = payload?.error?.details;
28
- throw error;
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 previousSnapshot = cloneSnapshot(model.store.snapshot);
40
- model.store.notice = null;
41
- model.store.isMutating = true;
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
- try {
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
- return runMutation({
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
- return runMutation({
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
- return runMutation({
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
- return runMutation({
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
- return runMutation({
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
- return runMutation({
272
+ queue.enqueue({
118
273
  optimistic,
119
274
  successMessage: "Dependency removed.",
120
275
  request: () => request(`/api/dependencies?sourceId=${encodeURIComponent(sourceId)}&dependsOnId=${encodeURIComponent(dependsOnId)}`, {