summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-13 03:17:04 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-13 03:17:04 +0000
commitfe414fac958330c2302d9175d66e1b338e5b1864 (patch)
treeef4941f5d01e84e7868e6b92bd0e6cecdcc2a64f
parentd5f83f8662c9f9c0fb52b206b06d4dd54a7788b4 (diff)
parent55c20922cc7a671787fe94fdd53a7eb72ebd2596 (diff)
merge: resolve conflicts with local/master (stats tab + summary styles)
Keep file-based summary approach (CLAUDOMATOR_SUMMARY_FILE) from HEAD. Combine Q&A History and Stats tab CSS from both branches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--docs/stats-tab-plan.md147
-rw-r--r--internal/api/docs/RAW_NARRATIVE.md9
-rw-r--r--internal/executor/preamble.go1
-rw-r--r--internal/executor/preamble_test.go26
-rw-r--r--web/app.js266
-rw-r--r--web/index.html2
-rw-r--r--web/style.css182
-rw-r--r--web/test/stats.test.mjs153
8 files changed, 785 insertions, 1 deletions
diff --git a/docs/stats-tab-plan.md b/docs/stats-tab-plan.md
new file mode 100644
index 0000000..6b6dcc4
--- /dev/null
+++ b/docs/stats-tab-plan.md
@@ -0,0 +1,147 @@
+# Stats Tab — Implementation Plan
+
+Generated: 2026-03-11
+
+---
+
+## Goal
+
+Add a **Stats** tab to the Claudomator web UI that shows:
+1. Task state distribution (counts by state)
+2. Execution health metrics for the last 24h (total runs, success rate, total cost, avg duration)
+3. A simple visual bar chart of execution outcomes
+
+---
+
+## Data Sources (no backend changes needed)
+
+| Metric | Endpoint | Fields Used |
+|--------|----------|-------------|
+| Task state counts | `GET /api/tasks` | `state` |
+| Execution history | `GET /api/executions?since=24h` | `state`, `cost_usd`, `duration_ms`, `started_at`, `finished_at` |
+
+Both endpoints already exist. No new API endpoints required.
+
+---
+
+## Component Structure
+
+```
+<div data-panel="stats" hidden>
+ <div class="stats-section">
+ <h2>Task Overview</h2>
+ <div class="stats-counts"> <!-- grid: one box per non-zero state -->
+ <div class="stats-count-box" data-state="RUNNING">
+ <span class="stats-count-number">3</span>
+ <span class="stats-count-label">RUNNING</span>
+ </div>
+ ...
+ </div>
+ </div>
+
+ <div class="stats-section">
+ <h2>Executions (Last 24h)</h2>
+ <div class="stats-kpis"> <!-- 4 KPI boxes -->
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">42</span>
+ <span class="stats-kpi-label">Total Runs</span>
+ </div>
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">88%</span>
+ <span class="stats-kpi-label">Success Rate</span>
+ </div>
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">$1.24</span>
+ <span class="stats-kpi-label">Total Cost</span>
+ </div>
+ <div class="stats-kpi-box">
+ <span class="stats-kpi-value">4m 12s</span>
+ <span class="stats-kpi-label">Avg Duration</span>
+ </div>
+ </div>
+ <div class="stats-bar-chart"> <!-- bar chart of outcome distribution -->
+ <!-- one bar per outcome (completed/failed/cancelled/etc.) -->
+ </div>
+ </div>
+</div>
+```
+
+---
+
+## Data Transformation Functions (exported for testing)
+
+### `computeTaskStats(tasks)`
+Input: `Task[]`
+Output:
+```js
+{
+ byState: { PENDING: 2, RUNNING: 1, COMPLETED: 45, ... } // all states present in tasks
+}
+```
+
+### `computeExecutionStats(executions)`
+Input: `RecentExecution[]`
+Output:
+```js
+{
+ total: 42,
+ successRate: 0.88, // fraction (0–1)
+ totalCostUSD: 1.24,
+ avgDurationMs: 252000, // null if no finished executions
+ byOutcome: { completed: 37, failed: 3, cancelled: 2, ... }
+}
+```
+
+---
+
+## Files Changed
+
+| File | Change |
+|------|--------|
+| `web/index.html` | Add Stats tab button + `<div data-panel="stats">` |
+| `web/app.js` | Add `computeTaskStats`, `computeExecutionStats`, `renderStatsPanel`; update `switchTab` and `poll` |
+| `web/style.css` | Add `.stats-*` CSS rules |
+| `web/test/stats.test.mjs` | Unit tests for `computeTaskStats` and `computeExecutionStats` |
+
+---
+
+## Visualization Approach
+
+No external library. Pure CSS:
+- **State count boxes**: small colored badges using existing `--state-*` CSS variables
+- **KPI boxes**: large number + label in a 4-column grid
+- **Bar chart**: flex row of divs with percentage widths, colored per outcome state
+
+---
+
+## Tab Integration
+
+In `switchTab(name)`:
+- When `name === 'stats'`: fetch tasks + recent executions, then call `renderStatsPanel`
+- Hide `#btn-new-task` (same as other non-tasks tabs)
+
+In `poll()`:
+- If stats tab is active: re-render stats after fetching tasks
+
+---
+
+## Tests (TDD — write first, then implement)
+
+`web/test/stats.test.mjs`:
+1. `computeTaskStats` groups tasks by state correctly
+2. `computeTaskStats` returns zero counts for missing states
+3. `computeExecutionStats` calculates total, success rate, cost, avg duration
+4. `computeExecutionStats` handles empty array (zero total, null avg duration)
+5. `computeExecutionStats` calculates success rate = 0 when all failed
+6. `computeExecutionStats` ignores executions with no `duration_ms` in avg calculation
+
+---
+
+## Implementation Order (TDD)
+
+1. Write `web/test/stats.test.mjs` — all tests fail (red)
+2. Add `computeTaskStats` and `computeExecutionStats` to `app.js` — tests pass (green)
+3. Add `renderStatsPanel` and integrate into `switchTab` / `poll`
+4. Add HTML panel to `index.html`
+5. Add CSS to `style.css`
+6. Manual smoke test
diff --git a/internal/api/docs/RAW_NARRATIVE.md b/internal/api/docs/RAW_NARRATIVE.md
index 7944463..3c7768a 100644
--- a/internal/api/docs/RAW_NARRATIVE.md
+++ b/internal/api/docs/RAW_NARRATIVE.md
@@ -115,3 +115,12 @@ do something
--- 2026-03-13T02:27:38Z ---
do something
+
+--- 2026-03-11T19:04:51Z ---
+run the Go test suite with race detector and fail if coverage < 80%
+
+--- 2026-03-11T19:04:51Z ---
+do something
+
+--- 2026-03-11T19:04:51Z ---
+do something
diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go
index bc5c32c..5e57852 100644
--- a/internal/executor/preamble.go
+++ b/internal/executor/preamble.go
@@ -56,7 +56,6 @@ and the outcome. Write it to the path in $CLAUDOMATOR_SUMMARY_FILE:
This summary is displayed in the task UI so the user knows what happened.
---
-
`
func withPlanningPreamble(instructions string) string {
diff --git a/internal/executor/preamble_test.go b/internal/executor/preamble_test.go
new file mode 100644
index 0000000..448ad3a
--- /dev/null
+++ b/internal/executor/preamble_test.go
@@ -0,0 +1,26 @@
+package executor
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestPlanningPreamble_ContainsFinalSummarySection(t *testing.T) {
+ if !strings.Contains(planningPreamble, "## Final Summary (mandatory)") {
+ t.Error("planningPreamble missing '## Final Summary (mandatory)' heading")
+ }
+}
+
+func TestPlanningPreamble_SummaryRequiresMarkdownHeader(t *testing.T) {
+ if !strings.Contains(planningPreamble, `Start it with "## Summary"`) {
+ t.Error("planningPreamble does not instruct agent to start summary with '## Summary'")
+ }
+}
+
+func TestPlanningPreamble_SummaryDescribesRequiredContent(t *testing.T) {
+ for _, phrase := range []string{"What was accomplished", "Key decisions made", "Any issues or follow-ups"} {
+ if !strings.Contains(planningPreamble, phrase) {
+ t.Errorf("planningPreamble missing required summary content description: %q", phrase)
+ }
+ }
+}
diff --git a/web/app.js b/web/app.js
index dddaeab..3121f28 100644
--- a/web/app.js
+++ b/web/app.js
@@ -301,6 +301,57 @@ export function setTaskFilterTab(tab) {
localStorage.setItem('taskFilterTab', tab);
}
+// ── Stats computations ─────────────────────────────────────────────────────────
+
+/**
+ * Computes task state distribution from a task array.
+ * Returns { byState: { [state]: count } } — only states with tasks are included.
+ */
+export function computeTaskStats(tasks) {
+ const byState = {};
+ for (const t of tasks) {
+ byState[t.state] = (byState[t.state] || 0) + 1;
+ }
+ return { byState };
+}
+
+/**
+ * Computes execution health metrics from a RecentExecution array.
+ * Returns:
+ * { total, successRate, totalCostUSD, avgDurationMs, byOutcome }
+ * where successRate is a fraction (0–1), avgDurationMs is null if no durations.
+ */
+export function computeExecutionStats(executions) {
+ if (executions.length === 0) {
+ return { total: 0, successRate: 0, totalCostUSD: 0, avgDurationMs: null, byOutcome: {} };
+ }
+
+ let completed = 0;
+ let totalCost = 0;
+ let durationSum = 0;
+ let durationCount = 0;
+ const byOutcome = {};
+
+ for (const e of executions) {
+ const outcome = e.state || 'unknown';
+ byOutcome[outcome] = (byOutcome[outcome] || 0) + 1;
+ if (outcome === 'completed') completed++;
+ totalCost += e.cost_usd || 0;
+ if (e.duration_ms != null) {
+ durationSum += e.duration_ms;
+ durationCount++;
+ }
+ }
+
+ return {
+ total: executions.length,
+ successRate: executions.length > 0 ? completed / executions.length : 0,
+ totalCostUSD: totalCost,
+ avgDurationMs: durationCount > 0 ? Math.round(durationSum / durationCount) : null,
+ byOutcome,
+ };
+}
+
export function updateFilterTabs() {
const current = getTaskFilterTab();
document.querySelectorAll('.filter-tab[data-filter]').forEach(el => {
@@ -817,6 +868,11 @@ async function handleStartNextTask(btn) {
// ── Polling ───────────────────────────────────────────────────────────────────
+function isStatsTabActive() {
+ const panel = document.querySelector('[data-panel="stats"]');
+ return panel && !panel.hasAttribute('hidden');
+}
+
async function poll() {
try {
const tasks = await fetchTasks();
@@ -832,6 +888,11 @@ async function poll() {
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(() => {});
+ }
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -1363,6 +1424,16 @@ function renderTaskPanel(task, executions) {
overview.appendChild(overviewGrid);
content.appendChild(overview);
+ // ── Summary ──
+ if (task.summary) {
+ const summarySection = makeSection("Summary");
+ const summaryText = document.createElement("div");
+ summaryText.className = "task-summary-text";
+ summaryText.textContent = task.summary;
+ summarySection.appendChild(summaryText);
+ content.appendChild(summarySection);
+ }
+
// ── Agent Config ──
const a = task.agent || {};
const agentSection = makeSection('Agent Config');
@@ -1390,6 +1461,34 @@ function renderTaskPanel(task, executions) {
agentSection.appendChild(agentGrid);
content.appendChild(agentSection);
+ // ── Q&A History ──
+ let interactions = [];
+ if (task.interactions) {
+ try { interactions = JSON.parse(task.interactions); } catch {}
+ }
+ if (interactions.length > 0) {
+ const qaSection = makeSection("Q&A History");
+ const timeline = document.createElement("div");
+ timeline.className = "qa-timeline";
+ for (const item of interactions) {
+ const entry = document.createElement("div");
+ entry.className = `qa-item qa-${item.type}`;
+ const label = document.createElement("span");
+ label.className = "qa-label";
+ label.textContent = item.type === "question" ? "Agent asked:" : "User answered:";
+ const text = document.createElement("div");
+ text.className = "qa-content";
+ text.textContent = item.content;
+ const ts = document.createElement("span");
+ ts.className = "qa-timestamp";
+ ts.textContent = item.timestamp ? formatDate(item.timestamp) : "";
+ entry.append(label, text, ts);
+ timeline.appendChild(entry);
+ }
+ qaSection.appendChild(timeline);
+ content.appendChild(qaSection);
+ }
+
// ── Execution Settings ──
const settingsSection = makeSection('Execution Settings');
const settingsGrid = document.createElement('div');
@@ -1925,6 +2024,163 @@ function renderRunningHistory(executions) {
histEl.appendChild(table);
}
+// ── Stats rendering ───────────────────────────────────────────────────────────
+
+// State display order for the task overview grid.
+const STATS_STATE_ORDER = [
+ 'RUNNING', 'QUEUED', 'READY', 'BLOCKED',
+ 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+function formatDurationMs(ms) {
+ if (ms == null) return '—';
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rs = s % 60;
+ if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
+ const h = Math.floor(m / 60);
+ const rm = m % 60;
+ return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
+}
+
+function renderStatsPanel(tasks, executions) {
+ const panel = document.querySelector('[data-panel="stats"]');
+ if (!panel) return;
+
+ const taskStats = computeTaskStats(tasks);
+ const execStats = computeExecutionStats(executions);
+
+ panel.innerHTML = '';
+
+ // ── Task Overview ──────────────────────────────────────────────────────────
+ const taskSection = document.createElement('div');
+ taskSection.className = 'stats-section';
+
+ const taskHeading = document.createElement('h2');
+ taskHeading.textContent = 'Task Overview';
+ taskSection.appendChild(taskHeading);
+
+ const countsGrid = document.createElement('div');
+ countsGrid.className = 'stats-counts';
+
+ const orderedStates = STATS_STATE_ORDER.filter(s => taskStats.byState[s] > 0);
+ const otherStates = Object.keys(taskStats.byState).filter(s => !STATS_STATE_ORDER.includes(s));
+
+ for (const state of [...orderedStates, ...otherStates]) {
+ const count = taskStats.byState[state] || 0;
+ if (count === 0) continue;
+ const box = document.createElement('div');
+ box.className = 'stats-count-box';
+ box.dataset.state = state;
+
+ const num = document.createElement('span');
+ num.className = 'stats-count-number';
+ num.textContent = String(count);
+
+ const label = document.createElement('span');
+ label.className = 'stats-count-label';
+ label.textContent = state.replace(/_/g, ' ');
+
+ box.appendChild(num);
+ box.appendChild(label);
+ countsGrid.appendChild(box);
+ }
+
+ if (orderedStates.length === 0 && otherStates.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'task-meta';
+ empty.textContent = 'No tasks yet.';
+ countsGrid.appendChild(empty);
+ }
+
+ taskSection.appendChild(countsGrid);
+ panel.appendChild(taskSection);
+
+ // ── Execution Health ───────────────────────────────────────────────────────
+ const execSection = document.createElement('div');
+ execSection.className = 'stats-section';
+
+ const execHeading = document.createElement('h2');
+ execHeading.textContent = 'Executions (Last 24h)';
+ execSection.appendChild(execHeading);
+
+ const kpisRow = document.createElement('div');
+ kpisRow.className = 'stats-kpis';
+
+ const kpis = [
+ { label: 'Total Runs', value: String(execStats.total) },
+ { label: 'Success Rate', value: execStats.total > 0 ? `${Math.round(execStats.successRate * 100)}%` : '—' },
+ { label: 'Total Cost', value: execStats.totalCostUSD > 0 ? `$${execStats.totalCostUSD.toFixed(2)}` : '$0.00' },
+ { label: 'Avg Duration', value: formatDurationMs(execStats.avgDurationMs) },
+ ];
+
+ for (const kpi of kpis) {
+ const box = document.createElement('div');
+ box.className = 'stats-kpi-box';
+
+ const val = document.createElement('span');
+ val.className = 'stats-kpi-value';
+ val.textContent = kpi.value;
+
+ const lbl = document.createElement('span');
+ lbl.className = 'stats-kpi-label';
+ lbl.textContent = kpi.label;
+
+ box.appendChild(val);
+ box.appendChild(lbl);
+ kpisRow.appendChild(box);
+ }
+ execSection.appendChild(kpisRow);
+
+ // Bar chart of outcome distribution.
+ if (execStats.total > 0) {
+ const chartSection = document.createElement('div');
+ chartSection.className = 'stats-bar-chart';
+
+ const chartLabel = document.createElement('p');
+ chartLabel.className = 'stats-bar-chart-label';
+ chartLabel.textContent = 'Outcome breakdown';
+ chartSection.appendChild(chartLabel);
+
+ const bars = document.createElement('div');
+ bars.className = 'stats-bars';
+
+ for (const [outcome, count] of Object.entries(execStats.byOutcome)) {
+ const pct = (count / execStats.total) * 100;
+ const row = document.createElement('div');
+ row.className = 'stats-bar-row';
+
+ const barLabel = document.createElement('span');
+ barLabel.className = 'stats-bar-row-label';
+ barLabel.textContent = outcome.replace(/_/g, ' ');
+
+ const barTrack = document.createElement('div');
+ barTrack.className = 'stats-bar-track';
+
+ const barFill = document.createElement('div');
+ barFill.className = 'stats-bar-fill';
+ barFill.dataset.state = outcome.toUpperCase();
+ barFill.style.width = `${pct.toFixed(1)}%`;
+
+ const barCount = document.createElement('span');
+ barCount.className = 'stats-bar-count';
+ barCount.textContent = `${count} (${Math.round(pct)}%)`;
+
+ barTrack.appendChild(barFill);
+ row.appendChild(barLabel);
+ row.appendChild(barTrack);
+ row.appendChild(barCount);
+ bars.appendChild(row);
+ }
+
+ chartSection.appendChild(bars);
+ execSection.appendChild(chartSection);
+ }
+
+ panel.appendChild(execSection);
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -1958,6 +2214,16 @@ function switchTab(name) {
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>';
+ });
+ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
diff --git a/web/index.html b/web/index.html
index 0b4ee35..438216f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -18,6 +18,7 @@
<button class="tab active" data-tab="tasks">Tasks</button>
<button class="tab" data-tab="active">Active</button>
<button class="tab" data-tab="running">Running</button>
+ <button class="tab" data-tab="stats">Stats</button>
</nav>
<main id="app">
<div data-panel="tasks">
@@ -38,6 +39,7 @@
<div class="running-current"></div>
<div class="running-history"></div>
</div>
+ <div data-panel="stats" hidden></div>
</main>
<dialog id="task-modal">
diff --git a/web/style.css b/web/style.css
index 342e0b3..67e4962 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1170,6 +1170,29 @@ dialog label select:focus {
margin-top: 0.5rem;
}
+.task-summary-text {
+ padding: 0.75rem 1rem;
+ background: var(--surface-1, #f8f9fa);
+ border-radius: 6px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+}
+.qa-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+.qa-item {
+ padding: 0.5rem 0.75rem;
+ border-left: 3px solid;
+ border-radius: 0 4px 4px 0;
+}
+.qa-question { border-color: #3b82f6; background: rgba(59,130,246,0.05); }
+.qa-answer { border-color: #10b981; background: rgba(16,185,129,0.05); }
+.qa-label { font-weight: 600; font-size: 0.85em; display: block; margin-bottom: 0.25rem; }
+.qa-content { white-space: pre-wrap; }
+.qa-timestamp { font-size: 0.75em; color: var(--text-muted, #6b7280); margin-top: 0.25rem; display: block; }
+
.inline-edit-success {
color: var(--state-completed);
font-size: 0.82rem;
@@ -1214,3 +1237,162 @@ dialog label select:focus {
color: var(--accent, #60a5fa);
font-style: italic;
}
+
+/* ── Stats tab ────────────────────────────────────────────────────────────── */
+
+[data-panel="stats"] {
+ padding: 1rem 1.5rem;
+ max-width: 900px;
+}
+
+.stats-section {
+ margin-bottom: 2rem;
+}
+
+.stats-section h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 0.75rem;
+}
+
+/* Task state count boxes */
+.stats-counts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.stats-count-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.75rem 1.25rem;
+ border-radius: 8px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ min-width: 80px;
+}
+
+.stats-count-box[data-state="RUNNING"] { border-color: var(--state-running); }
+.stats-count-box[data-state="QUEUED"] { border-color: var(--state-queued); }
+.stats-count-box[data-state="READY"] { border-color: var(--state-running); }
+.stats-count-box[data-state="BLOCKED"] { border-color: var(--state-blocked); }
+.stats-count-box[data-state="PENDING"] { border-color: var(--state-pending); }
+.stats-count-box[data-state="COMPLETED"] { border-color: var(--state-completed); }
+.stats-count-box[data-state="FAILED"] { border-color: var(--state-failed); }
+.stats-count-box[data-state="TIMED_OUT"] { border-color: var(--state-timed-out); }
+.stats-count-box[data-state="CANCELLED"] { border-color: var(--state-cancelled); }
+.stats-count-box[data-state="BUDGET_EXCEEDED"] { border-color: var(--state-budget-exceeded); }
+
+.stats-count-number {
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.stats-count-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ white-space: nowrap;
+}
+
+/* KPI row */
+.stats-kpis {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.75rem;
+ margin-bottom: 1.25rem;
+}
+
+@media (max-width: 600px) {
+ .stats-kpis {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.stats-kpi-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem;
+ border-radius: 8px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ text-align: center;
+}
+
+.stats-kpi-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.stats-kpi-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ white-space: nowrap;
+}
+
+/* Outcome bar chart */
+.stats-bar-chart-label {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0 0 0.5rem;
+}
+
+.stats-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.stats-bar-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.stats-bar-row-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ width: 120px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.stats-bar-track {
+ flex: 1;
+ height: 16px;
+ background: var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.stats-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ background: var(--state-pending);
+ transition: width 0.3s ease;
+}
+
+.stats-bar-fill[data-state="COMPLETED"] { background: var(--state-completed); }
+.stats-bar-fill[data-state="FAILED"] { background: var(--state-failed); }
+.stats-bar-fill[data-state="CANCELLED"] { background: var(--state-cancelled); }
+.stats-bar-fill[data-state="TIMED_OUT"] { background: var(--state-timed-out); }
+.stats-bar-fill[data-state="BUDGET_EXCEEDED"] { background: var(--state-budget-exceeded); }
+.stats-bar-fill[data-state="RUNNING"] { background: var(--state-running); }
+
+.stats-bar-count {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ width: 80px;
+ flex-shrink: 0;
+}
diff --git a/web/test/stats.test.mjs b/web/test/stats.test.mjs
new file mode 100644
index 0000000..a7fe657
--- /dev/null
+++ b/web/test/stats.test.mjs
@@ -0,0 +1,153 @@
+// stats.test.mjs — Unit tests for stats tab computation functions.
+//
+// Run with: node --test web/test/stats.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { computeTaskStats, computeExecutionStats } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+function makeExec(state, costUSD = 0, durationMs = null) {
+ const started = new Date('2024-01-01T10:00:00Z');
+ const finished = durationMs != null
+ ? new Date(started.getTime() + durationMs).toISOString()
+ : null;
+ return {
+ id: `exec-${Math.random()}`,
+ task_id: 'task-1',
+ task_name: 'Test Task',
+ state,
+ started_at: started.toISOString(),
+ finished_at: finished,
+ duration_ms: durationMs,
+ cost_usd: costUSD,
+ exit_code: state === 'completed' ? 0 : 1,
+ };
+}
+
+// ── computeTaskStats ───────────────────────────────────────────────────────────
+
+describe('computeTaskStats', () => {
+ it('groups tasks by state', () => {
+ const tasks = [
+ makeTask('RUNNING'), makeTask('RUNNING'),
+ makeTask('PENDING'),
+ makeTask('COMPLETED'), makeTask('COMPLETED'), makeTask('COMPLETED'),
+ ];
+ const stats = computeTaskStats(tasks);
+ assert.equal(stats.byState.RUNNING, 2);
+ assert.equal(stats.byState.PENDING, 1);
+ assert.equal(stats.byState.COMPLETED, 3);
+ });
+
+ it('only includes states that have tasks', () => {
+ const tasks = [makeTask('RUNNING')];
+ const stats = computeTaskStats(tasks);
+ assert.equal(stats.byState.RUNNING, 1);
+ assert.equal(stats.byState.PENDING, undefined);
+ assert.equal(stats.byState.COMPLETED, undefined);
+ });
+
+ it('returns empty byState for empty task list', () => {
+ const stats = computeTaskStats([]);
+ assert.deepEqual(stats.byState, {});
+ });
+
+ it('counts all distinct states correctly', () => {
+ const states = ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'COMPLETED',
+ 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'BLOCKED'];
+ const tasks = states.map(makeTask);
+ const stats = computeTaskStats(tasks);
+ for (const s of states) {
+ assert.equal(stats.byState[s], 1, `expected count 1 for state ${s}`);
+ }
+ });
+});
+
+// ── computeExecutionStats ──────────────────────────────────────────────────────
+
+describe('computeExecutionStats', () => {
+ it('returns zeros for empty executions', () => {
+ const stats = computeExecutionStats([]);
+ assert.equal(stats.total, 0);
+ assert.equal(stats.successRate, 0);
+ assert.equal(stats.totalCostUSD, 0);
+ assert.equal(stats.avgDurationMs, null);
+ assert.deepEqual(stats.byOutcome, {});
+ });
+
+ it('calculates total correctly', () => {
+ const execs = [makeExec('completed'), makeExec('failed'), makeExec('cancelled')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.total, 3);
+ });
+
+ it('calculates success rate as fraction of completed out of total', () => {
+ const execs = [
+ makeExec('completed'), makeExec('completed'), makeExec('completed'),
+ makeExec('failed'),
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 0.75);
+ });
+
+ it('returns success rate 0 when all executions failed', () => {
+ const execs = [makeExec('failed'), makeExec('failed')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 0);
+ });
+
+ it('returns success rate 1 when all executions completed', () => {
+ const execs = [makeExec('completed'), makeExec('completed')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 1);
+ });
+
+ it('sums total cost correctly', () => {
+ const execs = [makeExec('completed', 0.5), makeExec('completed', 0.25), makeExec('failed', 0.1)];
+ const stats = computeExecutionStats(execs);
+ assert.ok(Math.abs(stats.totalCostUSD - 0.85) < 0.0001, `expected 0.85, got ${stats.totalCostUSD}`);
+ });
+
+ it('calculates average duration from executions with duration_ms', () => {
+ const execs = [
+ makeExec('completed', 0, 60000), // 1 min
+ makeExec('completed', 0, 120000), // 2 min
+ makeExec('failed', 0, 30000), // 30 sec
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, 70000); // (60000+120000+30000)/3
+ });
+
+ it('ignores executions without duration_ms in avg calculation', () => {
+ const execs = [
+ makeExec('completed', 0, 60000),
+ makeExec('running', 0, null), // still running, no duration
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, 60000);
+ });
+
+ it('returns null avgDurationMs when no executions have duration_ms', () => {
+ const execs = [makeExec('running', 0, null)];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, null);
+ });
+
+ it('groups executions by outcome in byOutcome', () => {
+ const execs = [
+ makeExec('completed'), makeExec('completed'),
+ makeExec('failed'),
+ makeExec('cancelled'),
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.byOutcome.completed, 2);
+ assert.equal(stats.byOutcome.failed, 1);
+ assert.equal(stats.byOutcome.cancelled, 1);
+ });
+});