summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator.local>2026-07-06 05:12:47 +0000
committerClaudomator Agent <agent@claudomator.local>2026-07-06 05:12:47 +0000
commitd9db12a25a42d00e0f64ac87e10ac0b612b841a8 (patch)
tree8bd36b95d0f9d7252c8d225b668903d023e501e7 /web
parent18170fd5fc68cee039e2a8b471d3a7d502866454 (diff)
feat(web): extend createTaskCard with DI doc param, log-tail placeholder, elapsed timer (task 4)
- 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 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js64
-rw-r--r--web/test/task-card-di.test.mjs169
2 files changed, 210 insertions, 23 deletions
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');
+ });
+});