|
| 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