diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 42 | ||||
| -rw-r--r-- | web/index.html | 6 | ||||
| -rw-r--r-- | web/style.css | 19 | ||||
| -rw-r--r-- | web/test/tab-badges.test.mjs | 110 |
4 files changed, 174 insertions, 3 deletions
@@ -339,6 +339,46 @@ export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } +// ── Tab badge counts ─────────────────────────────────────────────────────────── + +/** + * Computes badge counts for the 'interrupted', 'ready', and 'running' tabs. + * Returns { interrupted: N, ready: N, running: N }. + */ +export function computeTabBadgeCounts(tasks) { + let interrupted = 0; + let ready = 0; + let running = 0; + for (const t of tasks) { + if (INTERRUPTED_STATES.has(t.state)) interrupted++; + if (t.state === 'READY') ready++; + if (t.state === 'RUNNING') running++; + } + return { interrupted, ready, running }; +} + +/** + * Updates the badge count spans inside the tab buttons for + * 'interrupted', 'ready', and 'running'. + * Badge is hidden (display:none) when count is zero. + */ +export function updateTabBadges(tasks, doc = (typeof document !== 'undefined' ? document : null)) { + if (!doc) return; + const counts = computeTabBadgeCounts(tasks); + for (const [tab, count] of Object.entries(counts)) { + const btn = doc.querySelector(`.tab[data-tab="${tab}"]`); + if (!btn) continue; + let badge = btn.querySelector('.tab-count-badge'); + if (!badge) { + badge = doc.createElement('span'); + badge.className = 'tab-count-badge'; + btn.appendChild(badge); + } + badge.textContent = String(count); + badge.hidden = count === 0; + } +} + // ── Stats computations ───────────────────────────────────────────────────────── /** @@ -961,6 +1001,8 @@ async function poll() { const tasks = await fetchTasks(); if (isUserEditing()) return; + updateTabBadges(tasks); + const activeTab = getActiveTab(); switch (activeTab) { case 'queue': diff --git a/web/index.html b/web/index.html index 19cba2c..59bc56e 100644 --- a/web/index.html +++ b/web/index.html @@ -23,9 +23,9 @@ </header> <nav class="tab-bar"> <button class="tab active" data-tab="queue" title="Queue">⏳</button> - <button class="tab" data-tab="interrupted" title="Interrupted">⚠️</button> - <button class="tab" data-tab="ready" title="Ready">✅</button> - <button class="tab" data-tab="running" title="Running">▶️</button> + <button class="tab" data-tab="interrupted" title="Interrupted">⚠️<span class="tab-count-badge" hidden></span></button> + <button class="tab" data-tab="ready" title="Ready">✅<span class="tab-count-badge" hidden></span></button> + <button class="tab" data-tab="running" title="Running">▶️<span class="tab-count-badge" hidden></span></button> <button class="tab" data-tab="all" title="All">☰</button> <button class="tab" data-tab="stats" title="Stats">📊</button> <button class="tab" data-tab="settings" title="Settings">⚙️</button> diff --git a/web/style.css b/web/style.css index ee1b69c..31f929e 100644 --- a/web/style.css +++ b/web/style.css @@ -111,6 +111,25 @@ header h1 { border-bottom-color: var(--accent); } +.tab-count-badge { + display: inline-block; + margin-left: 0.3em; + padding: 0 0.4em; + border-radius: 999px; + font-size: 0.7em; + font-weight: 700; + line-height: 1.5; + background: var(--accent); + color: #fff; + vertical-align: middle; + min-width: 1.4em; + text-align: center; +} + +.tab-count-badge[hidden] { + display: none; +} + /* Main layout */ main { max-width: 640px; diff --git a/web/test/tab-badges.test.mjs b/web/test/tab-badges.test.mjs new file mode 100644 index 0000000..c07338f --- /dev/null +++ b/web/test/tab-badges.test.mjs @@ -0,0 +1,110 @@ +// tab-badges.test.mjs — TDD tests for computeTabBadgeCounts +// +// Tests the pure function that computes badge counts for the +// 'interrupted', 'ready', and 'running' tabs. +// +// Run with: node --test web/test/tab-badges.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Inline implementation (will be replaced by import once exported) ─────────── + +const INTERRUPTED_STATES = new Set(['CANCELLED', 'FAILED', 'BUDGET_EXCEEDED', 'BLOCKED']); + +function computeTabBadgeCounts(tasks) { + let interrupted = 0; + let ready = 0; + let running = 0; + for (const t of tasks) { + if (INTERRUPTED_STATES.has(t.state)) interrupted++; + if (t.state === 'READY') ready++; + if (t.state === 'RUNNING') running++; + } + return { interrupted, ready, running }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeTask(state) { + return { id: state, name: `task-${state}`, state }; +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('computeTabBadgeCounts', () => { + it('returns all zeros for empty task list', () => { + assert.deepEqual(computeTabBadgeCounts([]), { interrupted: 0, ready: 0, running: 0 }); + }); + + it('counts RUNNING tasks', () => { + const tasks = [makeTask('RUNNING'), makeTask('RUNNING'), makeTask('QUEUED')]; + const counts = computeTabBadgeCounts(tasks); + assert.equal(counts.running, 2); + assert.equal(counts.ready, 0); + assert.equal(counts.interrupted, 0); + }); + + it('counts READY tasks', () => { + const tasks = [makeTask('READY'), makeTask('READY'), makeTask('QUEUED')]; + const counts = computeTabBadgeCounts(tasks); + assert.equal(counts.ready, 2); + assert.equal(counts.running, 0); + assert.equal(counts.interrupted, 0); + }); + + it('counts CANCELLED as interrupted', () => { + const counts = computeTabBadgeCounts([makeTask('CANCELLED')]); + assert.equal(counts.interrupted, 1); + }); + + it('counts FAILED as interrupted', () => { + const counts = computeTabBadgeCounts([makeTask('FAILED')]); + assert.equal(counts.interrupted, 1); + }); + + it('counts BUDGET_EXCEEDED as interrupted', () => { + const counts = computeTabBadgeCounts([makeTask('BUDGET_EXCEEDED')]); + assert.equal(counts.interrupted, 1); + }); + + it('counts BLOCKED as interrupted', () => { + const counts = computeTabBadgeCounts([makeTask('BLOCKED')]); + assert.equal(counts.interrupted, 1); + }); + + it('does not count COMPLETED as interrupted', () => { + const counts = computeTabBadgeCounts([makeTask('COMPLETED')]); + assert.equal(counts.interrupted, 0); + }); + + it('does not count TIMED_OUT as interrupted', () => { + const counts = computeTabBadgeCounts([makeTask('TIMED_OUT')]); + assert.equal(counts.interrupted, 0); + }); + + it('counts across multiple states simultaneously', () => { + const tasks = [ + makeTask('RUNNING'), + makeTask('RUNNING'), + makeTask('READY'), + makeTask('CANCELLED'), + makeTask('FAILED'), + makeTask('BLOCKED'), + makeTask('QUEUED'), + makeTask('COMPLETED'), + ]; + const counts = computeTabBadgeCounts(tasks); + assert.equal(counts.running, 2); + assert.equal(counts.ready, 1); + assert.equal(counts.interrupted, 3); + }); + + it('returns zero for a tab when no tasks match that state', () => { + const tasks = [makeTask('QUEUED'), makeTask('PENDING'), makeTask('COMPLETED')]; + const counts = computeTabBadgeCounts(tasks); + assert.equal(counts.running, 0); + assert.equal(counts.ready, 0); + assert.equal(counts.interrupted, 0); + }); +}); |
