Public: anyone can read this; the crew can change it.
Home opens with a KPI row in studio words, built from the design system's own parts #2878
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2871-home-opens-with-a-kpi-row-in-studio-word → main 8 commits fork 9 threads · 9 unresolved
README.md +9
| @@ -254,6 +254,15 @@ settings until asked. `/?all=1` is the whole home, and its "Just my apps" link | ||
| 254 | 254 | comes back. The terminal's first screen opens with the same lines, from the |
| 255 | 255 | same query (`src/core/client-apps.ts`). |
| 256 | 256 | |
| 257 | Home shows three figures first, from `src/flow/kpis.ts`: shipped per hour (with | |
| 258 | a 24 h sparkline), the live version and since when, and the median check time. | |
| 259 | They count every repository, so only a site admin sees them, on both doors; | |
| 260 | nobody else does, and neither does the lean client home. On the web each is a | |
| 261 | card linking to `/board`, `/releases` or `/boards`. The terminal's first screen | |
| 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 | |
| 264 | do not refresh on their own. | |
| 265 | ||
| 257 | 266 | On a host that cannot hold cells or the crew's shell sandbox, a site admin |
| 258 | 267 | sees `Reduced protection: …` in one line, under the filter on the terminal's |
| 259 | 268 | first screen and in the web footer; nobody else does, and a fully protected |
docs/DESIGN.md +1 −1
| @@ -386,7 +386,7 @@ gone, so this list only shrinks. | ||
| 386 | 386 | - **`pages/limb.ts`** — acting on a whole limb from its milestone: `.limb-confirm` `.limb-rows` `.limb-why` |
| 387 | 387 | - **`pages/map.ts`** — the map of what waits for what: `.map-ask` `.map-breath` `.map-canvas` `.map-card` `.map-counts` `.map-dot` `.map-edge` `.map-edge-crit` `.map-edge-done` `.map-empty` `.map-lane` `.map-lane-caret` `.map-lane-crew` `.map-lane-head` `.map-lane-name` `.map-lane-word` `.map-lanes` `.map-list` `.map-main` `.map-meta` `.map-node` `.map-pillars` `.map-rule` `.map-svg` `.map-title` `.map-tool` `.map-tools` `.map-who` `.map-who-i` `.map-who-n` `.map-word` `.map-wrap` |
| 388 | 388 | - **`pages/milestones.ts`** — milestones and one limb opened: `.ms-actions` `.ms-bar` `.ms-count` `.ms-figures` `.ms-head` `.ms-limb` `.ms-limbs` `.ms-more` `.ms-move` `.ms-name` `.ms-planner` `.ms-rows` `.ms-section` `.ms-state` `.ms-top` `.ms-trim` `.ms-weight` |
| 389 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` | |
| 389 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard` `.fcard-acts` `.fcard-form` `.fcard-head` `.fcard-more` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi` `.kpi-note` `.kpi-spark` `.kpi-value` `.kpis` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` | |
| 390 | 390 | - **`studio/style.ts`** — the studio's tell box, app head, word and shelf rows: `.fcard-app` `.shelf-desc` `.shelf-facts` `.shelf-meta` `.studio-ask-actions` `.studio-head` `.studio-tell` `.studio-word` |
| 391 | 391 | |
| 392 | 392 | --- |
src/flow/kpi_words.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * The figures row in words (#2871): shared by home and the terminal, which | |
| 3 | * must not depend on each other. Plain data in, plain strings out. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { Kpis } from "./kpis.ts"; | |
| 7 | ||
| 8 | /** A duration the way a person reads a check: `45s`, `3m 05s`, `1h 02m`. */ | |
| 9 | export function checkTime(ms: number | null): string { | |
| 10 | if (ms === null) return "—"; | |
| 11 | const s = Math.round(ms / 1000); | |
| 12 | if (s < 60) return `${s}s`; | |
| 13 | const m = Math.floor(s / 60); | |
| 14 | if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; | |
| 15 | return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; | |
| 16 | } | |
| 17 | ||
| 18 | const BLOCKS = "▁▂▃▄▅▆▇█"; | |
| 19 | ||
| 20 | /** The terminal's sparkline: the same 24 hours, oldest on the left, one block each. */ | |
| 21 | export function sparkText(hours: readonly number[]): string { | |
| 22 | const top = Math.max(1, ...hours); | |
| 23 | return [...hours] | |
| 24 | .reverse() | |
| 25 | .map((v) => (v === 0 ? BLOCKS[0]! : BLOCKS[Math.min(BLOCKS.length - 1, Math.ceil((v / top) * (BLOCKS.length - 1)))]!)) | |
| 26 | .join(""); | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * The terminal's row: the same three figures in the same words, as plain | |
| 31 | * lines. Forge-wide like the web's, so the caller shows them to an admin only. | |
| 32 | */ | |
| 33 | export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] { | |
| 34 | return [ | |
| 35 | `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, | |
| 36 | `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`, | |
| 37 | `Median check time ${checkTime(k.medianCheckMs)}`, | |
| 38 | ]; | |
| 39 | } |
src/tui/screens/dashboard.ts +17
| @@ -69,6 +69,7 @@ import { | ||
| 69 | 69 | ISSUE_MARK, |
| 70 | 70 | ListState, |
| 71 | 71 | PIN_MARK, |
| 72 | ago, | |
| 72 | 73 | PULL_MARK, |
| 73 | 74 | confirmScreen, |
| 74 | 75 | countsColumn, |
| @@ -83,6 +84,10 @@ import { | ||
| 83 | 84 | promptScreen, |
| 84 | 85 | plural, |
| 85 | 86 | } from "./common.ts"; |
| 87 | import { readKpis } from "../../flow/kpis.ts"; | |
outdated
The terminal should not import from a web page module. Move |
||
| 88 | import { kpiLines } from "../../flow/kpi_words.ts"; | |
| 89 | import { BOOTED } from "../../release/gate.ts"; | |
| 90 | import { running } from "../../kernel/version.ts"; | |
| 86 | 91 | import { deciderLine, deciderScreen } from "./decider.ts"; |
| 87 | 92 | import { issueScreen } from "./issue.ts"; |
| 88 | 93 | import { newRepoScreen } from "./newrepo.ts"; |
| @@ -149,6 +154,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 149 | 154 | let lane: string | null = null; |
| 150 | 155 | /** What the decider did today, the board's Today line; null on a quiet day or for a reader. */ |
| 151 | 156 | let decider: string | null = null; |
| 157 | /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */ | |
| 158 | let figures: string[] = []; | |
| 152 | 159 | const filter = new Filter(); |
| 153 | 160 | /** One cursor per pane: moving in one must never move another. */ |
| 154 | 161 | const cursors: Record<PaneId, ListState> = { |
| @@ -161,6 +168,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 161 | 168 | const loadClientLines = async (): Promise<void> => { |
| 162 | 169 | clientLines = []; |
| 163 | 170 | if (!f.user || (await nextStepsFor(f.app, f.user)) === undefined) return; |
| 171 | // A client's home never carries the forge-wide figures, on either door. | |
| 172 | figures = []; | |
| 164 | 173 | const apps = await clientAppRows(f.app, f.user); |
| 165 | 174 | clientLines = |
| 166 | 175 | apps.length === 0 |
| @@ -203,6 +212,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 203 | 212 | } |
outdated
Same gate as the web: admin only. Any signed-in user currently gets forge-wide figures on the terminal. |
||
| 204 | 213 | lane = forgeLane(f.app, f.user); |
| 205 | 214 | decider = deciderLine(f.app, f.user); |
| 215 | figures = f.user?.isAdmin !== true ? [] : kpiLines(readKpis(f.db, now), running().commit === null ? null : { sha: running().commit!, since: ago(BOOTED) }); | |
| 206 | 216 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 207 | 217 | // filter reaches every repository you can see. |
| 208 | 218 | const orgs = f.user |
| @@ -763,6 +773,13 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 763 | 773 | ); |
| 764 | 774 | body = rest!; |
| 765 | 775 | } |
| 776 | if (figures.length > 0) { | |
| 777 | const [strip, rest] = split(body, "v", [len(figures.length), fill(1)]); | |
| 778 | figures.forEach((line, i) => | |
| 779 | s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w), | |
| 780 | ); | |
| 781 | body = rest!; | |
| 782 | } | |
| 766 | 783 | if (decider !== null) { |
| 767 | 784 | const [strip, rest] = split(body, "v", [len(1), fill(1)]); |
| 768 | 785 | s.text( |
src/web/studio/feed_style.ts +7
| @@ -24,6 +24,13 @@ export const feedCss = /* css */ ` | ||
| 24 | 24 | .composer textarea { width: 100%; min-height: 64px; resize: vertical; } |
| 25 | 25 | .composer-bar { display: flex; align-items: center; gap: 8px; min-width: 0; } |
| 26 | 26 | .composer-bar select { min-width: 0; max-width: 60%; } |
| 27 | /* The figures row: three cards at a fixed height, so first paint is final. */ | |
outdated
The "Your apps" comment now sits above the figures block instead of above .home-tabs. Put the new block above that comment. |
||
| 28 | .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 12px; } | |
| 29 | .kpi { display: grid; grid-template-rows: 20px 32px 20px 24px; align-content: start; gap: 2px; padding: 12px 14px; color: inherit; text-decoration: none; } | |
| 30 | .kpi:hover { border-color: var(--border-strong); } | |
| 31 | .kpi-value { font-size: 16px; line-height: 32px; font-weight: 600; } | |
outdated
20px is the page-title size, and 600 is not on the scale for it. Hierarchy should come from weight. Use 16px/600. |
||
| 32 | .kpi-note { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
Honest truncation needs a |
||
| 33 | .kpi-spark { display: block; color: var(--muted-fg); max-width: 100%; } | |
outdated
Accent is interaction only (DESIGN.md rule 2). The sparkline is data, so use |
||
| 27 | 34 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 28 | 35 | .home-tabs { display: none; margin-bottom: 12px; } |
| 29 | 36 | .home-app { gap: 8px; } |
src/web/studio/home.ts +3 −1
| @@ -48,6 +48,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 48 | 48 | import { needsYouSection } from "./asks_page.ts"; |
| 49 | 49 | import { flowCards } from "./flow_card.ts"; |
| 50 | 50 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 51 | import { kpiRow } from "./kpi_row.ts"; | |
| 51 | 52 | import { homeTabs } from "./home_tabs.ts"; |
| 52 | 53 | import { crewKeyNote } from "./crew.ts"; |
| 53 | 54 | import { everydayCard } from "../pages/everyday.ts"; |
| @@ -243,8 +244,9 @@ export async function studioHome( | ||
| 243 | 244 | }); |
| 244 | 245 | } |
| 245 | 246 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 247 | const figures = user.isAdmin && steps === undefined; | |
| 246 | 248 | const body = html`<h1 class="sr-only">Home</h1> |
outdated
This wiring is untested. Add a home-page test that fails without this line, for admin, non-admin and the lean home.
outdated
This is the only door that gets the row. Put the same three figures on the terminal dashboard (src/tui/screens/dashboard.ts) from readKpis, with a test.
outdated
Gate on user.isAdmin as well as steps === undefined. readKpis is forge-wide, so showing it to every signed-in person leaks private-repo activity. |
||
| 247 | ${ask} ${address} ${liveLine(ctx)} | |
| 249 | ${ask} ${address} ${figures ? kpiRow(ctx.app.db) : liveLine(ctx)} | |
| 248 | 250 | ${when( |
| 249 | 251 | steps, |
| 250 | 252 | () => html`<p class="small muted" id="lean-home"> |
src/web/studio/kpi_row.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * Home's row of three figures (#2871), from `flow/kpis.ts`: shipped per hour | |
| 3 | * with a day's sparkline, the live version and since when, and the median | |
| 4 | * check time. Server-rendered, one fixed-height card each, so nothing moves | |
| 5 | * when it paints. Only the studio's words: shipped, change, check time. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { checkTime } from "../../flow/kpi_words.ts"; | |
| 10 | import { readKpis, type Kpis } from "../../flow/kpis.ts"; | |
| 11 | import { html, raw, type Html } from "../html.ts"; | |
| 12 | import { BOOTED } from "../../release/gate.ts"; | |
| 13 | import { running } from "../../kernel/version.ts"; | |
| 14 | import { sha, time } from "../ui.ts"; | |
| 15 | ||
| 16 | const W = 96; | |
| 17 | const H = 24; | |
| 18 | ||
| 19 | /** The last 24 hours, oldest on the left, as an SVG polyline; the numbers are the only input. */ | |
| 20 | export function sparkline(hours: readonly number[]): Html { | |
| 21 | const series = [...hours].reverse(); | |
| 22 | const top = Math.max(1, ...series); | |
| 23 | const step = W / Math.max(1, series.length - 1); | |
| 24 | const points = series | |
| 25 | .map((v, i) => `${(i * step).toFixed(1)},${(H - 2 - (v / top) * (H - 4)).toFixed(1)}`) | |
| 26 | .join(" "); | |
| 27 | return raw( | |
| 28 | `<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`, | |
| 29 | ); | |
| 30 | } | |
| 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}"> | |
| 34 | <span class="small muted">${label}</span> | |
| 35 | <span class="kpi-value num">${value}</span> | |
| 36 | <span class="kpi-note small muted" title="${title}">${note}</span> | |
| 37 | ${extra} | |
| 38 | </a>`; | |
| 39 | } | |
| 40 | ||
| 41 | /** The version this forge is running, and since when: the same source as `/releases`. */ | |
| 42 | export function liveNow(): { sha: string; at: string } | null { | |
| 43 | const commit = running().commit; | |
| 44 | return commit === null ? null : { sha: commit, at: BOOTED }; | |
| 45 | } | |
| 46 | ||
| 47 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Html { | |
| 48 | const rate = k.landed / k.landedHours.length; | |
| 49 | return html`<section class="kpis" aria-label="The last 24 hours, as of this page load"> | |
| 50 | ${tile( | |
| 51 | "Shipped per hour", | |
| 52 | "/board", | |
| 53 | rate.toFixed(1), | |
| 54 | `${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, | |
| 55 | `${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`, | |
| 56 | sparkline(k.landedHours), | |
| 57 | )} | |
| 58 | ${tile( | |
| 59 | "Live version", | |
| 60 | "/releases", | |
| 61 | live === null ? "—" : sha(live.sha), | |
outdated
This sha is the newest production deploy of any app ( |
||
| 62 | live === null ? "Nothing live yet" : html`live ${time(live.at)}`, | |
| 63 | live === null ? "Nothing live yet" : `Live since ${live.at}`, | |
| 64 | )} | |
| 65 | ${tile( | |
| 66 | "Median check time", | |
| 67 | "/boards", | |
| 68 | checkTime(k.medianCheckMs), | |
| 69 | k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h", | |
| 70 | k.medianCheckMs === null ? "No check finished in 24 h" : "over the last 24 h", | |
| 71 | )} | |
| 72 | </section>`; | |
| 73 | } | |
| 74 | ||
| 75 | /** The figures count every repository, so the caller shows this to an admin only. */ | |
| 76 | export function kpiRow(db: Database, now = new Date()): Html { | |
| 77 | return kpiRowFor(readKpis(db, now), liveNow()); | |
| 78 | } |
test/client-home.test.ts +6
| @@ -241,6 +241,11 @@ describe("a client's first screen", () => { | ||
| 241 | 241 | expect(all).toContain('href="/"'); |
| 242 | 242 | }); |
| 243 | 243 | |
| 244 | test("a client sees no forge-wide figures, lean or whole", async () => { | |
| 245 | expect(await page(owner)).not.toContain('class="kpis"'); | |
| 246 | expect(await page(owner, "/?all=1")).not.toContain('class="kpis"'); | |
| 247 | }); | |
| 248 | ||
| 244 | 249 | test("queued agents and no running ones read as about to start", async () => { |
| 245 | 250 | const repo = child.db |
| 246 | 251 | .query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'bakery'") |
| @@ -283,6 +288,7 @@ describe("a client's first screen", () => { | ||
| 283 | 288 | app.start(); |
| 284 | 289 | await settleApp(app); |
| 285 | 290 | expect(app.screen.toText()).toContain("bakery: Its agents are waiting for something to do."); |
| 291 | expect(app.screen.toText()).not.toContain("Shipped per hour"); | |
| 286 | 292 | app.stop(); |
| 287 | 293 | }); |
| 288 | 294 | }); |
test/home-kpi-page.test.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** The figures row on the real home (#2871): an admin's links, a non-admin's, and where it is not. */ | |
| 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 { createSession } from "../src/core/sessions.ts"; | |
| 8 | import { createUser, type User } from "../src/core/users.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { promote } from "./helpers/members.ts"; | |
| 11 | ||
| 12 | let dataDir: string; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | ||
| 16 | beforeAll(async () => { | |
| 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-home-kpi-")); | |
| 18 | app = createApp({ dataDir, port: 0 }); | |
| 19 | servers = await serve(app); | |
| 20 | }); | |
| 21 | ||
| 22 | afterAll(async () => { | |
| 23 | servers.stop(); | |
| 24 | app.close(); | |
| 25 | await rm(dataDir, { recursive: true, force: true }); | |
| 26 | }); | |
| 27 | ||
| 28 | test("home renders the three tiles; only an admin sees them", async () => { | |
| 29 | const mk = async (username: string, first = false): Promise<User> => { | |
| 30 | const r = await createUser(app.db, { | |
| 31 | origin: "seed", | |
| 32 | trusted: true, | |
| 33 | username, | |
| 34 | email: `${username}@forge.local`, | |
| 35 | password: "a-good-passphrase", | |
| 36 | adminIfFirst: first, | |
| 37 | }); | |
| 38 | if (!r.ok) throw new Error(r.error); | |
| 39 | promote(app.db, r.value); | |
| 40 | return r.value; | |
| 41 | }; | |
| 42 | const admin = await mk("keeper", true); | |
| 43 | const bob = await mk("bob"); | |
| 44 | const home = async (u?: User) => | |
| 45 | ( | |
| 46 | await fetch(`${servers.url}/`, { | |
| 47 | headers: u ? { cookie: `forge_session=${createSession(app.db, u.id).token}; forge_mode=studio` } : {}, | |
| 48 | }) | |
| 49 | ).text(); | |
| 50 | ||
| 51 | const a = await home(admin); | |
| 52 | expect(a).toContain('class="kpis"'); | |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(3); | |
| 54 | for (const href of ["/board", "/releases", "/boards"]) expect(a).toContain(`class="card kpi" href="${href}"`); | |
| 55 | ||
| 56 | // The figures count every repository, so a non-admin sees none. | |
| 57 | expect(await home(bob)).not.toContain('class="kpis"'); | |
| 58 | expect(await home()).not.toContain('class="kpis"'); | |
| 59 | }); |
test/home-kpi-row.test.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** Home's figures row (#2871): the three tiles, their links, studio words only, a sparkline of 24 points. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts"; | |
| 4 | import { running } from "../src/kernel/version.ts"; | |
| 5 | import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts"; | |
| 6 | ||
| 7 | const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0)); | |
| 8 | ||
| 9 | describe("home kpi row", () => { | |
| 10 | test("three tiles with their links and figures", () => { | |
| 11 | const out = kpiRowFor( | |
| 12 | { landedHours: hours(6), landed: 6, live: null, medianCheckMs: 185_000 }, | |
| 13 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, | |
| 14 | ).toString(); | |
| 15 | expect(out).toContain('<span class="sha">abcdef0</span>'); | |
| 16 | expect(out).toContain("Shipped per hour"); | |
| 17 | expect(out).toContain("0.3"); | |
| 18 | expect(out).toContain("6 changes in 24 h"); | |
| 19 | expect(out).toContain("abcdef0"); | |
| 20 | expect(out).not.toContain("abcdef01"); | |
| 21 | expect(out).toContain("3m 05s"); | |
| 22 | for (const href of ["/board", "/releases", "/boards"]) expect(out).toContain(`href="${href}"`); | |
| 23 | }); | |
| 24 | ||
| 25 | test("empty figures say so", () => { | |
| 26 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString(); | |
| 27 | expect(out).toContain("Nothing live yet"); | |
| 28 | expect(out).toContain("No check finished in 24 h"); | |
| 29 | }); | |
| 30 | ||
| 31 | test("no git words render, not even in a title or label", () => { | |
| 32 | const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, medianCheckMs: 1000 }, null).toString(); | |
| 33 | expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i); | |
| 34 | }); | |
| 35 | ||
| 36 | test("the sparkline has one point per hour, oldest first", () => { | |
| 37 | const svg = sparkline(hours(4)).toString(); | |
| 38 | const pts = /points="([^"]+)"/.exec(svg)![1]!.split(" "); | |
| 39 | expect(pts).toHaveLength(24); | |
| 40 | expect(pts[20]!.endsWith(",2.0")).toBe(true); // hour 3 ago is index 20, at the top | |
| 41 | }); | |
| 42 | ||
| 43 | test("the live tile is the running commit, the same as /releases", () => { | |
| 44 | const prev = process.env["FORGE_COMMIT"]; | |
| 45 | process.env["FORGE_COMMIT"] = "1234567abcdef"; | |
| 46 | try { | |
| 47 | expect(liveNow()?.sha).toBe(running().commit!); | |
| 48 | expect(liveNow()?.sha).toBe("1234567abcdef"); | |
| 49 | } finally { | |
| 50 | if (prev === undefined) delete process.env["FORGE_COMMIT"]; | |
| 51 | else process.env["FORGE_COMMIT"] = prev; | |
| 52 | } | |
| 53 | }); | |
| 54 | ||
| 55 | test("a truncated note carries its whole text as a title", () => { | |
| 56 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null }, null).toString(); | |
| 57 | expect(out).toContain('title="No check finished in 24 h"'); | |
| 58 | }); | |
| 59 | ||
| 60 | test("check time reads as a person would", () => { | |
| 61 | expect(checkTime(null)).toBe("—"); | |
| 62 | expect(checkTime(45_000)).toBe("45s"); | |
| 63 | expect(checkTime(3_720_000)).toBe("1h 02m"); | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("the terminal's figures", () => { | |
| 68 | const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, medianCheckMs: 185_000 }; | |
| 69 | test("the same three figures in the same words", () => { | |
| 70 | const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" }); | |
| 71 | expect(lines).toHaveLength(3); | |
| 72 | expect(lines[0]).toContain("Shipped per hour 0.3"); | |
| 73 | expect(lines[0]).toContain("6 changes in 24 h"); | |
| 74 | expect(lines[1]).toContain("Live version abcdef0"); | |
| 75 | expect(lines[2]).toBe("Median check time 3m 05s"); | |
| 76 | expect(lines.join(" ")).not.toMatch(/commit|merge|branch|repositor|pull request/i); | |
| 77 | }); | |
| 78 | test("the block sparkline has 24 cells, the busy hour tallest", () => { | |
| 79 | const s = sparkText(hours(4)); | |
| 80 | expect([...s]).toHaveLength(24); | |
| 81 | expect(s[20]).toBe("█"); | |
| 82 | }); | |
| 83 | }); |
test/tui-kpis.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** The terminal's first screen carries the figures the web home does (#2871): an admin sees them; nobody else does. */ | |
| 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 as Forge } from "../src/app.ts"; | |
| 7 | import { createUser, type User } from "../src/core/users.ts"; | |
| 8 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 9 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | import { settleApp } from "./helpers/tui.ts"; | |
| 12 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let dir: string; | |
| 17 | let forge: Forge; | |
| 18 | let alice: User; | |
| 19 | let bob: User; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | dir = await mkdtemp(join(tmpdir(), "forge-tui-kpis-")); | |
| 23 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 24 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 25 | if (!a.ok) throw new Error("signup failed"); | |
| 26 | alice = a.value; | |
| 27 | const b = await createUser(forge.db, { | |
| 28 | origin: "seed", | |
| 29 | trusted: true, | |
| 30 | username: "bob", | |
| 31 | email: "[email protected]", | |
| 32 | password: "a-long-passphrase", | |
| 33 | }); | |
| 34 | if (!b.ok) throw new Error(b.error); | |
| 35 | bob = b.value; | |
| 36 | }); | |
| 37 | ||
| 38 | afterAll(async () => { | |
| 39 | forge.close(); | |
| 40 | await rm(dir, { recursive: true, force: true }); | |
| 41 | }); | |
| 42 | ||
| 43 | async function firstScreen(user?: User): Promise<string> { | |
| 44 | const terminal = headlessTerminal(200, 32); | |
| 45 | const { app } = createForgeApp( | |
| 46 | forge, | |
| 47 | { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) }, | |
| 48 | { terminal, exitHooks: false }, | |
| 49 | ); | |
| 50 | app.start(); | |
| 51 | await settleApp(app); | |
| 52 | const text = app.screen.toText(); | |
| 53 | app.stop(); | |
| 54 | return text; | |
| 55 | } | |
| 56 | ||
| 57 | async function withCommit<T>(commit: string | undefined, fn: () => Promise<T>): Promise<T> { | |
| 58 | const saved = { c: process.env["FORGE_COMMIT"], a: process.env["FORGE_APP_COMMIT"] }; | |
| 59 | delete process.env["FORGE_APP_COMMIT"]; | |
| 60 | if (commit === undefined) delete process.env["FORGE_COMMIT"]; | |
| 61 | else process.env["FORGE_COMMIT"] = commit; | |
| 62 | try { | |
| 63 | return await fn(); | |
| 64 | } finally { | |
| 65 | for (const [k, v] of [["FORGE_COMMIT", saved.c], ["FORGE_APP_COMMIT", saved.a]] as const) { | |
| 66 | if (v === undefined) delete process.env[k]; | |
| 67 | else process.env[k] = v; | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | test("the terminal's first screen shows the three figures; a non-admin and a guest have none", async () => { | |
| 73 | const text = await withCommit(undefined, () => firstScreen(alice)); | |
| 74 | expect(text).toContain("Shipped per hour 0.0"); | |
| 75 | expect(text).toContain("Live version — nothing live yet"); | |
| 76 | expect(text).toContain("Median check time —"); | |
| 77 | expect(await firstScreen(bob)).not.toContain("Shipped per hour"); | |
| 78 | expect(await firstScreen()).not.toContain("Shipped per hour"); | |
| 79 | }); | |
| 80 | ||
| 81 | test("the live figure is the running commit, as /releases shows it", async () => { | |
| 82 | const text = await withCommit("1234567abcdef", () => firstScreen(alice)); | |
| 83 | expect(text).toContain("Live version 1234567, live"); | |
| 84 | }); |
kpiLinesis insrc/flow/kpi_words.tsnow, notsrc/web/studio/kpi_row.ts. Fix the path.