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:
parent
50bea6e73a
commit
326cef0b2d
2 changed files with 737 additions and 3 deletions
|
|
@ -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);
|
||||
})();
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue