trekoon 0.2.8 → 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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/board/assets/app.js +468 -1377
  3. package/src/board/assets/components/ClampedText.js +1 -1
  4. package/src/board/assets/components/Component.js +271 -0
  5. package/src/board/assets/components/ConfirmDialog.js +81 -0
  6. package/src/board/assets/components/EpicRow.js +23 -21
  7. package/src/board/assets/components/EpicsOverview.js +48 -11
  8. package/src/board/assets/components/Inspector.js +335 -0
  9. package/src/board/assets/components/Notice.js +80 -0
  10. package/src/board/assets/components/SubtaskModal.js +100 -0
  11. package/src/board/assets/components/TaskCard.js +82 -0
  12. package/src/board/assets/components/TaskModal.js +99 -0
  13. package/src/board/assets/components/TopBar.js +167 -0
  14. package/src/board/assets/components/Workspace.js +308 -0
  15. package/src/board/assets/components/assetMap.js +29 -14
  16. package/src/board/assets/components/helpers.js +244 -0
  17. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  18. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  19. package/src/board/assets/index.html +20 -57
  20. package/src/board/assets/main.js +2 -18
  21. package/src/board/assets/runtime/delegation.js +309 -0
  22. package/src/board/assets/state/actions.js +136 -16
  23. package/src/board/assets/state/api.js +201 -46
  24. package/src/board/assets/state/store.js +417 -117
  25. package/src/board/assets/state/url.js +184 -0
  26. package/src/board/assets/state/utils.js +222 -0
  27. package/src/board/assets/styles/board.css +811 -127
  28. package/src/board/assets/styles/fonts.css +22 -0
  29. package/src/board/routes.ts +15 -6
  30. package/src/board/server.ts +1 -0
  31. package/src/board/assets/components/AppShell.js +0 -17
  32. package/src/board/assets/components/BoardTopbar.js +0 -78
  33. package/src/board/assets/components/WorkspaceHeader.js +0 -70
  34. package/src/board/assets/utils/dom.js +0 -308
