syntaxai/tdd.md · commit 312fbc3

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]>
author
syntaxai <[email protected]>
date
2026-05-03 17:15:49 +01:00
parent
031ac4b
commit
312fbc3b789143a2486c438d1cdf73a79fc557ce

4 files changed · +178 −24

modified public/style.css +1 −0
@@ -177,6 +177,7 @@ main.md strong { font-weight: 600; }
177177 .red { color: var(--red); }
178178 .green { color: var(--green); }
179179 .blue { color: var(--blue); }
180+.muted { color: var(--muted); }
180181
181182 ::selection {
182183 background: var(--accent);
added 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+});
added 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+};
modified src/server.ts +74 −24
@@ -1,6 +1,8 @@
11 import { renderPage, renderNotFound } from "./render";
22 import * as github from "./github_oauth";
33 import * as forgejo from "./forgejo";
4+import { parseCommit, computeProgress, type Phase } from "./commits";
5+import { loadGame } from "./games";
46
57 const HOME_MD = "./content/home.md";
68 const GAME_DIR = "./content/games";
@@ -206,6 +208,19 @@ interface ForgejoCommit {
206208 commit: { message: string; author: { name: string; date: string } };
207209 }
208210
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+
209224 const renderRepoView = async (owner: string, repo: string): Promise<Response> => {
210225 const repoApi = `${FORGEJO_INTERNAL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
211226 const repoRes = await fetch(repoApi);
@@ -221,31 +236,70 @@ const renderRepoView = async (owner: string, repo: string): Promise<Response> =>
221236 return htmlResponse(html, 502);
222237 }
223238 const info = (await repoRes.json()) as ForgejoRepoSummary;
239+ const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
224240
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[] = [];
226254 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")}`;
242285 }
243286
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
245295
246- const body = `# ${owner}/${repo}
296+## phase log
247297
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>` : ""}.
249303
250304 ## clone
251305
@@ -253,11 +307,7 @@ ${info.description ? `> ${info.description}\n` : ""}
253307 git clone ${cloneUrl}
254308 \`\`\`
255309
256-## recent commits
257-
258-${commitList}
259-
260-[← agents/${owner}](/agents/${owner})
310+[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""}
261311 `;
262312
263313 const html = await renderPage({