summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-13 09:31:31 +0000
committerClaudomator Agent <agent@claudomator>2026-03-13 09:31:31 +0000
commit03f8b0e8b1aef2429f825b300c427147c30d4b0b (patch)
tree7c7f689543178d0ae2d0e5b477df1caafb1c0291 /web
parentc602ddd799d94bf3bbd35a57b98ad09e28df8ee9 (diff)
feat: reorganize web UI to 7-tab layout (Queue, Interrupted, Ready, Running, All, Stats, Settings)
- Replace Tasks/Active tabs with Queue (QUEUED+PENDING), Interrupted, Ready top-level tabs - Add All tab (COMPLETED, TIMED_OUT, BUDGET_EXCEEDED within last 24h) and Settings placeholder - Export filterQueueTasks, filterReadyTasks, filterAllDoneTasks from app.js - Refactor poll() to dispatch to active tab's render function instead of always rendering all panels - Add renderQueuePanel, renderInterruptedPanel, renderReadyPanel, renderAllPanel helpers - Add tests in web/test/tab-filters.test.mjs covering all new filter functions (16 tests) - All 165 JS tests and all Go tests pass Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js179
-rw-r--r--web/index.html32
-rw-r--r--web/style.css2
-rw-r--r--web/test/tab-filters.test.mjs126
4 files changed, 241 insertions, 98 deletions
diff --git a/web/app.js b/web/app.js
index 187795f..afb6a75 100644
--- a/web/app.js
+++ b/web/app.js
@@ -296,6 +296,29 @@ export function filterTasksByTab(tasks, tab) {
return tasks;
}
+// Returns tasks with state QUEUED or PENDING.
+export function filterQueueTasks(tasks) {
+ return tasks.filter(t => t.state === 'QUEUED' || t.state === 'PENDING');
+}
+
+// Returns tasks with state READY.
+export function filterReadyTasks(tasks) {
+ return tasks.filter(t => t.state === 'READY');
+}
+
+// Returns COMPLETED, TIMED_OUT, BUDGET_EXCEEDED tasks from last 24h.
+// Pass since24h=false to disable the time filter.
+export function filterAllDoneTasks(tasks, since24h = true) {
+ const DONE_TAB_STATES = new Set(['COMPLETED', 'TIMED_OUT', 'BUDGET_EXCEEDED']);
+ return tasks.filter(t => {
+ if (!DONE_TAB_STATES.has(t.state)) return false;
+ if (!since24h) return true;
+ if (!t.created_at) return true; // defensive: keep if no date
+ const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
+ return new Date(t.created_at) > twentyFourHoursAgo;
+ });
+}
+
export function getTaskFilterTab() {
return localStorage.getItem('taskFilterTab') ?? 'active';
}
@@ -379,41 +402,44 @@ function updateToggleButton() {
: 'Hide completed/failed';
}
-function renderTaskList(tasks) {
- const container = document.querySelector('.task-list');
-
+// Shared helper: renders an array of tasks as cards into a container element.
+function renderTasksIntoContainer(tasks, container, emptyMsg) {
if (!tasks || tasks.length === 0) {
- container.innerHTML = '<div id="loading">No tasks found.</div>';
+ container.innerHTML = `<div class="task-empty">${emptyMsg}</div>`;
return;
}
-
- const tab = getTaskFilterTab();
- const descend = (tab === 'done' || tab === 'interrupted');
- const visible = sortTasksByDate(filterTasksByTab(tasks, tab), descend);
-
- // Replace contents with task cards
container.innerHTML = '';
- for (const task of visible) {
+ for (const task of tasks) {
container.appendChild(createTaskCard(task));
}
}
-function renderActiveTaskList(tasks) {
- const container = document.querySelector('.active-task-list');
+function renderQueuePanel(tasks) {
+ const container = document.querySelector('[data-panel="queue"] .panel-task-list');
if (!container) return;
- if (!tasks || tasks.length === 0) {
- container.innerHTML = '<div id="loading">No active tasks.</div>';
- return;
- }
- const active = sortTasksByDate(filterActiveTasks(tasks));
- container.innerHTML = '';
- if (active.length === 0) {
- container.innerHTML = '<div id="loading">No active tasks.</div>';
- return;
- }
- for (const task of active) {
- container.appendChild(createTaskCard(task));
- }
+ const visible = sortTasksByDate(filterQueueTasks(tasks));
+ renderTasksIntoContainer(visible, container, 'No tasks queued.');
+}
+
+function renderInterruptedPanel(tasks) {
+ const container = document.querySelector('[data-panel="interrupted"] .panel-task-list');
+ if (!container) return;
+ const visible = sortTasksByDate(tasks.filter(t => INTERRUPTED_STATES.has(t.state)), true);
+ renderTasksIntoContainer(visible, container, 'No interrupted tasks.');
+}
+
+function renderReadyPanel(tasks) {
+ const container = document.querySelector('[data-panel="ready"] .panel-task-list');
+ if (!container) return;
+ const visible = sortTasksByDate(filterReadyTasks(tasks));
+ renderTasksIntoContainer(visible, container, 'No tasks awaiting review.');
+}
+
+function renderAllPanel(tasks) {
+ const container = document.querySelector('[data-panel="all"] .all-history');
+ if (!container) return;
+ const visible = sortTasksByDate(filterAllDoneTasks(tasks), true);
+ renderTasksIntoContainer(visible, container, 'No completed tasks in the last 24h.');
}
// ── Run action ────────────────────────────────────────────────────────────────
@@ -438,9 +464,8 @@ async function handleRun(taskId, btn, footer) {
try {
await runTask(taskId);
- // Refresh list immediately so state flips to QUEUED
- const tasks = await fetchTasks();
- renderTaskList(tasks);
+ // Refresh active panel so state flips to QUEUED
+ await poll();
} catch (err) {
btn.disabled = false;
btn.textContent = 'Run';
@@ -871,34 +896,51 @@ async function handleStartNextTask(btn) {
// ── Polling ───────────────────────────────────────────────────────────────────
-function isStatsTabActive() {
- const panel = document.querySelector('[data-panel="stats"]');
- return panel && !panel.hasAttribute('hidden');
+function getActiveTab() {
+ const active = document.querySelector('.tab.active');
+ return active ? active.dataset.tab : 'queue';
}
async function poll() {
try {
const tasks = await fetchTasks();
if (isUserEditing()) return;
- renderTaskList(tasks);
- renderActiveTaskList(tasks);
- if (isRunningTabActive()) {
- renderRunningView(tasks);
- fetchRecentExecutions(BASE_PATH, fetch)
- .then(execs => renderRunningHistory(execs))
- .catch(() => {
- const histEl = document.querySelector('.running-history');
- if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
- });
- }
- if (isStatsTabActive()) {
- fetchRecentExecutions(BASE_PATH, fetch)
- .then(execs => renderStatsPanel(tasks, execs))
- .catch(() => {});
+
+ const activeTab = getActiveTab();
+ switch (activeTab) {
+ case 'queue':
+ renderQueuePanel(tasks);
+ break;
+ case 'interrupted':
+ renderInterruptedPanel(tasks);
+ break;
+ case 'ready':
+ renderReadyPanel(tasks);
+ break;
+ case 'running':
+ renderRunningView(tasks);
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderRunningHistory(execs))
+ .catch(() => {
+ const histEl = document.querySelector('.running-history');
+ if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
+ });
+ break;
+ case 'all':
+ renderAllPanel(tasks);
+ break;
+ case 'stats':
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderStatsPanel(tasks, execs))
+ .catch(() => {});
+ break;
+ case 'settings':
+ // nothing to render
+ break;
}
} catch {
- document.querySelector('.task-list').innerHTML =
- '<div id="loading">Could not reach server.</div>';
+ const panel = document.querySelector('[data-panel="queue"] .panel-task-list');
+ if (panel) panel.innerHTML = '<div class="task-empty">Could not reach server.</div>';
}
}
@@ -1232,8 +1274,7 @@ async function createTask(formData) {
}
closeTaskModal();
- const tasks = await fetchTasks();
- renderTaskList(tasks);
+ await poll();
}
// ── Task side panel ───────────────────────────────────────────────────────────
@@ -2201,48 +2242,18 @@ function switchTab(name) {
}
});
- if (name === 'running') {
- fetchTasks().then(renderRunningView).catch(() => {
- const currentEl = document.querySelector('.running-current');
- if (currentEl) currentEl.innerHTML = '<p class="task-meta">Could not reach server.</p>';
- });
- fetchRecentExecutions(BASE_PATH, fetch)
- .then(execs => renderRunningHistory(execs))
- .catch(() => {
- const histEl = document.querySelector('.running-history');
- if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
- });
- }
-
- if (name === 'stats') {
- Promise.all([
- fetchTasks(),
- fetchRecentExecutions(BASE_PATH, fetch),
- ]).then(([tasks, execs]) => renderStatsPanel(tasks, execs)).catch(() => {
- const panel = document.querySelector('[data-panel="stats"]');
- if (panel) panel.innerHTML = '<p class="task-meta">Could not load stats.</p>';
- });
- }
+ // Trigger immediate render for the newly active tab
+ poll();
}
// ── Boot ──────────────────────────────────────────────────────────────────────
if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => {
- updateFilterTabs();
-
- document.querySelectorAll(".filter-tab[data-filter]").forEach(btn => {
- btn.addEventListener("click", () => {
- setTaskFilterTab(btn.dataset.filter);
- updateFilterTabs();
- poll();
- });
- });
-
document.getElementById('btn-start-next').addEventListener('click', function() {
handleStartNextTask(this);
});
- switchTab('tasks');
+ switchTab('queue');
startPolling();
connectWebSocket();
diff --git a/web/index.html b/web/index.html
index 438216f..fd7228a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -15,31 +15,37 @@
<button id="btn-new-task" class="btn-primary">New Task</button>
</header>
<nav class="tab-bar">
- <button class="tab active" data-tab="tasks">Tasks</button>
- <button class="tab" data-tab="active">Active</button>
+ <button class="tab active" data-tab="queue">Queue</button>
+ <button class="tab" data-tab="interrupted">Interrupted</button>
+ <button class="tab" data-tab="ready">Ready</button>
<button class="tab" data-tab="running">Running</button>
+ <button class="tab" data-tab="all">All</button>
<button class="tab" data-tab="stats">Stats</button>
+ <button class="tab" data-tab="settings">Settings</button>
</nav>
<main id="app">
- <div data-panel="tasks">
- <div class="task-list-toolbar">
- <button class="filter-tab active" data-filter="active">Active</button>
- <button class="filter-tab" data-filter="interrupted">Interrupted</button>
- <button class="filter-tab" data-filter="done">Done</button>
- <button class="filter-tab" data-filter="all">All</button>
- </div>
- <div class="task-list">
- <div id="loading">Loading tasks…</div>
+ <div data-panel="queue">
+ <div class="panel-task-list">
+ <div class="task-empty">Loading…</div>
</div>
</div>
- <div data-panel="active" hidden>
- <div class="active-task-list"></div>
+ <div data-panel="interrupted" hidden>
+ <div class="panel-task-list"></div>
+ </div>
+ <div data-panel="ready" hidden>
+ <div class="panel-task-list"></div>
</div>
<div data-panel="running" hidden>
<div class="running-current"></div>
<div class="running-history"></div>
</div>
+ <div data-panel="all" hidden>
+ <div class="all-history"></div>
+ </div>
<div data-panel="stats" hidden></div>
+ <div data-panel="settings" hidden>
+ <p class="task-meta" style="padding:1rem">Settings coming soon.</p>
+ </div>
</main>
<dialog id="task-modal">
diff --git a/web/style.css b/web/style.css
index 9659ff5..b3ac4d7 100644
--- a/web/style.css
+++ b/web/style.css
@@ -153,7 +153,7 @@ main {
gap: 0.75rem;
}
-#loading {
+#loading, .task-empty {
color: var(--text-muted);
text-align: center;
padding: 2rem 0;
diff --git a/web/test/tab-filters.test.mjs b/web/test/tab-filters.test.mjs
new file mode 100644
index 0000000..f173521
--- /dev/null
+++ b/web/test/tab-filters.test.mjs
@@ -0,0 +1,126 @@
+// tab-filters.test.mjs — TDD tests for new tab filter functions
+//
+// Run with: node --test web/test/tab-filters.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { filterQueueTasks, filterReadyTasks, filterAllDoneTasks } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state, created_at = null) {
+ return { id: state, name: `task-${state}`, state, created_at };
+}
+
+const ALL_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED',
+ 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+// ── filterQueueTasks ──────────────────────────────────────────────────────────
+
+describe('filterQueueTasks', () => {
+ it('includes QUEUED tasks', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterQueueTasks(tasks);
+ assert.ok(result.some(t => t.state === 'QUEUED'), 'QUEUED should be included');
+ });
+
+ it('includes PENDING tasks', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterQueueTasks(tasks);
+ assert.ok(result.some(t => t.state === 'PENDING'), 'PENDING should be included');
+ });
+
+ it('excludes all other states', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterQueueTasks(tasks);
+ for (const state of ['RUNNING', 'READY', 'BLOCKED', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns only QUEUED and PENDING (length 2 from all states)', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterQueueTasks(tasks);
+ assert.equal(result.length, 2);
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterQueueTasks([]), []);
+ });
+});
+
+// ── filterReadyTasks ──────────────────────────────────────────────────────────
+
+describe('filterReadyTasks', () => {
+ it('includes READY tasks', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterReadyTasks(tasks);
+ assert.ok(result.some(t => t.state === 'READY'), 'READY should be included');
+ });
+
+ it('excludes all non-READY states', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterReadyTasks(tasks);
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'BLOCKED', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns only READY tasks (length 1 from all states)', () => {
+ const tasks = ALL_STATES.map(s => makeTask(s));
+ const result = filterReadyTasks(tasks);
+ assert.equal(result.length, 1);
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterReadyTasks([]), []);
+ });
+});
+
+// ── filterAllDoneTasks ────────────────────────────────────────────────────────
+
+describe('filterAllDoneTasks', () => {
+ it('includes COMPLETED tasks within 24h', () => {
+ const now = new Date().toISOString();
+ const result = filterAllDoneTasks([makeTask('COMPLETED', now)]);
+ assert.equal(result.length, 1);
+ });
+
+ it('includes TIMED_OUT tasks within 24h', () => {
+ const now = new Date().toISOString();
+ const result = filterAllDoneTasks([makeTask('TIMED_OUT', now)]);
+ assert.equal(result.length, 1);
+ });
+
+ it('includes BUDGET_EXCEEDED tasks within 24h', () => {
+ const now = new Date().toISOString();
+ const result = filterAllDoneTasks([makeTask('BUDGET_EXCEEDED', now)]);
+ assert.equal(result.length, 1);
+ });
+
+ it('excludes non-done states', () => {
+ const now = new Date().toISOString();
+ const tasks = ALL_STATES.map(s => makeTask(s, now));
+ const result = filterAllDoneTasks(tasks);
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED', 'FAILED', 'CANCELLED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('excludes COMPLETED tasks older than 24h by default', () => {
+ const longAgo = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
+ const result = filterAllDoneTasks([makeTask('COMPLETED', longAgo)]);
+ assert.equal(result.length, 0, 'should hide tasks older than 24h');
+ });
+
+ it('includes tasks with null created_at by default (defensive)', () => {
+ const result = filterAllDoneTasks([makeTask('COMPLETED', null)]);
+ assert.equal(result.length, 1);
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterAllDoneTasks([]), []);
+ });
+});