feat: enhance HTML report with derived metrics, visualizations, and interactivity (#1078)

* feat: enhance HTML report with derived metrics, visualizations, and interactivity

Add 13 features to the HTML report generator across 6 implementation waves:

Wave 1 - Summary enhancements:
- Executive summary paragraph with project completion %, cost, and budget context
- ETA calculation based on completion rate and remaining slices
- Cost/slice and Tokens/tool efficiency metrics in KV grid
- Cache hit ratio percentage
- Milestone scope indicator when scoped to a milestone

Wave 2 - Metrics visualizations:
- Cost over time inline SVG area chart with grid lines and axis labels
- Duration by slice bar chart (third chart using existing buildBarChart)
- Budget burndown horizontal stacked bar (spent/projected/overshoot)
- Chart row CSS changed to auto-fit for flexible multi-column layout

Wave 3 - Blockers section:
- New section with card-based layout for blocker verifications and high-risk
  incomplete slices, added to sections array and TOC nav

Wave 4 - Gantt chart:
- SVG horizontal bar timeline grouped by slice with done/active/pending
  coloring and time axis labels

Wave 5 - Interactive JS features:
- Timeline filter input for text-based row filtering
- Collapsible sections with toggle buttons (localStorage persisted)
- Dark/light theme toggle in header (localStorage persisted)

Wave 6 - Mobile responsiveness:
- 768px and 480px breakpoints with stacked layouts and compressed padding

All changes in a single file (export-html.ts). No data layer changes needed.
30 new tests covering all features and edge cases.

* fix: correct Phase type literal in export-html-enhancements test

Change "execution" to "executing" to match the Phase type definition.
This commit is contained in:
Jeremy McSpadden 2026-03-17 22:46:51 -05:00 committed by GitHub
parent 50bea6e73a
commit 326cef0b2d
2 changed files with 737 additions and 3 deletions

View file

@ -27,6 +27,7 @@ import type {
} from './visualizer-data.js';
import { formatDateShort, formatDuration } from '../shared/format-utils.js';
import { formatCost, formatTokenCount } from './metrics.js';
import type { UnitMetrics } from './metrics.js';
// ─── Public API ────────────────────────────────────────────────────────────────
@ -46,6 +47,7 @@ export function generateHtmlReport(
const sections = [
buildSummarySection(data, opts, generated),
buildBlockersSection(data),
buildProgressSection(data),
buildTimelineSection(data),
buildDepGraphSection(data),
@ -94,6 +96,7 @@ export function generateHtmlReport(
<nav class="toc" aria-label="Report sections">
<ul>
<li><a href="#summary">Summary</a></li>
<li><a href="#blockers">Blockers</a></li>
<li><a href="#progress">Progress</a></li>
<li><a href="#timeline">Timeline</a></li>
<li><a href="#depgraph">Dependencies</a></li>
@ -128,7 +131,7 @@ ${sections.join('\n')}
function buildSummarySection(
data: VisualizerData,
_opts: HtmlReportOptions,
opts: HtmlReportOptions,
_generated: string,
): string {
const t = data.totals;
@ -150,6 +153,12 @@ function buildSummarySection(
t ? kvi('Units', String(t.units)) : '',
data.remainingSliceCount > 0 ? kvi('Remaining', String(data.remainingSliceCount)) : '',
act ? kvi('Rate', `${act.completionRate.toFixed(1)}/hr`) : '',
t && doneSlices > 0 ? kvi('Cost/slice', formatCost(t.cost / doneSlices)) : '',
t && t.toolCalls > 0 ? kvi('Tokens/tool', formatTokenCount(t.tokens.total / t.toolCalls)) : '',
t && (t.tokens.input + t.tokens.cacheRead) > 0
? kvi('Cache hit', ((t.tokens.cacheRead / (t.tokens.input + t.tokens.cacheRead)) * 100).toFixed(1) + '%')
: '',
opts.milestoneId ? kvi('Scope', opts.milestoneId) : '',
].filter(Boolean).join('');
const activeInfo = activeMilestone ? (() => {
@ -168,7 +177,11 @@ function buildSummarySection(
<span class="muted">${formatDuration(act.elapsed)} elapsed</span>
</div>` : '';
const execSummary = buildExecutiveSummary(data, opts);
const etaLine = buildEtaLine(data);
return section('summary', 'Summary', `
${execSummary}
<div class="kv-grid">${kv}</div>
<div class="progress-wrap">
<div class="progress-track"><div class="progress-fill" style="width:${pct}%"></div></div>
@ -176,9 +189,68 @@ function buildSummarySection(
</div>
${activeInfo}
${activityHtml}
${etaLine}
`);
}
function buildExecutiveSummary(data: VisualizerData, opts: HtmlReportOptions): string {
const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0);
const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
const pct = totalSlices > 0 ? Math.round((doneSlices / totalSlices) * 100) : 0;
const spent = data.totals?.cost ?? 0;
const activeMilestone = data.milestones.find(m => m.status === 'active');
const activeSlice = activeMilestone?.slices.find(s => s.active);
const currentExec = activeMilestone && activeSlice
? ` Currently executing ${esc(activeMilestone.id)}/${esc(activeSlice.id)}.`
: '';
const budgetCtx = data.health.budgetCeiling
? ` Budget: ${formatCost(spent)} of ${formatCost(data.health.budgetCeiling)} ceiling (${((spent / data.health.budgetCeiling) * 100).toFixed(0)}% used).`
: '';
return `<p class="exec-summary">${esc(opts.projectName)} is ${pct}% complete across ${data.milestones.length} milestones. ${formatCost(spent)} spent.${currentExec}${budgetCtx}</p>`;
}
function buildEtaLine(data: VisualizerData): string {
const act = data.agentActivity;
if (!act || act.completionRate <= 0 || data.remainingSliceCount <= 0) return '';
const hoursRemaining = data.remainingSliceCount / act.completionRate;
const formatted = formatDuration(hoursRemaining * 3_600_000);
return `<div class="eta-line">ETA: ~${formatted} remaining (${data.remainingSliceCount} slices at ${act.completionRate.toFixed(1)}/hr)</div>`;
}
// ─── Section: Blockers ────────────────────────────────────────────────────────
function buildBlockersSection(data: VisualizerData): string {
const blockers = data.sliceVerifications.filter(v => v.blockerDiscovered === true);
const highRisk: { msId: string; slId: string }[] = [];
for (const ms of data.milestones) {
for (const sl of ms.slices) {
if (!sl.done && sl.risk?.toLowerCase() === 'high') {
highRisk.push({ msId: ms.id, slId: sl.id });
}
}
}
if (blockers.length === 0 && highRisk.length === 0) {
return section('blockers', 'Blockers', '<p class="empty">No blockers or high-risk items found.</p>');
}
const blockerCards = blockers.map(v => `
<div class="blocker-card">
<div class="blocker-id">${esc(v.milestoneId)}/${esc(v.sliceId)}</div>
<div class="blocker-text">${esc(v.verificationResult ?? 'Blocker discovered')}</div>
</div>`).join('');
const riskCards = highRisk
.filter(hr => !blockers.some(b => b.sliceId === hr.slId))
.map(hr => `
<div class="blocker-card">
<div class="blocker-id">${esc(hr.msId)}/${esc(hr.slId)}</div>
<div class="blocker-text">High risk incomplete</div>
</div>`).join('');
return section('blockers', 'Blockers', `${blockerCards}${riskCards}`);
}
// ─── Section: Health ──────────────────────────────────────────────────────────
function buildHealthSection(data: VisualizerData): string {
@ -486,16 +558,171 @@ function buildMetricsSection(data: VisualizerData): string {
label: shortModel(m.model), value: m.cost, display: formatCost(m.cost),
sub: `${m.units} units`,
}))) : ''}
${data.bySlice.length > 0 ? buildBarChart('Duration by slice', data.bySlice.map(s => ({
label: s.sliceId, value: s.duration, display: formatDuration(s.duration),
sub: formatCost(s.cost),
}))) : ''}
</div>` : '';
const costOverTime = buildCostOverTimeChart(data.units);
const budgetBurndown = buildBudgetBurndown(data);
const gantt = buildSliceGantt(data);
return section('metrics', 'Metrics', `
<div class="kv-grid">${grid}</div>
${budgetBurndown}
${tokenBreakdown}
${costOverTime}
${phaseRow}
${sliceModelRow}
${gantt}
`);
}
function buildCostOverTimeChart(units: UnitMetrics[]): string {
if (units.length < 2) return '';
const sorted = [...units].sort((a, b) => a.startedAt - b.startedAt);
const cumulative: number[] = [];
let running = 0;
for (const u of sorted) {
running += u.cost;
cumulative.push(running);
}
const padL = 50, padR = 30, padT = 20, padB = 30;
const w = 600, h = 200;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const maxCost = cumulative[cumulative.length - 1] || 1;
const n = cumulative.length;
const points = cumulative.map((c, i) => {
const x = padL + (i / (n - 1)) * plotW;
const y = padT + plotH - (c / maxCost) * plotH;
return { x, y };
});
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
const areaPath = `${linePath} L${points[points.length - 1].x.toFixed(1)},${(padT + plotH).toFixed(1)} L${points[0].x.toFixed(1)},${(padT + plotH).toFixed(1)} Z`;
const gridLines: string[] = [];
for (let i = 0; i <= 4; i++) {
const y = padT + (plotH / 4) * i;
const val = formatCost(maxCost * (1 - i / 4));
gridLines.push(`<line x1="${padL}" y1="${y}" x2="${w - padR}" y2="${y}" class="cost-grid"/>`);
gridLines.push(`<text x="${padL - 4}" y="${y + 3}" class="cost-axis" text-anchor="end">${val}</text>`);
}
return `
<div class="token-block">
<h3>Cost over time</h3>
<svg class="cost-svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">
${gridLines.join('')}
<path d="${areaPath}" class="cost-area"/>
<path d="${linePath}" class="cost-line"/>
<text x="${padL}" y="${h - 4}" class="cost-axis">#1</text>
<text x="${w - padR}" y="${h - 4}" class="cost-axis" text-anchor="end">#${n}</text>
</svg>
</div>`;
}
function buildBudgetBurndown(data: VisualizerData): string {
if (!data.health.budgetCeiling) return '';
const ceiling = data.health.budgetCeiling;
const spent = data.totals?.cost ?? 0;
const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0);
const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
const avgCostPerSlice = doneSlices > 0 ? spent / doneSlices : 0;
const projected = avgCostPerSlice > 0 ? avgCostPerSlice * data.remainingSliceCount + spent : spent;
const maxVal = Math.max(ceiling, projected, spent);
const spentPct = (spent / maxVal) * 100;
const projectedRemPct = Math.max(0, ((projected - spent) / maxVal) * 100);
const overshoot = projected > ceiling ? ((projected - ceiling) / maxVal) * 100 : 0;
const projectedClean = projectedRemPct - overshoot;
const legend = [
`<span><span class="burndown-dot" style="background:var(--accent)"></span> Spent: ${formatCost(spent)}</span>`,
`<span><span class="burndown-dot" style="background:var(--caution)"></span> Projected remaining: ${formatCost(Math.max(0, projected - spent))}</span>`,
`<span><span class="burndown-dot" style="background:var(--border-2)"></span> Ceiling: ${formatCost(ceiling)}</span>`,
overshoot > 0 ? `<span><span class="burndown-dot" style="background:var(--warn)"></span> Overshoot: ${formatCost(projected - ceiling)}</span>` : '',
].filter(Boolean).join('');
return `
<div class="burndown-wrap">
<h3>Budget burndown</h3>
<div class="burndown-bar">
<div class="burndown-spent" style="width:${spentPct.toFixed(1)}%"></div>
${projectedClean > 0 ? `<div class="burndown-projected" style="width:${projectedClean.toFixed(1)}%"></div>` : ''}
${overshoot > 0 ? `<div class="burndown-overshoot" style="width:${overshoot.toFixed(1)}%"></div>` : ''}
</div>
<div class="burndown-legend">${legend}</div>
</div>`;
}
function buildSliceGantt(data: VisualizerData): string {
const sliceTimings = new Map<string, { min: number; max: number }>();
for (const u of data.units) {
const parts = u.id.split('/');
const sliceKey = parts.length >= 2 ? `${parts[0]}/${parts[1]}` : u.id;
if (u.startedAt <= 0) continue;
const existing = sliceTimings.get(sliceKey);
const end = u.finishedAt > 0 ? u.finishedAt : Date.now();
if (existing) {
existing.min = Math.min(existing.min, u.startedAt);
existing.max = Math.max(existing.max, end);
} else {
sliceTimings.set(sliceKey, { min: u.startedAt, max: end });
}
}
if (sliceTimings.size < 2) return '';
const sliceEntries = [...sliceTimings.entries()].sort((a, b) => a[1].min - b[1].min);
const globalMin = Math.min(...sliceEntries.map(e => e[1].min));
const globalMax = Math.max(...sliceEntries.map(e => e[1].max));
const range = globalMax - globalMin || 1;
const sliceCount = sliceEntries.length;
const barH = 18, rowH = 30, padL = 140, padR = 20, padT = 30, padB = 30;
const plotW = 700 - padL - padR;
const svgH = sliceCount * rowH + padT + padB;
// Build a lookup of slice status
const sliceStatusMap = new Map<string, string>();
for (const ms of data.milestones) {
for (const sl of ms.slices) {
const key = `${ms.id}/${sl.id}`;
sliceStatusMap.set(key, sl.done ? 'done' : sl.active ? 'active' : 'pending');
}
}
const bars = sliceEntries.map(([sliceId, timing], i) => {
const x = padL + ((timing.min - globalMin) / range) * plotW;
const w = Math.max(2, ((timing.max - timing.min) / range) * plotW);
const y = padT + i * rowH + (rowH - barH) / 2;
const status = sliceStatusMap.get(sliceId) ?? 'pending';
return `<text x="${padL - 6}" y="${y + barH / 2 + 4}" class="gantt-label" text-anchor="end">${esc(truncStr(sliceId, 18))}</text>
<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${barH}" rx="2" class="gantt-bar-${status}"><title>${esc(sliceId)}: ${formatDuration(timing.max - timing.min)}</title></rect>`;
}).join('\n');
// Time axis labels
const axisLabels = [0, 0.25, 0.5, 0.75, 1].map(frac => {
const t = globalMin + frac * range;
const x = padL + frac * plotW;
return `<text x="${x.toFixed(1)}" y="${svgH - 8}" class="gantt-axis" text-anchor="middle">${formatDateShort(new Date(t).toISOString())}</text>`;
}).join('');
return `
<div class="gantt-wrap">
<h3>Slice timeline</h3>
<svg class="gantt-svg" viewBox="0 0 700 ${svgH}" width="700" height="${svgH}">
${bars}
${axisLabels}
</svg>
</div>`;
}
function buildTokenBreakdown(tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }): string {
if (tokens.total === 0) return '';
const segs = [
@ -938,8 +1165,7 @@ h3{font-size:13px;font-weight:600;color:var(--text-1);margin:20px 0 8px}
.token-legend{display:flex;flex-wrap:wrap;gap:12px}
.leg-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--text-2)}
.leg-dot{width:8px;height:8px;border-radius:2px;flex-shrink:0}
.chart-row{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
@media(max-width:860px){.chart-row{grid-template-columns:1fr}}
.chart-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:16px}
.chart-block{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px}
.bar-row{display:grid;grid-template-columns:120px 1fr 68px;align-items:center;gap:6px;margin-bottom:2px}
.bar-lbl{font-size:12px;color:var(--text-2);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@ -965,6 +1191,80 @@ h3{font-size:13px;font-weight:600;color:var(--text-1);margin:20px 0 8px}
footer{border-top:1px solid var(--border-1);padding:20px 32px;margin-top:40px}
.footer-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)}
/* Executive summary & ETA */
.exec-summary{font-size:13px;color:var(--text-1);margin-bottom:12px;line-height:1.7}
.eta-line{font-size:12px;color:var(--accent);margin-top:4px}
/* Cost over time chart */
.cost-svg{display:block;margin:8px 0;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px}
.cost-line{fill:none;stroke:var(--accent);stroke-width:2}
.cost-area{fill:var(--accent-subtle);stroke:none}
.cost-axis{fill:var(--text-2);font-family:var(--mono);font-size:10px}
.cost-grid{stroke:var(--border-1);stroke-width:1;stroke-dasharray:4,4}
/* Budget burndown */
.burndown-wrap{background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:14px;margin-bottom:16px}
.burndown-bar{display:flex;height:20px;border-radius:3px;overflow:hidden;gap:1px;margin-bottom:8px}
.burndown-spent{background:var(--accent);height:100%}
.burndown-projected{background:var(--caution);height:100%;opacity:.6}
.burndown-overshoot{background:var(--warn);height:100%;opacity:.7}
.burndown-legend{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:var(--text-2)}
.burndown-legend span{display:flex;align-items:center;gap:4px}
.burndown-dot{display:inline-block;width:8px;height:8px;border-radius:2px}
/* Blockers */
.blocker-card{border-left:3px solid var(--warn);background:var(--bg-1);border-radius:0 4px 4px 0;padding:10px 14px;margin-bottom:8px}
.blocker-id{font-family:var(--mono);font-size:12px;color:var(--warn);margin-bottom:2px}
.blocker-text{font-size:12px;color:var(--text-1)}
.blocker-risk{font-size:11px;color:var(--caution);margin-top:2px}
/* Gantt */
.gantt-wrap{overflow-x:auto;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;padding:16px;margin-top:16px}
.gantt-svg{display:block}
.gantt-bar-done{fill:var(--ok);opacity:.7}
.gantt-bar-active{fill:var(--accent)}
.gantt-bar-pending{fill:var(--border-2)}
.gantt-label{fill:var(--text-2);font-family:var(--mono);font-size:10px}
.gantt-axis{fill:var(--text-2);font-family:var(--mono);font-size:9px}
/* Interactive */
.tl-filter{display:block;width:100%;padding:6px 10px;margin-bottom:8px;background:var(--bg-2);border:1px solid var(--border-1);border-radius:4px;color:var(--text-0);font-size:12px;font-family:var(--font);outline:none}
.tl-filter:focus{border-color:var(--accent)}
.tl-filter::placeholder{color:var(--text-2)}
.sec-toggle{background:none;border:1px solid var(--border-2);color:var(--text-2);width:20px;height:20px;border-radius:3px;cursor:pointer;font-size:14px;line-height:1;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
.sec-toggle:hover{border-color:var(--text-1);color:var(--text-1)}
.theme-toggle{background:var(--bg-3);border:1px solid var(--border-2);color:var(--text-1);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:var(--font)}
.theme-toggle:hover{border-color:var(--accent);color:var(--accent)}
/* Light theme */
.light-theme{--bg-0:#fff;--bg-1:#fafafa;--bg-2:#f5f5f5;--bg-3:#ebebeb;--border-1:#e5e5e5;--border-2:#d4d4d4;--text-0:#1a1a1a;--text-1:#525252;--text-2:#a3a3a3;--accent:#4f46e5;--accent-subtle:rgba(79,70,229,.08);--ok:#16a34a;--ok-subtle:rgba(22,163,74,.08);--warn:#dc2626;--caution:#ca8a04;--c0:#4f46e5;--c1:#dc2626;--c2:#0d9488;--c3:#7c3aed;--c4:#d97706;--c5:#059669;--tk-input:#4f46e5;--tk-output:#dc2626;--tk-cache-r:#0d9488;--tk-cache-w:#64748b}
/* Responsive */
@media(max-width:768px){
header{padding:10px 16px}
.header-inner{flex-wrap:wrap;gap:8px}
.header-meta h1{font-size:13px}
main{padding:16px}
.kv-grid{gap:1px}
.kv{min-width:80px;padding:8px 10px}
.kv-val{font-size:14px}
.chart-row{grid-template-columns:1fr}
.toc ul{padding:0 16px}
.toc a{padding:6px 8px;font-size:11px}
.bar-row{grid-template-columns:80px 1fr 56px}
.ms-body{padding-left:12px}
}
@media(max-width:480px){
.kv{min-width:60px;padding:6px 8px}
.kv-val{font-size:12px}
.kv-lbl{font-size:9px}
.bar-row{grid-template-columns:60px 1fr 48px}
.bar-lbl{font-size:10px}
.toc ul{flex-wrap:wrap}
.header-right{display:none}
.gantt-wrap{overflow-x:auto}
}
/* Print */
@media print{
header,nav.toc{position:static}
@ -992,4 +1292,64 @@ const JS = `
},{rootMargin:'-10% 0px -80% 0px',threshold:0});
for(const s of sections)obs.observe(s);
})();
(function(){
var tl=document.getElementById('timeline');
if(!tl)return;
var table=tl.querySelector('.tbl');
if(!table)return;
var input=document.createElement('input');
input.className='tl-filter';
input.placeholder='Filter timeline\\u2026';
input.type='text';
table.parentNode.insertBefore(input,table);
var rows=table.querySelectorAll('tbody tr');
input.addEventListener('input',function(){
var q=this.value.toLowerCase();
for(var i=0;i<rows.length;i++){
rows[i].style.display=rows[i].textContent.toLowerCase().indexOf(q)>-1?'':'none';
}
});
})();
(function(){
var saved=JSON.parse(localStorage.getItem('gsd-collapsed')||'{}');
document.querySelectorAll('section[id]').forEach(function(sec){
var h2=sec.querySelector('h2');
if(!h2)return;
var btn=document.createElement('button');
btn.className='sec-toggle';
btn.textContent=saved[sec.id]?'+':'-';
btn.setAttribute('aria-label','Toggle section');
h2.prepend(btn);
if(saved[sec.id])toggleSection(sec,true);
btn.addEventListener('click',function(e){
e.preventDefault();
var collapsed=btn.textContent==='-';
toggleSection(sec,collapsed);
btn.textContent=collapsed?'+':'-';
saved[sec.id]=collapsed;
localStorage.setItem('gsd-collapsed',JSON.stringify(saved));
});
});
function toggleSection(sec,hide){
var children=sec.children;
for(var i=0;i<children.length;i++){
if(children[i].tagName!=='H2')children[i].style.display=hide?'none':'';
}
}
})();
(function(){
var hr=document.querySelector('.header-right');
if(!hr)return;
var btn=document.createElement('button');
btn.className='theme-toggle';
btn.textContent=localStorage.getItem('gsd-theme')==='light'?'Dark':'Light';
if(localStorage.getItem('gsd-theme')==='light')document.documentElement.classList.add('light-theme');
btn.addEventListener('click',function(){
document.documentElement.classList.toggle('light-theme');
var isLight=document.documentElement.classList.contains('light-theme');
btn.textContent=isLight?'Dark':'Light';
localStorage.setItem('gsd-theme',isLight?'light':'dark');
});
hr.prepend(btn);
})();
`;

View file

@ -0,0 +1,374 @@
import test from "node:test";
import assert from "node:assert/strict";
import { generateHtmlReport, type HtmlReportOptions } from "../export-html.js";
import type { VisualizerData } from "../visualizer-data.js";
// ─── Mock Data ──────────────────────────────────────────────────────────────
function mockOpts(overrides: Partial<HtmlReportOptions> = {}): HtmlReportOptions {
return {
projectName: "TestProject",
projectPath: "/tmp/test",
gsdVersion: "2.28.0",
...overrides,
};
}
function mockTokens(input = 5000, output = 2000, cacheRead = 3000, cacheWrite = 500) {
return { input, output, cacheRead, cacheWrite, total: input + output + cacheRead + cacheWrite };
}
function mockUnit(id: string, cost: number, startedAt: number, finishedAt: number, type = "execute-task") {
return {
type,
id,
model: "claude-sonnet-4-20250514",
startedAt,
finishedAt,
tokens: mockTokens(),
cost,
toolCalls: 10,
assistantMessages: 5,
userMessages: 3,
};
}
function mockData(overrides: Partial<VisualizerData> = {}): VisualizerData {
return {
milestones: [
{
id: "M001",
title: "First Milestone",
status: "complete",
dependsOn: [],
slices: [
{ id: "S01", title: "Slice One", done: true, active: false, risk: "low", depends: [], tasks: [] },
{ id: "S02", title: "Slice Two", done: true, active: false, risk: "medium", depends: ["S01"], tasks: [] },
],
},
{
id: "M002",
title: "Second Milestone",
status: "active",
dependsOn: ["M001"],
slices: [
{ id: "S01", title: "Active Slice", done: false, active: true, risk: "high", depends: [], tasks: [] },
{ id: "S02", title: "Pending Slice", done: false, active: false, risk: "low", depends: ["S01"], tasks: [] },
],
},
],
phase: "executing",
totals: {
units: 4,
tokens: mockTokens(),
cost: 2.50,
duration: 3_600_000,
toolCalls: 40,
assistantMessages: 20,
userMessages: 12,
totalTruncationSections: 2,
continueHereFiredCount: 1,
},
byPhase: [
{ phase: "execution", units: 4, tokens: mockTokens(), cost: 2.50, duration: 3_600_000 },
],
bySlice: [
{ sliceId: "M001/S01", units: 2, tokens: mockTokens(), cost: 1.20, duration: 1_800_000 },
{ sliceId: "M001/S02", units: 2, tokens: mockTokens(), cost: 1.30, duration: 1_800_000 },
],
byModel: [
{ model: "claude-sonnet-4-20250514", units: 4, tokens: mockTokens(), cost: 2.50 },
],
byTier: [],
tierSavingsLine: "",
units: [
mockUnit("M001/S01/T01", 0.50, Date.now() - 4_000_000, Date.now() - 3_000_000),
mockUnit("M001/S01/T02", 0.70, Date.now() - 3_000_000, Date.now() - 2_000_000),
mockUnit("M001/S02/T01", 0.60, Date.now() - 2_000_000, Date.now() - 1_000_000),
mockUnit("M001/S02/T02", 0.70, Date.now() - 1_000_000, Date.now() - 500_000),
],
criticalPath: {
milestonePath: ["M001", "M002"],
slicePath: ["S01", "S02"],
milestoneSlack: new Map(),
sliceSlack: new Map(),
},
remainingSliceCount: 2,
agentActivity: {
currentUnit: { type: "execute-task", id: "M002/S01/T01", startedAt: Date.now() - 30_000 },
elapsed: 30_000,
completedUnits: 4,
totalSlices: 4,
completionRate: 2.5,
active: true,
sessionCost: 2.50,
sessionTokens: 10_500,
},
changelog: { entries: [] },
sliceVerifications: [],
knowledge: { rules: [], patterns: [], lessons: [], exists: false },
captures: { entries: [], pendingCount: 0, totalCount: 0 },
health: {
budgetCeiling: undefined,
tokenProfile: "standard",
truncationRate: 5.0,
continueHereRate: 2.0,
tierBreakdown: [],
tierSavingsLine: "",
toolCalls: 40,
assistantMessages: 20,
userMessages: 12,
},
discussion: [],
stats: { missingCount: 0, missingSlices: [], updatedCount: 0, updatedSlices: [], recentEntries: [] },
...overrides,
};
}
// ─── Wave 1: Summary Enhancements ──────────────────────────────────────────
test("Feature 1: executive summary paragraph is rendered", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes('class="exec-summary"'), "should contain exec-summary class");
assert.ok(html.includes("TestProject is"), "should contain project name in exec summary");
assert.ok(html.includes("% complete across"), "should contain completion percentage");
assert.ok(html.includes("milestones"), "should mention milestones");
assert.ok(html.includes("$2.50 spent"), "should contain cost");
});
test("Feature 1: executive summary includes budget context when set", () => {
const data = mockData({ health: { ...mockData().health, budgetCeiling: 10.00 } });
const html = generateHtmlReport(data, mockOpts());
assert.ok(html.includes("Budget:"), "should include budget line");
assert.ok(html.includes("ceiling"), "should mention ceiling");
});
test("Feature 2: ETA line is rendered when completion rate > 0", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes('class="eta-line"'), "should contain eta-line class");
assert.ok(html.includes("ETA:"), "should contain ETA text");
assert.ok(html.includes("remaining"), "should mention remaining");
assert.ok(html.includes("2.5/hr"), "should show completion rate");
});
test("Feature 2: ETA line is skipped when rate is 0", () => {
const data = mockData({
agentActivity: { ...mockData().agentActivity!, completionRate: 0 },
});
const html = generateHtmlReport(data, mockOpts());
assert.ok(!html.includes('class="eta-line"'), "should not contain eta-line when rate is 0");
});
test("Feature 2: ETA line is skipped when no remaining slices", () => {
const data = mockData({ remainingSliceCount: 0 });
const html = generateHtmlReport(data, mockOpts());
assert.ok(!html.includes('class="eta-line"'), "should not contain eta-line when no remaining slices");
});
test("Feature 3: cost efficiency metrics shown in KV grid", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("Cost/slice"), "should contain Cost/slice KV");
assert.ok(html.includes("Tokens/tool"), "should contain Tokens/tool KV");
});
test("Feature 4: cache hit ratio shown in KV grid", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("Cache hit"), "should contain Cache hit KV");
// 3000 / (5000 + 3000) = 37.5%
assert.ok(html.includes("37.5%"), "should show correct cache hit percentage");
});
test("Feature 4: cache hit ratio skipped when no input tokens", () => {
const data = mockData({
totals: {
...mockData().totals!,
tokens: { input: 0, output: 100, cacheRead: 0, cacheWrite: 0, total: 100 },
},
});
const html = generateHtmlReport(data, mockOpts());
assert.ok(!html.includes("Cache hit"), "should not contain Cache hit when no input/cacheRead");
});
test("Feature 15: scope shown when milestoneId is set", () => {
const html = generateHtmlReport(mockData(), mockOpts({ milestoneId: "M001" }));
assert.ok(html.includes("Scope"), "should contain Scope KV");
assert.ok(html.includes("M001"), "should show milestone ID");
});
test("Feature 15: scope not shown when no milestoneId", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(!html.includes("Scope"), "should not contain Scope KV without milestoneId");
});
// ─── Wave 2: Metrics Enhancements ──────────────────────────────────────────
test("Feature 5: cost over time chart is rendered", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes('class="cost-svg"'), "should contain cost-svg class");
assert.ok(html.includes('class="cost-line"'), "should contain cost line path");
assert.ok(html.includes('class="cost-area"'), "should contain cost area path");
assert.ok(html.includes("Cost over time"), "should have chart title");
});
test("Feature 5: cost over time chart skipped with < 2 units", () => {
const data = mockData({ units: [mockUnit("M001/S01/T01", 0.50, 1000, 2000)] });
const html = generateHtmlReport(data, mockOpts());
assert.ok(!html.includes('class="cost-svg"'), "should not render cost chart with single unit");
});
test("Feature 6: duration by slice bar chart is rendered", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("Duration by slice"), "should contain duration by slice chart");
});
test("Feature 7: budget burndown rendered when ceiling is set", () => {
const data = mockData({ health: { ...mockData().health, budgetCeiling: 10.00 } });
const html = generateHtmlReport(data, mockOpts());
assert.ok(html.includes('class="burndown-wrap"'), "should contain burndown-wrap");
assert.ok(html.includes("Budget burndown"), "should have burndown title");
assert.ok(html.includes("burndown-spent"), "should show spent bar");
assert.ok(html.includes("Ceiling:"), "should show ceiling in legend");
});
test("Feature 7: budget burndown skipped without ceiling", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(!html.includes('class="burndown-wrap"'), "should not render burndown without ceiling");
});
// ─── Wave 3: Blockers Section ───────────────────────────────────────────────
test("Feature 8: blockers section renders clean state", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes('id="blockers"'), "should contain blockers section");
// M002/S01 is high risk and incomplete
assert.ok(html.includes("blocker-card"), "should contain high-risk blocker card");
assert.ok(html.includes("High risk"), "should flag high-risk slice");
});
test("Feature 8: blockers section renders blocker verifications", () => {
const data = mockData({
sliceVerifications: [
{
milestoneId: "M001",
sliceId: "S01",
verificationResult: "Tests failing on CI",
blockerDiscovered: true,
keyDecisions: [],
patternsEstablished: [],
provides: [],
requires: [],
},
],
});
const html = generateHtmlReport(data, mockOpts());
assert.ok(html.includes("Tests failing on CI"), "should show blocker verification text");
assert.ok(html.includes("M001"), "should show milestone ID in blocker");
});
test("Feature 8: blockers section shows no-blockers message when clean", () => {
const data = mockData({
milestones: [
{
id: "M001",
title: "Clean Milestone",
status: "complete",
dependsOn: [],
slices: [
{ id: "S01", title: "Done", done: true, active: false, risk: "low", depends: [], tasks: [] },
],
},
],
});
const html = generateHtmlReport(data, mockOpts());
assert.ok(html.includes("No blockers or high-risk items found"), "should show clean message");
});
test("Feature 8: blockers section in TOC nav", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes('href="#blockers"'), "TOC should contain blockers link");
});
// ─── Wave 4: Gantt Chart ──────────────────────────────────────────────────
test("Feature 13: slice Gantt chart is rendered with timing data", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes('class="gantt-wrap"'), "should contain gantt-wrap");
assert.ok(html.includes('class="gantt-svg"'), "should contain gantt-svg");
assert.ok(html.includes("Slice timeline"), "should have Gantt title");
assert.ok(html.includes("gantt-bar-"), "should contain gantt bars");
});
test("Feature 13: Gantt chart skipped with < 2 slices", () => {
const data = mockData({
units: [mockUnit("M001/S01/T01", 0.50, 1000, 2000)],
});
const html = generateHtmlReport(data, mockOpts());
assert.ok(!html.includes('class="gantt-wrap"'), "should not render Gantt with single slice");
});
// ─── Wave 5: Interactive JS Features ────────────────────────────────────────
test("Feature 9: timeline filter JS is included", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("tl-filter"), "should contain timeline filter class in JS");
assert.ok(html.includes("Filter timeline"), "should contain filter placeholder text");
});
test("Feature 10: collapsible sections JS is included", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("sec-toggle"), "should contain section toggle class");
assert.ok(html.includes("gsd-collapsed"), "should reference localStorage key for collapsed state");
});
test("Feature 11: dark/light theme toggle JS is included", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("theme-toggle"), "should contain theme toggle class");
assert.ok(html.includes("gsd-theme"), "should reference localStorage key for theme");
assert.ok(html.includes("light-theme"), "should reference light-theme class");
});
// ─── Wave 6: Responsive CSS ────────────────────────────────────────────────
test("Feature 12: responsive media queries are included", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("max-width:768px"), "should contain 768px breakpoint");
assert.ok(html.includes("max-width:480px"), "should contain 480px breakpoint");
});
// ─── Edge Cases ─────────────────────────────────────────────────────────────
test("Edge: no totals data renders without crash", () => {
const data = mockData({ totals: null, units: [], byPhase: [], bySlice: [], byModel: [] });
const html = generateHtmlReport(data, mockOpts());
assert.ok(html.includes('id="summary"'), "should render summary section");
assert.ok(html.includes('id="metrics"'), "should render metrics section");
assert.ok(!html.includes("Cost/slice"), "should not show cost/slice without totals");
});
test("Edge: zero completion rate and zero remaining slices", () => {
const data = mockData({
agentActivity: null,
remainingSliceCount: 0,
});
const html = generateHtmlReport(data, mockOpts());
assert.ok(!html.includes('class="eta-line"'), "no ETA line with null activity");
assert.ok(html.includes('id="summary"'), "summary still renders");
});
test("Edge: empty milestones array", () => {
const data = mockData({ milestones: [] });
const html = generateHtmlReport(data, mockOpts());
assert.ok(html.includes("0% complete across 0 milestones"), "should show 0% completion");
});
test("Edge: light theme CSS variables are defined", () => {
const html = generateHtmlReport(mockData(), mockOpts());
// Verify that light-theme class contains override variables
assert.ok(html.includes(".light-theme{"), "should include light-theme CSS rule");
assert.ok(html.includes("--bg-0:#fff"), "should override bg-0 in light theme");
});
test("Edge: print media query still present", () => {
const html = generateHtmlReport(mockData(), mockOpts());
assert.ok(html.includes("@media print"), "should still contain print media query");
});