Public: anyone can read this; the crew can change it.
Design: the home KPIs are §5 stat tiles, and the kpi* classes leave §5d #4048
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3982-design-the-home-kpis-are-5-stat-tiles-an → main 2 commits fork
11 files
changed +62
−63
docs/DESIGN.md +2 −2
| @@ -145,7 +145,7 @@ specification. | ||
| 145 | 145 | | **form field** `field()` | `.field` (`label`, `.hint`, `.error`) | `data-invalid` | — | label line, value line, hint line | |
| 146 | 146 | | **checkbox** | `.check` | — | native, `accent-color` themed | `[x]` / `[ ]` | |
| 147 | 147 | | **badge** `badge()` / `stateBadge()` | `.badge` | `variant=secondary\|outline\|success\|danger\|merged\|attn`; `data-state=open\|closed`, `data-pr=merged` | — | a bracketed word in the state color | |
| 148 | | **stat** `stat()` | `.stat` (`.stat-label`, `.stat-value`, `.stat-note`, `.stat-spark`; grid: `.stats`) | — | hover (`--border-strong`, only when `href` makes it a link); fixed heights, so a changing value shifts no neighbour; value is `.num` with its full form in `title`; note clamps to two lines; `.stats` auto-fits columns of at least 160px | a number under its label, a note under that, an optional sparkline top right | | |
| 148 | | **stat** `stat()` | `.stat` (`.stat-label`, `.stat-value`, `.stat-note`, `.stat-spark`; grid: `.stats`) | `data-tone=attn` | hover (`--border-strong`, only when `href` makes it a link, named by `aria` when given); fixed heights, so a changing value shifts no neighbour; value is `.num` with its full form in `title`; note clamps to two lines, its whole text in `noteTitle`; the sparkline carries its own `role=img` and `aria-label`, or is decorative; `.stats` auto-fits columns of at least 160px | a number under its label, a note under that, an optional sparkline top right | | |
| 149 | 149 | | **label chip** `labelChip()` | `.label` | color via `--label` (see §7) | — | a colored `‹name›` | |
| 150 | 150 | | **author** `userLink()` / `userTag()` | `.byline` (`.byline-name`) | — | `userTag` inside anything already a link — see below | `@name` in the avatar hue | |
| 151 | 151 | | **name mark** `nameMark()` | `.name-mark[data-mark]` | `owner` / `crew`, from the account | never typed: escaped names cannot make it | beside the name, after the link | |
| @@ -400,7 +400,7 @@ gone, so this list only shrinks. | ||
| 400 | 400 | - **`style/content.ts`, search results** — search results, the inbox's ask cards, the client list and the claim link: `.ask-acts` `.ask-card` `.ask-group` `.ask-kind` `.ask-meta` `.ask-more-acts` `.ask-pick` `.ask-pick-hit` `.ask-short` `.ask-waiting` `.claim-link-box` `.forges-sent` `.live-toggle` `.needs-you` `.no-hits` |
| 401 | 401 | - **`pages/board.ts`** — the spend tables `spend.ts` draws, first on the board (the board itself is §5's **board**): `.spend-table` `.spend-totals` |
| 402 | 402 | - **`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-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` |
| 403 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.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` | |
| 403 | - **`studio/feed_style.ts`** — the home feed's cards, its composer and the apps rail: `.composer` `.composer-bar` `.fcard-form` `.fcard-reason` `.fcard-reply` `.fcard-text` `.fcard-unblock` `.fcard-who` `.fcard-why` `.feed` `.feed-head` `.kpi-spark` `.home-app` `.home-apps` `.home-apps-verbs` `.home-pick` `.home-rail` `.home-tabs` | |
| 404 | 404 | |
| 405 | 405 | --- |
| 406 | 406 |
src/web/studio/feed_style.ts +1 −6
| @@ -19,12 +19,7 @@ export const feedCss = /* css */ ` | ||
| 19 | 19 | .composer textarea { width: 100%; min-height: 64px; resize: vertical; } |
| 20 | 20 | .composer-bar { display: flex; align-items: center; gap: 8px; min-width: 0; } |
| 21 | 21 | .composer-bar select { min-width: 0; max-width: 60%; } |
| 22 | /* The figures row: three cards at a fixed height, so first paint is final; the note wraps into two reserved lines. */ | |
| 23 | .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 12px; } | |
| 24 | .kpi { display: grid; grid-template-rows: 20px 32px 40px 24px; align-content: start; gap: 2px; padding: 12px 14px; color: inherit; text-decoration: none; } | |
| 25 | .kpi:hover { border-color: var(--border-strong); } | |
| 26 | .kpi-value { font-size: 16px; line-height: 32px; font-weight: 600; } | |
| 27 | .kpi-note { line-height: 20px; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow-wrap: anywhere; } | |
| 22 | /* A day sparkline outside a stat tile: the app settings page's visitors card (daySpark). */ | |
| 28 | 23 | .kpi-spark { display: block; color: var(--muted-fg); max-width: 100%; } |
| 29 | 24 | /* Your apps: a narrow column on a desk, a tab away on a phone. */ |
| 30 | 25 | .home-tabs { display: none; margin-bottom: 12px; } |
src/web/studio/kpi_row.ts +9 −13
| @@ -3,8 +3,8 @@ | ||
| 3 | 3 | * hour with a day's sparkline, the live version and since when, the median |
| 4 | 4 | * check time, the crew builders running of their slots, the crew's spend |
| 5 | 5 | * today of the daily ceiling, the age of the last off-box backup, visitors with 30 days as a sparkline, accounts signed in, and page speed. |
| 6 | * Server-rendered, one fixed-height card each, so nothing moves when it | |
| 7 | * paints. Only the studio's words: shipped, change, check time. | |
| 6 | * Server-rendered, one fixed-height §5 stat tile each, so nothing moves when | |
| 7 | * it paints. Only the studio's words: shipped, change, check time. | |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| @@ -14,7 +14,7 @@ import { cachedKpis } from "./home_cache.ts"; | ||
| 14 | 14 | import { escapeHtml, html, raw, type Html } from "../html.ts"; |
| 15 | 15 | import { BOOTED } from "../../release/gate.ts"; |
| 16 | 16 | import { running } from "../../kernel/version.ts"; |
| 17 | import { sha, time } from "../ui.ts"; | |
| 17 | import { sha, stat, time } from "../ui.ts"; | |
| 18 | 18 | |
| 19 | 19 | const W = 96; |
| 20 | 20 | const H = 24; |
| @@ -25,8 +25,9 @@ function at(i: number, n: number, v: number, top: number): [string, string] { | ||
| 25 | 25 | return [(i * step).toFixed(1), (H - 2 - (v / top) * (H - 4)).toFixed(1)]; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | function svg(label: string, body: string): Html { | |
| 29 | return raw(`<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}">${body}</svg>`); | |
| 28 | /** `cls` is for a sparkline outside a stat tile (the app settings page's), which styles itself. */ | |
| 29 | function svg(label: string, body: string, cls = ""): Html { | |
| 30 | return raw(`<svg${cls === "" ? "" : ` class="${cls}"`} viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}">${body}</svg>`); | |
| 30 | 31 | } |
| 31 | 32 | |
| 32 | 33 | /** A series oldest first as a fixed-size SVG polyline; the numbers are the only input. */ |
| @@ -62,7 +63,7 @@ export function daySpark(days: readonly (number | null)[], label: string): Html | ||
| 62 | 63 | : `<polyline points="${r.map((p) => p.join(",")).join(" ")}" fill="none" stroke="currentColor" stroke-width="1.5"/>`, |
| 63 | 64 | ) |
| 64 | 65 | .join(""); |
| 65 | return svg(escapeHtml(label), body); | |
| 66 | return svg(escapeHtml(label), body, "kpi-spark"); | |
| 66 | 67 | } |
| 67 | 68 | |
| 68 | 69 | /** The last 24 hours, newest first in, oldest on the left out. */ |
| @@ -76,12 +77,7 @@ export function visitorsSpark(days: readonly number[]): Html { | ||
| 76 | 77 | } |
| 77 | 78 | |
| 78 | 79 | function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null, tone: "attn" | null = null): Html { |
| 79 | return html`<a class="card kpi" href="${href}"${tone === null ? "" : raw(` data-tone="${tone}"`)} aria-label="${label}: ${full}, ${title}"> | |
| 80 | <span class="small muted">${label}</span> | |
| 81 | <span class="kpi-value num" title="${full}">${value}</span> | |
| 82 | <span class="kpi-note small muted" title="${title}">${note}</span> | |
| 83 | ${extra} | |
| 84 | </a>`; | |
| 80 | return stat({ label, href, value, title: full, note, noteTitle: title, aria: `${label}: ${full}, ${title}`, spark: extra, ...(tone === null ? {} : { tone }) }); | |
| 85 | 81 | } |
| 86 | 82 | |
| 87 | 83 | /** The version this forge is running, and since when: the same source as `/releases`. */ |
| @@ -93,7 +89,7 @@ export function liveNow(): { sha: string; at: string } | null { | ||
| 93 | 89 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html { |
| 94 | 90 | const overdue = backupOverdue(k.backupAt, now); |
| 95 | 91 | const rate = k.landed / k.landedHours.length; |
| 96 | return html`<section class="kpis" aria-label="The last 24 hours, as of this page load"> | |
| 92 | return html`<section class="stats mb" aria-label="The last 24 hours, as of this page load"> | |
| 97 | 93 | ${tile( |
| 98 | 94 | "Shipped per hour", |
| 99 | 95 | "/board", |
src/web/style/components.ts +1
| @@ -160,6 +160,7 @@ fieldset.field > .check + .check { margin-top: 8px; } | ||
| 160 | 160 | background: var(--card); color: var(--fg); text-decoration: none; |
| 161 | 161 | } |
| 162 | 162 | a.stat:hover { border-color: var(--border-strong); } |
| 163 | .stat[data-tone="attn"] { border-color: color-mix(in srgb, var(--attn) 40%, var(--border)); } | |
| 163 | 164 | .stat-label { display: block; height: 18px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--fs-min); line-height: 18px; color: var(--muted-fg); } |
| 164 | 165 | .stat-value { display: block; height: 32px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 20px; font-weight: 600; line-height: 32px; } |
| 165 | 166 | .stat-note { |
src/web/ui.ts +17 −13
| @@ -238,25 +238,29 @@ export function badge( | ||
| 238 | 238 | } |
| 239 | 239 | |
| 240 | 240 | /** A number with a label: one tile of a `.stats` grid. The value is `.num` |
| 241 | * with its full form in `title`; the note is clamped to two lines; `spark` is | |
| 242 | * trusted markup (an inline SVG) the caller built. A link when `href` is set. */ | |
| 241 | * with its full form in `title`; the note is clamped to two lines, its whole | |
| 242 | * text in `noteTitle`; `spark` is trusted markup (an inline SVG) the caller | |
| 243 | * built, labelled by its own `role="img"` and `aria-label` or by nothing. A | |
| 244 | * link when `href` is set, named by `aria` when given; `tone` marks it. */ | |
| 243 | 245 | export function stat(o: { |
| 244 | 246 | label: string; |
| 245 | value: string | number; | |
| 247 | value: string | number | Html; | |
| 246 | 248 | title?: string; |
| 247 | note?: string; | |
| 249 | note?: string | Html; | |
| 250 | noteTitle?: string; | |
| 248 | 251 | href?: string; |
| 249 | spark?: Html; | |
| 252 | aria?: string; | |
| 253 | tone?: "attn"; | |
| 254 | spark?: Html | null; | |
| 250 | 255 | }): Html { |
| 251 | const body = html`<span class="stat-label">${o.label}</span | |
| 252 | ><span class="stat-value num" title="${o.title ?? String(o.value)}" | |
| 253 | >${String(o.value)}</span | |
| 254 | >${o.note ? html`<span class="stat-note">${o.note}</span>` : null}${o.spark | |
| 255 | ? html`<span class="stat-spark" aria-hidden="true">${o.spark}</span>` | |
| 256 | : null}`; | |
| 256 | const noteTitle = o.noteTitle === undefined ? null : html` title="${o.noteTitle}"`; | |
| 257 | const body = html`<span class="stat-label">${o.label}</span><span class="stat-value num" title="${o.title ?? String(o.value)}">${o.value}</span>${o.note | |
| 258 | ? html`<span class="stat-note"${noteTitle}>${o.note}</span>` | |
| 259 | : null}${o.spark ? html`<span class="stat-spark">${o.spark}</span>` : null}`; | |
| 260 | const tone = o.tone === undefined ? null : html` data-tone="${o.tone}"`; | |
| 257 | 261 | return o.href !== undefined |
| 258 | ? html`<a class="stat" href="${o.href}">${body}</a>` | |
| 259 | : html`<div class="stat">${body}</div>`; | |
| 262 | ? html`<a class="stat" href="${o.href}"${tone}${o.aria === undefined ? null : html` aria-label="${o.aria}"`}>${body}</a>` | |
| 263 | : html`<div class="stat"${tone}>${body}</div>`; | |
| 260 | 264 | } |
| 261 | 265 | |
| 262 | 266 | /** The one badge whose value is a state machine, so it renders identically |
test/client-home.test.ts +2 −2
| @@ -244,8 +244,8 @@ describe("a client's first screen", () => { | ||
| 244 | 244 | }); |
| 245 | 245 | |
| 246 | 246 | test("a client sees no forge-wide figures, lean or whole", async () => { |
| 247 | expect(await page(owner)).not.toContain('class="kpis"'); | |
| 248 | expect(await page(owner, "/?all=1")).not.toContain('class="kpis"'); | |
| 247 | expect(await page(owner)).not.toContain('class="stats mb"'); | |
| 248 | expect(await page(owner, "/?all=1")).not.toContain('class="stats mb"'); | |
| 249 | 249 | }); |
| 250 | 250 | |
| 251 | 251 | test("queued agents and no running ones read as about to start", async () => { |
test/home-cache.test.ts +3 −3
| @@ -31,7 +31,7 @@ afterAll(async () => { | ||
| 31 | 31 | |
| 32 | 32 | test("a warm home reads neither the KPIs nor the panels again; a write does", async () => { |
| 33 | 33 | const a = await home(f.ownerCookie); |
| 34 | expect(a).toContain('class="kpis"'); | |
| 34 | expect(a).toContain('class="stats mb"'); | |
| 35 | 35 | expect(a).toContain("<h2>Pin A</h2>"); |
| 36 | 36 | const kpi = kpiCache(f.app.db); |
| 37 | 37 | const panel = panelCache(f.app.db); |
| @@ -50,8 +50,8 @@ test("the owner's KPIs and panels never render for another viewer, warm or not", | ||
| 50 | 50 | await home(f.ownerCookie); |
| 51 | 51 | for (const cookie of [f.peerCookie, undefined]) { |
| 52 | 52 | const page = await home(cookie); |
| 53 | expect(page).not.toContain('class="kpis"'); | |
| 54 | expect(page).not.toContain('class="card kpi"'); | |
| 53 | expect(page).not.toContain('class="stats mb"'); | |
| 54 | expect(page).not.toContain('class="stat"'); | |
| 55 | 55 | expect(page).not.toContain("Pin A"); |
| 56 | 56 | } |
| 57 | 57 | }); |
test/home-kpi-page.test.ts +9 −6
| @@ -59,9 +59,12 @@ test("home renders the nine tiles; only an admin sees them", async () => { | ||
| 59 | 59 | view(day, "a", 1900, null); |
| 60 | 60 | view(yesterday, "z", 400, 160); |
| 61 | 61 | const a = await home(admin); |
| 62 | expect(a).toContain('class="kpis"'); | |
| 63 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(9); | |
| 64 | for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`); | |
| 62 | expect(a).toContain('class="stats mb"'); | |
| 63 | expect((a.match(/class="stat"/g) ?? []).length).toBe(9); | |
| 64 | for (const href of ["/board", "/releases", "/boards", "/settings/crew", "/settings/backups"]) expect(a).toContain(`class="stat" href="${href}"`); | |
| 65 | // The tiles are §5 stats (#3982): none of the page-local kpi classes is left on home. | |
| 66 | for (const name of ["kpis", "kpi", "kpi-value", "kpi-note", "kpi-spark"]) expect(a).not.toMatch(new RegExp(`class="[^"]*\\b${name}\\b(?!-)`)); | |
| 67 | expect(a).toContain('role="img" aria-label="Shipped each hour over the last 24 hours"'); | |
| 65 | 68 | |
| 66 | 69 | // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days; |
| 67 | 70 | // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms. |
| @@ -70,14 +73,14 @@ test("home renders the nine tiles; only an admin sees them", async () => { | ||
| 70 | 73 | expect(a).toContain(">4 in 7 days<"); |
| 71 | 74 | expect(a).toContain(">400 ms<"); |
| 72 | 75 | expect(a).toContain(">reacts in 120 ms<"); |
| 73 | expect((a.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 76 | expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 74 | 77 | |
| 75 | 78 | // The figures count every repository, so a non-admin sees none. |
| 76 | 79 | expect(a).toContain("no ceiling"); |
| 77 | 80 | expect(a).toContain("last 30 days"); |
| 78 | expect(await home(bob)).not.toContain('class="kpis"'); | |
| 81 | expect(await home(bob)).not.toContain('class="stats mb"'); | |
| 79 | 82 | expect(await home(bob)).not.toContain("last 30 days"); |
| 80 | expect(await home()).not.toContain('class="kpis"'); | |
| 83 | expect(await home()).not.toContain('class="stats mb"'); | |
| 81 | 84 | expect(await home()).not.toContain("last 30 days"); |
| 82 | 85 | }); |
| 83 | 86 |
test/home-kpi-row.test.ts +11 −11
| @@ -5,7 +5,7 @@ import { NO_REACH, readReach } from "../src/flow/reach.ts"; | ||
| 5 | 5 | import { describe, expect, test } from "bun:test"; |
| 6 | 6 | import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts"; |
| 7 | 7 | import { running } from "../src/kernel/version.ts"; |
| 8 | import { feedCss } from "../src/web/studio/feed_style.ts"; | |
| 8 | import { components } from "../src/web/style/components.ts"; | |
| 9 | 9 | import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts"; |
| 10 | 10 | |
| 11 | 11 | const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0)); |
| @@ -75,24 +75,24 @@ describe("home kpi row", () => { | ||
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | 77 | test("the note wraps into two reserved lines instead of cutting off on a phone", () => { |
| 78 | const rule = (sel: string) => new RegExp(`\\${sel} \\{([^}]*)\\}`).exec(feedCss)![1]!; | |
| 79 | expect(rule(".kpi-note")).not.toContain("nowrap"); | |
| 80 | expect(rule(".kpi-note")).toContain("line-height: 20px"); | |
| 81 | expect(rule(".kpi-note")).toContain("line-clamp: 2"); | |
| 82 | expect(rule(".kpi")).toContain("grid-template-rows: 20px 32px 40px 24px"); | |
| 78 | const rule = (sel: string) => new RegExp(`\\${sel} \\{([^}]*)\\}`).exec(components)![1]!; | |
| 79 | expect(rule(".stat-note")).not.toContain("nowrap"); | |
| 80 | expect(rule(".stat-note")).toContain("line-clamp: 2"); | |
| 81 | expect(rule(".stat-note")).toContain("height: 36px"); | |
| 82 | expect(rule(".stat")).toContain("height: 104px"); | |
| 83 | 83 | }); |
| 84 | 84 | |
| 85 | 85 | test("the builders tile says running of slots, titles it, and links to the crew", () => { |
| 86 | 86 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); |
| 87 | 87 | expect(out).toContain('href="/settings/crew"'); |
| 88 | expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<'); | |
| 88 | expect(out).toContain('class="stat-value num" title="2 of 4">2 of 4<'); | |
| 89 | 89 | expect(out).toContain('title="Crew builders running now, of 4 slots"'); |
| 90 | 90 | expect(out).toContain('aria-label="Builders running: 2 of 4, Crew builders running now, of 4 slots"'); |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | 93 | test("with no slots set the builders tile shows a dash and says so", () => { |
| 94 | 94 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); |
| 95 | expect(out).toContain('class="kpi-value num" title="—">—<'); | |
| 95 | expect(out).toContain('class="stat-value num" title="—">—<'); | |
| 96 | 96 | expect(out).toContain('title="No slots set">No slots set<'); |
| 97 | 97 | expect(out).toContain(`aria-label="Builders running: —, No slots set"`); |
| 98 | 98 | expect(out).not.toContain("Builders running: 0 of 0"); |
| @@ -155,7 +155,7 @@ describe("the spend tile", () => { | ||
| 155 | 155 | |
| 156 | 156 | test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => { |
| 157 | 157 | const out = row({ todayUsd: 12.34, ceilingUsd: 40 }); |
| 158 | expect(out).toContain('class="kpi-value num" title="$12.34 of $40.00">$12 of $40<'); | |
| 158 | expect(out).toContain('class="stat-value num" title="$12.34 of $40.00">$12 of $40<'); | |
| 159 | 159 | expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the daily ceiling"'); |
| 160 | 160 | expect(out).toContain(">of the daily ceiling<"); |
| 161 | 161 | }); |
| @@ -198,7 +198,7 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 198 | 198 | const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); |
| 199 | 199 | const row = (backupAt: string | null) => |
| 200 | 200 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, reach: NO_REACH, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString(); |
| 201 | const tile = (out: string) => /<a class="card kpi"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0]; | |
| 201 | const tile = (out: string) => /<a class="stat"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0]; | |
| 202 | 202 | |
| 203 | 203 | test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => { |
| 204 | 204 | const out = row(hoursAgo(2)); |
| @@ -246,7 +246,7 @@ describe("the reach tiles", () => { | ||
| 246 | 246 | const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 }; |
| 247 | 247 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); |
| 248 | 248 | for (const v of ["5 today", "31 in 7 days", "3 of 12", "signed in today", "1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`); |
| 249 | expect((out.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 249 | expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3); | |
| 250 | 250 | expect(out).not.toContain("INP"); |
| 251 | 251 | expect(out).not.toContain("p75"); |
| 252 | 252 | }); |
test/home-kpis.test.ts +5 −5
| @@ -137,16 +137,16 @@ describe("home kpi row titles", () => { | ||
| 137 | 137 | ).toString(); |
| 138 | 138 | |
| 139 | 139 | test("every shortened value and note carries its whole text in a title", () => { |
| 140 | expect(full).toContain('class="kpi-value num" title="abcdef0123456789"'); | |
| 141 | expect(full).toContain('class="kpi-value num" title="0.25"'); | |
| 142 | expect(full).toContain('class="kpi-value num" title="3m 05s"'); | |
| 140 | expect(full).toContain('class="stat-value num" title="abcdef0123456789"'); | |
| 141 | expect(full).toContain('class="stat-value num" title="0.25"'); | |
| 142 | expect(full).toContain('class="stat-value num" title="3m 05s"'); | |
| 143 | 143 | expect(full).toContain('title="Live since 2026-10-19T11:00:00.000Z"'); |
| 144 | 144 | expect(full).toContain('title="6 changes in 24 h"'); |
| 145 | 145 | }); |
| 146 | 146 | |
| 147 | 147 | test("no value or note lacks a title, and none brings back a git word", () => { |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(18); | |
| 149 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(18); | |
| 148 | expect(full.match(/class="stat-(value num|note)"/g)).toHaveLength(18); | |
| 149 | expect(full.match(/class="stat-(value num|note)" title=/g)).toHaveLength(18); | |
| 150 | 150 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); |
| 151 | 151 | }); |
| 152 | 152 | }); |
test/kpi-spark.test.ts +2 −2
| @@ -50,13 +50,13 @@ describe("daySpark", () => { | ||
| 50 | 50 | describe("the numbers-only sparklines are unchanged", () => { |
| 51 | 51 | test("visitorsSpark", () => { |
| 52 | 52 | expect(visitorsSpark([0, 2, 4]).toString()).toBe( |
| 53 | `<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Distinct visitors each day over the last 3 days"><polyline points="0.0,22.0 48.0,12.0 96.0,2.0" ${LINE}/></svg>`, | |
| 53 | `<svg viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Distinct visitors each day over the last 3 days"><polyline points="0.0,22.0 48.0,12.0 96.0,2.0" ${LINE}/></svg>`, | |
| 54 | 54 | ); |
| 55 | 55 | }); |
| 56 | 56 | |
| 57 | 57 | test("sparkline, newest first in, oldest on the left", () => { |
| 58 | 58 | expect(sparkline([2, 0]).toString()).toBe( |
| 59 | `<svg class="kpi-spark" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="0.0,22.0 96.0,2.0" ${LINE}/></svg>`, | |
| 59 | `<svg viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="0.0,22.0 96.0,2.0" ${LINE}/></svg>`, | |
| 60 | 60 | ); |
| 61 | 61 | }); |
| 62 | 62 | }); |
new above
offline