trekoon 0.3.0 → 0.3.2
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/.agents/skills/trekoon/SKILL.md +274 -26
- package/.agents/skills/trekoon/reference/execution-with-team.md +213 -0
- package/.agents/skills/trekoon/reference/execution.md +210 -0
- package/.agents/skills/trekoon/reference/planning.md +244 -0
- package/README.md +24 -10
- package/docs/ai-agents.md +108 -30
- package/docs/commands.md +81 -5
- package/docs/machine-contracts.md +120 -0
- package/docs/plans/r1-unified-skill-rewrite.md +290 -0
- package/docs/plans/r10-suggest-command-skill-integration.md +152 -0
- package/docs/plans/r9-task-done-diff-skill-integration.md +113 -0
- package/docs/quickstart.md +31 -0
- package/package.json +2 -2
- package/src/board/assets/app.js +5 -0
- package/src/board/assets/components/EpicsOverview.js +13 -0
- package/src/board/assets/components/Workspace.js +27 -12
- package/src/board/assets/components/helpers.js +3 -2
- package/src/board/assets/runtime/delegation.js +69 -1
- package/src/board/assets/state/actions.js +27 -1
- package/src/board/assets/state/store.js +37 -8
- package/src/board/assets/state/utils.js +42 -0
- package/src/board/assets/styles/board.css +68 -0
- package/src/board/routes.ts +2 -0
- package/src/commands/epic.ts +74 -3
- package/src/commands/session.ts +7 -75
- package/src/commands/skills.ts +39 -32
- package/src/commands/subtask.ts +7 -5
- package/src/commands/suggest.ts +283 -0
- package/src/commands/sync-helpers.ts +75 -0
- package/src/commands/task-readiness.ts +8 -20
- package/src/commands/task.ts +59 -3
- package/src/domain/mutation-service.ts +69 -42
- package/src/domain/tracker-domain.ts +151 -22
- package/src/domain/types.ts +12 -0
- package/src/index.ts +1 -1
- package/src/io/output.ts +4 -2
- package/src/runtime/cli-shell.ts +26 -3
- package/src/runtime/command-types.ts +1 -1
- package/src/storage/database.ts +43 -1
- package/src/storage/events-retention.ts +57 -8
- package/src/storage/migrations.ts +58 -3
- package/src/sync/service.ts +101 -24
- package/src/sync/types.ts +1 -0
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
STATUS_LABELS,
|
|
21
21
|
STATUS_ORDER,
|
|
22
22
|
} from "./helpers.js";
|
|
23
|
-
import { orderEpicsNewestFirst } from "../state/store.js";
|
|
24
|
-
import { VIEW_MODES } from "../state/utils.js";
|
|
23
|
+
import { DEFAULT_STATUS_FILTER, orderEpicsNewestFirst } from "../state/store.js";
|
|
24
|
+
import { getSelectableStatuses, VIEW_MODES } from "../state/utils.js";
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Workspace header
|
|
@@ -58,6 +58,9 @@ function renderWorkspaceHeader(props) {
|
|
|
58
58
|
visibleTasks,
|
|
59
59
|
} = props;
|
|
60
60
|
|
|
61
|
+
const taskStatusFilter = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
62
|
+
const isTaskFilterNonDefault = STATUS_ORDER.some(s => taskStatusFilter[s] !== DEFAULT_STATUS_FILTER[s]);
|
|
63
|
+
|
|
61
64
|
const description = selectedEpic.description?.trim() || "";
|
|
62
65
|
const inlineSelect = `${fieldClasses()} !py-1 !px-2 !text-xs !min-h-0 !rounded-xl`;
|
|
63
66
|
const epicStatusTooltip = "Change this epic's overall status.";
|
|
@@ -79,7 +82,7 @@ function renderWorkspaceHeader(props) {
|
|
|
79
82
|
<label class="board-wh__control-label">
|
|
80
83
|
<span class="board-wh__control-text">Epic status</span>
|
|
81
84
|
<select class="${inlineSelect}" name="status" aria-label="Epic status" title="${escapeHtml(epicStatusTooltip)}" ${store.isMutating ? "disabled" : ""}>
|
|
82
|
-
${
|
|
85
|
+
${getSelectableStatuses(selectedEpic.status).map(s => `<option value="${escapeHtml(s)}" ${selectedEpic.status === s ? 'selected' : ''}>${escapeHtml(STATUS_LABELS[s] ?? s)}</option>`).join('')}
|
|
83
86
|
</select>
|
|
84
87
|
</label>
|
|
85
88
|
</form>
|
|
@@ -114,6 +117,14 @@ function renderWorkspaceHeader(props) {
|
|
|
114
117
|
<span class="${neutralChipClasses()}">${visibleTasks.length} visible</span>
|
|
115
118
|
${store.isMutating ? `<span class="${neutralChipClasses()}">Saving\u2026</span>` : ""}
|
|
116
119
|
</div>
|
|
120
|
+
<div class="board-filter-bar">
|
|
121
|
+
${STATUS_ORDER.map(status => {
|
|
122
|
+
const active = taskStatusFilter[status] !== false;
|
|
123
|
+
const count = selectedEpic.counts?.[status] ?? 0;
|
|
124
|
+
return `<button type="button" class="board-filter-pill ${active ? 'board-filter-pill--active' : 'board-filter-pill--inactive'} board-filter-pill--${status}" data-toggle-task-status-filter="${status}" aria-pressed="${active}" title="${active ? 'Hide' : 'Show'} ${STATUS_LABELS[status]} tasks">${STATUS_LABELS[status]} (${count})</button>`;
|
|
125
|
+
}).join('')}
|
|
126
|
+
${isTaskFilterNonDefault ? `<button type="button" class="board-filter-pill board-filter-pill--reset" data-reset-task-filter title="Reset filters to defaults">Reset</button>` : ''}
|
|
127
|
+
</div>
|
|
117
128
|
<div class="board-wh__actions">
|
|
118
129
|
<div class="board-wh__action-group">
|
|
119
130
|
<button type="button" class="board-copy-btn ${isCopied ? "board-copy-btn--active" : ""}" data-copy-epic-id="${escapeHtml(selectedEpic.id)}" aria-label="${escapeHtml(isCopied ? `Copied epic UUID for ${selectedEpic.title}` : copyLabel)}" title="${escapeHtml(isCopied ? "Epic UUID copied." : copyTooltip)}">
|
|
@@ -155,9 +166,12 @@ function renderWorkspaceHeader(props) {
|
|
|
155
166
|
// ---------------------------------------------------------------------------
|
|
156
167
|
|
|
157
168
|
function renderKanbanColumns(props) {
|
|
158
|
-
const { visibleTasks, selectedTaskId, isMutating } = props;
|
|
169
|
+
const { visibleTasks, selectedTaskId, isMutating, taskStatusFilter } = props;
|
|
170
|
+
const filter = taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
159
171
|
|
|
160
|
-
const columnsMarkup = STATUS_ORDER
|
|
172
|
+
const columnsMarkup = STATUS_ORDER
|
|
173
|
+
.filter((status) => filter[status] !== false)
|
|
174
|
+
.map((status) => {
|
|
161
175
|
const columnTasks = visibleTasks.filter((t) => t.status === status);
|
|
162
176
|
const columnTitle = readStatusLabel(status);
|
|
163
177
|
const content = columnTasks.length === 0
|
|
@@ -265,19 +279,20 @@ function render(props) {
|
|
|
265
279
|
const headerMarkup = renderWorkspaceHeader({
|
|
266
280
|
selectedEpic,
|
|
267
281
|
snapshotEpics,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
282
|
+
store: {
|
|
283
|
+
copyFeedback: store.copyFeedback,
|
|
284
|
+
notesPanelOpen: store.notesPanelOpen,
|
|
285
|
+
isMutating: store.isMutating,
|
|
286
|
+
selectedEpicId: selectedEpic.id,
|
|
287
|
+
view: store.view,
|
|
274
288
|
viewModes,
|
|
289
|
+
taskStatusFilter: store.taskStatusFilter,
|
|
275
290
|
},
|
|
276
291
|
visibleTasks,
|
|
277
292
|
});
|
|
278
293
|
|
|
279
294
|
const contentMarkup = store.view === "kanban"
|
|
280
|
-
? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating })
|
|
295
|
+
? renderKanbanColumns({ visibleTasks, selectedTaskId, isMutating: store.isMutating, taskStatusFilter: store.taskStatusFilter })
|
|
281
296
|
: renderListView({ visibleTasks, selectedTaskId });
|
|
282
297
|
|
|
283
298
|
return `
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
|
|
1
|
+
import { escapeHtml, formatDate, getSelectableStatuses, normalizeStatus, STATUS_ORDER } from "../state/utils.js";
|
|
2
2
|
|
|
3
3
|
export { escapeHtml, formatDate, normalizeStatus, STATUS_ORDER };
|
|
4
4
|
|
|
@@ -123,9 +123,10 @@ export function renderStatusBadge(rawStatus, label = readStatusLabel(rawStatus))
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function renderStatusSelect(name, selectedStatus, disabled = false) {
|
|
126
|
+
const statuses = getSelectableStatuses(selectedStatus);
|
|
126
127
|
return `
|
|
127
128
|
<select class="${fieldClasses()}" name="${escapeHtml(name)}" ${disabled ? "disabled" : ""}>
|
|
128
|
-
${
|
|
129
|
+
${statuses.map((status) => `
|
|
129
130
|
<option value="${escapeHtml(status)}" ${selectedStatus === status ? "selected" : ""}>${escapeHtml(STATUS_LABELS[status] ?? status)}</option>
|
|
130
131
|
`).join("")}
|
|
131
132
|
</select>
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isValidTransition } from "../state/utils.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Event delegation system for the board runtime.
|
|
3
5
|
*
|
|
@@ -89,6 +91,31 @@ export function createDelegation(rootElement, actions) {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
// -- Status filter pills ---------------------------------------------------
|
|
95
|
+
const epicFilterEl = target.closest("[data-toggle-epic-status-filter]");
|
|
96
|
+
if (epicFilterEl) {
|
|
97
|
+
actions.toggleEpicStatusFilter(epicFilterEl.dataset.toggleEpicStatusFilter);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const taskFilterEl = target.closest("[data-toggle-task-status-filter]");
|
|
102
|
+
if (taskFilterEl) {
|
|
103
|
+
actions.toggleTaskStatusFilter(taskFilterEl.dataset.toggleTaskStatusFilter);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const resetEpicFilterEl = target.closest("[data-reset-epic-filter]");
|
|
108
|
+
if (resetEpicFilterEl) {
|
|
109
|
+
actions.resetEpicFilter();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const resetTaskFilterEl = target.closest("[data-reset-task-filter]");
|
|
114
|
+
if (resetTaskFilterEl) {
|
|
115
|
+
actions.resetTaskFilter();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
92
119
|
// -- Navigation -----------------------------------------------------------
|
|
93
120
|
|
|
94
121
|
const navEl = target.closest("[data-nav]");
|
|
@@ -276,6 +303,15 @@ export function createDelegation(rootElement, actions) {
|
|
|
276
303
|
// ---------------------------------------------------------------------------
|
|
277
304
|
// Drag-and-drop delegation
|
|
278
305
|
// ---------------------------------------------------------------------------
|
|
306
|
+
let draggedTaskStatus = null;
|
|
307
|
+
|
|
308
|
+
function cleanupDragFeedback() {
|
|
309
|
+
draggedTaskStatus = null;
|
|
310
|
+
for (const el of rootElement.querySelectorAll(".board-drop-valid, .board-drop-invalid")) {
|
|
311
|
+
el.classList.remove("board-drop-valid", "board-drop-invalid");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
279
315
|
function handleDragstart(event) {
|
|
280
316
|
const draggable = event.target.closest("[data-draggable-task]");
|
|
281
317
|
if (!draggable) return;
|
|
@@ -290,11 +326,28 @@ export function createDelegation(rootElement, actions) {
|
|
|
290
326
|
|
|
291
327
|
event.dataTransfer?.setData("text/task-id", taskId);
|
|
292
328
|
event.dataTransfer?.setData("text/plain", taskId);
|
|
329
|
+
draggedTaskStatus = actions.getTaskStatus(taskId);
|
|
293
330
|
}
|
|
294
331
|
|
|
295
332
|
function handleDragover(event) {
|
|
296
|
-
|
|
333
|
+
const column = event.target.closest("[data-drop-status]");
|
|
334
|
+
if (!column) return;
|
|
335
|
+
|
|
336
|
+
const targetStatus = column.dataset.dropStatus;
|
|
337
|
+
if (draggedTaskStatus && isValidTransition(draggedTaskStatus, targetStatus)) {
|
|
297
338
|
event.preventDefault();
|
|
339
|
+
column.classList.add("board-drop-valid");
|
|
340
|
+
column.classList.remove("board-drop-invalid");
|
|
341
|
+
} else if (draggedTaskStatus && targetStatus !== draggedTaskStatus) {
|
|
342
|
+
column.classList.add("board-drop-invalid");
|
|
343
|
+
column.classList.remove("board-drop-valid");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function handleDragleave(event) {
|
|
348
|
+
const column = event.target.closest("[data-drop-status]");
|
|
349
|
+
if (column && !column.contains(event.relatedTarget)) {
|
|
350
|
+
column.classList.remove("board-drop-valid", "board-drop-invalid");
|
|
298
351
|
}
|
|
299
352
|
}
|
|
300
353
|
|
|
@@ -309,7 +362,18 @@ export function createDelegation(rootElement, actions) {
|
|
|
309
362
|
event.dataTransfer?.getData("text/task-id") ||
|
|
310
363
|
event.dataTransfer?.getData("text/plain");
|
|
311
364
|
const nextStatus = column.dataset.dropStatus;
|
|
365
|
+
|
|
366
|
+
if (draggedTaskStatus && !isValidTransition(draggedTaskStatus, nextStatus)) {
|
|
367
|
+
cleanupDragFeedback();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
312
371
|
actions.dropTaskStatus(taskId, nextStatus);
|
|
372
|
+
cleanupDragFeedback();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function handleDragend() {
|
|
376
|
+
cleanupDragFeedback();
|
|
313
377
|
}
|
|
314
378
|
|
|
315
379
|
// ---------------------------------------------------------------------------
|
|
@@ -321,7 +385,9 @@ export function createDelegation(rootElement, actions) {
|
|
|
321
385
|
rootElement.addEventListener("submit", handleSubmit);
|
|
322
386
|
rootElement.addEventListener("dragstart", handleDragstart);
|
|
323
387
|
rootElement.addEventListener("dragover", handleDragover);
|
|
388
|
+
rootElement.addEventListener("dragleave", handleDragleave);
|
|
324
389
|
rootElement.addEventListener("drop", handleDrop);
|
|
390
|
+
rootElement.addEventListener("dragend", handleDragend);
|
|
325
391
|
rootElement.addEventListener("keydown", handleDelegatedKeydown);
|
|
326
392
|
window.addEventListener("keydown", handleKeydown);
|
|
327
393
|
|
|
@@ -335,7 +401,9 @@ export function createDelegation(rootElement, actions) {
|
|
|
335
401
|
rootElement.removeEventListener("submit", handleSubmit);
|
|
336
402
|
rootElement.removeEventListener("dragstart", handleDragstart);
|
|
337
403
|
rootElement.removeEventListener("dragover", handleDragover);
|
|
404
|
+
rootElement.removeEventListener("dragleave", handleDragleave);
|
|
338
405
|
rootElement.removeEventListener("drop", handleDrop);
|
|
406
|
+
rootElement.removeEventListener("dragend", handleDragend);
|
|
339
407
|
rootElement.removeEventListener("keydown", handleDelegatedKeydown);
|
|
340
408
|
window.removeEventListener("keydown", handleKeydown);
|
|
341
409
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { copyTextToClipboard } from "../runtime/clipboard.js";
|
|
2
|
-
import { orderEpicsNewestFirst } from "./store.js";
|
|
2
|
+
import { DEFAULT_STATUS_FILTER, orderEpicsNewestFirst } from "./store.js";
|
|
3
3
|
|
|
4
4
|
function cloneSnapshot(snapshot) {
|
|
5
5
|
if (typeof structuredClone === "function") {
|
|
@@ -404,6 +404,10 @@ export function createBoardActions(options) {
|
|
|
404
404
|
removeDependency(sourceId, dependsOnId) {
|
|
405
405
|
api.removeDependency(sourceId, dependsOnId, (snapshot) => removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
|
|
406
406
|
},
|
|
407
|
+
getTaskStatus(taskId) {
|
|
408
|
+
const task = getTaskById(taskId);
|
|
409
|
+
return task?.status ?? null;
|
|
410
|
+
},
|
|
407
411
|
dropTaskStatus(taskId, nextStatus) {
|
|
408
412
|
const task = getTaskById(taskId);
|
|
409
413
|
if (!task || !nextStatus || task.status === nextStatus) {
|
|
@@ -426,6 +430,28 @@ export function createBoardActions(options) {
|
|
|
426
430
|
cascadeEpicStatusInSnapshot(snapshot, epicId, normalizedStatus, normalizeSnapshot),
|
|
427
431
|
);
|
|
428
432
|
},
|
|
433
|
+
toggleEpicStatusFilter(status) {
|
|
434
|
+
const current = store.epicStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
435
|
+
store.epicStatusFilter = { ...current, [status]: !current[status] };
|
|
436
|
+
persist();
|
|
437
|
+
rerender();
|
|
438
|
+
},
|
|
439
|
+
toggleTaskStatusFilter(status) {
|
|
440
|
+
const current = store.taskStatusFilter || { ...DEFAULT_STATUS_FILTER };
|
|
441
|
+
store.taskStatusFilter = { ...current, [status]: !current[status] };
|
|
442
|
+
persist();
|
|
443
|
+
rerender();
|
|
444
|
+
},
|
|
445
|
+
resetEpicFilter() {
|
|
446
|
+
store.epicStatusFilter = { ...DEFAULT_STATUS_FILTER };
|
|
447
|
+
persist();
|
|
448
|
+
rerender();
|
|
449
|
+
},
|
|
450
|
+
resetTaskFilter() {
|
|
451
|
+
store.taskStatusFilter = { ...DEFAULT_STATUS_FILTER };
|
|
452
|
+
persist();
|
|
453
|
+
rerender();
|
|
454
|
+
},
|
|
429
455
|
handleKeydown(event) {
|
|
430
456
|
const boardState = getBoardState();
|
|
431
457
|
const activeElement = document.activeElement;
|
|
@@ -9,6 +9,18 @@ function normalizeSearch(value) {
|
|
|
9
9
|
|
|
10
10
|
// --- Persistence helpers ---
|
|
11
11
|
|
|
12
|
+
export const DEFAULT_STATUS_FILTER = { todo: true, blocked: true, in_progress: true, done: false };
|
|
13
|
+
|
|
14
|
+
function readStatusFilter(raw) {
|
|
15
|
+
if (typeof raw !== "object" || raw === null) return { ...DEFAULT_STATUS_FILTER };
|
|
16
|
+
return {
|
|
17
|
+
todo: typeof raw.todo === "boolean" ? raw.todo : DEFAULT_STATUS_FILTER.todo,
|
|
18
|
+
blocked: typeof raw.blocked === "boolean" ? raw.blocked : DEFAULT_STATUS_FILTER.blocked,
|
|
19
|
+
in_progress: typeof raw.in_progress === "boolean" ? raw.in_progress : DEFAULT_STATUS_FILTER.in_progress,
|
|
20
|
+
done: typeof raw.done === "boolean" ? raw.done : DEFAULT_STATUS_FILTER.done,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
export function readStoredState() {
|
|
13
25
|
try {
|
|
14
26
|
return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}");
|
|
@@ -77,13 +89,25 @@ export function orderEpicsNewestFirst(epics) {
|
|
|
77
89
|
|
|
78
90
|
// --- Derived state selectors ---
|
|
79
91
|
|
|
92
|
+
/** Recently-done epics stay visible for 24h even when the "done" filter is off. */
|
|
93
|
+
const DONE_GRACE_PERIOD_MS = 86400000;
|
|
94
|
+
|
|
80
95
|
const selectVisibleEpics = createSelector(
|
|
81
|
-
(s) => [s.snapshot?.epics, s.searchQuery],
|
|
82
|
-
(epics, searchQuery) => {
|
|
96
|
+
(s) => [s.snapshot?.epics, s.searchQuery, s.epicStatusFilter],
|
|
97
|
+
(epics, searchQuery, epicStatusFilter) => {
|
|
83
98
|
if (!epics) return [];
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const filtered = epics.filter((epic) => {
|
|
101
|
+
if (epic.status === "done") {
|
|
102
|
+
if (!epicStatusFilter.done && (now - epic.updatedAt) > DONE_GRACE_PERIOD_MS) return false;
|
|
103
|
+
} else if (!epicStatusFilter[epic.status]) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
84
108
|
const matchingEpics = searchQuery.length === 0
|
|
85
|
-
?
|
|
86
|
-
:
|
|
109
|
+
? filtered
|
|
110
|
+
: filtered.filter((epic) => epic.searchText.includes(searchQuery));
|
|
87
111
|
|
|
88
112
|
return orderEpicsNewestFirst(matchingEpics);
|
|
89
113
|
},
|
|
@@ -100,11 +124,12 @@ const selectTasksInScope = createSelector(
|
|
|
100
124
|
);
|
|
101
125
|
|
|
102
126
|
const selectVisibleTasks = createSelector(
|
|
103
|
-
(s) => [selectTasksInScope(s), s.searchQuery],
|
|
104
|
-
(tasksInScope, searchQuery) => {
|
|
127
|
+
(s) => [selectTasksInScope(s), s.searchQuery, s.taskStatusFilter],
|
|
128
|
+
(tasksInScope, searchQuery, taskStatusFilter) => {
|
|
129
|
+
const filtered = tasksInScope.filter((task) => taskStatusFilter[task.status]);
|
|
105
130
|
return searchQuery.length === 0
|
|
106
|
-
?
|
|
107
|
-
:
|
|
131
|
+
? filtered
|
|
132
|
+
: filtered.filter((task) => task.searchText.includes(searchQuery));
|
|
108
133
|
},
|
|
109
134
|
);
|
|
110
135
|
|
|
@@ -287,6 +312,8 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
287
312
|
notice: null,
|
|
288
313
|
isMutating: false,
|
|
289
314
|
notesPanelOpen: storedState.notesPanelOpen === true,
|
|
315
|
+
epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
|
|
316
|
+
taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
|
|
290
317
|
};
|
|
291
318
|
|
|
292
319
|
/** @type {Set<(state: object) => void>} */
|
|
@@ -306,6 +333,8 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
306
333
|
view: state.view,
|
|
307
334
|
selectedTaskId: state.selectedTaskId,
|
|
308
335
|
notesPanelOpen: state.notesPanelOpen,
|
|
336
|
+
epicStatusFilter: state.epicStatusFilter,
|
|
337
|
+
taskStatusFilter: state.taskStatusFilter,
|
|
309
338
|
});
|
|
310
339
|
}
|
|
311
340
|
|
|
@@ -220,3 +220,45 @@ export function escapeHtml(value) {
|
|
|
220
220
|
.replaceAll(">", ">")
|
|
221
221
|
.replaceAll('"', """);
|
|
222
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Valid status transitions mirroring the backend state machine (src/domain/types.ts).
|
|
226
|
+
* @type {Map<string, Set<string>>}
|
|
227
|
+
*/
|
|
228
|
+
export const VALID_TRANSITIONS = new Map([
|
|
229
|
+
["todo", new Set(["in_progress", "blocked"])],
|
|
230
|
+
["in_progress", new Set(["done", "blocked"])],
|
|
231
|
+
["blocked", new Set(["in_progress", "todo"])],
|
|
232
|
+
["done", new Set(["in_progress"])],
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get the list of statuses a node can transition to from its current status.
|
|
237
|
+
* @param {string} currentStatus
|
|
238
|
+
* @returns {string[]}
|
|
239
|
+
*/
|
|
240
|
+
export function getValidTargets(currentStatus) {
|
|
241
|
+
const targets = VALID_TRANSITIONS.get(currentStatus);
|
|
242
|
+
return targets ? Array.from(targets) : [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check whether transitioning from one status to another is valid.
|
|
247
|
+
* @param {string} from
|
|
248
|
+
* @param {string} to
|
|
249
|
+
* @returns {boolean}
|
|
250
|
+
*/
|
|
251
|
+
export function isValidTransition(from, to) {
|
|
252
|
+
const targets = VALID_TRANSITIONS.get(from);
|
|
253
|
+
return targets ? targets.has(to) : false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Return the current status plus all valid targets, useful for populating
|
|
258
|
+
* status select dropdowns.
|
|
259
|
+
* @param {string} currentStatus
|
|
260
|
+
* @returns {string[]}
|
|
261
|
+
*/
|
|
262
|
+
export function getSelectableStatuses(currentStatus) {
|
|
263
|
+
return [currentStatus, ...getValidTargets(currentStatus)];
|
|
264
|
+
}
|
|
@@ -1929,3 +1929,71 @@ textarea,
|
|
|
1929
1929
|
.\!px-2 { padding-inline: 0.5rem !important; }
|
|
1930
1930
|
.\!text-xs { font-size: 0.75rem !important; line-height: 1rem !important; }
|
|
1931
1931
|
.\!min-h-0 { min-height: 0 !important; }
|
|
1932
|
+
|
|
1933
|
+
/* --- Drag-and-drop transition feedback --- */
|
|
1934
|
+
.board-drop-valid {
|
|
1935
|
+
outline: 2px solid var(--board-accent);
|
|
1936
|
+
outline-offset: -2px;
|
|
1937
|
+
background: rgba(var(--board-accent-rgb, 99, 102, 241), 0.06);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
.board-drop-invalid {
|
|
1941
|
+
outline: 2px dashed rgba(239, 68, 68, 0.4);
|
|
1942
|
+
outline-offset: -2px;
|
|
1943
|
+
opacity: 0.6;
|
|
1944
|
+
cursor: not-allowed;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/* --- Filter pills --- */
|
|
1948
|
+
.board-filter-bar {
|
|
1949
|
+
display: flex;
|
|
1950
|
+
flex-wrap: wrap;
|
|
1951
|
+
align-items: center;
|
|
1952
|
+
gap: 0.375rem;
|
|
1953
|
+
margin-top: 0.75rem;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
.board-filter-pill {
|
|
1957
|
+
display: inline-flex;
|
|
1958
|
+
align-items: center;
|
|
1959
|
+
gap: 0.25rem;
|
|
1960
|
+
padding: 0.25rem 0.75rem;
|
|
1961
|
+
border-radius: 9999px;
|
|
1962
|
+
border: 1px solid var(--board-border);
|
|
1963
|
+
background: var(--board-surface-2);
|
|
1964
|
+
color: var(--board-text-muted);
|
|
1965
|
+
font-size: 0.6875rem;
|
|
1966
|
+
font-weight: 600;
|
|
1967
|
+
text-transform: uppercase;
|
|
1968
|
+
letter-spacing: 0.14em;
|
|
1969
|
+
cursor: pointer;
|
|
1970
|
+
transition: opacity 0.2s, border-color 0.2s, background 0.2s;
|
|
1971
|
+
user-select: none;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
.board-filter-pill:hover {
|
|
1975
|
+
border-color: var(--board-border-strong);
|
|
1976
|
+
background: rgba(255, 255, 255, 0.08);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
.board-filter-pill:focus-visible {
|
|
1980
|
+
outline: none;
|
|
1981
|
+
box-shadow: 0 0 0 2px var(--board-border-strong);
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
.board-filter-pill--active {
|
|
1985
|
+
opacity: 1;
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
.board-filter-pill--inactive {
|
|
1989
|
+
opacity: 0.4;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
.board-filter-pill--reset {
|
|
1993
|
+
color: var(--board-accent);
|
|
1994
|
+
border-color: var(--board-accent);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
.board-filter-pill--reset:hover {
|
|
1998
|
+
background: rgba(var(--board-accent-rgb, 99, 102, 241), 0.1);
|
|
1999
|
+
}
|
package/src/board/routes.ts
CHANGED
|
@@ -229,6 +229,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
229
229
|
title: readOptionalString(body, "title"),
|
|
230
230
|
description: readOptionalString(body, "description"),
|
|
231
231
|
status: readOptionalString(body, "status"),
|
|
232
|
+
owner: readOptionalString(body, "owner"),
|
|
232
233
|
});
|
|
233
234
|
return buildMutationResponse(domain, { task });
|
|
234
235
|
}
|
|
@@ -240,6 +241,7 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
|
|
|
240
241
|
title: readOptionalString(body, "title"),
|
|
241
242
|
description: readOptionalString(body, "description"),
|
|
242
243
|
status: readOptionalString(body, "status"),
|
|
244
|
+
owner: readOptionalString(body, "owner"),
|
|
243
245
|
});
|
|
244
246
|
return buildMutationResponse(domain, { subtask });
|
|
245
247
|
}
|
package/src/commands/epic.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
suggestOptions,
|
|
19
19
|
} from "./arg-parser";
|
|
20
20
|
import { unexpectedFailureResult } from "./error-utils";
|
|
21
|
+
import { buildTaskReadiness } from "./task-readiness";
|
|
21
22
|
|
|
22
23
|
import { MutationService } from "../domain/mutation-service";
|
|
23
24
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
@@ -44,7 +45,7 @@ function formatEpic(epic: EpicRecord): string {
|
|
|
44
45
|
const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
45
46
|
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
46
47
|
const DEFAULT_LIST_LIMIT = 10;
|
|
47
|
-
const DEFAULT_OPEN_STATUSES = ["in_progress", "
|
|
48
|
+
const DEFAULT_OPEN_STATUSES = ["in_progress", "todo"] as const;
|
|
48
49
|
const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
|
|
49
50
|
const LIST_OPTIONS = ["status", "s", "limit", "l", "cursor", "all", "view"] as const;
|
|
50
51
|
const SHOW_OPTIONS = ["view", "all"] as const;
|
|
@@ -115,7 +116,7 @@ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage:
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
function getStatusPriority(status: string): number {
|
|
118
|
-
if (status === "in_progress"
|
|
119
|
+
if (status === "in_progress") {
|
|
119
120
|
return 0;
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -1391,6 +1392,76 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
1391
1392
|
data: { epic },
|
|
1392
1393
|
});
|
|
1393
1394
|
}
|
|
1395
|
+
case "progress": {
|
|
1396
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
1397
|
+
if (epicId.length === 0) {
|
|
1398
|
+
return failResult({
|
|
1399
|
+
command: "epic.progress",
|
|
1400
|
+
human: "Provide an epic id. Usage: trekoon epic progress <epic-id>",
|
|
1401
|
+
data: { code: "invalid_input" },
|
|
1402
|
+
error: {
|
|
1403
|
+
code: "invalid_input",
|
|
1404
|
+
message: "Missing epic id",
|
|
1405
|
+
},
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const epic = domain.getEpic(epicId);
|
|
1410
|
+
if (!epic) {
|
|
1411
|
+
return failResult({
|
|
1412
|
+
command: "epic.progress",
|
|
1413
|
+
human: `Epic not found: ${epicId}`,
|
|
1414
|
+
data: { code: "not_found", id: epicId },
|
|
1415
|
+
error: {
|
|
1416
|
+
code: "not_found",
|
|
1417
|
+
message: `Epic not found: ${epicId}`,
|
|
1418
|
+
},
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
const allTasks = domain.listTasks(epicId);
|
|
1423
|
+
let doneCount = 0;
|
|
1424
|
+
let inProgressCount = 0;
|
|
1425
|
+
let blockedCount = 0;
|
|
1426
|
+
let todoCount = 0;
|
|
1427
|
+
for (const t of allTasks) {
|
|
1428
|
+
if (t.status === "done") doneCount += 1;
|
|
1429
|
+
else if (t.status === "in_progress") inProgressCount += 1;
|
|
1430
|
+
else if (t.status === "blocked") blockedCount += 1;
|
|
1431
|
+
else if (t.status === "todo") todoCount += 1;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
const readiness = buildTaskReadiness(domain, epicId);
|
|
1435
|
+
const readyCount = readiness.summary.readyCount;
|
|
1436
|
+
const nextCandidate = readiness.candidates[0] ?? null;
|
|
1437
|
+
|
|
1438
|
+
const nextTask = nextCandidate !== null
|
|
1439
|
+
? { id: nextCandidate.task.id, title: nextCandidate.task.title }
|
|
1440
|
+
: null;
|
|
1441
|
+
|
|
1442
|
+
const total = allTasks.length;
|
|
1443
|
+
let human = `Epic: ${epic.title}\n`;
|
|
1444
|
+
human += `Total: ${total}, Done: ${doneCount}, In Progress: ${inProgressCount}, Blocked: ${blockedCount}, Todo: ${todoCount}, Ready: ${readyCount}`;
|
|
1445
|
+
if (nextTask !== null) {
|
|
1446
|
+
human += `\nNext candidate: ${nextTask.id} | ${nextTask.title}`;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return okResult({
|
|
1450
|
+
command: "epic.progress",
|
|
1451
|
+
human,
|
|
1452
|
+
data: {
|
|
1453
|
+
epicId: epic.id,
|
|
1454
|
+
title: epic.title,
|
|
1455
|
+
total,
|
|
1456
|
+
doneCount,
|
|
1457
|
+
inProgressCount,
|
|
1458
|
+
blockedCount,
|
|
1459
|
+
todoCount,
|
|
1460
|
+
readyCount,
|
|
1461
|
+
nextCandidate: nextTask,
|
|
1462
|
+
},
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1394
1465
|
case "delete": {
|
|
1395
1466
|
const epicId: string = parsed.positional[1] ?? "";
|
|
1396
1467
|
mutations.deleteEpic(epicId);
|
|
@@ -1404,7 +1475,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
|
1404
1475
|
default:
|
|
1405
1476
|
return failResult({
|
|
1406
1477
|
command: "epic",
|
|
1407
|
-
human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
|
|
1478
|
+
human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress>",
|
|
1408
1479
|
data: {
|
|
1409
1480
|
args: context.args,
|
|
1410
1481
|
},
|