@@ -1,3 +1,5 @@
1
+ import { orderEpicsNewestFirst } from "./store.js";
2
+
1
3
  function cloneSnapshot(snapshot) {
2
4
  if (typeof structuredClone === "function") {
3
5
  return structuredClone(snapshot);
@@ -38,6 +40,40 @@ export function updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeS
38
40
  return normalizeSnapshot(nextSnapshot);
39
41
  }
40
42
 
43
+ export function cascadeEpicStatusInSnapshot(snapshot, epicId, status, normalizeSnapshot) {
44
+ const nextSnapshot = cloneSnapshot(snapshot);
45
+ const epic = nextSnapshot.epics.find((candidate) => candidate.id === epicId);
46
+ if (!epic) {
47
+ return snapshot;
48
+ }
49
+
50
+ const updatedAt = Date.now();
51
+ epic.status = status;
52
+ epic.updatedAt = updatedAt;
53
+
54
+ const taskIds = new Set();
55
+ for (const task of nextSnapshot.tasks) {
56
+ if (task.epicId !== epicId) {
57
+ continue;
58
+ }
59
+
60
+ task.status = status;
61
+ task.updatedAt = updatedAt;
62
+ taskIds.add(task.id);
63
+ }
64
+
65
+ for (const subtask of nextSnapshot.subtasks) {
66
+ if (!taskIds.has(subtask.taskId)) {
67
+ continue;
68
+ }
69
+
70
+ subtask.status = status;
71
+ subtask.updatedAt = updatedAt;
72
+ }
73
+
74
+ return normalizeSnapshot(nextSnapshot);
75
+ }
76
+
41
77
  export function addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot) {
42
78
  const nextSnapshot = cloneSnapshot(snapshot);
43
79
  const duplicate = normalizeArray(nextSnapshot.dependencies).some(
@@ -98,6 +134,8 @@ export function createBoardActions(options) {
98
134
  applyTheme,
99
135
  closeTopmostDisclosure,
100
136
  dismissSearch,
137
+ hasOpenOverlay,
138
+ closeActiveOverlay,
101
139
  focusSearch,
102
140
  focusTaskDetail,
103
141
  searchFocusKeys,
@@ -117,6 +155,47 @@ export function createBoardActions(options) {
117
155
  };
118
156
 
119
157
  let searchTimer = null;
158
+ let pendingSearchValue = null;
159
+
160
+ const syncSearchInputToState = () => {
161
+ const input = document.querySelector("#board-search-input");
162
+ if (input instanceof HTMLInputElement) {
163
+ input.value = store.search;
164
+ }
165
+ };
166
+
167
+ const cancelPendingSearch = (options = {}) => {
168
+ const { syncInput = true } = options;
169
+ pendingSearchValue = null;
170
+ if (searchTimer !== null) {
171
+ clearTimeout(searchTimer);
172
+ searchTimer = null;
173
+ }
174
+ if (syncInput) {
175
+ syncSearchInputToState();
176
+ }
177
+ };
178
+
179
+ const focusSearchInput = () => {
180
+ const input = document.querySelector("#board-search-input");
181
+ if (input instanceof HTMLInputElement) {
182
+ input.focus({ preventScroll: true });
183
+ input.setSelectionRange(input.value.length, input.value.length);
184
+ }
185
+ };
186
+
187
+ const shouldRefocusSearchInput = () => document.activeElement?.id === "board-search-input";
188
+
189
+ const commitSearch = (nextSearch, options = {}) => {
190
+ const { focusInput = false } = options;
191
+ cancelPendingSearch({ syncInput: false });
192
+ syncState({ search: nextSearch });
193
+ persist();
194
+ rerender({ preserveFocus: false });
195
+ if (focusInput) {
196
+ focusSearchInput();
197
+ }
198
+ };
120
199
 
121
200
  return {
122
201
  toggleTheme() {
@@ -124,23 +203,26 @@ export function createBoardActions(options) {
124
203
  applyTheme(store.theme);
125
204
  rerender();
126
205
  },
206
+ toggleNotesPanel() {
207
+ store.notesPanelOpen = !store.notesPanelOpen;
208
+ persist();
209
+ rerender();
210
+ },
127
211
  updateSearch(value) {
128
- store.search = typeof value === "string" ? value : "";
129
- if (searchTimer !== null) {
130
- clearTimeout(searchTimer);
131
- }
212
+ const nextSearch = typeof value === "string" ? value : "";
213
+ cancelPendingSearch({ syncInput: false });
214
+ pendingSearchValue = nextSearch;
132
215
  searchTimer = setTimeout(() => {
133
- searchTimer = null;
134
- syncState({ search: store.search });
135
- persist();
136
- rerender({ preserveFocus: false });
137
- const input = document.querySelector("#board-search-input");
138
- if (input instanceof HTMLInputElement) {
139
- input.focus({ preventScroll: true });
140
- input.setSelectionRange(input.value.length, input.value.length);
216
+ if (pendingSearchValue !== nextSearch) {
217
+ return;
141
218
  }
219
+ commitSearch(nextSearch, { focusInput: shouldRefocusSearchInput() });
142
220
  }, 180);
143
221
  },
222
+ clearSearch() {
223
+ commitSearch("");
224
+ },
225
+ cancelPendingSearch,
144
226
  openEpic(epicId) {
145
227
  transition({
146
228
  screen: "tasks",
@@ -165,7 +247,11 @@ export function createBoardActions(options) {
165
247
  });
166
248
  },
167
249
  showBoard() {
168
- const fallbackEpicId = getBoardState().selectedEpicId || store.snapshot.epics[0]?.id || null;
250
+ const boardState = getBoardState();
251
+ const fallbackEpicId = boardState.selectedEpicId
252
+ || boardState.visibleEpics[0]?.id
253
+ || orderEpicsNewestFirst(store.snapshot.epics)[0]?.id
254
+ || null;
169
255
  if (!fallbackEpicId) {
170
256
  return;
171
257
  }
@@ -261,6 +347,20 @@ export function createBoardActions(options) {
261
347
  transition({ selectedTaskId: taskId }, { rerenderBoard: false });
262
348
  api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
263
349
  },
350
+ changeEpicStatus(epicId, newStatus) {
351
+ const normalizedStatus = normalizeStatus(newStatus);
352
+ api.patchEpic(epicId, { status: normalizedStatus }, (snapshot) => {
353
+ const epic = snapshot.epics.find(e => e.id === epicId);
354
+ if (epic) epic.status = normalizedStatus;
355
+ return snapshot;
356
+ });
357
+ },
358
+ bulkSetStatus(epicId, newStatus) {
359
+ const normalizedStatus = normalizeStatus(newStatus);
360
+ api.cascadeEpicStatus(epicId, normalizedStatus, (snapshot) =>
361
+ cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
362
+ );
363
+ },
264
364
  handleKeydown(event) {
265
365
  const boardState = getBoardState();
266
366
  const activeElement = document.activeElement;
@@ -276,20 +376,36 @@ export function createBoardActions(options) {
276
376
  }
277
377
 
278
378
  if (event.key === "Escape") {
379
+ if (activeElement?.id === "board-search-input" && pendingSearchValue !== null) {
380
+ event.preventDefault();
381
+ activeElement.value = "";
382
+ this.clearSearch();
383
+ activeElement.blur();
384
+ return;
385
+ }
386
+
279
387
  if (closeTopmostDisclosure?.(boardState, activeElement)) {
280
388
  event.preventDefault();
281
389
  return;
282
390
  }
283
391
 
392
+ if (dismissSearch?.(boardState, activeElement)) {
393
+ event.preventDefault();
394
+ return;
395
+ }
396
+
397
+ if (hasOpenOverlay?.()) {
398
+ event.preventDefault();
399
+ closeActiveOverlay?.();
400
+ return;
401
+ }
402
+
284
403
  if (boardState.selectedSubtaskId) {
285
404
  event.preventDefault();
286
405
  this.closeSubtask();
287
406
  } else if (boardState.selectedTaskId) {
288
407
  event.preventDefault();
289
408
  this.closeTask();
290
- } else if (dismissSearch?.(boardState, activeElement)) {
291
- event.preventDefault();
292
- return;
293
409
  } else if (boardState.screen === "tasks") {
294
410
  event.preventDefault();
295
411
  this.showEpics();
@@ -301,6 +417,10 @@ export function createBoardActions(options) {
301
417
  return;
302
418
  }
303
419
 
420
+ if (hasOpenOverlay?.()) {
421
+ return;
422
+ }
423
+
304
424
  if (boardState.screen !== "tasks" || isTypingTarget || visibleTasks.length === 0) {
305
425
  return;
306
426
  }
@@ -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)}`, {