Public: anyone can read this; the crew can change it.
/board/returns, what the window's dollars bought, speaks the home page's language #9899
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9896-board-returns-what-the-window-s-dollars → main 3 commits fork 1 thread · 1 unresolved
2 files
changed +122
−19
src/web/pages/returns.ts +32 −19
| @@ -14,7 +14,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 14 | 14 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { page } from "../layout.ts"; |
| 17 | import { card, listCard, row } from "../ui.ts"; | |
| 17 | import { row } from "../ui.ts"; | |
| 18 | 18 | import { usd } from "../../core/money.ts"; |
| 19 | 19 | |
| 20 | 20 | const runs = (m: Money): string => `${m.runs} ${m.runs === 1 ? "run" : "runs"}`; |
| @@ -26,6 +26,16 @@ const line = (title: string, m: Money, note?: string): Html => | ||
| 26 | 26 | trail: html`<b>${usd(m.usd)}</b>`, |
| 27 | 27 | }); |
| 28 | 28 | |
| 29 | /** A plain section under its own h2, rows in a list; no card (#9896). */ | |
| 30 | const section = (id: string, heading: string, rows: Html[], note?: string): Html => | |
| 31 | html`<section class="stack" aria-labelledby="${id}"> | |
| 32 | <h2 id="${id}">${heading}</h2> | |
| 33 | ${note === undefined ? null : html`<p class="muted small">${note}</p>`} | |
| 34 | <ul class="list"> | |
| 35 | ${rows} | |
| 36 | </ul> | |
| 37 | </section>`; | |
| 38 | ||
| 29 | 39 | /** GET /board/returns */ |
| 30 | 40 | export function returnsPage(ctx: Ctx): Response { |
| 31 | 41 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -80,7 +90,9 @@ export function returnsPage(ctx: Ctx): Response { | ||
| 80 | 90 | ${usd(nothing.closed.usd)} closed with nothing · ${usd(nothing.open.usd)} still open |
| 81 | 91 | </p> |
| 82 | 92 | <div class="stack"> |
| 83 | ${listCard( | |
| 93 | ${section( | |
| 94 | "returns-where", | |
| 95 | "Where it went", | |
| 84 | 96 | [ |
| 85 | 97 | line( |
| 86 | 98 | "On work that landed", |
| @@ -90,20 +102,22 @@ export function returnsPage(ctx: Ctx): Response { | ||
| 90 | 102 | line("On work closed without a merge", nothing.closed), |
| 91 | 103 | line("On work still open, nothing merged yet", nothing.open), |
| 92 | 104 | ], |
| 93 | { head: html`<h2>Where it went</h2>` }, | |
| 94 | 105 | )} |
| 95 | ${listCard( | |
| 106 | ${section( | |
| 107 | "returns-not-landed", | |
| 108 | "What did not land, and why", | |
| 96 | 109 | [ |
| 97 | 110 | line("Runs that failed", nothing.failed), |
| 98 | 111 | // Reasons are a part of "Runs that failed", not beside it: said so. |
| 99 | 112 | ...nothing.topFailures.map((f) => line(`of which: ${f.reason}`, f)), |
| 100 | 113 | line("Runs cancelled", nothing.cancelled), |
| 101 | 114 | ], |
| 102 | { head: html`<h2>What did not land, and why</h2>` }, | |
| 103 | 115 | )} |
| 104 | 116 | ${r.byPillar.length === 0 |
| 105 | 117 | ? null |
| 106 | : listCard( | |
| 118 | : section( | |
| 119 | "returns-by-pillar", | |
| 120 | "By pillar", | |
| 107 | 121 | r.byPillar.map((b) => |
| 108 | 122 | row({ |
| 109 | 123 | title: b.pillar, |
| @@ -115,9 +129,10 @@ export function returnsPage(ctx: Ctx): Response { | ||
| 115 | 129 | trail: html`<b>${usd(b.usd)}</b>`, |
| 116 | 130 | }), |
| 117 | 131 | ), |
| 118 | { head: html`<h2 id="returns-by-pillar">By pillar</h2>` }, | |
| 119 | 132 | )} |
| 120 | ${listCard( | |
| 133 | ${section( | |
| 134 | "returns-costliest", | |
| 135 | "What cost the most", | |
| 121 | 136 | r.costliest.map((c) => { |
| 122 | 137 | const repo = repos.get(c.repoId); |
| 123 | 138 | const slug = repo ? `${repo.ownerName}/${repo.name}` : ""; |
| @@ -133,11 +148,12 @@ export function returnsPage(ctx: Ctx): Response { | ||
| 133 | 148 | trail: html`<b>${usd(c.usd)}</b>`, |
| 134 | 149 | }); |
| 135 | 150 | }), |
| 136 | { head: html`<h2>What cost the most</h2>` }, | |
| 137 | 151 | )} |
| 138 | 152 | ${r.outside.length === 0 |
| 139 | 153 | ? null |
| 140 | : listCard( | |
| 154 | : section( | |
| 155 | "returns-outside", | |
| 156 | "Reported from outside", | |
| 141 | 157 | r.outside.map((o) => |
| 142 | 158 | row({ |
| 143 | 159 | title: o.who, |
| @@ -146,19 +162,16 @@ export function returnsPage(ctx: Ctx): Response { | ||
| 146 | 162 | trail: html`<b>${usd(o.usd)}</b>`, |
| 147 | 163 | }), |
| 148 | 164 | ), |
| 149 | { | |
| 150 | head: html`<h2>Reported from outside</h2> | |
| 151 | <span class="muted small">what sessions the forge did not run say they spent; not in the figures above</span>`, | |
| 152 | }, | |
| 165 | "what sessions the forge did not run say they spent; not in the figures above", | |
| 153 | 166 | )} |
| 154 | ${card( | |
| 155 | html`<p> | |
| 167 | <section class="stack" aria-labelledby="returns-run"> | |
| 168 | <h2 id="returns-run">A run</h2> | |
| 169 | <p> | |
| 156 | 170 | A run that finished well costs ${usd(r.okRun.usd.median)} and takes |
| 157 | 171 | ${r.okRun.minutes.median} minutes (median); one in ten costs over |
| 158 | 172 | ${usd(r.okRun.usd.p90)} or runs over ${r.okRun.minutes.p90} minutes. |
| 159 | </p>`, | |
| 160 | { head: html`<h2>A run</h2>` }, | |
| 161 | )} | |
| 173 | </p> | |
| 174 | </section> | |
| 162 | 175 | </div>`; |
| 163 | 176 | const response = page({ ctx, title: "What the tokens bought", body }); |
| 164 | 177 | notePhase(ctx.request, "render", performance.now() - rendering); |
test/returns-language.test.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** /board/returns speaks the home page's language (#9896): one h1, each part a plain section under an h2, no card. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { openIssue } from "../src/actions/issues.ts"; | |
| 8 | import { createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { addDependency } from "../src/core/dependencies.ts"; | |
| 10 | import { recordOutsideSpend } from "../src/core/outside_spend.ts"; | |
| 11 | import { createRun } from "../src/core/runs.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | ||
| 16 | let dir = ""; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let cookie = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dir = await mkdtemp(join(tmpdir(), "forge-returns-lang-")); | |
| 23 | app = createApp({ dataDir: dir, port: 0 }); | |
| 24 | servers = await serve(app); | |
| 25 | const u = await createUser(app.db, { | |
| 26 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 27 | }); | |
| 28 | if (!u.ok) throw new Error("user"); | |
| 29 | cookie = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 30 | }); | |
| 31 | afterAll(async () => { | |
| 32 | servers.stop(); | |
| 33 | app.close(); | |
| 34 | await rm(dir, { recursive: true, force: true }); | |
| 35 | }); | |
| 36 | ||
| 37 | const main = (page: string): string => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 38 | ||
| 39 | test("the owner's /board/returns: one h1, plain sections under h2s in order, no card", async () => { | |
| 40 | const res = await fetch(`${servers.url}/board/returns`, { headers: { cookie } }); | |
| 41 | expect(res.status).toBe(200); | |
| 42 | const page = main(await res.text()); | |
| 43 | expect(page.match(/<h1[\s>]/g)?.length).toBe(1); | |
| 44 | expect(page).not.toContain('class="card'); | |
| 45 | expect(page).not.toContain("card-body"); | |
| 46 | const heads = [...page.matchAll(/<section class="stack" aria-labelledby="[^"]+">\s*<h2 [^>]*>([^<]+)<\/h2>/g)].map((m) => m[1]); | |
| 47 | expect(heads).toEqual(["Where it went", "What did not land, and why", "What cost the most", "A run"]); | |
| 48 | }); | |
| 49 | ||
| 50 | test("?days= still widens the window and a stranger is still turned away", async () => { | |
| 51 | const res = await fetch(`${servers.url}/board/returns?days=30`, { headers: { cookie } }); | |
| 52 | expect(await res.text()).toContain("30 days"); | |
| 53 | const anon = await fetch(`${servers.url}/board/returns`, { redirect: "manual" }); | |
| 54 | expect(anon.status).not.toBe(200); | |
| 55 | }); | |
| 56 | ||
| 57 | test("with a pillar and an outside report: every part is a plain section, in order, A run last", async () => { | |
| 58 | const alice = getUserByName(app.db, "alice")!; | |
| 59 | const made = await createRepo(app, alice, { name: "mill", visibility: "public" }); | |
| 60 | if (!made.ok) throw new Error(String(made.error)); | |
| 61 | const repo = made.value; | |
| 62 | const work = openIssue(app, alice, repo, { title: "Some work" }); | |
| 63 | const umbrella = openIssue(app, alice, repo, { title: "P1 Ship: it" }); | |
| 64 | if (!work.ok || !umbrella.ok) throw new Error("issues"); | |
| 65 | addDependency(app.db, umbrella.value, work.value, alice.id); | |
| 66 | const r = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: work.value.id, actorId: alice.id, reason: "assigned" }); | |
| 67 | app.db | |
| 68 | .query("UPDATE run SET state = 'failed', cost_usd = 2.5, summary = 'failed', started_at = ?2, finished_at = ?3 WHERE id = ?1") | |
| 69 | .run(r.id, new Date(Date.now() - 600_000).toISOString(), new Date().toISOString()); | |
| 70 | recordOutsideSpend(app.db, { | |
| 71 | repoId: repo.id, issueId: null, reporterId: alice.id, who: "the build team", | |
| 72 | model: "", tokensIn: 1000, tokensOut: 200, usd: 3.25, note: "", | |
| 73 | }); | |
| 74 | const res = await fetch(`${servers.url}/board/returns`, { headers: { cookie } }); | |
| 75 | expect(res.status).toBe(200); | |
| 76 | const page = main(await res.text()); | |
| 77 | expect(page.match(/<h1[\s>]/g)?.length).toBe(1); | |
| 78 | expect(page).not.toContain('class="card'); | |
| 79 | expect(page).not.toContain("card-body"); | |
| 80 | const heads = [...page.matchAll(/<section class="stack" aria-labelledby="[^"]+">\s*<h2 [^>]*>([^<]+)<\/h2>/g)].map((m) => m[1]); | |
| 81 | expect(heads).toEqual([ | |
| 82 | "Where it went", | |
| 83 | "What did not land, and why", | |
| 84 | "By pillar", | |
| 85 | "What cost the most", | |
| 86 | "Reported from outside", | |
| 87 | "A run", | |
| 88 | ]); | |
| 89 | expect(page).toContain("the build team"); | |
| 90 | }); |
Principle-neutral but required: the expected headings omit 'By pillar' and 'Reported from outside'. Seed data for both and assert the full list in order, so every converted section is covered by a test that fails on main.