summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator.local>2026-07-06 01:29:45 +0000
committerClaudomator Agent <agent@claudomator.local>2026-07-06 01:29:45 +0000
commit54be094e65ffafe69e3490cbe9b5ae37d1fa927d (patch)
treefce0a7ced2f50b7c6aa9670b9d37ac48bbe04873
parent08d4591c81e6d4d72424df8cbb70c8456dd615a5 (diff)
feat(web): add Tasks board column model (TASK_COLUMNS, columnForTaskState, groupTasksByColumn)
Pure logic layer for the new Tasks tab Kanban board: a 5-column partition of all 10 task states (Queue/Running/Ready/Interrupted/Done) with per-column sort directions (Queue+Ready oldest-first, Running+Interrupted+Done newest-first). Also fixes 2 pre-existing stale assertions in tab-persistence.test.mjs (default tab is 'stories', not 'queue', since cc6b323). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--web/app.js54
-rw-r--r--web/test/tab-persistence.test.mjs8
-rw-r--r--web/test/tasks-board.test.mjs129
3 files changed, 187 insertions, 4 deletions
diff --git a/web/app.js b/web/app.js
index 2f5aacf..9cd3100 100644
--- a/web/app.js
+++ b/web/app.js
@@ -3350,6 +3350,60 @@ export function groupStoriesByColumn(stories) {
return groups;
}
+// ---------------------------------------------------------------------------
+// Tasks board — column model
+// ---------------------------------------------------------------------------
+
+export const TASK_COLUMNS = [
+ { key: 'queue', label: 'Queue', states: ['PENDING', 'QUEUED'] },
+ { key: 'running', label: 'Running', states: ['RUNNING', 'BLOCKED'] },
+ { key: 'ready', label: 'Ready', states: ['READY'] },
+ { key: 'interrupted', label: 'Interrupted', states: ['FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED'] },
+ { key: 'done', label: 'Done', states: ['COMPLETED'] },
+];
+
+// columnForTaskState returns the column key for a given task state, falling
+// back to 'queue' for an empty/unrecognized state so a task is never dropped
+// off the board entirely.
+export function columnForTaskState(state) {
+ const col = TASK_COLUMNS.find(c => c.states.includes(state));
+ return col ? col.key : 'queue';
+}
+
+// Sort directions are per-column, matching the semantics the old (removed)
+// panels used:
+// queue — oldest-first (FIFO)
+// ready — oldest-first (longest-waiting surfaces first)
+// interrupted — newest-first (most recent failure is most urgent)
+// done — newest-first (most recently completed is most relevant)
+// running — newest-first (new-but-inconsequential default)
+const TASK_COLUMN_SORT = {
+ queue: (a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0),
+ running: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0),
+ ready: (a, b) => new Date(a.created_at || 0) - new Date(b.created_at || 0),
+ interrupted: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0),
+ done: (a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0),
+};
+
+// groupTasksByColumn returns { [columnKey]: task[] }, each sub-array sorted
+// per the per-column sort direction above.
+export function groupTasksByColumn(tasks) {
+ const groups = {};
+ for (const col of TASK_COLUMNS) groups[col.key] = [];
+ for (const t of tasks || []) {
+ const key = columnForTaskState(t.state);
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(t);
+ }
+ for (const col of TASK_COLUMNS) {
+ const cmp = TASK_COLUMN_SORT[col.key];
+ if (cmp) groups[col.key].sort(cmp);
+ }
+ return groups;
+}
+
+// ---------------------------------------------------------------------------
+
// A story "has reached validation" once evaluator verdicts could plausibly
// exist for it — VALIDATING or later in the lifecycle (see StoryOrchestrator
// stage 2 in CLAUDE.md). Used to gate the eval-verdict indicator so we don't
diff --git a/web/test/tab-persistence.test.mjs b/web/test/tab-persistence.test.mjs
index 9311453..972723c 100644
--- a/web/test/tab-persistence.test.mjs
+++ b/web/test/tab-persistence.test.mjs
@@ -20,8 +20,8 @@ import { getActiveMainTab, setActiveMainTab } from '../app.js';
describe('getActiveMainTab', () => {
beforeEach(() => store.clear());
- it('returns "queue" when localStorage has no stored value', () => {
- assert.equal(getActiveMainTab(), 'queue');
+ it('returns "stories" when localStorage has no stored value', () => {
+ assert.equal(getActiveMainTab(), 'stories');
});
it('returns the tab name stored by setActiveMainTab', () => {
@@ -29,10 +29,10 @@ describe('getActiveMainTab', () => {
assert.equal(getActiveMainTab(), 'settings');
});
- it('returns "queue" after localStorage value is removed', () => {
+ it('returns "stories" after localStorage value is removed', () => {
setActiveMainTab('stats');
localStorage.removeItem('activeMainTab');
- assert.equal(getActiveMainTab(), 'queue');
+ assert.equal(getActiveMainTab(), 'stories');
});
it('reflects the most recent setActiveMainTab call', () => {
diff --git a/web/test/tasks-board.test.mjs b/web/test/tasks-board.test.mjs
new file mode 100644
index 0000000..85bb04a
--- /dev/null
+++ b/web/test/tasks-board.test.mjs
@@ -0,0 +1,129 @@
+// tasks-board.test.mjs — Unit tests for the Tasks tab's pure column logic:
+// column-mapping completeness, fallback-state behaviour, and per-column
+// grouping/sorting correctness.
+//
+// Run with: node --test web/test/tasks-board.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ TASK_COLUMNS,
+ columnForTaskState,
+ groupTasksByColumn,
+} from '../app.js';
+
+describe('columnForTaskState', () => {
+ it('maps every documented task state to a column', () => {
+ assert.equal(columnForTaskState('PENDING'), 'queue');
+ assert.equal(columnForTaskState('QUEUED'), 'queue');
+ assert.equal(columnForTaskState('RUNNING'), 'running');
+ assert.equal(columnForTaskState('BLOCKED'), 'running');
+ assert.equal(columnForTaskState('READY'), 'ready');
+ assert.equal(columnForTaskState('FAILED'), 'interrupted');
+ assert.equal(columnForTaskState('TIMED_OUT'), 'interrupted');
+ assert.equal(columnForTaskState('CANCELLED'), 'interrupted');
+ assert.equal(columnForTaskState('BUDGET_EXCEEDED'), 'interrupted');
+ assert.equal(columnForTaskState('COMPLETED'), 'done');
+ });
+
+ it('falls back to queue for an unrecognized/empty state', () => {
+ assert.equal(columnForTaskState(''), 'queue');
+ assert.equal(columnForTaskState(undefined), 'queue');
+ assert.equal(columnForTaskState('SOME_FUTURE_STATE'), 'queue');
+ });
+
+ it('every column key is unique and covers all 10 task states', () => {
+ const keys = TASK_COLUMNS.map(c => c.key);
+ assert.equal(new Set(keys).size, keys.length, 'column keys must be unique');
+ const allStates = TASK_COLUMNS.flatMap(c => c.states);
+ for (const s of [
+ 'PENDING', 'QUEUED', 'RUNNING', 'BLOCKED', 'READY',
+ 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'COMPLETED',
+ ]) {
+ assert.ok(allStates.includes(s), `${s} missing from any column`);
+ }
+ });
+});
+
+describe('groupTasksByColumn', () => {
+ it('returns an object with a key per column, all initially empty arrays', () => {
+ const groups = groupTasksByColumn([]);
+ for (const col of TASK_COLUMNS) {
+ assert.ok(Array.isArray(groups[col.key]), `groups.${col.key} should be an array`);
+ assert.equal(groups[col.key].length, 0);
+ }
+ });
+
+ it('places tasks in the correct column', () => {
+ const tasks = [
+ { id: '1', state: 'PENDING', created_at: '2024-01-01T00:00:00Z' },
+ { id: '2', state: 'RUNNING', created_at: '2024-01-02T00:00:00Z' },
+ { id: '3', state: 'READY', created_at: '2024-01-03T00:00:00Z' },
+ { id: '4', state: 'FAILED', created_at: '2024-01-04T00:00:00Z' },
+ { id: '5', state: 'COMPLETED', created_at: '2024-01-05T00:00:00Z' },
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.equal(groups.queue.length, 1);
+ assert.equal(groups.queue[0].id, '1');
+ assert.equal(groups.running.length, 1);
+ assert.equal(groups.running[0].id, '2');
+ assert.equal(groups.ready.length, 1);
+ assert.equal(groups.ready[0].id, '3');
+ assert.equal(groups.interrupted.length, 1);
+ assert.equal(groups.interrupted[0].id, '4');
+ assert.equal(groups.done.length, 1);
+ assert.equal(groups.done[0].id, '5');
+ });
+
+ it('sorts queue oldest-first (ascending created_at)', () => {
+ const tasks = [
+ { id: 'newer', state: 'QUEUED', created_at: '2024-02-01T00:00:00Z' },
+ { id: 'older', state: 'PENDING', created_at: '2024-01-01T00:00:00Z' },
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.queue.map(t => t.id), ['older', 'newer']);
+ });
+
+ it('sorts ready oldest-first (ascending created_at)', () => {
+ const tasks = [
+ { id: 'newer', state: 'READY', created_at: '2024-02-01T00:00:00Z' },
+ { id: 'older', state: 'READY', created_at: '2024-01-01T00:00:00Z' },
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.ready.map(t => t.id), ['older', 'newer']);
+ });
+
+ it('sorts interrupted newest-first (descending created_at)', () => {
+ const tasks = [
+ { id: 'older', state: 'FAILED', created_at: '2024-01-01T00:00:00Z' },
+ { id: 'newer', state: 'CANCELLED', created_at: '2024-02-01T00:00:00Z' },
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.interrupted.map(t => t.id), ['newer', 'older']);
+ });
+
+ it('sorts done newest-first (descending created_at)', () => {
+ const tasks = [
+ { id: 'older', state: 'COMPLETED', created_at: '2024-01-01T00:00:00Z' },
+ { id: 'newer', state: 'COMPLETED', created_at: '2024-02-01T00:00:00Z' },
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.done.map(t => t.id), ['newer', 'older']);
+ });
+
+ it('sorts running newest-first (descending created_at)', () => {
+ const tasks = [
+ { id: 'older', state: 'RUNNING', created_at: '2024-01-01T00:00:00Z' },
+ { id: 'newer', state: 'BLOCKED', created_at: '2024-02-01T00:00:00Z' },
+ ];
+ const groups = groupTasksByColumn(tasks);
+ assert.deepEqual(groups.running.map(t => t.id), ['newer', 'older']);
+ });
+
+ it('handles null/undefined gracefully', () => {
+ const groups = groupTasksByColumn(null);
+ for (const col of TASK_COLUMNS) {
+ assert.equal(groups[col.key].length, 0);
+ }
+ });
+});