mirror of https://github.com/garrytan/gstack.git
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
/**
|
|
* dashboard-cli — terminal renderers for the gstack sprint dashboard.
|
|
*
|
|
* Three modes:
|
|
* renderOneliner(data) — single line, ~80 chars, for the PreToolUse hook
|
|
* renderCompact(data) — ~30 lines, default for /dashboard skill
|
|
* renderFull(data) — ~80 lines, all sections
|
|
*/
|
|
|
|
import type { DashboardData, OnelinerData, Stage } from "./dashboard-data";
|
|
import { STAGE_ORDER, STAGE_LABELS } from "./dashboard-data";
|
|
|
|
// ─── Shared helpers ─────────────────────────────────────────────────────────
|
|
|
|
function relativeTime(ts: string): string {
|
|
const diff = Date.now() - new Date(ts).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 2) return "just now";
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return `${hrs}h ago`;
|
|
return `${Math.floor(hrs / 24)}d ago`;
|
|
}
|
|
|
|
function stageChar(reached: boolean, isCurrent: boolean): string {
|
|
if (!reached) return "○";
|
|
if (isCurrent) return "▶";
|
|
return "✓";
|
|
}
|
|
|
|
function avgQualityScore(quality: DashboardData["quality"]): string {
|
|
if (quality.length === 0) return "—";
|
|
const avg = quality.reduce((s, q) => s + q.score, 0) / quality.length;
|
|
return `${avg.toFixed(1)}/10`;
|
|
}
|
|
|
|
// ─── One-liner ──────────────────────────────────────────────────────────────
|
|
|
|
export function renderOneliner(data: OnelinerData): string {
|
|
const parts: string[] = [];
|
|
|
|
parts.push(`● ${data.branch}`);
|
|
if (data.version) parts.push(`v${data.version}`);
|
|
parts.push(`${data.inFlightCount} in-flight`);
|
|
|
|
// Show stages: completed ones + current + first unstarted (pipeline context)
|
|
const latestIdx = data.currentBranchLatestStage
|
|
? STAGE_ORDER.indexOf(data.currentBranchLatestStage)
|
|
: -1;
|
|
const showUpTo = Math.min(latestIdx + 2, STAGE_ORDER.length - 1);
|
|
const visibleStages = latestIdx >= 0 ? STAGE_ORDER.slice(0, showUpTo + 1) : [];
|
|
|
|
if (visibleStages.length > 0) {
|
|
const stageParts = visibleStages.map((s: Stage) => {
|
|
const reached = data.currentBranchStages.has(s);
|
|
const isCurrent = data.currentBranchLatestStage === s;
|
|
return `${STAGE_LABELS[s]}:${stageChar(reached, isCurrent)}`;
|
|
});
|
|
parts.push(stageParts.join(" "));
|
|
}
|
|
|
|
if (data.lastShipDate) {
|
|
const diffDays = Math.floor(
|
|
(Date.now() - new Date(data.lastShipDate).getTime()) / 86400000
|
|
);
|
|
parts.push(`shipped ${diffDays === 0 ? "today" : `${diffDays}d ago`}`);
|
|
}
|
|
if (data.p1Count !== null) parts.push(`P1:${data.p1Count}`);
|
|
|
|
return parts.join(" ");
|
|
}
|
|
|
|
// ─── Compact (~30 lines) ────────────────────────────────────────────────────
|
|
|
|
export function renderCompact(data: DashboardData): string {
|
|
const W = 72;
|
|
const heavy = "━".repeat(W);
|
|
const thin = "─".repeat(W);
|
|
const lines: string[] = [];
|
|
|
|
// Header
|
|
const vStr = data.version ? `v${data.version}` : "—";
|
|
const avgStr =
|
|
data.velocity.avgDaysBetween !== null
|
|
? `avg ${data.velocity.avgDaysBetween.toFixed(1)}d`
|
|
: null;
|
|
const headerParts = [
|
|
`gstack ${data.slug}`,
|
|
vStr,
|
|
`${data.inFlightCount} in-flight`,
|
|
`${data.velocity.releasesThisMonth} ships/mo`,
|
|
...(avgStr ? [avgStr] : []),
|
|
...(data.backlog?.P1 != null ? [`P1:${data.backlog.P1}`] : []),
|
|
];
|
|
lines.push(heavy);
|
|
lines.push(` ${headerParts.join(" · ")}`);
|
|
lines.push(heavy);
|
|
lines.push("");
|
|
|
|
// Pipeline
|
|
const stageHdr = STAGE_ORDER.map((s: Stage) => STAGE_LABELS[s].padEnd(6)).join(" ");
|
|
lines.push(` PIPELINE ${stageHdr}`);
|
|
lines.push(` ${thin}`);
|
|
|
|
const tracked = data.features.filter((f) => f.latestStage !== null).slice(0, 8);
|
|
const untrackedCount = data.features.filter((f) => f.latestStage === null).length;
|
|
if (tracked.length === 0) {
|
|
lines.push(" no tracked branches yet");
|
|
} else {
|
|
for (const f of tracked) {
|
|
const nameCol = f.branch.slice(0, 20).padEnd(20);
|
|
const stages = STAGE_ORDER.map((s: Stage) =>
|
|
stageChar(f.stagesReached.has(s), f.latestStage === s).padEnd(6)
|
|
).join(" ");
|
|
const pr = data.prMap.get(f.branch);
|
|
const prTag = pr ? ` #${pr.number}` : "";
|
|
const current = f.branch === data.branch ? " ◀" : "";
|
|
lines.push(` ${nameCol} ${stages}${prTag}${current}`);
|
|
}
|
|
}
|
|
if (untrackedCount > 0) {
|
|
lines.push(` (+${untrackedCount} untracked — no skill activity)`);
|
|
}
|
|
lines.push("");
|
|
|
|
// Activity
|
|
lines.push(" RECENT ACTIVITY");
|
|
lines.push(` ${thin}`);
|
|
const acts = data.activity.slice(0, 5);
|
|
if (acts.length === 0) {
|
|
lines.push(" no activity yet");
|
|
} else {
|
|
for (const a of acts) {
|
|
const time = relativeTime(a.ts).padEnd(9);
|
|
const skill = `/${a.skill}`.padEnd(20);
|
|
const branch = a.branch.slice(0, 22).padEnd(22);
|
|
lines.push(` ${time} ${skill} ${branch} ${a.event}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
// Quality + top skills footer
|
|
const qualStr = `QUALITY ${avgQualityScore(data.quality)}`;
|
|
const topStr = data.topSkills
|
|
.slice(0, 4)
|
|
.map((s) => `/${s.skill}:${s.count}`)
|
|
.join(" ");
|
|
lines.push(` ${qualStr} · TOP SKILLS ${topStr}`);
|
|
lines.push("");
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// ─── Full (~80 lines) ───────────────────────────────────────────────────────
|
|
|
|
export function renderFull(data: DashboardData): string {
|
|
const W = 80;
|
|
const heavy = "━".repeat(W);
|
|
const thin = "─".repeat(W);
|
|
const lines: string[] = [];
|
|
|
|
// Header
|
|
lines.push(heavy);
|
|
lines.push(` gstack sprint dashboard — ${data.slug}`);
|
|
const vStr = data.version ? `v${data.version}` : "—";
|
|
const avgStr =
|
|
data.velocity.avgDaysBetween !== null
|
|
? `${data.velocity.avgDaysBetween.toFixed(1)}d`
|
|
: "—";
|
|
lines.push(
|
|
` ${vStr} | ${data.inFlightCount} in-flight | ${data.velocity.releasesThisMonth} ships/mo` +
|
|
` | avg ${avgStr}/release | P1:${data.backlog?.P1 ?? "—"} | decisions:${data.openDecisions ?? "—"}`
|
|
);
|
|
lines.push(` generated ${data.generatedAt.toLocaleString()}`);
|
|
lines.push(heavy);
|
|
lines.push("");
|
|
|
|
// Pipeline
|
|
const stageHdr = STAGE_ORDER.map((s: Stage) => STAGE_LABELS[s].padEnd(7)).join(" ");
|
|
lines.push(` PIPELINE ${stageHdr} Last Activity`);
|
|
lines.push(` ${thin}`);
|
|
|
|
if (data.features.length === 0) {
|
|
lines.push(" no local branches found");
|
|
} else {
|
|
for (const f of data.features) {
|
|
const nameCol = f.branch.slice(0, 22).padEnd(22);
|
|
const stages = STAGE_ORDER.map((s: Stage) =>
|
|
stageChar(f.stagesReached.has(s), f.latestStage === s).padEnd(7)
|
|
).join(" ");
|
|
const lastAct = f.latestTs
|
|
? `${relativeTime(f.latestTs)} /${f.latestSkill ?? ""}`.slice(0, 22).padEnd(22)
|
|
: "—".padEnd(22);
|
|
const pr = data.prMap.get(f.branch);
|
|
const prTag = pr ? ` #${pr.number} ${pr.state}` : "";
|
|
const current = f.branch === data.branch ? " ◀" : "";
|
|
lines.push(` ${nameCol} ${stages} ${lastAct}${prTag}${current}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
// Activity feed
|
|
lines.push(" ACTIVITY FEED");
|
|
lines.push(` ${thin}`);
|
|
if (data.activity.length === 0) {
|
|
lines.push(" no activity yet");
|
|
} else {
|
|
for (const a of data.activity) {
|
|
const time = relativeTime(a.ts).padEnd(10);
|
|
const skill = `/${a.skill}`.padEnd(22);
|
|
const branch = a.branch.slice(0, 24).padEnd(24);
|
|
lines.push(` ${time} ${skill} ${branch} ${a.event}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
// Top skills
|
|
lines.push(" TOP SKILLS (last 30d)");
|
|
lines.push(` ${thin}`);
|
|
if (data.topSkills.length === 0) {
|
|
lines.push(" no data yet");
|
|
} else {
|
|
const max = data.topSkills[0].count;
|
|
for (const s of data.topSkills) {
|
|
const barLen = Math.round((s.count / max) * 28);
|
|
const bar = "█".repeat(barLen) + "░".repeat(28 - barLen);
|
|
lines.push(` /${s.skill.padEnd(18)} ${bar} ${s.count}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
// Quality scores
|
|
lines.push(" QUALITY SCORES (last 30d)");
|
|
lines.push(` ${thin}`);
|
|
if (data.quality.length === 0) {
|
|
lines.push(" no data yet");
|
|
} else {
|
|
for (const q of data.quality) {
|
|
const date = new Date(q.ts).toLocaleDateString().padEnd(12);
|
|
const skill = `/${q.skill}`.padEnd(20);
|
|
const score = `${q.score}/10`.padStart(5);
|
|
lines.push(` ${date} ${skill} ${score} (${q.iterations} iter)`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
// Release velocity
|
|
lines.push(" RELEASE VELOCITY (last 30d)");
|
|
lines.push(` ${thin}`);
|
|
if (data.velocity.recentVersions.length === 0) {
|
|
lines.push(" no releases in the last 30 days");
|
|
} else {
|
|
const blocks = " ▁▂▃▄▅▆▇█";
|
|
const max = Math.max(1, ...data.velocity.recentVersions.map((v) => v.commitCount));
|
|
const sparkline = data.velocity.recentVersions
|
|
.map((v) => blocks[Math.min(8, Math.round((v.commitCount / max) * 8))])
|
|
.join("");
|
|
lines.push(` ${sparkline}`);
|
|
for (const v of data.velocity.recentVersions) {
|
|
lines.push(` v${v.version.padEnd(16)} ${v.date} ${v.commitCount} commit(s)`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
// Design docs
|
|
if (data.designDocs.length > 0) {
|
|
lines.push(" ACTIVE DESIGN DOCS");
|
|
lines.push(` ${thin}`);
|
|
for (const d of data.designDocs) {
|
|
const date = d.mtime ? new Date(d.mtime).toLocaleDateString().padEnd(12) : "—".padEnd(12);
|
|
lines.push(` ${d.name.slice(0, 52).padEnd(52)} ${date}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
// Backlog
|
|
lines.push(" TODOS BACKLOG");
|
|
lines.push(` ${thin}`);
|
|
if (!data.backlog) {
|
|
lines.push(" no TODOS.md found");
|
|
} else {
|
|
const priorities = (["P0", "P1", "P2", "P3", "P4"] as const).filter(
|
|
(p) => data.backlog![p] > 0
|
|
);
|
|
if (priorities.length === 0) {
|
|
lines.push(" backlog is empty");
|
|
} else {
|
|
for (const p of priorities) {
|
|
lines.push(` ${p}: ${data.backlog![p]}`);
|
|
}
|
|
}
|
|
}
|
|
lines.push("");
|
|
|
|
return lines.join("\n");
|
|
}
|