#!/usr/bin/env bun /** * gstack-sprint-dashboard — sprint board in three output modes. * * Usage: * gstack-sprint-dashboard # compact terminal (default) * gstack-sprint-dashboard --compact # compact terminal (explicit) * gstack-sprint-dashboard --full # full terminal view * gstack-sprint-dashboard --web # write sprint.html + open in browser * gstack-sprint-dashboard --out # write HTML to (implies --web) * gstack-sprint-dashboard --slug # override project slug * * Backwards-compat positional args (mapped to --web): * gstack-sprint-dashboard open # alias for --web * gstack-sprint-dashboard out # alias for --web --out */ import { writeFileSync, readFileSync } from "fs"; import { resolve } from "path"; import { execFileSync, spawn } from "child_process"; import { buildDashboard } from "../lib/dashboard-data"; import { renderCompact, renderFull } from "../lib/dashboard-cli"; // ─── HTML helpers (web mode only) ─────────────────────────────────────────── function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function stageIcon(reached: boolean, isCurrent: boolean): string { if (!reached) return ""; return ``; } function renderHtml(data: ReturnType): string { const { slug, branch, version, generatedAt, inFlightCount, features, activity, velocity, topSkills, quality, designDocs, backlog, openDecisions, prMap } = data; // ── Stage constants for HTML ── const STAGES = [ { id: "office-hours", label: "OH" }, { id: "spec", label: "Spec" }, { id: "plan-review", label: "Plan" }, { id: "implement", label: "Impl" }, { id: "review", label: "Review" }, { id: "ship", label: "Ship" }, { id: "canary", label: "Canary" }, ] as const; const now = new Date(); const HISTORY_DAYS = 30; // ── Header stats ── const statsBar = `
${[ ["Version", version ? `v${version}` : null], ["In-flight", inFlightCount], ["Ships this month", velocity.releasesThisMonth], ["Avg days/release", velocity.avgDaysBetween !== null ? velocity.avgDaysBetween.toFixed(1) : null], ["P1 open", backlog?.P1 ?? null], ["Open decisions", openDecisions], ].map(([label, value]) => `
${value === null || value === undefined ? "—" : escapeHtml(String(value))}
${escapeHtml(String(label))}
` ).join("")}
`; // ── Pipeline ── const stageHeaders = STAGES.map((s) => `${s.label}`).join(""); const pipelineRows = features.map((f) => { const stageCells = STAGES.map((s) => { const reached = f.stagesReached.has(s.id as any); const isCurrent = f.latestStage === s.id; return `${stageIcon(reached, isCurrent)}`; }).join(""); const pr = prMap.get(f.branch); const prCell = pr ? `#${pr.number} ${pr.state}` : "—"; const lastAct = f.latestTs ? `${escapeHtml(f.latestSkill ?? "")} · ${new Date(f.latestTs).toLocaleString()}` : "—"; return ` ${escapeHtml(f.branch)}${f.latestStage === null ? ' (untracked)' : ""} ${stageCells} ${lastAct} ${prCell} `; }).join("\n"); const pipeline = features.length === 0 ? `
no data yet — no local git branches found
` : `${stageHeaders}${pipelineRows}
BranchLast ActivityPR
`; // ── Activity feed ── const activityHtml = activity.length === 0 ? `
no data yet
` : `
    ${activity.map((e) => `
  • ${new Date(e.ts).toLocaleString()} ${escapeHtml(e.skill)} ${escapeHtml(e.branch)} ${escapeHtml(e.event)}
  • `).join("\n")}
`; // ── Top skills ── const topSkillsHtml = topSkills.length === 0 ? `
no data yet
` : (() => { const max = topSkills[0]?.count || 1; return `
${topSkills.map((s) => `
${escapeHtml(s.skill)}
${s.count}
`).join("\n")}
`; })(); // ── Quality ── const qualityHtml = quality.length === 0 ? `
no data yet
` : `${quality.map((e) => ``).join("\n")}
DateSkillIterationsScore
${new Date(e.ts).toLocaleDateString()} ${escapeHtml(e.skill)} ${e.iterations} ${e.score}/10
`; // ── Design docs ── const designDocsHtml = designDocs.length === 0 ? `
no data yet
` : `
    ${designDocs.map((d) => `
  • ${escapeHtml(d.name)} ${d.mtime ? new Date(d.mtime).toLocaleDateString() : "—"}
  • `).join("\n")}
