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,454 @@
1
+ import { orderEpicsNewestFirst } from "./store.js";
2
+
3
+ function cloneSnapshot(snapshot) {
4
+ if (typeof structuredClone === "function") {
5
+ return structuredClone(snapshot);
6
+ }
7
+
8
+ return JSON.parse(JSON.stringify(snapshot));
9
+ }
10
+
11
+ function normalizeArray(value) {
12
+ return Array.isArray(value) ? value : [];
13
+ }
14
+
15
+ export function updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot) {
16
+ const nextSnapshot = cloneSnapshot(snapshot);
17
+ const task = nextSnapshot.tasks.find((candidate) => candidate.id === taskId);
18
+ if (!task) {
19
+ return snapshot;
20
+ }
21
+
22
+ if (updates.title !== undefined) task.title = updates.title;
23
+ if (updates.description !== undefined) task.description = updates.description;
24
+ if (updates.status !== undefined) task.status = updates.status;
25
+ task.updatedAt = Date.now();
26
+ return normalizeSnapshot(nextSnapshot);
27
+ }
28
+
29
+ export function updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot) {
30
+ const nextSnapshot = cloneSnapshot(snapshot);
31
+ const subtask = nextSnapshot.subtasks.find((candidate) => candidate.id === subtaskId);
32
+ if (!subtask) {
33
+ return snapshot;
34
+ }
35
+
36
+ if (updates.title !== undefined) subtask.title = updates.title;
37
+ if (updates.description !== undefined) subtask.description = updates.description;
38
+ if (updates.status !== undefined) subtask.status = updates.status;
39
+ subtask.updatedAt = Date.now();
40
+ return normalizeSnapshot(nextSnapshot);
41
+ }
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
+
77
+ export function addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot) {
78
+ const nextSnapshot = cloneSnapshot(snapshot);
79
+ const duplicate = normalizeArray(nextSnapshot.dependencies).some(
80
+ (dependency) => dependency.sourceId === sourceId && dependency.dependsOnId === dependsOnId,
81
+ );
82
+ if (!duplicate) {
83
+ normalizeArray(nextSnapshot.dependencies).push({
84
+ id: crypto.randomUUID(),
85
+ sourceId,
86
+ sourceKind: nextSnapshot.subtasks.some((subtask) => subtask.id === sourceId) ? "subtask" : "task",
87
+ dependsOnId,
88
+ dependsOnKind: nextSnapshot.subtasks.some((subtask) => subtask.id === dependsOnId) ? "subtask" : "task",
89
+ createdAt: Date.now(),
90
+ updatedAt: Date.now(),
91
+ });
92
+ }
93
+ return normalizeSnapshot(nextSnapshot);
94
+ }
95
+
96
+ export function removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot) {
97
+ const nextSnapshot = cloneSnapshot(snapshot);
98
+ nextSnapshot.dependencies = normalizeArray(nextSnapshot.dependencies).filter(
99
+ (dependency) => !(dependency.sourceId === sourceId && dependency.dependsOnId === dependsOnId),
100
+ );
101
+ return normalizeSnapshot(nextSnapshot);
102
+ }
103
+
104
+ export function createSubtaskInSnapshot(snapshot, input, normalizeSnapshot) {
105
+ const nextSnapshot = cloneSnapshot(snapshot);
106
+ normalizeArray(nextSnapshot.subtasks).push({
107
+ id: crypto.randomUUID(),
108
+ taskId: input.taskId,
109
+ title: input.title,
110
+ description: input.description ?? "",
111
+ status: input.status ?? "todo",
112
+ createdAt: Date.now(),
113
+ updatedAt: Date.now(),
114
+ });
115
+ return normalizeSnapshot(nextSnapshot);
116
+ }
117
+
118
+ export function deleteSubtaskInSnapshot(snapshot, subtaskId, normalizeSnapshot) {
119
+ const nextSnapshot = cloneSnapshot(snapshot);
120
+ nextSnapshot.subtasks = normalizeArray(nextSnapshot.subtasks).filter((candidate) => candidate.id !== subtaskId);
121
+ nextSnapshot.dependencies = normalizeArray(nextSnapshot.dependencies).filter(
122
+ (dependency) => dependency.sourceId !== subtaskId && dependency.dependsOnId !== subtaskId,
123
+ );
124
+ return normalizeSnapshot(nextSnapshot);
125
+ }
126
+
127
+ export function createBoardActions(options) {
128
+ const {
129
+ model,
130
+ api,
131
+ rerender,
132
+ normalizeSnapshot,
133
+ normalizeStatus,
134
+ applyTheme,
135
+ closeTopmostDisclosure,
136
+ dismissSearch,
137
+ hasOpenOverlay,
138
+ closeActiveOverlay,
139
+ focusSearch,
140
+ focusTaskDetail,
141
+ searchFocusKeys,
142
+ } = options;
143
+ const { store, persist, getBoardState, getTaskById, syncState } = model;
144
+
145
+ const transition = (patch = {}, options = {}) => {
146
+ const { persistState = true, rerenderBoard = true } = options;
147
+ const boardState = syncState(patch);
148
+ if (persistState) {
149
+ persist();
150
+ }
151
+ if (rerenderBoard) {
152
+ rerender();
153
+ }
154
+ return boardState;
155
+ };
156
+
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
+ };
199
+
200
+ return {
201
+ toggleTheme() {
202
+ store.theme = store.theme === "dark" ? "light" : "dark";
203
+ applyTheme(store.theme);
204
+ rerender();
205
+ },
206
+ toggleNotesPanel() {
207
+ store.notesPanelOpen = !store.notesPanelOpen;
208
+ persist();
209
+ rerender();
210
+ },
211
+ updateSearch(value) {
212
+ const nextSearch = typeof value === "string" ? value : "";
213
+ cancelPendingSearch({ syncInput: false });
214
+ pendingSearchValue = nextSearch;
215
+ searchTimer = setTimeout(() => {
216
+ if (pendingSearchValue !== nextSearch) {
217
+ return;
218
+ }
219
+ commitSearch(nextSearch, { focusInput: shouldRefocusSearchInput() });
220
+ }, 180);
221
+ },
222
+ clearSearch() {
223
+ commitSearch("");
224
+ },
225
+ cancelPendingSearch,
226
+ openEpic(epicId) {
227
+ transition({
228
+ screen: "tasks",
229
+ selectedEpicId: epicId || null,
230
+ selectedTaskId: null,
231
+ selectedSubtaskId: null,
232
+ });
233
+ },
234
+ selectEpic(epicId) {
235
+ transition({
236
+ screen: "tasks",
237
+ selectedEpicId: epicId || null,
238
+ selectedTaskId: null,
239
+ selectedSubtaskId: null,
240
+ });
241
+ },
242
+ showEpics() {
243
+ transition({
244
+ screen: "epics",
245
+ selectedTaskId: null,
246
+ selectedSubtaskId: null,
247
+ });
248
+ },
249
+ showBoard() {
250
+ const boardState = getBoardState();
251
+ const fallbackEpicId = boardState.selectedEpicId
252
+ || boardState.visibleEpics[0]?.id
253
+ || orderEpicsNewestFirst(store.snapshot.epics)[0]?.id
254
+ || null;
255
+ if (!fallbackEpicId) {
256
+ return;
257
+ }
258
+
259
+ transition({
260
+ screen: "tasks",
261
+ selectedEpicId: fallbackEpicId,
262
+ selectedTaskId: null,
263
+ selectedSubtaskId: null,
264
+ });
265
+ },
266
+ setView(view) {
267
+ transition({ view });
268
+ },
269
+ selectTask(taskId) {
270
+ const task = getTaskById(taskId);
271
+ if (!task) {
272
+ return;
273
+ }
274
+ transition({
275
+ screen: "tasks",
276
+ selectedEpicId: task.epicId,
277
+ selectedTaskId: taskId,
278
+ });
279
+ },
280
+ closeTask() {
281
+ transition({ selectedTaskId: null, selectedSubtaskId: null });
282
+ },
283
+ openSubtask(subtaskId) {
284
+ transition({ selectedSubtaskId: subtaskId || null }, { persistState: false });
285
+ },
286
+ closeSubtask() {
287
+ transition({ selectedSubtaskId: null }, { persistState: false });
288
+ },
289
+ submitTaskForm(taskId, formData) {
290
+ const updates = {
291
+ title: String(formData.get("title") || "").trim(),
292
+ description: String(formData.get("description") || "").trim(),
293
+ status: normalizeStatus(String(formData.get("status") || "todo")),
294
+ };
295
+ api.patchTask(taskId, updates, (snapshot) => updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot));
296
+ },
297
+ submitSubtaskForm(subtaskId, formData) {
298
+ const updates = {
299
+ title: String(formData.get("title") || "").trim(),
300
+ description: String(formData.get("description") || "").trim(),
301
+ status: normalizeStatus(String(formData.get("status") || "todo")),
302
+ };
303
+ syncState({ selectedSubtaskId: subtaskId });
304
+ api.patchSubtask(subtaskId, updates, (snapshot) => updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot));
305
+ },
306
+ submitCreateSubtask(taskId, formData) {
307
+ const input = {
308
+ taskId,
309
+ title: String(formData.get("title") || "").trim(),
310
+ description: String(formData.get("description") || "").trim(),
311
+ status: normalizeStatus(String(formData.get("status") || "todo")),
312
+ };
313
+
314
+ if (!taskId || input.title.length === 0) {
315
+ store.notice = { type: "error", message: "Subtasks need a title before they can be added." };
316
+ rerender();
317
+ return;
318
+ }
319
+
320
+ api.createSubtask(input, (snapshot) => createSubtaskInSnapshot(snapshot, input, normalizeSnapshot));
321
+ },
322
+ deleteSubtask(subtaskId) {
323
+ if (!subtaskId) {
324
+ return;
325
+ }
326
+
327
+ api.deleteSubtask(subtaskId, (snapshot) => deleteSubtaskInSnapshot(snapshot, subtaskId, normalizeSnapshot));
328
+ },
329
+ addDependency(sourceId, formData) {
330
+ const dependsOnId = String(formData.get("dependsOnId") || "").trim();
331
+ if (!dependsOnId) {
332
+ store.notice = { type: "error", message: "Choose a dependency target first." };
333
+ rerender();
334
+ return;
335
+ }
336
+
337
+ api.addDependency(sourceId, dependsOnId, (snapshot) => addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
338
+ },
339
+ removeDependency(sourceId, dependsOnId) {
340
+ api.removeDependency(sourceId, dependsOnId, (snapshot) => removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
341
+ },
342
+ dropTaskStatus(taskId, nextStatus) {
343
+ const task = getTaskById(taskId);
344
+ if (!task || !nextStatus || task.status === nextStatus) {
345
+ return;
346
+ }
347
+ transition({ selectedTaskId: taskId }, { rerenderBoard: false });
348
+ api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
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
+ },
364
+ handleKeydown(event) {
365
+ const boardState = getBoardState();
366
+ const activeElement = document.activeElement;
367
+ const tagName = activeElement?.tagName?.toLowerCase();
368
+ const isTypingTarget = tagName === "input" || tagName === "textarea" || tagName === "select";
369
+ const visibleTasks = boardState.visibleTasks;
370
+ const currentIndex = visibleTasks.findIndex((task) => task.id === boardState.selectedTaskId);
371
+
372
+ if (searchFocusKeys.has(event.key.toLowerCase()) && activeElement?.id !== "board-search-input" && !isTypingTarget) {
373
+ event.preventDefault();
374
+ focusSearch?.(activeElement);
375
+ return;
376
+ }
377
+
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
+
387
+ if (closeTopmostDisclosure?.(boardState, activeElement)) {
388
+ event.preventDefault();
389
+ return;
390
+ }
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
+
403
+ if (boardState.selectedSubtaskId) {
404
+ event.preventDefault();
405
+ this.closeSubtask();
406
+ } else if (boardState.selectedTaskId) {
407
+ event.preventDefault();
408
+ this.closeTask();
409
+ } else if (boardState.screen === "tasks") {
410
+ event.preventDefault();
411
+ this.showEpics();
412
+ } else if (store.notice) {
413
+ event.preventDefault();
414
+ store.notice = null;
415
+ rerender();
416
+ }
417
+ return;
418
+ }
419
+
420
+ if (hasOpenOverlay?.()) {
421
+ return;
422
+ }
423
+
424
+ if (boardState.screen !== "tasks" || isTypingTarget || visibleTasks.length === 0) {
425
+ return;
426
+ }
427
+
428
+ if (event.key.toLowerCase() === "j" || event.key === "ArrowDown") {
429
+ event.preventDefault();
430
+ const nextTask = visibleTasks[Math.min(currentIndex + 1, visibleTasks.length - 1)] ?? visibleTasks[0];
431
+ this.selectTask(nextTask.id);
432
+ return;
433
+ }
434
+
435
+ if (event.key.toLowerCase() === "k" || event.key === "ArrowUp") {
436
+ event.preventDefault();
437
+ const previousTask = visibleTasks[Math.max(currentIndex - 1, 0)] ?? visibleTasks[0];
438
+ this.selectTask(previousTask.id);
439
+ return;
440
+ }
441
+
442
+ if (event.key === "Enter" && currentIndex >= 0) {
443
+ event.preventDefault();
444
+ focusTaskDetail?.();
445
+ return;
446
+ }
447
+
448
+ if (event.key === "Enter" && currentIndex === -1 && visibleTasks[0]) {
449
+ event.preventDefault();
450
+ this.selectTask(visibleTasks[0].id);
451
+ }
452
+ },
453
+ };
454
+ }