Public: anyone can read this; the crew can change it.
The week page speaks the home page's language #9779
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9732-the-week-page-speaks-the-home-page-s-lan → main 4 commits fork 1 thread · 1 unresolved
3 files
changed +74
−52
src/web/pages/week.ts +58 −51
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * and nothing else on the page changes anything. |
| 6 | 6 | */ |
| 7 | 7 | import { takeProposal } from "../../actions/week.ts"; |
| 8 | import { polishSpend, polishWords } from "../../crew/small_share.ts"; | |
| 8 | import { polishSpend } from "../../crew/small_share.ts"; | |
| 9 | 9 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 10 | 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 11 | 11 | import { latestRetro, takenOf, type Line, type Retro } from "../../flow/retro.ts"; |
| @@ -14,66 +14,74 @@ import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, s | ||
| 14 | 14 | import { backTo, field, readForm } from "../form.ts"; |
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { flashOrJson, page } from "../layout.ts"; |
| 17 | import { btn, card, meter, row, stat } from "../ui.ts"; | |
| 17 | import { btn, meter, row } from "../ui.ts"; | |
| 18 | 18 | |
| 19 | 19 | const lines = (base: string, ls: readonly Line[], none: string): Html => |
| 20 | 20 | ls.length === 0 |
| 21 | 21 | ? html`<p class="muted">${none}</p>` |
| 22 | : card( | |
| 23 | html`<ul class="list setting-list">${ls.map((l) => | |
| 24 | row({ href: `${base}/issues/${l.number}`, title: l.title, meta: html`#${String(l.number)} · ${l.why}` }), | |
| 25 | )}</ul>`, | |
| 26 | { pad: false }, | |
| 27 | ); | |
| 22 | : html`<ul class="list setting-list">${ls.map((l) => | |
| 23 | row({ href: `${base}/issues/${l.number}`, title: l.title, meta: html`#${String(l.number)} · ${l.why}` }), | |
| 24 | )}</ul>`; | |
| 25 | ||
| 26 | /** A figure in the page's words, in the home page's type: `<strong class="num">`. */ | |
| 27 | const N = (n: number): Html => html`<strong class="num">${String(n)}</strong>`; | |
| 28 | ||
| 29 | /** One section: the heading, then its words, on the spacing the settings pages use between sections. */ | |
| 30 | const block = (heading: string, ...body: (Html | null)[]): Html => | |
| 31 | html`<section class="settings-block"><h2>${heading}</h2>${body}</section>`; | |
| 28 | 32 | |
| 29 | 33 | function screen(base: string, retro: Retro, taken: ReadonlySet<number>, writer: boolean): Html { |
| 30 | 34 | const p = retro.projected; |
| 31 | 35 | const shipped = retro.shipped.length; |
| 32 | 36 | const acc = retro.accuracy; |
| 33 | return html`<h2>What shipped</h2> | |
| 34 | <p>${p === null | |
| 35 | ? `${shipped} shipped. No projection was made for this week.` | |
| 36 | : `${shipped} shipped against a projection of ${p.shipLow === p.shipHigh ? p.shipHigh : `${p.shipLow} to ${p.shipHigh}`} (${p.confidence} confidence).`}</p> | |
| 37 | ${lines(base, retro.shipped, "Nothing shipped this week.")} | |
| 38 | <h2>Estimate accuracy</h2> | |
| 39 | <p>${acc.compared === 0 | |
| 40 | ? "No shipped issue had an estimate to compare." | |
| 41 | : `${acc.inside} of ${acc.compared} shipped issues cost inside their estimated range.`}</p> | |
| 42 | <h2>Cost most for least</h2> | |
| 43 | ${lines(base, retro.costly, "Nothing open spent money this week.")} | |
| 44 | <h2>Stuck</h2> | |
| 45 | ${lines(base, retro.stuck, "Nothing is stuck.")} | |
| 46 | <h2>Proposed changes</h2> | |
| 47 | ${retro.proposals.length === 0 | |
| 48 | ? html`<p class="muted">Nothing to propose.</p>` | |
| 49 | : html`<ul class="list setting-list">${retro.proposals.map((pr, idx) => | |
| 50 | row({ | |
| 51 | title: html`${pr.words}`, | |
| 52 | meta: html`${taken.has(idx) ? "Taken." : writer ? "Nothing changes until you tap." : "Only someone who writes here can take it."}`, | |
| 53 | ...(taken.has(idx) || !writer | |
| 54 | ? {} | |
| 55 | : { | |
| 56 | after: html`<form method="post" action="${base}/week"> | |
| 57 | <input type="hidden" name="week_end" value="${retro.weekEnd}" /> | |
| 58 | <input type="hidden" name="idx" value="${String(idx)}" /> | |
| 59 | ${btn("Take it", { size: "sm" })} | |
| 60 | </form>`, | |
| 61 | }), | |
| 62 | }), | |
| 63 | )}</ul>`}`; | |
| 37 | const proposals = retro.proposals; | |
| 38 | return html`${block( | |
| 39 | "What shipped", | |
| 40 | html`<p>${p === null | |
| 41 | ? html`${N(shipped)} shipped. No projection was made for this week.` | |
| 42 | : html`${N(shipped)} shipped against a projection of ${p.shipLow === p.shipHigh ? N(p.shipHigh) : html`${N(p.shipLow)} to ${N(p.shipHigh)}`} <span class="muted">(${p.confidence} confidence)</span>.`}</p>`, | |
| 43 | lines(base, retro.shipped, "Nothing shipped this week."), | |
| 44 | )} | |
| 45 | ${block( | |
| 46 | "Estimate accuracy", | |
| 47 | html`<p>${acc.compared === 0 | |
| 48 | ? "No shipped issue had an estimate to compare." | |
| 49 | : html`${N(acc.inside)} of ${N(acc.compared)} shipped issues cost inside their estimated range.`}</p>`, | |
| 50 | )} | |
| 51 | ${block("Cost most for least", lines(base, retro.costly, "Nothing open spent money this week."))} | |
| 52 | ${block("Stuck", lines(base, retro.stuck, "Nothing is stuck."))} | |
| 53 | ${block( | |
| 54 | "Proposed changes", | |
| 55 | proposals.length === 0 | |
| 56 | ? html`<p class="muted">Nothing to propose.</p>` | |
| 57 | : html`<p class="muted">${writer ? "Nothing changes until you tap." : "Only someone who writes here can take it."}</p> | |
| 58 | <ul class="list setting-list">${proposals.map((pr, idx) => | |
| 59 | row({ | |
| 60 | title: html`${pr.words}`, | |
| 61 | ...(taken.has(idx) ? { meta: html`Taken.` } : {}), | |
| 62 | ...(taken.has(idx) || !writer | |
| 63 | ? {} | |
| 64 | : { | |
| 65 | after: html`<form method="post" action="${base}/week"> | |
| 66 | <input type="hidden" name="week_end" value="${retro.weekEnd}" /> | |
| 67 | <input type="hidden" name="idx" value="${String(idx)}" /> | |
| 68 | ${btn("Take it", { size: "sm" })} | |
| 69 | </form>`, | |
| 70 | }), | |
| 71 | }), | |
| 72 | )}</ul>`, | |
| 73 | )}`; | |
| 64 | 74 | } |
| 65 | 75 | |
| 66 | /** Polish spent against its share: a labelled fact, with the words the terminal and the tool print under it. */ | |
| 67 | function polishCard(ctx: Ctx, repo: Repo): Html { | |
| 76 | /** Polish spent against its share, with the facts the terminal and the tool print. */ | |
| 77 | function polishBlock(ctx: Ctx, repo: Repo): Html { | |
| 68 | 78 | const p = polishSpend(ctx.app.db, repo.id, repo.polishShare, new Date()); |
| 69 | return card( | |
| 70 | html`<div data-polish>${stat({ | |
| 71 | label: "Polish share", | |
| 72 | value: `${p.percent.toFixed(1)}% of ${p.share}%`, | |
| 73 | title: "Agent spend in the last seven days on issues labelled polish or in the small-fixes lane, against all agent spend", | |
| 74 | note: html`${polishWords(p)}`, | |
| 75 | })}${meter(p.share === 0 ? 100 : (p.percent / p.share) * 100, `polish has used ${p.percent.toFixed(1)}% of the week's spend; its share is ${p.share}%`, p.under ? undefined : "attn")}</div>`, | |
| 76 | ); | |
| 79 | const usd = (n: number) => `$${n.toFixed(2)}`; | |
| 80 | return html`<div data-polish>${block( | |
| 81 | "Polish share", | |
| 82 | html`<p title="Agent spend in the last seven days on issues labelled polish or in the small-fixes lane, against all agent spend"><strong class="num">${p.percent.toFixed(1)}% of ${String(p.share)}%</strong> <span class="muted">(${usd(p.polishUsd)} of ${usd(p.totalUsd)}): ${p.under ? "ready polish may start" : "no polish starts until it is under"}</span></p>`, | |
| 83 | meter(p.share === 0 ? 100 : (p.percent / p.share) * 100, `polish has used ${p.percent.toFixed(1)}% of the week's spend; its share is ${p.share}%`, p.under ? undefined : "attn"), | |
| 84 | )}</div>`; | |
| 77 | 85 | } |
| 78 | 86 | |
| 79 | 87 | export function weekPage(ctx: Ctx): Response { |
| @@ -91,8 +99,7 @@ export function weekPage(ctx: Ctx): Response { | ||
| 91 | 99 | ? "The forge writes this when the weekly window closes. None has closed yet." |
| 92 | 100 | : `Written when the window ended ${retro.weekEnd.slice(0, 10)}, once, and never edited.`} <a href="${base}/next">Next up</a></p> |
| 93 | 101 | </div> |
| 94 | <h2>Polish this week</h2> | |
| 95 | ${polishCard(ctx, repo)} | |
| 102 | ${polishBlock(ctx, repo)} | |
| 96 | 103 | ${retro === undefined ? null : screen(base, retro, takenOf(ctx.app.db, repo.id, retro.weekEnd), writer)}`; |
| 97 | 104 | const response = page({ ctx, title: `The week · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body }); |
| 98 | 105 | notePhase(ctx.request, "render", performance.now() - rendering); |
test/week-doors.test.ts +14
| @@ -291,6 +291,20 @@ describe("the week's doors", () => { | ||
| 291 | 291 | expect(takenOf(forge.db, repo.id, retro.weekEnd).size).toBe(0); |
| 292 | 292 | }); |
| 293 | 293 | |
| 294 | test("the page is plain sections: no card around a list or the polish share, figures in strong.num, the note said once", async () => { | |
| 295 | const res = await fetch(`${base}/alice/plan/week`, { headers: { cookie: aliceCookie } }); | |
| 296 | const body = await res.text(); | |
| 297 | const main = body.slice(body.indexOf("<h1>The week</h1>")); | |
| 298 | expect(main).not.toMatch(/class="[^"]*\bcard\b/); | |
| 299 | expect(main).not.toContain('class="stat'); | |
| 300 | expect(main).not.toContain('class="mt"'); | |
| 301 | expect(main).toContain('<section class="settings-block"><h2>Polish share</h2>'); | |
| 302 | expect(main).not.toContain("Polish this week"); | |
| 303 | expect(main).toMatch(/<strong class="num">\d+<\/strong> shipped/); | |
| 304 | expect(main.split("Nothing changes until you tap.").length - 1).toBe(1); | |
| 305 | expect(main).toContain("Take it"); | |
| 306 | }); | |
| 307 | ||
| 294 | 308 | test("POST /week: no cookie, another origin, a reader and an agent are refused", async () => { |
| 295 | 309 | const same = { origin: base }; |
| 296 | 310 | expect((await post({ ...same })).status).not.toBe(200); |