Skip to content

Commit 774af43

Browse files
Merge pull request #36 from ashutosh-rath02/feat/report
feat(cli): add report command (Markdown summary + SVG card)
2 parents d8f3bc4 + 8564b50 commit 774af43

3 files changed

Lines changed: 261 additions & 0 deletions

File tree

cli/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "../engine/scoring.js";
2323
import { Store, defaultDbPath } from "../store/db.js";
2424
import { runDoctor } from "./doctor.js";
25+
import { reportCommand } from "./report.js";
2526

2627
const __dirname = dirname(fileURLToPath(import.meta.url));
2728

@@ -466,6 +467,19 @@ program
466467
}
467468
});
468469

470+
program
471+
.command("report")
472+
.description("a shareable summary of your baseline (Markdown, or an SVG card with --out *.svg)")
473+
.option("-o, --out <file>", "write to a file (.svg renders a card, otherwise Markdown)")
474+
.action((flags: { out?: string }) => {
475+
const store = new Store();
476+
try {
477+
reportCommand(store, flags);
478+
} finally {
479+
store.close();
480+
}
481+
});
482+
469483
program.parseAsync().catch((err) => {
470484
console.error(pc.red(String(err instanceof Error ? err.message : err)));
471485
process.exit(1);

cli/report.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { Store } from "../store/db.js";
6+
import { buildReport, renderMarkdown, renderSvg, type ReportModel } from "./report.js";
7+
8+
let dir: string;
9+
let store: Store;
10+
11+
beforeEach(() => {
12+
dir = mkdtempSync(join(tmpdir(), "atrophy-report-"));
13+
store = new Store(join(dir, "t.db"));
14+
});
15+
afterEach(() => {
16+
store.close();
17+
rmSync(dir, { recursive: true, force: true });
18+
});
19+
20+
function sess(mode: "ai-off" | "ai-on", score: number, ts: string) {
21+
store.recordSession({
22+
ts,
23+
exercise_id: "x",
24+
axis: "debugging",
25+
language: "python",
26+
tier: 1,
27+
mode,
28+
passed: score,
29+
total: 1,
30+
elapsed_seconds: 10,
31+
score,
32+
rating_before: 1200,
33+
rating_after: 1200,
34+
});
35+
}
36+
37+
describe("buildReport", () => {
38+
it("summarizes ratings, overall, and untested axes", () => {
39+
store.saveRating("debugging", { rating: 1320, rd: 90, reps: 8 }, 2);
40+
const m = buildReport(store);
41+
const dbg = m.axes.find((a) => a.axis === "debugging")!;
42+
expect(dbg.rating).toBe(1320);
43+
expect(dbg.reps).toBe(8);
44+
expect(dbg.state).not.toBe("untested");
45+
const untested = m.axes.find((a) => a.axis === "code-reading")!;
46+
expect(untested.rating).toBeNull();
47+
expect(untested.state).toBe("untested");
48+
// overall = mean across 5 axes with untested at 1200
49+
expect(m.overall).toBe(Math.round((1320 + 4 * 1200) / 5));
50+
});
51+
52+
it("computes the with/without-AI gap only when both exist", () => {
53+
expect(buildReport(store).gap).toBeNull();
54+
sess("ai-off", 0.5, "2026-07-01T10:00:00Z");
55+
sess("ai-on", 0.9, "2026-07-02T10:00:00Z");
56+
const m = buildReport(store);
57+
expect(m.gap).toBeCloseTo(0.4, 5);
58+
});
59+
});
60+
61+
const model: ReportModel = {
62+
generatedAt: "2026-07-10T00:00:00Z",
63+
overall: 1193,
64+
totalReps: 6,
65+
streakWeeks: 1,
66+
axes: [
67+
{ axis: "syntax-recall", rating: 1215, reps: 2, state: "calibrating" },
68+
{ axis: "debugging", rating: 1208, reps: 1, state: "calibrating" },
69+
{ axis: "code-reading", rating: null, reps: 0, state: "untested" },
70+
{ axis: "api-memory", rating: 1176, reps: 1, state: "calibrating" },
71+
{ axis: "decomposition", rating: 1188, reps: 1, state: "calibrating" },
72+
],
73+
gap: 0.3,
74+
};
75+
76+
describe("renderMarkdown", () => {
77+
it("includes the overall, each axis, and the gap", () => {
78+
const md = renderMarkdown(model);
79+
expect(md).toContain("Overall 1193");
80+
expect(md).toContain("| syntax-recall | 1215 | 2 | calibrating |");
81+
expect(md).toContain("| code-reading | - | 0 | untested |");
82+
expect(md).toContain("gap: +0.30");
83+
expect(md).toContain("github.com/ashutosh-rath02/atrophy");
84+
});
85+
it("omits the gap line when there is no gap", () => {
86+
expect(renderMarkdown({ ...model, gap: null })).not.toContain("gap:");
87+
});
88+
});
89+
90+
describe("renderSvg", () => {
91+
it("is a self-contained svg with the overall and axis names", () => {
92+
const svg = renderSvg(model);
93+
expect(svg.startsWith("<svg")).toBe(true);
94+
expect(svg).toContain(">1193<");
95+
expect(svg).toContain("debugging");
96+
expect(svg).toContain("untested");
97+
expect(svg).toContain("github.com/ashutosh-rath02/atrophy");
98+
expect(svg).not.toContain("http://www.w3.org/1999/xhtml"); // no foreign objects / external refs
99+
});
100+
});

cli/report.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { writeFileSync } from "node:fs";
2+
import pc from "picocolors";
3+
import { AXES, type Axis } from "../bank/schema.js";
4+
import { freshness } from "../engine/scoring.js";
5+
import { computeStreak } from "../engine/streak.js";
6+
import type { Store } from "../store/db.js";
7+
import { buildSnapshot } from "./publish.js";
8+
9+
/**
10+
* `atrophy report`: a shareable summary of your baseline. Markdown by default
11+
* (great for a README or a post), or a self-contained SVG card with `--out
12+
* *.svg` (no external assets, so it embeds/shares anywhere). The data model is
13+
* pure and unit-tested; rendering is just string building.
14+
*/
15+
16+
export interface ReportAxis {
17+
axis: Axis;
18+
rating: number | null; // null when untested
19+
reps: number;
20+
state: string;
21+
}
22+
23+
export interface ReportModel {
24+
generatedAt: string;
25+
overall: number;
26+
totalReps: number;
27+
streakWeeks: number;
28+
axes: ReportAxis[];
29+
/** Mean(ai-on score) - mean(ai-off score), or null without both. */
30+
gap: number | null;
31+
}
32+
33+
function axisState(rd: number, reps: number): string {
34+
if (reps === 0) return "untested";
35+
const f = freshness(rd);
36+
return reps < 5 && f !== "fresh" ? "calibrating" : f;
37+
}
38+
39+
export function buildReport(store: Store, now = new Date()): ReportModel {
40+
const snap = buildSnapshot(store);
41+
const axes: ReportAxis[] = AXES.map((axis) => {
42+
const r = store.getRating(axis, now);
43+
return {
44+
axis,
45+
rating: r.reps === 0 ? null : Math.round(r.rating),
46+
reps: r.reps,
47+
state: axisState(r.rd, r.reps),
48+
};
49+
});
50+
51+
const sessions = store.allSessions();
52+
const on = sessions.filter((s) => s.mode === "ai-on").map((s) => s.score);
53+
const off = sessions.filter((s) => s.mode === "ai-off").map((s) => s.score);
54+
const mean = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
55+
const gap = on.length > 0 && off.length > 0 ? mean(on) - mean(off) : null;
56+
57+
return {
58+
generatedAt: now.toISOString(),
59+
overall: Math.round(snap.overall),
60+
totalReps: snap.reps,
61+
streakWeeks: computeStreak(sessions, now).weeks,
62+
axes,
63+
gap,
64+
};
65+
}
66+
67+
export function renderMarkdown(m: ReportModel): string {
68+
const lines: string[] = [];
69+
lines.push("# Atrophy card");
70+
lines.push("");
71+
lines.push("Unaided coding-skill baseline, measured with AI off.");
72+
lines.push("");
73+
lines.push(
74+
`**Overall ${m.overall}** · ${m.totalReps} rep${m.totalReps === 1 ? "" : "s"} · ` +
75+
`${m.streakWeeks}-week streak`,
76+
);
77+
lines.push("");
78+
lines.push("| skill | rating | reps | state |");
79+
lines.push("|---|---|---|---|");
80+
for (const a of m.axes) lines.push(`| ${a.axis} | ${a.rating ?? "-"} | ${a.reps} | ${a.state} |`);
81+
lines.push("");
82+
if (m.gap !== null) {
83+
lines.push(`With-AI vs unaided gap: ${m.gap >= 0 ? "+" : ""}${m.gap.toFixed(2)} (per-drill score).`);
84+
lines.push("");
85+
}
86+
lines.push("Measured with Atrophy - https://github.com/ashutosh-rath02/atrophy");
87+
return lines.join("\n") + "\n";
88+
}
89+
90+
export function renderSvg(m: ReportModel): string {
91+
const W = 720;
92+
const H = 440;
93+
const bg = "#0d0d0d";
94+
const ink = "#ffffff";
95+
const ink2 = "#b9b8b0";
96+
const accent = "#3987e5";
97+
const grid = "#26262a";
98+
const barX = 210;
99+
const barW = 380;
100+
const rowH = 46;
101+
const top = 150;
102+
103+
const rows = m.axes
104+
.map((a, i) => {
105+
const y = top + i * rowH;
106+
const frac = a.rating === null ? 0 : Math.max(0, Math.min(1, (a.rating - 1000) / 500));
107+
return (
108+
`<text x="40" y="${y + 5}" fill="${ink2}" font-size="15">${a.axis}</text>` +
109+
`<rect x="${barX}" y="${y - 9}" width="${barW}" height="12" rx="6" fill="${grid}"/>` +
110+
(a.rating === null
111+
? ""
112+
: `<rect x="${barX}" y="${y - 9}" width="${Math.round(barW * frac)}" height="12" rx="6" fill="${accent}"/>`) +
113+
`<text x="${W - 40}" y="${y + 5}" fill="${ink}" font-size="15" text-anchor="end" font-weight="600">${a.rating ?? "untested"}</text>`
114+
);
115+
})
116+
.join("");
117+
118+
const gapLine =
119+
m.gap === null
120+
? ""
121+
: `<text x="40" y="${top + 5 * rowH + 22}" fill="${ink2}" font-size="14">with-AI vs unaided gap: ${m.gap >= 0 ? "+" : ""}${m.gap.toFixed(2)}</text>`;
122+
123+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
124+
<rect width="${W}" height="${H}" rx="18" fill="${bg}"/>
125+
<text x="40" y="58" fill="${ink}" font-size="30" font-weight="700">Atrophy</text>
126+
<text x="40" y="84" fill="${ink2}" font-size="15">Unaided coding-skill baseline, measured with AI off</text>
127+
<text x="${W - 40}" y="56" fill="${accent}" font-size="46" font-weight="800" text-anchor="end">${m.overall}</text>
128+
<text x="${W - 40}" y="80" fill="${ink2}" font-size="13" text-anchor="end">overall · ${m.totalReps} reps · ${m.streakWeeks}w streak</text>
129+
<line x1="40" y1="110" x2="${W - 40}" y2="110" stroke="${grid}"/>
130+
${rows}
131+
${gapLine}
132+
<text x="40" y="${H - 24}" fill="${ink2}" font-size="13">github.com/ashutosh-rath02/atrophy</text>
133+
</svg>
134+
`;
135+
}
136+
137+
export function reportCommand(store: Store, opts: { out?: string }): void {
138+
const model = buildReport(store);
139+
const isSvg = opts.out !== undefined && opts.out.toLowerCase().endsWith(".svg");
140+
const content = isSvg ? renderSvg(model) : renderMarkdown(model);
141+
if (opts.out) {
142+
writeFileSync(opts.out, content, "utf8");
143+
console.log(pc.green(`wrote ${opts.out}`));
144+
} else {
145+
console.log(content);
146+
}
147+
}

0 commit comments

Comments
 (0)