Public: anyone can read this; the crew can change it.
Home's admin-only KPI row lands, and every shortened figure carries its whole text in a title #2900
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2899-home-s-admin-only-kpi-row-lands-and-ever → main 12 commits fork 6 threads · 6 unresolved
README.md +1 −1
| @@ -260,7 +260,7 @@ They count every repository, so only a site admin sees them, on both doors; | ||
| 260 | 260 | nobody else does, and neither does the lean client home. On the web each is a |
| 261 | 261 | card linking to `/board`, `/releases` or `/boards`. The terminal's first screen |
| 262 | 262 | shows the same three as lines above the forge strip (`kpiLines` in |
| 263 | `src/web/studio/kpi_row.ts`). They are read when the page or screen loads and | |
| 263 | `src/flow/kpi_words.ts`). They are read when the page or screen loads and | |
| 264 | 264 | do not refresh on their own. |
| 265 | 265 | |
| 266 | 266 | On a host that cannot hold cells or the crew's shell sandbox, a site admin |
src/web/studio/kpi_row.ts +6 −3
| @@ -29,10 +29,10 @@ export function sparkline(hours: readonly number[]): Html { | ||
| 29 | 29 | ); |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | function tile(label: string, href: string, value: Html | string, note: string | Html, title: string, extra: Html | null = null): Html { | |
| 33 | return html`<a class="card kpi" href="${href}"> | |
| 32 | function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null): Html { | |
| 33 | return html`<a class="card kpi" href="${href}" aria-label="${label}: ${full}, ${title}"> | |
Label-in-name: the visible note on the Live version tile is "live <time>", but this label says "Live since <timestamp>". Start the label with the visible words, then add the full text. Also, the sparkline is inside this link; check that it stays hidden from the label. |
||
| 34 | 34 | <span class="small muted">${label}</span> |
| 35 | <span class="kpi-value num">${value}</span> | |
| 35 | <span class="kpi-value num" title="${full}">${value}</span> | |
| 36 | 36 | <span class="kpi-note small muted" title="${title}">${note}</span> |
| 37 | 37 | ${extra} |
| 38 | 38 | </a>`; |
| @@ -51,6 +51,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht | ||
| 51 | 51 | "Shipped per hour", |
| 52 | 52 | "/board", |
| 53 | 53 | rate.toFixed(1), |
| 54 | Number(rate.toFixed(2)).toString(), | |
outdated
String(rate) gives 0.2916666666666667 for 7 landed. Round to two decimals and test a non-terminating case. |
||
| 54 | 55 | `${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, |
| 55 | 56 | `${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, |
| 56 | 57 | sparkline(k.landedHours), |
| @@ -59,6 +60,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht | ||
| 59 | 60 | "Live version", |
| 60 | 61 | "/releases", |
| 61 | 62 | live === null ? "—" : sha(live.sha), |
| 63 | live === null ? "Nothing live yet" : live.sha, | |
| 62 | 64 | live === null ? "Nothing live yet" : html`live ${time(live.at)}`, |
| 63 | 65 | live === null ? "Nothing live yet" : `Live since ${live.at}`, |
| 64 | 66 | )} |
| @@ -66,6 +68,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht | ||
| 66 | 68 | "Median check time", |
| 67 | 69 | "/boards", |
| 68 | 70 | checkTime(k.medianCheckMs), |
| 71 | k.medianCheckMs === null ? "No check finished in 24 h" : checkTime(k.medianCheckMs), | |
outdated
|
||
| 69 | 72 | k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h", |
| 70 | 73 | k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h", |
| 71 | 74 | )} |
test/home-kpi-row.test.ts +14 −1
| @@ -17,11 +17,24 @@ describe("home kpi row", () => { | ||
| 17 | 17 | expect(out).toContain("0.3"); |
| 18 | 18 | expect(out).toContain("6 changes in 24 h"); |
| 19 | 19 | expect(out).toContain("abcdef0"); |
| 20 | expect(out).not.toContain("abcdef01"); | |
| 20 | expect(out).not.toContain(">abcdef01"); // shortened on screen; the whole id is in the title | |
Drop the trailing comment; the test name and the assertion already say it. |
||
| 21 | 21 | expect(out).toContain("3m 05s"); |
| 22 | 22 | for (const href of ["/board", "/releases", "/boards"]) expect(out).toContain(`href="${href}"`); |
| 23 | 23 | }); |
| 24 | 24 | |
| 25 | test("the whole figure is in the title and the link's label, rounded and in studio words", () => { | |
| 26 | const out = kpiRowFor( | |
| 27 | { landedHours: hours(7), landed: 7, live: null, medianCheckMs: 185_000 }, | |
| 28 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, | |
| 29 | ).toString(); | |
| 30 | expect(out).toContain('title="0.29"'); | |
| 31 | expect(out).not.toContain("0.2916"); | |
| 32 | expect(out).toContain('title="3m 05s"'); | |
| 33 | expect(out).not.toContain(" ms"); | |
| 34 | expect(out).toContain('aria-label="Live version: abcdef0123456, Live since 2026-10-19T11:00:00.000Z"'); | |
| 35 | expect(out).toContain('aria-label="Shipped per hour: 0.29, 7 changes in 24 h"'); | |
| 36 | }); | |
| 37 | ||
| 25 | 38 | test("empty figures say so", () => { |
| 26 | 39 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString(); |
| 27 | 40 | expect(out).toContain("Nothing live yet"); |
test/home-kpis.test.ts +22
| @@ -15,6 +15,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 15 | 15 | import { createRun } from "../src/core/runs.ts"; |
| 16 | 16 | import { createUser } from "../src/core/users.ts"; |
| 17 | 17 | import { readKpis } from "../src/flow/kpis.ts"; |
| 18 | import { kpiRowFor } from "../src/web/studio/kpi_row.ts"; | |
| 18 | 19 | |
| 19 | 20 | const HOUR = 3_600_000; |
| 20 | 21 | const MIN = 60_000; |
| @@ -123,3 +124,24 @@ describe("home kpis", () => { | ||
| 123 | 124 | }); |
| 124 | 125 | }); |
| 125 | 126 | }); |
| 127 | ||
| 128 | describe("home kpi row titles", () => { | |
| 129 | const full = kpiRowFor( | |
| 130 | { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, medianCheckMs: 185_000 }, | |
| 131 | { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" }, | |
| 132 | ).toString(); | |
| 133 | ||
| 134 | test("every shortened value and note carries its whole text in a title", () => { | |
| 135 | expect(full).toContain('class="kpi-value num" title="abcdef0123456789"'); | |
| 136 | expect(full).toContain('class="kpi-value num" title="0.25"'); | |
| 137 | expect(full).toContain('class="kpi-value num" title="3m 05s"'); | |
| 138 | expect(full).toContain('title="Live since 2026-10-19T11:00:00.000Z"'); | |
| 139 | expect(full).toContain('title="6 changes in 24 h"'); | |
| 140 | }); | |
| 141 | ||
| 142 | test("no value or note lacks a title, and none brings back a git word", () => { | |
| 143 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(6); | |
| 144 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(6); | |
| 145 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); | |
| 146 | }); | |
| 147 | }); |
title is the only carrier of the full text, which touch and keyboard users cannot reach. Next step: expose it in the link's aria-label or visibly.