Repo page is a game scoreboard, not a repo browser
The /:owner/:repo route now reads as a kata-in-progress card:
- Header turns the repo name into a kata link ("playing [string-calc →]")
- Live status line — "awaiting first push" / "in progress" / "kata
complete" — backed by a verified-steps counter (N / total)
- Phase log replaces "recent commits": each row carries a colored
red/green/refactor/init/untagged tag, the step it targets, the
subject, and a relative timestamp
- Score block keeps phase tallies visible while honestly noting that
final scoring waits on the judge module
src/commits.ts parses commit subjects (red:/green:/refactor: with an
optional (step) suffix) and computes which steps are verified by a
red→green pair for the same step. .muted utility added for the
init/untagged phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
4 files changed · +178 −24
public/style.css
+1
−0
| @@ -177,6 +177,7 @@ main.md strong { font-weight: 600; } | ||
| 177 | 177 | .red { color: var(--red); } |
| 178 | 178 | .green { color: var(--green); } |
| 179 | 179 | .blue { color: var(--blue); } |
| 180 | +.muted { color: var(--muted); } | |
| 180 | 181 | |
| 181 | 182 | ::selection { |
| 182 | 183 | background: var(--accent); |
src/commits.test.ts
+42
−0
| @@ -0,0 +1,42 @@ | ||
| 1 | +import { test, expect } from "bun:test"; | |
| 2 | +import { parseCommit, computeProgress } from "./commits"; | |
| 3 | + | |
| 4 | +test("parseCommit reads a phase prefix", () => { | |
| 5 | + expect(parseCommit("red: failing test for empty")).toEqual({ | |
| 6 | + phase: "red", | |
| 7 | + step: null, | |
| 8 | + subject: "failing test for empty", | |
| 9 | + }); | |
| 10 | +}); | |
| 11 | + | |
| 12 | +test("parseCommit extracts step from phase(step): form", () => { | |
| 13 | + expect(parseCommit("green(single-number): return n for one number")).toEqual({ | |
| 14 | + phase: "green", | |
| 15 | + step: "single-number", | |
| 16 | + subject: "return n for one number", | |
| 17 | + }); | |
| 18 | +}); | |
| 19 | + | |
| 20 | +test("parseCommit recognizes 'Initial commit' as init", () => { | |
| 21 | + expect(parseCommit("Initial commit").phase).toBe("init"); | |
| 22 | +}); | |
| 23 | + | |
| 24 | +test("parseCommit returns untagged for unknown messages", () => { | |
| 25 | + expect(parseCommit("wip — fixing something").phase).toBe("untagged"); | |
| 26 | +}); | |
| 27 | + | |
| 28 | +test("computeProgress verifies a step after red→green for the same step", () => { | |
| 29 | + const commits = [ | |
| 30 | + { commit: { message: "green(empty): returns 0" } }, | |
| 31 | + { commit: { message: "red(empty): empty string returns 0" } }, | |
| 32 | + ]; // newest first, like Forgejo | |
| 33 | + const p = computeProgress(commits); | |
| 34 | + expect(p.verifiedSteps).toEqual(new Set(["empty"])); | |
| 35 | + expect(p.redCount).toBe(1); | |
| 36 | + expect(p.greenCount).toBe(1); | |
| 37 | +}); | |
| 38 | + | |
| 39 | +test("computeProgress does not verify green-without-prior-red", () => { | |
| 40 | + const commits = [{ commit: { message: "green(empty): returns 0" } }]; | |
| 41 | + expect(computeProgress(commits).verifiedSteps.size).toBe(0); | |
| 42 | +}); | |
src/commits.ts
+61
−0
| @@ -0,0 +1,61 @@ | ||
| 1 | +export type Phase = "red" | "green" | "refactor" | "init" | "untagged"; | |
| 2 | + | |
| 3 | +export interface ParsedCommit { | |
| 4 | + phase: Phase; | |
| 5 | + step: string | null; | |
| 6 | + subject: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +const PHASE_RE = /^(red|green|refactor)(?:\(([a-z][a-z0-9-]*)\))?:\s*(.*)$/i; | |
| 10 | + | |
| 11 | +export const parseCommit = (message: string): ParsedCommit => { | |
| 12 | + const subject = message.split("\n")[0] ?? ""; | |
| 13 | + const m = subject.match(PHASE_RE); | |
| 14 | + if (m) { | |
| 15 | + return { | |
| 16 | + phase: m[1]!.toLowerCase() as Phase, | |
| 17 | + step: m[2] ?? null, | |
| 18 | + subject: m[3] ?? "", | |
| 19 | + }; | |
| 20 | + } | |
| 21 | + if (/^Initial commit$/i.test(subject)) { | |
| 22 | + return { phase: "init", step: null, subject }; | |
| 23 | + } | |
| 24 | + return { phase: "untagged", step: null, subject }; | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export interface Progress { | |
| 28 | + verifiedSteps: Set<string>; | |
| 29 | + redCount: number; | |
| 30 | + greenCount: number; | |
| 31 | + refactorCount: number; | |
| 32 | + untaggedCount: number; | |
| 33 | +} | |
| 34 | + | |
| 35 | +// A step counts as "verified" when its red commit is followed by a green | |
| 36 | +// for the same step. Refactor and untagged commits are tallied separately | |
| 37 | +// for the score breakdown but don't move verification. | |
| 38 | +export const computeProgress = (commits: { commit: { message: string } }[]): Progress => { | |
| 39 | + const pendingRed = new Set<string>(); | |
| 40 | + const verifiedSteps = new Set<string>(); | |
| 41 | + let redCount = 0; | |
| 42 | + let greenCount = 0; | |
| 43 | + let refactorCount = 0; | |
| 44 | + let untaggedCount = 0; | |
| 45 | + // Forgejo returns commits newest-first; walk oldest-first to get sequence. | |
| 46 | + for (const c of [...commits].reverse()) { | |
| 47 | + const p = parseCommit(c.commit.message); | |
| 48 | + if (p.phase === "red") { | |
| 49 | + redCount++; | |
| 50 | + if (p.step) pendingRed.add(p.step); | |
| 51 | + } else if (p.phase === "green") { | |
| 52 | + greenCount++; | |
| 53 | + if (p.step && pendingRed.has(p.step)) verifiedSteps.add(p.step); | |
| 54 | + } else if (p.phase === "refactor") { | |
| 55 | + refactorCount++; | |
| 56 | + } else if (p.phase === "untagged") { | |
| 57 | + untaggedCount++; | |
| 58 | + } | |
| 59 | + } | |
| 60 | + return { verifiedSteps, redCount, greenCount, refactorCount, untaggedCount }; | |
| 61 | +}; | |
src/server.ts
+74
−24
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { renderPage, renderNotFound } from "./render"; |
| 2 | 2 | import * as github from "./github_oauth"; |
| 3 | 3 | import * as forgejo from "./forgejo"; |
| 4 | +import { parseCommit, computeProgress, type Phase } from "./commits"; | |
| 5 | +import { loadGame } from "./games"; | |
| 4 | 6 | |
| 5 | 7 | const HOME_MD = "./content/home.md"; |
| 6 | 8 | const GAME_DIR = "./content/games"; |
| @@ -206,6 +208,19 @@ interface ForgejoCommit { | ||
| 206 | 208 | commit: { message: string; author: { name: string; date: string } }; |
| 207 | 209 | } |
| 208 | 210 | |
| 211 | +const phaseSpan = (p: Phase): string => { | |
| 212 | + const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted"; | |
| 213 | + return `<span class="${cls}">${p}</span>`; | |
| 214 | +}; | |
| 215 | + | |
| 216 | +const relativeTime = (iso: string): string => { | |
| 217 | + const ms = Date.now() - new Date(iso).getTime(); | |
| 218 | + if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`; | |
| 219 | + if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`; | |
| 220 | + if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`; | |
| 221 | + return `${Math.floor(ms / 86_400_000)}d ago`; | |
| 222 | +}; | |
| 223 | + | |
| 209 | 224 | const renderRepoView = async (owner: string, repo: string): Promise<Response> => { |
| 210 | 225 | const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; |
| 211 | 226 | const repoRes = await fetch(repoApi); |
| @@ -221,31 +236,70 @@ const renderRepoView = async (owner: string, repo: string): Promise<Response> => | ||
| 221 | 236 | return htmlResponse(html, 502); |
| 222 | 237 | } |
| 223 | 238 | const info = (await repoRes.json()) as ForgejoRepoSummary; |
| 239 | + const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; | |
| 224 | 240 | |
| 225 | - let commitList = "> No commits yet."; | |
| 241 | + // The repo name is by convention the kata id. If the kata exists, the | |
| 242 | + // header link is meaningful and we know the total step count. | |
| 243 | + let totalSteps: number | null = null; | |
| 244 | + let kataExists = false; | |
| 245 | + try { | |
| 246 | + const game = await loadGame(repo); | |
| 247 | + totalSteps = game.steps.length; | |
| 248 | + kataExists = true; | |
| 249 | + } catch { | |
| 250 | + // Repo isn't a known kata — still render, just without step totals. | |
| 251 | + } | |
| 252 | + | |
| 253 | + let commits: ForgejoCommit[] = []; | |
| 226 | 254 | if (!info.empty) { |
| 227 | - const commitsRes = await fetch(`${repoApi}/commits?limit=8&stat=false`); | |
| 228 | - if (commitsRes.ok) { | |
| 229 | - const commits = (await commitsRes.json()) as ForgejoCommit[]; | |
| 230 | - if (commits.length > 0) { | |
| 231 | - commitList = commits | |
| 232 | - .map((c) => { | |
| 233 | - const sha = c.sha.slice(0, 7); | |
| 234 | - const subject = c.commit.message.split("\n")[0]!; | |
| 235 | - const escaped = subject.replace(/\|/g, "\\|"); | |
| 236 | - return `| \`${sha}\` | ${escaped} | ${c.commit.author.name} |`; | |
| 237 | - }) | |
| 238 | - .join("\n"); | |
| 239 | - commitList = `| sha | message | author |\n|---|---|---|\n${commitList}`; | |
| 240 | - } | |
| 241 | - } | |
| 255 | + const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`); | |
| 256 | + if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[]; | |
| 257 | + } | |
| 258 | + const progress = computeProgress(commits); | |
| 259 | + const verified = progress.verifiedSteps.size; | |
| 260 | + | |
| 261 | + let status: string; | |
| 262 | + if (commits.length === 0) { | |
| 263 | + status = "awaiting first push"; | |
| 264 | + } else if (totalSteps !== null && verified >= totalSteps) { | |
| 265 | + status = "kata complete"; | |
| 266 | + } else if (verified > 0) { | |
| 267 | + status = "in progress"; | |
| 268 | + } else { | |
| 269 | + status = "no verified steps yet"; | |
| 270 | + } | |
| 271 | + const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`; | |
| 272 | + | |
| 273 | + let phaseLog: string; | |
| 274 | + if (commits.length === 0) { | |
| 275 | + phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._"; | |
| 276 | + } else { | |
| 277 | + const rows = commits.map((c) => { | |
| 278 | + const sha = c.sha.slice(0, 7); | |
| 279 | + const p = parseCommit(c.commit.message); | |
| 280 | + const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|"); | |
| 281 | + const stepCell = p.step ? `\`${p.step}\`` : "—"; | |
| 282 | + return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`; | |
| 283 | + }); | |
| 284 | + phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`; | |
| 242 | 285 | } |
| 243 | 286 | |
| 244 | - const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`; | |
| 287 | + const kataLink = kataExists | |
| 288 | + ? `[\`${repo}\` →](/games/${repo})` | |
| 289 | + : `\`${repo}\``; | |
| 290 | + | |
| 291 | + const body = `# ${owner} · playing ${kataLink} | |
| 292 | + | |
| 293 | +> ${status} | |
| 294 | +> **${stepCounter}** steps verified | |
| 245 | 295 | |
| 246 | - const body = `# ${owner}/${repo} | |
| 296 | +## phase log | |
| 247 | 297 | |
| 248 | -${info.description ? `> ${info.description}\n` : ""} | |
| 298 | +${phaseLog} | |
| 299 | + | |
| 300 | +## score | |
| 301 | + | |
| 302 | +> Final scoring lands when the judge module ships. Phase tally: <span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>${progress.untaggedCount > 0 ? ` · <span class="muted">untagged ${progress.untaggedCount}</span>` : ""}. | |
| 249 | 303 | |
| 250 | 304 | ## clone |
| 251 | 305 | |
| @@ -253,11 +307,7 @@ ${info.description ? `> ${info.description}\n` : ""} | ||
| 253 | 307 | git clone ${cloneUrl} |
| 254 | 308 | \`\`\` |
| 255 | 309 | |
| 256 | -## recent commits | |
| 257 | - | |
| 258 | -${commitList} | |
| 259 | - | |
| 260 | -[← agents/${owner}](/agents/${owner}) | |
| 310 | +[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""} | |
| 261 | 311 | `; |
| 262 | 312 | |
| 263 | 313 | const html = await renderPage({ |