feat: HTML report generator with progression index (#876)
BIN
docs/pr-876/01-index.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/pr-876/02-summary.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
docs/pr-876/03-progress.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
docs/pr-876/04-depgraph.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
docs/pr-876/05-metrics.png
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
docs/pr-876/06-changelog.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
docs/pr-876/06-timeline.png
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
docs/pr-876/07-changelog.png
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
docs/pr-876/07-knowledge.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
docs/pr-876/08-knowledge.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
docs/pr-876/09-captures.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
docs/pr-876/10-artifacts.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
|
|
@ -2334,6 +2334,55 @@ async function dispatchNextUnit(
|
|||
if (vizPrefs?.auto_visualize) {
|
||||
ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
|
||||
}
|
||||
// Auto-generate HTML report snapshot on milestone completion (default: on, disable with auto_report: false)
|
||||
if (vizPrefs?.auto_report !== false) {
|
||||
try {
|
||||
const { loadVisualizerData } = await import("./visualizer-data.js");
|
||||
const { generateHtmlReport } = await import("./export-html.js");
|
||||
const { writeReportSnapshot, reportsDir } = await import("./reports.js");
|
||||
const { basename } = await import("node:path");
|
||||
const snapData = await loadVisualizerData(basePath);
|
||||
const completedMs = snapData.milestones.find(m => m.id === currentMilestoneId);
|
||||
const msTitle = completedMs?.title ?? currentMilestoneId;
|
||||
const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
|
||||
const projName = basename(basePath);
|
||||
const doneSlices = snapData.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
|
||||
const totalSlices = snapData.milestones.reduce((s, m) => s + m.slices.length, 0);
|
||||
const outPath = writeReportSnapshot({
|
||||
basePath,
|
||||
html: generateHtmlReport(snapData, {
|
||||
projectName: projName,
|
||||
projectPath: basePath,
|
||||
gsdVersion,
|
||||
milestoneId: currentMilestoneId,
|
||||
indexRelPath: "index.html",
|
||||
}),
|
||||
milestoneId: currentMilestoneId,
|
||||
milestoneTitle: msTitle,
|
||||
kind: "milestone",
|
||||
projectName: projName,
|
||||
projectPath: basePath,
|
||||
gsdVersion,
|
||||
totalCost: snapData.totals?.cost ?? 0,
|
||||
totalTokens: snapData.totals?.tokens.total ?? 0,
|
||||
totalDuration: snapData.totals?.duration ?? 0,
|
||||
doneSlices,
|
||||
totalSlices,
|
||||
doneMilestones: snapData.milestones.filter(m => m.status === "complete").length,
|
||||
totalMilestones: snapData.milestones.length,
|
||||
phase: snapData.phase,
|
||||
});
|
||||
ctx.ui.notify(
|
||||
`Report saved: .gsd/reports/${basename(outPath)} — open index.html to browse progression.`,
|
||||
"info",
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.ui.notify(
|
||||
`Report generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reset stuck detection for new milestone
|
||||
unitDispatchCount.clear();
|
||||
unitRecoveryCount.clear();
|
||||
|
|
|
|||
|
|
@ -625,7 +625,7 @@ function showHelp(ctx: ExtensionCommandContext): void {
|
|||
"",
|
||||
"MAINTENANCE",
|
||||
" /gsd doctor Diagnose and repair .gsd/ state [audit|fix|heal] [scope]",
|
||||
" /gsd export Export milestone/slice results [--json|--markdown]",
|
||||
" /gsd export Export milestone/slice results [--json|--markdown|--html]",
|
||||
" /gsd cleanup Remove merged branches or snapshots [branches|snapshots]",
|
||||
" /gsd migrate Upgrade .gsd/ structures to new format",
|
||||
" /gsd remote Control remote auto-mode [slack|discord|status|disconnect]",
|
||||
|
|
|
|||
1001
src/resources/extensions/gsd/export-html.ts
Normal file
|
|
@ -93,9 +93,57 @@ export function writeExportFile(
|
|||
}
|
||||
|
||||
/**
|
||||
* Export session/milestone data to JSON or markdown.
|
||||
* Export session/milestone data to JSON, markdown, or HTML.
|
||||
*/
|
||||
export async function handleExport(args: string, ctx: ExtensionCommandContext, basePath: string): Promise<void> {
|
||||
// HTML report — delegates to the full visualizer-data pipeline
|
||||
if (args.includes("--html")) {
|
||||
try {
|
||||
const { loadVisualizerData } = await import("./visualizer-data.js");
|
||||
const { generateHtmlReport } = await import("./export-html.js");
|
||||
const { writeReportSnapshot, reportsDir } = await import("./reports.js");
|
||||
const { basename: bn } = await import("node:path");
|
||||
const data = await loadVisualizerData(basePath);
|
||||
const projName = basename(basePath);
|
||||
const gsdVersion = process.env.GSD_VERSION ?? "0.0.0";
|
||||
const doneSlices = data.milestones.reduce((s, m) => s + m.slices.filter(sl => sl.done).length, 0);
|
||||
const totalSlices = data.milestones.reduce((s, m) => s + m.slices.length, 0);
|
||||
const outPath = writeReportSnapshot({
|
||||
basePath,
|
||||
html: generateHtmlReport(data, {
|
||||
projectName: projName,
|
||||
projectPath: basePath,
|
||||
gsdVersion,
|
||||
indexRelPath: "index.html",
|
||||
}),
|
||||
milestoneId: data.milestones.find(m => m.status === "active")?.id ?? "manual",
|
||||
milestoneTitle: data.milestones.find(m => m.status === "active")?.title ?? "",
|
||||
kind: "manual",
|
||||
projectName: projName,
|
||||
projectPath: basePath,
|
||||
gsdVersion,
|
||||
totalCost: data.totals?.cost ?? 0,
|
||||
totalTokens: data.totals?.tokens.total ?? 0,
|
||||
totalDuration: data.totals?.duration ?? 0,
|
||||
doneSlices,
|
||||
totalSlices,
|
||||
doneMilestones: data.milestones.filter(m => m.status === "complete").length,
|
||||
totalMilestones: data.milestones.length,
|
||||
phase: data.phase,
|
||||
});
|
||||
ctx.ui.notify(
|
||||
`HTML report saved: .gsd/reports/${bn(outPath)}\nBrowse all reports: .gsd/reports/index.html`,
|
||||
"success",
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.ui.notify(
|
||||
`HTML export failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const format = args.includes("--json") ? "json" : "markdown";
|
||||
|
||||
const ledger = getLedger();
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"token_profile",
|
||||
"phases",
|
||||
"auto_visualize",
|
||||
"auto_report",
|
||||
"parallel",
|
||||
"verification_commands",
|
||||
"verification_auto_fix",
|
||||
|
|
@ -175,6 +176,8 @@ export interface GSDPreferences {
|
|||
token_profile?: TokenProfile;
|
||||
phases?: PhaseSkipPreferences;
|
||||
auto_visualize?: boolean;
|
||||
/** Generate HTML report snapshot after each milestone completion. Default: true. Set false to disable. */
|
||||
auto_report?: boolean;
|
||||
parallel?: import("./types.js").ParallelConfig;
|
||||
verification_commands?: string[];
|
||||
verification_auto_fix?: boolean;
|
||||
|
|
|
|||
510
src/resources/extensions/gsd/reports.ts
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
/**
|
||||
* GSD Reports Registry
|
||||
*
|
||||
* Manages .gsd/reports/ — the persistent progression log of HTML snapshots.
|
||||
*
|
||||
* Layout:
|
||||
* .gsd/reports/
|
||||
* reports.json lightweight metadata index (never re-parses HTML)
|
||||
* index.html auto-regenerated on every new snapshot
|
||||
* M001-20260101T120000.html per-milestone snapshot
|
||||
* final-20260201T090000.html full-project final snapshot
|
||||
*
|
||||
* Auto-triggered: after each milestone completion (when auto_report: true).
|
||||
* Manual: /gsd export --html
|
||||
*/
|
||||
|
||||
import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { gsdRoot } from './paths.js';
|
||||
import { formatCost, formatTokenCount } from './metrics.js';
|
||||
import { formatDuration } from './history.js';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ReportEntry {
|
||||
/** Filename relative to the reports/ dir, e.g. "M001-20260101T120000.html" */
|
||||
filename: string;
|
||||
/** ISO timestamp when this report was generated */
|
||||
generatedAt: string;
|
||||
/** Milestone ID this snapshot covers, or "final" for a full-project snapshot */
|
||||
milestoneId: string | 'final';
|
||||
/** Milestone title at snapshot time */
|
||||
milestoneTitle: string;
|
||||
/** Human-readable label shown in the index */
|
||||
label: string;
|
||||
/** Snapshot kind */
|
||||
kind: 'milestone' | 'manual' | 'final';
|
||||
// Metrics at snapshot time — for the index progression view
|
||||
totalCost: number;
|
||||
totalTokens: number;
|
||||
totalDuration: number;
|
||||
doneSlices: number;
|
||||
totalSlices: number;
|
||||
doneMilestones: number;
|
||||
totalMilestones: number;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export interface ReportsIndex {
|
||||
version: 1;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
gsdVersion: string;
|
||||
entries: ReportEntry[];
|
||||
}
|
||||
|
||||
// ─── Paths ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function reportsDir(basePath: string): string {
|
||||
return join(gsdRoot(basePath), 'reports');
|
||||
}
|
||||
|
||||
function reportsIndexPath(basePath: string): string {
|
||||
return join(reportsDir(basePath), 'reports.json');
|
||||
}
|
||||
|
||||
function reportsHtmlIndexPath(basePath: string): string {
|
||||
return join(reportsDir(basePath), 'index.html');
|
||||
}
|
||||
|
||||
// ─── Registry ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function loadReportsIndex(basePath: string): ReportsIndex | null {
|
||||
const p = reportsIndexPath(basePath);
|
||||
if (!existsSync(p)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf-8')) as ReportsIndex;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveReportsIndex(basePath: string, index: ReportsIndex): void {
|
||||
const dir = reportsDir(basePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(reportsIndexPath(basePath), JSON.stringify(index, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
// ─── Write a report snapshot ──────────────────────────────────────────────────
|
||||
|
||||
export interface WriteReportSnapshotArgs {
|
||||
basePath: string;
|
||||
html: string;
|
||||
milestoneId: string | 'final';
|
||||
milestoneTitle: string;
|
||||
kind: 'milestone' | 'manual' | 'final';
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
gsdVersion: string;
|
||||
// metrics
|
||||
totalCost: number;
|
||||
totalTokens: number;
|
||||
totalDuration: number;
|
||||
doneSlices: number;
|
||||
totalSlices: number;
|
||||
doneMilestones: number;
|
||||
totalMilestones: number;
|
||||
phase: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a report snapshot to .gsd/reports/, update reports.json, regenerate index.html.
|
||||
* Returns the path of the written report file.
|
||||
*/
|
||||
export function writeReportSnapshot(args: WriteReportSnapshotArgs): string {
|
||||
const dir = reportsDir(args.basePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const prefix = args.milestoneId === 'final' ? 'final' : args.milestoneId;
|
||||
const filename = `${prefix}-${timestamp}.html`;
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
writeFileSync(filePath, args.html, 'utf-8');
|
||||
|
||||
// Load or init registry
|
||||
const existing = loadReportsIndex(args.basePath);
|
||||
const index: ReportsIndex = existing ?? {
|
||||
version: 1,
|
||||
projectName: args.projectName,
|
||||
projectPath: args.projectPath,
|
||||
gsdVersion: args.gsdVersion,
|
||||
entries: [],
|
||||
};
|
||||
|
||||
// Keep metadata fresh
|
||||
index.projectName = args.projectName;
|
||||
index.projectPath = args.projectPath;
|
||||
index.gsdVersion = args.gsdVersion;
|
||||
|
||||
const label = args.milestoneId === 'final'
|
||||
? 'Final Report'
|
||||
: `${args.milestoneId}: ${args.milestoneTitle}`;
|
||||
|
||||
const entry: ReportEntry = {
|
||||
filename,
|
||||
generatedAt: new Date().toISOString(),
|
||||
milestoneId: args.milestoneId,
|
||||
milestoneTitle: args.milestoneTitle,
|
||||
label,
|
||||
kind: args.kind,
|
||||
totalCost: args.totalCost,
|
||||
totalTokens: args.totalTokens,
|
||||
totalDuration: args.totalDuration,
|
||||
doneSlices: args.doneSlices,
|
||||
totalSlices: args.totalSlices,
|
||||
doneMilestones: args.doneMilestones,
|
||||
totalMilestones: args.totalMilestones,
|
||||
phase: args.phase,
|
||||
};
|
||||
|
||||
index.entries.push(entry);
|
||||
saveReportsIndex(args.basePath, index);
|
||||
regenerateHtmlIndex(args.basePath, index);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// ─── HTML Index Generator ─────────────────────────────────────────────────────
|
||||
|
||||
export function regenerateHtmlIndex(basePath: string, index: ReportsIndex): void {
|
||||
const html = buildIndexHtml(index);
|
||||
writeFileSync(reportsHtmlIndexPath(basePath), html, 'utf-8');
|
||||
}
|
||||
|
||||
function buildIndexHtml(index: ReportsIndex): string {
|
||||
const { projectName, projectPath, gsdVersion, entries } = index;
|
||||
const generated = new Date().toISOString();
|
||||
|
||||
// Sort oldest → newest for the progression timeline
|
||||
const sorted = [...entries].sort(
|
||||
(a, b) => new Date(a.generatedAt).getTime() - new Date(b.generatedAt).getTime()
|
||||
);
|
||||
|
||||
const latestEntry = sorted[sorted.length - 1];
|
||||
const overallPct = latestEntry
|
||||
? (latestEntry.totalSlices > 0
|
||||
? Math.round((latestEntry.doneSlices / latestEntry.totalSlices) * 100)
|
||||
: 0)
|
||||
: 0;
|
||||
|
||||
// TOC: group by milestone
|
||||
const milestoneGroups = new Map<string, ReportEntry[]>();
|
||||
for (const e of sorted) {
|
||||
const key = e.milestoneId;
|
||||
const arr = milestoneGroups.get(key) ?? [];
|
||||
arr.push(e);
|
||||
milestoneGroups.set(key, arr);
|
||||
}
|
||||
|
||||
const tocHtml = [...milestoneGroups.entries()].map(([mid, group]) => {
|
||||
const links = group.map(e =>
|
||||
`<li><a href="${esc(e.filename)}">${formatDateShort(e.generatedAt)}</a> <span class="toc-kind toc-${e.kind}">${e.kind}</span></li>`
|
||||
).join('');
|
||||
return `
|
||||
<div class="toc-group">
|
||||
<div class="toc-group-label">${esc(mid === 'final' ? 'Final' : mid)}</div>
|
||||
<ul>${links}</ul>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Progression cards
|
||||
const cardHtml = sorted.map((e, i) => {
|
||||
const pct = e.totalSlices > 0 ? Math.round((e.doneSlices / e.totalSlices) * 100) : 0;
|
||||
const isLatest = i === sorted.length - 1;
|
||||
|
||||
// Delta vs previous
|
||||
let deltaHtml = '';
|
||||
if (i > 0) {
|
||||
const prev = sorted[i - 1];
|
||||
const dCost = e.totalCost - prev.totalCost;
|
||||
const dSlices = e.doneSlices - prev.doneSlices;
|
||||
const dMillestones = e.doneMilestones - prev.doneMilestones;
|
||||
const parts: string[] = [];
|
||||
if (dCost > 0) parts.push(`+${formatCost(dCost)}`);
|
||||
if (dSlices > 0) parts.push(`+${dSlices} slice${dSlices !== 1 ? 's' : ''}`);
|
||||
if (dMillestones > 0) parts.push(`+${dMillestones} milestone${dMillestones !== 1 ? 's' : ''}`);
|
||||
if (parts.length > 0) {
|
||||
deltaHtml = `<div class="card-delta">${parts.map(p => `<span>${esc(p)}</span>`).join('')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<a class="report-card${isLatest ? ' card-latest' : ''}" href="${esc(e.filename)}">
|
||||
<div class="card-top">
|
||||
<span class="card-label">${esc(e.label)}</span>
|
||||
<span class="card-kind card-kind-${e.kind}">${e.kind}</span>
|
||||
</div>
|
||||
<div class="card-date">${formatDateShort(e.generatedAt)}</div>
|
||||
<div class="card-progress">
|
||||
<div class="card-bar-track">
|
||||
<div class="card-bar-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="card-pct">${pct}%</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<span>${esc(formatCost(e.totalCost))}</span>
|
||||
<span>${esc(formatTokenCount(e.totalTokens))}</span>
|
||||
<span>${esc(formatDuration(e.totalDuration))}</span>
|
||||
<span>${e.doneSlices}/${e.totalSlices} slices</span>
|
||||
</div>
|
||||
${deltaHtml}
|
||||
${isLatest ? '<div class="card-latest-badge">Latest</div>' : ''}
|
||||
</a>`;
|
||||
}).join('');
|
||||
|
||||
// Cost progression mini-chart (inline SVG sparkline)
|
||||
const sparklineSvg = sorted.length > 1 ? buildCostSparkline(sorted) : '';
|
||||
|
||||
// Summary of latest state
|
||||
const summaryHtml = latestEntry ? `
|
||||
<div class="idx-summary">
|
||||
<div class="idx-stat"><span class="idx-val">${formatCost(latestEntry.totalCost)}</span><span class="idx-lbl">Total Cost</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">${formatTokenCount(latestEntry.totalTokens)}</span><span class="idx-lbl">Total Tokens</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">${formatDuration(latestEntry.totalDuration)}</span><span class="idx-lbl">Duration</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">${latestEntry.doneSlices}/${latestEntry.totalSlices}</span><span class="idx-lbl">Slices</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">${latestEntry.doneMilestones}/${latestEntry.totalMilestones}</span><span class="idx-lbl">Milestones</span></div>
|
||||
<div class="idx-stat"><span class="idx-val">${entries.length}</span><span class="idx-lbl">Reports</span></div>
|
||||
</div>
|
||||
<div class="idx-progress">
|
||||
<div class="idx-bar-track"><div class="idx-bar-fill" style="width:${overallPct}%"></div></div>
|
||||
<span class="idx-pct">${overallPct}% complete</span>
|
||||
</div>` : '<p class="empty">No reports generated yet.</p>';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GSD Reports — ${esc(projectName)}</title>
|
||||
<style>${INDEX_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="hdr-inner">
|
||||
<div class="branding">
|
||||
<span class="logo">GSD</span>
|
||||
<span class="ver">v${esc(gsdVersion)}</span>
|
||||
</div>
|
||||
<div class="hdr-meta">
|
||||
<h1>${esc(projectName)} <span class="hdr-subtitle">Reports</span></h1>
|
||||
<span class="hdr-path">${esc(projectPath)}</span>
|
||||
</div>
|
||||
<div class="hdr-right">
|
||||
<span class="gen-lbl">Updated</span>
|
||||
<span class="gen">${formatDateShort(generated)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<!-- Sidebar TOC -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-title">Reports</div>
|
||||
${sorted.length > 0 ? tocHtml : '<p class="empty">No reports yet.</p>'}
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main>
|
||||
<section class="idx-overview">
|
||||
<h2>Project Overview</h2>
|
||||
${summaryHtml}
|
||||
${sparklineSvg ? `<div class="sparkline-wrap"><h3>Cost Progression</h3>${sparklineSvg}</div>` : ''}
|
||||
</section>
|
||||
|
||||
<section class="idx-cards">
|
||||
<h2>Progression <span class="sec-count">${entries.length}</span></h2>
|
||||
${sorted.length > 0
|
||||
? `<div class="cards-grid">${cardHtml}</div>`
|
||||
: '<p class="empty">No reports generated yet. Run <code>/gsd export --html</code> or enable <code>auto_report: true</code>.</p>'}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="ftr-inner">
|
||||
<span class="ftr-brand">GSD v${esc(gsdVersion)}</span>
|
||||
<span class="ftr-sep">—</span>
|
||||
<span>${esc(projectName)}</span>
|
||||
<span class="ftr-sep">—</span>
|
||||
<span>${esc(projectPath)}</span>
|
||||
<span class="ftr-sep">—</span>
|
||||
<span>Updated ${formatDateShort(generated)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// ─── Cost sparkline (inline SVG) ──────────────────────────────────────────────
|
||||
|
||||
function buildCostSparkline(entries: ReportEntry[]): string {
|
||||
const costs = entries.map(e => e.totalCost);
|
||||
const maxCost = Math.max(...costs, 0.001);
|
||||
const W = 600, H = 60, PAD = 12;
|
||||
const xStep = entries.length > 1 ? (W - PAD * 2) / (entries.length - 1) : W - PAD * 2;
|
||||
|
||||
const points = costs.map((c, i) => {
|
||||
const x = PAD + i * xStep;
|
||||
const y = PAD + (1 - c / maxCost) * (H - PAD * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
const dots = costs.map((c, i) => {
|
||||
const x = PAD + i * xStep;
|
||||
const y = PAD + (1 - c / maxCost) * (H - PAD * 2);
|
||||
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" class="spark-dot">
|
||||
<title>${esc(entries[i].label)} — ${formatCost(c)}</title>
|
||||
</circle>`;
|
||||
}).join('');
|
||||
|
||||
// Labels at start and end
|
||||
const startLabel = formatCost(costs[0]);
|
||||
const endLabel = formatCost(costs[costs.length - 1]);
|
||||
|
||||
return `
|
||||
<div class="sparkline">
|
||||
<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" class="spark-svg">
|
||||
<polyline points="${esc(points)}" class="spark-line" fill="none"/>
|
||||
${dots}
|
||||
<text x="${PAD}" y="${H - 2}" class="spark-lbl">${esc(startLabel)}</text>
|
||||
<text x="${W - PAD}" y="${H - 2}" text-anchor="end" class="spark-lbl">${esc(endLabel)}</text>
|
||||
</svg>
|
||||
<div class="spark-axis">
|
||||
${entries.map((e, i) => {
|
||||
const x = (PAD + i * xStep) / W * 100;
|
||||
return `<span class="spark-tick" style="left:${x.toFixed(1)}%" title="${esc(e.generatedAt)}">${esc(e.milestoneId === 'final' ? 'final' : e.milestoneId)}</span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateShort(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function esc(s: string | number | undefined | null): string {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── Index CSS ────────────────────────────────────────────────────────────────
|
||||
|
||||
const INDEX_CSS = `
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg-0:#0f1115;--bg-1:#16181d;--bg-2:#1e2028;--bg-3:#272a33;
|
||||
--border-1:#2b2e38;--border-2:#3b3f4c;
|
||||
--text-0:#ededef;--text-1:#a1a1aa;--text-2:#71717a;
|
||||
--accent:#5e6ad2;--accent-subtle:rgba(94,106,210,.12);
|
||||
--font:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
||||
--mono:'JetBrains Mono','Fira Code',ui-monospace,monospace;
|
||||
}
|
||||
html{font-size:13px}
|
||||
body{background:var(--bg-0);color:var(--text-0);font-family:var(--font);line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--accent);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
h2{font-size:14px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-1);margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-1)}
|
||||
h3{font-size:13px;font-weight:600;color:var(--text-1);margin:16px 0 8px}
|
||||
code{font-family:var(--mono);font-size:12px;background:var(--bg-3);padding:1px 5px;border-radius:3px}
|
||||
.empty{color:var(--text-2);font-size:13px;padding:8px 0}
|
||||
.count{font-size:11px;font-weight:500;color:var(--text-2);background:var(--bg-3);border-radius:3px;padding:1px 6px}
|
||||
|
||||
/* Header */
|
||||
header{background:var(--bg-1);border-bottom:1px solid var(--border-1);padding:12px 32px;position:sticky;top:0;z-index:100}
|
||||
.hdr-inner{display:flex;align-items:center;gap:16px;max-width:1280px;margin:0 auto}
|
||||
.branding{display:flex;align-items:baseline;gap:6px;flex-shrink:0}
|
||||
.logo{font-size:18px;font-weight:800;letter-spacing:-.5px;color:var(--text-0)}
|
||||
.ver{font-size:10px;color:var(--text-2);font-family:var(--mono)}
|
||||
.hdr-meta{flex:1;min-width:0}
|
||||
.hdr-meta h1{font-size:15px;font-weight:600}
|
||||
.hdr-subtitle{color:var(--text-2);font-weight:400;font-size:13px;margin-left:4px}
|
||||
.hdr-path{font-size:11px;color:var(--text-2);font-family:var(--mono);display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.hdr-right{text-align:right;flex-shrink:0}
|
||||
.gen-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;display:block}
|
||||
.gen{font-size:11px;color:var(--text-1)}
|
||||
|
||||
/* Layout */
|
||||
.layout{display:grid;grid-template-columns:200px 1fr;gap:0;max-width:1280px;margin:0 auto;min-height:calc(100vh - 120px)}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar{background:var(--bg-1);border-right:1px solid var(--border-1);padding:20px 14px;position:sticky;top:52px;height:calc(100vh - 52px);overflow-y:auto}
|
||||
.sidebar-title{font-size:10px;font-weight:600;color:var(--text-2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:12px}
|
||||
.toc-group{margin-bottom:14px}
|
||||
.toc-group-label{font-size:11px;font-weight:600;color:var(--text-1);margin-bottom:3px;font-family:var(--mono)}
|
||||
.toc-group ul{list-style:none;display:flex;flex-direction:column;gap:1px}
|
||||
.toc-group li{display:flex;align-items:center;gap:6px}
|
||||
.toc-group a{font-size:11px;color:var(--text-2);padding:2px 4px;border-radius:3px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.toc-group a:hover{background:var(--bg-2);color:var(--text-0);text-decoration:none}
|
||||
.toc-kind{font-size:9px;color:var(--text-2);font-family:var(--mono);flex-shrink:0}
|
||||
|
||||
/* Main */
|
||||
main{padding:28px;display:flex;flex-direction:column;gap:40px}
|
||||
|
||||
/* Overview */
|
||||
.idx-summary{display:flex;flex-wrap:wrap;gap:1px;background:var(--border-1);border:1px solid var(--border-1);border-radius:4px;overflow:hidden;margin-bottom:16px}
|
||||
.idx-stat{background:var(--bg-1);padding:10px 16px;display:flex;flex-direction:column;gap:2px;min-width:100px;flex:1}
|
||||
.idx-val{font-size:18px;font-weight:600;color:var(--text-0);font-variant-numeric:tabular-nums}
|
||||
.idx-lbl{font-size:10px;color:var(--text-2);text-transform:uppercase;letter-spacing:.4px}
|
||||
.idx-progress{display:flex;align-items:center;gap:10px;margin-top:10px}
|
||||
.idx-bar-track{flex:1;height:4px;background:var(--bg-3);border-radius:2px;overflow:hidden}
|
||||
.idx-bar-fill{height:100%;background:var(--accent);border-radius:2px}
|
||||
.idx-pct{font-size:12px;font-weight:600;color:var(--text-1);min-width:40px;text-align:right}
|
||||
|
||||
/* Sparkline */
|
||||
.sparkline-wrap{margin-top:20px}
|
||||
.sparkline{position:relative}
|
||||
.spark-svg{display:block;background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;overflow:visible;max-width:100%}
|
||||
.spark-line{stroke:var(--accent);stroke-width:1.5;fill:none}
|
||||
.spark-dot{fill:var(--accent);stroke:var(--bg-1);stroke-width:2;cursor:pointer}
|
||||
.spark-dot:hover{r:4;fill:var(--text-0)}
|
||||
.spark-lbl{font-size:10px;fill:var(--text-2);font-family:var(--mono)}
|
||||
.spark-axis{display:flex;position:relative;height:18px;margin-top:2px}
|
||||
.spark-tick{position:absolute;transform:translateX(-50%);font-size:9px;color:var(--text-2);font-family:var(--mono);white-space:nowrap}
|
||||
|
||||
/* Report cards */
|
||||
.cards-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px}
|
||||
.report-card{
|
||||
display:flex;flex-direction:column;gap:6px;
|
||||
background:var(--bg-1);border:1px solid var(--border-1);border-radius:4px;
|
||||
padding:14px;text-decoration:none;color:var(--text-0);
|
||||
transition:border-color .12s;
|
||||
}
|
||||
.report-card:hover{border-color:var(--accent);text-decoration:none}
|
||||
.card-latest{border-color:var(--accent)}
|
||||
.card-top{display:flex;align-items:center;gap:8px}
|
||||
.card-label{flex:1;font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.card-kind{font-size:10px;color:var(--text-2);font-family:var(--mono);flex-shrink:0}
|
||||
.card-date{font-size:11px;color:var(--text-2)}
|
||||
.card-progress{display:flex;align-items:center;gap:6px}
|
||||
.card-bar-track{flex:1;height:3px;background:var(--bg-3);border-radius:2px;overflow:hidden}
|
||||
.card-bar-fill{height:100%;background:var(--accent);border-radius:2px}
|
||||
.card-pct{font-size:11px;color:var(--text-2);min-width:30px;text-align:right}
|
||||
.card-stats{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.card-stats span{font-size:11px;color:var(--text-2);font-variant-numeric:tabular-nums}
|
||||
.card-delta{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.card-delta span{font-size:10px;color:var(--text-1);font-family:var(--mono)}
|
||||
.card-latest-badge{display:none}
|
||||
|
||||
/* Footer */
|
||||
footer{border-top:1px solid var(--border-1);padding:16px 32px}
|
||||
.ftr-inner{display:flex;align-items:center;gap:6px;justify-content:center;font-size:11px;color:var(--text-2)}
|
||||
.ftr-sep{color:var(--border-2)}
|
||||
|
||||
@media(max-width:768px){
|
||||
.layout{grid-template-columns:1fr}
|
||||
.sidebar{position:static;height:auto;border-right:none;border-bottom:1px solid var(--border-1)}
|
||||
}
|
||||
@media print{
|
||||
.sidebar{display:none}
|
||||
header{position:static}
|
||||
body{background:#fff;color:#1a1a1a}
|
||||
:root{--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}
|
||||
}
|
||||
`;
|
||||