`; // ── Release velocity ── const releaseVelocityHtml = (() => { const recent = velocity.recentVersions; if (recent.length === 0) return `
no releases in the last ${HISTORY_DAYS} days
`; const blocks = " ▁▂▃▄▅▆▇█"; const max = Math.max(1, ...recent.map((v) => v.commitCount)); const sparkline = recent .map((v) => `${blocks[Math.min(8, Math.round((v.commitCount / max) * 8))]}`) .join(""); const versionList = recent .map((v) => `
  • v${escapeHtml(v.version)} — ${v.date} — ${v.commitCount} commit(s)
  • `) .join("\n"); return `
    ${sparkline}
      ${versionList}
    `; })(); // ── Backlog ── const backlogHtml = !backlog ? `
    no TODOS.md found
    ` : (() => { const total = Object.values(backlog).reduce((a, b) => a + b, 0); if (total === 0) return `
    no level-3 headings in TODOS.md
    `; const rows = (["P0", "P1", "P2", "P3", "P4", "unparsed"] as const) .filter((p) => backlog[p] > 0) .map((p) => `${p}${backlog[p]}`) .join("\n"); return `${rows}
    PriorityCount
    `; })(); return ` gstack sprint dashboard — ${escapeHtml(slug)}

    gstack sprint dashboard

    project: ${escapeHtml(slug)} · branch: ${escapeHtml(branch)} · generated ${escapeHtml(generatedAt.toLocaleString())}
    ${statsBar}

    Pipeline board

    ${pipeline}

    Activity feed

    ${activityHtml}

    Top skills (last ${HISTORY_DAYS}d)

    ${topSkillsHtml}

    Quality scores (last ${HISTORY_DAYS}d)

    ${qualityHtml}

    Active design docs

    ${designDocsHtml}

    Release velocity (last ${HISTORY_DAYS}d)

    ${releaseVelocityHtml}

    TODOS backlog health

    ${backlogHtml}
    `; } // ─── Open helper ───────────────────────────────────────────────────────────── function isWsl(): boolean { if (process.platform !== "linux") return false; try { return /microsoft/i.test(readFileSync("/proc/version", "utf-8")); } catch { return false; } } function openInBrowser(path: string) { const absPath = resolve(path); let cmd: string; let args: string[]; if (process.platform === "darwin") { cmd = "open"; args = [absPath]; } else if (process.platform === "win32") { cmd = "explorer.exe"; args = [absPath]; } else if (isWsl()) { cmd = "explorer.exe"; try { const winPath = execFileSync("wslpath", ["-w", absPath], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], }).trim(); args = [winPath]; } catch { args = [absPath]; } } else { cmd = "xdg-open"; args = [absPath]; } try { const child = spawn(cmd, args, { stdio: "ignore", detached: true }); child.on("error", () => { console.error(`Could not open ${absPath} automatically — open it manually.`); }); child.unref(); } catch { console.error(`Could not open ${absPath} automatically — open it manually.`); } } // ─── Main ───────────────────────────────────────────────────────────────────── function main() { const args = process.argv.slice(2).filter((a) => a !== "--"); // Backwards-compat: positional "open" and "out " const positionalOpen = args.includes("open"); const positionalOutIdx = args.indexOf("out"); const positionalOutPath = positionalOutIdx >= 0 ? args[positionalOutIdx + 1] : undefined; // Determine mode let mode: "compact" | "full" | "web" = "compact"; if (args.includes("--full")) mode = "full"; if (args.includes("--web") || args.includes("--open") || positionalOpen || positionalOutPath) mode = "web"; // Options const outArgIdx = args.indexOf("--out"); const outPath = outArgIdx >= 0 ? args[outArgIdx + 1] : (positionalOutPath ?? "sprint.html"); const slugArgIdx = args.indexOf("--slug"); const slugArg = slugArgIdx >= 0 ? args[slugArgIdx + 1] : undefined; if (mode === "compact") { const data = buildDashboard({ slug: slugArg, loadPr: false, loadDecisions: false }); process.stdout.write(renderCompact(data)); return; } if (mode === "full") { const data = buildDashboard({ slug: slugArg, loadPr: true, loadDecisions: true }); process.stdout.write(renderFull(data)); return; } // web mode const data = buildDashboard({ slug: slugArg, loadPr: true, loadDecisions: true }); const html = renderHtml(data); writeFileSync(outPath, html, "utf-8"); console.log(`Wrote ${outPath}`); openInBrowser(outPath); } main();