From d9db12a25a42d00e0f64ac87e10ac0b612b841a8 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 6 Jul 2026 05:12:47 +0000 Subject: feat(web): extend createTaskCard with DI doc param, log-tail placeholder, elapsed timer (task 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export createTaskCard and add `doc = document` parameter; replace every document.createElement call inside the function with doc.createElement, enabling unit testing without a real DOM. - RUNNING tasks: append a span.running-elapsed (data-started-at, elapsed text) to the card header, consistent with renderRunningView so updateRunningElapsed() picks it up automatically. - PENDING/QUEUED tasks: append div.task-log-tail > p.task-log-placeholder ("Waiting to start…") — the queue column has no execution yet, so a placeholder is shown instead of an empty log box. - New web/test/task-card-di.test.mjs covers DI doc plumbing, placeholder presence/absence, and elapsed-timer presence/absence across states. Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 64 ++++++++++------ web/test/task-card-di.test.mjs | 169 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 web/test/task-card-di.test.mjs diff --git a/web/app.js b/web/app.js index c192d20..5444511 100644 --- a/web/app.js +++ b/web/app.js @@ -275,42 +275,49 @@ function truncateToWordBoundary(text, maxLen = 120) { return (cut > 0 ? text.slice(0, cut) : text.slice(0, maxLen)) + '…'; } -function createTaskCard(task) { - const card = document.createElement('div'); +export function createTaskCard(task, doc = document) { + const card = doc.createElement('div'); card.className = 'task-card'; card.dataset.taskId = task.id; // Header: name + state badge - const header = document.createElement('div'); + const header = doc.createElement('div'); header.className = 'task-card-header'; - const name = document.createElement('span'); + const name = doc.createElement('span'); name.className = 'task-name'; name.textContent = task.name; - const badge = document.createElement('span'); + const badge = doc.createElement('span'); badge.className = 'state-badge'; badge.dataset.state = task.state; badge.textContent = task.state.replace(/_/g, ' '); header.append(name, badge); + if (task.state === 'RUNNING') { + const elapsed = doc.createElement('span'); + elapsed.className = 'running-elapsed'; + elapsed.dataset.startedAt = task.updated_at ?? ''; + elapsed.textContent = formatElapsed(task.updated_at); + header.append(elapsed); + } card.appendChild(header); // Meta: priority + created_at - const meta = document.createElement('div'); + const meta = doc.createElement('div'); meta.className = 'task-meta'; if (task.priority) { - const prio = document.createElement('span'); + const prio = doc.createElement('span'); prio.textContent = task.priority; meta.appendChild(prio); } if (task.created_at) { - const when = document.createElement('span'); + const when = doc.createElement('span'); when.textContent = formatDate(task.created_at); meta.appendChild(when); } if (task.project) { - const proj = document.createElement('span'); + const proj = doc.createElement('span'); proj.className = 'task-project'; proj.textContent = task.project; meta.appendChild(proj); @@ -319,7 +326,7 @@ function createTaskCard(task) { // Description (truncated via CSS) if (task.description) { - const desc = document.createElement('div'); + const desc = doc.createElement('div'); desc.className = 'task-description'; desc.textContent = task.description; card.appendChild(desc); @@ -328,7 +335,7 @@ function createTaskCard(task) { // Error message for failed tasks const FAILED_STATES = new Set(['FAILED', 'BUDGET_EXCEEDED', 'TIMED_OUT']); if (FAILED_STATES.has(task.state) && task.error_msg) { - const errEl = document.createElement('div'); + const errEl = doc.createElement('div'); errEl.className = 'task-error-msg'; errEl.textContent = task.error_msg; errEl.title = task.error_msg; @@ -337,12 +344,12 @@ function createTaskCard(task) { // Checker report for READY tasks where the checker flagged a problem. if (task.state === 'READY' && task.checker_report) { - const reportEl = document.createElement('div'); + const reportEl = doc.createElement('div'); reportEl.className = 'task-checker-report'; - const label = document.createElement('span'); + const label = doc.createElement('span'); label.className = 'task-checker-report-label'; label.textContent = '⚠ Checker flagged:'; - const text = document.createElement('span'); + const text = doc.createElement('span'); text.textContent = task.checker_report; reportEl.appendChild(label); reportEl.appendChild(text); @@ -363,17 +370,28 @@ function createTaskCard(task) { if (depBadge) card.appendChild(depBadge); } + // Log-tail placeholder for PENDING/QUEUED tasks (no execution exists yet). + if (task.state === 'PENDING' || task.state === 'QUEUED') { + const logTail = doc.createElement('div'); + logTail.className = 'task-log-tail'; + const placeholder = doc.createElement('p'); + placeholder.className = 'task-log-placeholder'; + placeholder.textContent = 'Waiting to start\u2026'; + logTail.appendChild(placeholder); + card.appendChild(logTail); + } + // Footer: action buttons based on state // Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart. // TIMED_OUT shows Resume only. Others show a single action. const RESUME_STATES = new Set(['TIMED_OUT', 'CANCELLED', 'FAILED', 'BUDGET_EXCEEDED']); const RESTART_STATES = new Set(['CANCELLED', 'FAILED', 'BUDGET_EXCEEDED']); if (task.state === 'PENDING' || task.state === 'RUNNING' || task.state === 'READY' || task.state === 'BLOCKED' || RESUME_STATES.has(task.state)) { - const footer = document.createElement('div'); + const footer = doc.createElement('div'); footer.className = 'task-card-footer'; if (task.state === 'PENDING') { - const btn = document.createElement('button'); + const btn = doc.createElement('button'); btn.className = 'btn-run'; btn.textContent = 'Run'; btn.addEventListener('click', (e) => { @@ -382,7 +400,7 @@ function createTaskCard(task) { }); footer.appendChild(btn); } else if (task.state === 'RUNNING') { - const btn = document.createElement('button'); + const btn = doc.createElement('button'); btn.className = 'btn-cancel'; btn.textContent = 'Cancel'; btn.addEventListener('click', (e) => { @@ -392,14 +410,14 @@ function createTaskCard(task) { footer.appendChild(btn); } else if (task.state === 'READY') { renderSubtaskRollup(task, footer); - const acceptBtn = document.createElement('button'); + const acceptBtn = doc.createElement('button'); acceptBtn.className = 'btn-accept'; acceptBtn.textContent = 'Accept'; acceptBtn.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(task.id, acceptBtn, footer); }); - const rejectBtn = document.createElement('button'); + const rejectBtn = doc.createElement('button'); rejectBtn.className = 'btn-reject'; rejectBtn.textContent = 'Reject'; rejectBtn.addEventListener('click', (e) => { @@ -414,7 +432,7 @@ function createTaskCard(task) { } else { renderSubtaskRollup(task, footer); } - const cancelBtn = document.createElement('button'); + const cancelBtn = doc.createElement('button'); cancelBtn.className = 'btn-cancel'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', (e) => { @@ -423,7 +441,7 @@ function createTaskCard(task) { }); footer.appendChild(cancelBtn); } else if (RESUME_STATES.has(task.state)) { - const resumeBtn = document.createElement('button'); + const resumeBtn = doc.createElement('button'); resumeBtn.className = 'btn-resume'; resumeBtn.textContent = 'Resume'; resumeBtn.addEventListener('click', (e) => { @@ -432,7 +450,7 @@ function createTaskCard(task) { }); footer.appendChild(resumeBtn); if (RESTART_STATES.has(task.state)) { - const restartBtn = document.createElement('button'); + const restartBtn = doc.createElement('button'); restartBtn.className = 'btn-restart'; restartBtn.textContent = 'Restart'; restartBtn.addEventListener('click', (e) => { @@ -447,7 +465,7 @@ function createTaskCard(task) { } if (!NON_DELETABLE_STATES.has(task.state)) { - const delBtn = document.createElement('button'); + const delBtn = doc.createElement('button'); delBtn.className = 'btn-delete-task'; delBtn.title = 'Delete task'; delBtn.textContent = '✕'; diff --git a/web/test/task-card-di.test.mjs b/web/test/task-card-di.test.mjs new file mode 100644 index 0000000..e5dff74 --- /dev/null +++ b/web/test/task-card-di.test.mjs @@ -0,0 +1,169 @@ +// task-card-di.test.mjs — Unit tests for the createTaskCard DI doc param, +// log-tail placeholder (PENDING/QUEUED), and elapsed timer (RUNNING). +// +// Run with: node --test web/test/task-card-di.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTaskCard } from '../app.js'; + +// ── Mock DOM ─────────────────────────────────────────────────────────────────── + +function makeEl(tag) { + return { + tag, + className: '', + textContent: '', + title: '', + hidden: false, + dataset: {}, + children: [], + classList: { add() {} }, + append(...nodes) { + for (const n of nodes) { + if (n != null && typeof n === 'object') this.children.push(n); + } + }, + appendChild(child) { + if (child != null) this.children.push(child); + return child; + }, + addEventListener() {}, + setAttribute() {}, + getAttribute() { return null; }, + }; +} + +function makeDoc() { + return { + createElement(tag) { return makeEl(tag); }, + }; +} + +// Depth-first search for first element with the given class name. +function findByClass(el, cls) { + for (const child of el.children || []) { + if (typeof child.className === 'string' && + child.className.split(' ').includes(cls)) return child; + const found = findByClass(child, cls); + if (found) return found; + } + return null; +} + +// ── Tests: DI doc param ──────────────────────────────────────────────────────── + +describe('createTaskCard doc DI param', () => { + it('accepts a custom doc and uses it for createElement', () => { + const created = []; + const doc = { + createElement(tag) { + const el = makeEl(tag); + created.push(tag); + return el; + }, + }; + createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test Task' }, doc); + assert.ok(created.length > 0, 'doc.createElement should have been called'); + assert.ok(created.includes('div'), 'should create at least one div'); + assert.ok(created.includes('span'), 'should create at least one span'); + }); + + it('returns a card element whose tag is div', () => { + const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); + assert.equal(card.tag, 'div'); + assert.equal(card.className, 'task-card'); + assert.equal(card.dataset.taskId, 't1'); + }); +}); + +// ── Tests: log-tail placeholder (QUEUED) ────────────────────────────────────── + +describe('log-tail placeholder for queue column', () => { + it('QUEUED task has a task-log-placeholder element', () => { + const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); + const placeholder = findByClass(card, 'task-log-placeholder'); + assert.ok(placeholder, 'QUEUED card should have a task-log-placeholder element'); + }); + + it('QUEUED task log-tail placeholder text is "Waiting to start\u2026"', () => { + const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); + const placeholder = findByClass(card, 'task-log-placeholder'); + assert.equal(placeholder.textContent, 'Waiting to start\u2026'); + }); + + it('QUEUED task has a task-log-tail wrapper containing the placeholder', () => { + const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); + const logTail = findByClass(card, 'task-log-tail'); + assert.ok(logTail, 'QUEUED card should have a task-log-tail wrapper'); + const placeholder = findByClass(logTail, 'task-log-placeholder'); + assert.ok(placeholder, 'task-log-tail should contain task-log-placeholder'); + }); + + it('RUNNING task does not have a task-log-placeholder', () => { + const card = createTaskCard({ id: 't1', state: 'RUNNING', name: 'Test' }, makeDoc()); + const placeholder = findByClass(card, 'task-log-placeholder'); + assert.equal(placeholder, null, 'RUNNING card should not have a task-log-placeholder'); + }); + + it('COMPLETED task does not have a task-log-placeholder', () => { + const card = createTaskCard({ id: 't1', state: 'COMPLETED', name: 'Test' }, makeDoc()); + const placeholder = findByClass(card, 'task-log-placeholder'); + assert.equal(placeholder, null, 'COMPLETED card should not have a task-log-placeholder'); + }); +}); + +// ── Tests: elapsed timer (RUNNING) ──────────────────────────────────────────── + +describe('elapsed timer for RUNNING tasks', () => { + it('RUNNING task has a running-elapsed span', () => { + const card = createTaskCard( + { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-01-01T00:00:00Z' }, + makeDoc(), + ); + const elapsed = findByClass(card, 'running-elapsed'); + assert.ok(elapsed, 'RUNNING card should have a running-elapsed element'); + }); + + it('RUNNING task running-elapsed has data-started-at from task.updated_at', () => { + const card = createTaskCard( + { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-06-15T12:00:00Z' }, + makeDoc(), + ); + const elapsed = findByClass(card, 'running-elapsed'); + assert.equal(elapsed.dataset.startedAt, '2024-06-15T12:00:00Z'); + }); + + it('RUNNING task running-elapsed data-started-at is empty string when updated_at is absent', () => { + const card = createTaskCard( + { id: 't1', state: 'RUNNING', name: 'Test' }, + makeDoc(), + ); + const elapsed = findByClass(card, 'running-elapsed'); + assert.equal(elapsed.dataset.startedAt, ''); + }); + + it('running-elapsed is inside the card header', () => { + const card = createTaskCard( + { id: 't1', state: 'RUNNING', name: 'Test', updated_at: '2024-01-01T00:00:00Z' }, + makeDoc(), + ); + // The header is the first child of the card (a div.task-card-header). + const header = findByClass(card, 'task-card-header'); + assert.ok(header, 'card should have a task-card-header'); + const elapsed = findByClass(header, 'running-elapsed'); + assert.ok(elapsed, 'running-elapsed should be inside the task-card-header'); + }); + + it('QUEUED task does not have a running-elapsed span', () => { + const card = createTaskCard({ id: 't1', state: 'QUEUED', name: 'Test' }, makeDoc()); + const elapsed = findByClass(card, 'running-elapsed'); + assert.equal(elapsed, null, 'QUEUED card should not have a running-elapsed element'); + }); + + it('COMPLETED task does not have a running-elapsed span', () => { + const card = createTaskCard({ id: 't1', state: 'COMPLETED', name: 'Test' }, makeDoc()); + const elapsed = findByClass(card, 'running-elapsed'); + assert.equal(elapsed, null, 'COMPLETED card should not have a running-elapsed element'); + }); +}); -- cgit v1.2.3