Public: anyone can read this; the crew can change it.
Home's KPI row shows the age of the last good off-box backup, flagged past 24 hours, linking to /backups #3001
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2969-home-s-kpi-row-shows-the-age-of-the-last → main 1 commit fork 1 thread · 1 unresolved
8 files
changed +125
−28
README.md +7 −3
| @@ -254,15 +254,19 @@ 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 five figures first, from `src/flow/kpis.ts`: shipped per hour (with | |
| 257 | Home shows six figures first, from `src/flow/kpis.ts`: shipped per hour (with | |
| 258 | 258 | a 24 h sparkline), the live version and since when, the median check time, the crew builders |
| 259 | 259 | running of their slots, and the crew's spend today of the forge's daily |
| 260 | 260 | ceiling (`FORGE_CREW_CEILING_USD`; without one, the spend alone and |
| 261 | `no ceiling`). The spend tile links to `/settings/crew`, where the meter is. | |
| 261 | `no ceiling`), and the age of the last off-box backup (the mirror's newest | |
| 262 | snapshot, `mirror/state.json`; `3 h ago`, the exact time in its title), linking to | |
| 263 | `/settings/backups`. The spend tile links to `/settings/crew`, where the meter is. | |
| 264 | The backup tile past 24 hours, or with none (`never`), takes the card's existing | |
| 265 | `data-tone="attn"` and says overdue in its label. | |
| 262 | 266 | They count every repository, so only a site admin sees them, on both doors; |
| 263 | 267 | nobody else does, and neither does the lean client home. On the web each is a |
| 264 | 268 | card linking to `/board`, `/releases` or `/boards`. The terminal's first screen |
| 265 | shows the same five as lines above the forge strip (`kpiLines` in | |
| 269 | shows the first five as lines above the forge strip (`kpiLines` in | |
| 266 | 270 | `src/flow/kpi_words.ts`). They are read when the page or screen loads and |
| 267 | 271 | do not refresh on their own. |
| 268 | 272 |
src/flow/kpi_words.ts +23
| @@ -45,6 +45,29 @@ export function spendFull(s: Kpis["spend"]): string { | ||
| 45 | 45 | return s.ceilingUsd === null ? cents(s.todayUsd) : `${cents(s.todayUsd)} of ${cents(s.ceilingUsd)}`; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | const DAY_MS = 24 * 3_600_000; | |
| 49 | ||
| 50 | /** Past a day, or with no backup at all, the last off-box backup is overdue. */ | |
| 51 | export function backupOverdue(at: string | null, now: Date): boolean { | |
| 52 | return at === null || !(now.getTime() - Date.parse(at) <= DAY_MS); | |
| 53 | } | |
| 54 | ||
| 55 | /** The backup's age as a person reads it: `3 h ago`, `2 d ago`, or `never`. */ | |
| 56 | export function backupAge(at: string | null, now: Date): string { | |
| 57 | const t = at === null ? NaN : Date.parse(at); | |
| 58 | if (Number.isNaN(t)) return "never"; | |
| 59 | const min = Math.max(0, Math.floor((now.getTime() - t) / 60_000)); | |
| 60 | if (min < 1) return "just now"; | |
| 61 | if (min < 60) return `${min} min ago`; | |
| 62 | if (min < 2880) return `${Math.floor(min / 60)} h ago`; | |
| 63 | return `${Math.floor(min / 1440)} d ago`; | |
| 64 | } | |
| 65 | ||
| 66 | /** The exact time, for the title. */ | |
| 67 | export function backupFull(at: string | null): string { | |
| 68 | return at === null || Number.isNaN(Date.parse(at)) ? "No off-box backup yet" : at; | |
| 69 | } | |
| 70 | ||
| 48 | 71 | /** |
| 49 | 72 | * The terminal's row: the same five figures in the same words, as plain |
| 50 | 73 | * lines. Forge-wide like the web's, so the caller shows them to an admin only. |
src/flow/kpis.ts +11 −1
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | 17 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 18 | import { lastMirror } from "../snapshot/mirror.ts"; | |
| 18 | 19 | import { HOURS, median } from "./throughput.ts"; |
| 19 | 20 | |
| 20 | 21 | const HOUR_MS = 3_600_000; |
| @@ -32,9 +33,15 @@ export interface Kpis { | ||
| 32 | 33 | readonly builders: { readonly running: number; readonly slots: number }; |
| 33 | 34 | /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */ |
| 34 | 35 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null }; |
| 36 | /** When the off-box mirror last finished holding a snapshot; null when it never has. */ | |
| 37 | readonly backupAt: string | null; | |
| 35 | 38 | } |
| 36 | 39 | |
| 37 | export function readKpis(db: Database, now: Date, forge: { readonly ceilingUsd: number | null } = { ceilingUsd: null }): Kpis { | |
| 40 | export function readKpis( | |
| 41 | db: Database, | |
| 42 | now: Date, | |
| 43 | forge: { readonly ceilingUsd: number | null; readonly dataDir?: string } = { ceilingUsd: null }, | |
| 44 | ): Kpis { | |
| 38 | 45 | const end = now.getTime(); |
| 39 | 46 | const to = now.toISOString(); |
| 40 | 47 | const since = new Date(end - HOURS * HOUR_MS).toISOString(); |
| @@ -84,7 +91,10 @@ export function readKpis(db: Database, now: Date, forge: { readonly ceilingUsd: | ||
| 84 | 91 | |
| 85 | 92 | const todayUsd = spentTodayEverywhere(db, now); |
| 86 | 93 | |
| 94 | const backupAt = forge.dataDir === undefined ? null : (lastMirror(forge.dataDir)?.newestAt ?? null); | |
| 95 | ||
| 87 | 96 | return { |
| 97 | backupAt, | |
| 88 | 98 | builders, |
| 89 | 99 | spend: { todayUsd, ceilingUsd: forge.ceilingUsd }, |
| 90 | 100 | landedHours, |
src/web/studio/home.ts +1 −1
| @@ -246,7 +246,7 @@ export async function studioHome( | ||
| 246 | 246 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 247 | 247 | const figures = user.isAdmin && steps === undefined; |
| 248 | 248 | const ceilingUsd = ctx.app.config.crewCeilingUsd; |
| 249 | const kpiOpts = ceilingUsd === undefined ? {} : { ceilingUsd }; | |
| 249 | const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === undefined ? {} : { ceilingUsd }) }; | |
| 250 | 250 | const body = html`<h1 class="sr-only">Home</h1> |
| 251 | 251 | ${ask} ${address} ${figures ? kpiRow(ctx.app.db, kpiOpts) : liveLine(ctx)} |
| 252 | 252 | ${when( |
src/web/studio/kpi_row.ts +21 −8
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | * Home's row of five figures (#2871), from `flow/kpis.ts`: shipped per hour | |
| 2 | * Home's row of six figures (#2871), from `flow/kpis.ts`: shipped per hour | |
| 3 | 3 | * with a day's sparkline, the live version and since when, the median check |
| 4 | 4 | * time, the crew builders running of their slots, and the crew's spend today |
| 5 | * of the daily ceiling. Server-rendered, one fixed-height card each, so | |
| 5 | * of the daily ceiling, and the age of the last off-box backup. Server-rendered, one fixed-height card each, so | |
| 6 | 6 | * nothing moves when it paints. Only the studio's words: shipped, change, |
| 7 | 7 | * check time. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts"; | |
| 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts"; | |
| 12 | 12 | import { readKpis, type Kpis } from "../../flow/kpis.ts"; |
| 13 | 13 | import { html, raw, type Html } from "../html.ts"; |
| 14 | 14 | import { BOOTED } from "../../release/gate.ts"; |
| @@ -31,8 +31,8 @@ export function sparkline(hours: readonly number[]): Html { | ||
| 31 | 31 | ); |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null): Html { | |
| 35 | return html`<a class="card kpi" href="${href}" aria-label="${label}: ${full}, ${title}"> | |
| 34 | 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 { | |
| 35 | return html`<a class="card kpi" href="${href}"${tone === null ? "" : raw(` data-tone="${tone}"`)} aria-label="${label}: ${full}, ${title}"> | |
| 36 | 36 | <span class="small muted">${label}</span> |
| 37 | 37 | <span class="kpi-value num" title="${full}">${value}</span> |
| 38 | 38 | <span class="kpi-note small muted" title="${title}">${note}</span> |
| @@ -46,7 +46,8 @@ export function liveNow(): { sha: string; at: string } | null { | ||
| 46 | 46 | return commit === null ? null : { sha: commit, at: BOOTED }; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Html { | |
| 49 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html { | |
| 50 | const overdue = backupOverdue(k.backupAt, now); | |
| 50 | 51 | const rate = k.landed / k.landedHours.length; |
| 51 | 52 | return html`<section class="kpis" aria-label="The last 24 hours, as of this page load"> |
| 52 | 53 | ${tile( |
| @@ -90,10 +91,22 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht | ||
| 90 | 91 | k.spend.ceilingUsd === null ? "no ceiling" : "of the daily ceiling", |
| 91 | 92 | k.spend.ceilingUsd === null ? "No daily ceiling is set" : "Crew spend today (UTC), of the daily ceiling", |
| 92 | 93 | )} |
| 94 | ${tile( | |
| 95 | "Last off-box backup", | |
| 96 | "/settings/backups", | |
| 97 | backupAge(k.backupAt, now), | |
| 98 | backupFull(k.backupAt), | |
| 99 | k.backupAt === null ? "no backup off the machine yet" : overdue ? "overdue, more than 24 h" : "off the machine", | |
| 100 | overdue ? "Overdue: the last off-box backup is more than 24 h old" : "Age of the last off-box backup", | |
| 101 | null, | |
| 102 | overdue ? "attn" : null, | |
| 103 | )} | |
| 93 | 104 | </section>`; |
| 94 | 105 | } |
| 95 | 106 | |
| 96 | 107 | /** The figures count every repository, so the caller shows this to an admin only. */ |
| 97 | export function kpiRow(db: Database, opts: { ceilingUsd?: number; now?: Date } = {}): Html { | |
| 98 | return kpiRowFor(readKpis(db, opts.now ?? new Date(), { ceilingUsd: opts.ceilingUsd ?? null }), liveNow()); | |
| 108 | export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: string; now?: Date } = {}): Html { | |
| 109 | const now = opts.now ?? new Date(); | |
| 110 | const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }) }; | |
| 111 | return kpiRowFor(readKpis(db, now, forge), liveNow(), now); | |
| 99 | 112 | } |
test/home-kpi-page.test.ts +2 −2
| @@ -25,7 +25,7 @@ afterAll(async () => { | ||
| 25 | 25 | await rm(dataDir, { recursive: true, force: true }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | test("home renders the five tiles; only an admin sees them", async () => { | |
| 28 | test("home renders the six tiles; only an admin sees them", async () => { | |
| 29 | 29 | const mk = async (username: string, first = false): Promise<User> => { |
| 30 | 30 | const r = await createUser(app.db, { |
| 31 | 31 | origin: "seed", |
| @@ -50,7 +50,7 @@ test("home renders the five tiles; only an admin sees them", async () => { | ||
| 50 | 50 | |
| 51 | 51 | const a = await home(admin); |
| 52 | 52 | expect(a).toContain('class="kpis"'); |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(5); | |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(6); | |
| 54 | 54 | for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`); |
| 55 | 55 | |
| 56 | 56 | // The figures count every repository, so a non-admin sees none. |
test/home-kpi-row.test.ts +57 −10
| @@ -10,7 +10,7 @@ const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : | ||
| 10 | 10 | describe("home kpi row", () => { |
| 11 | 11 | test("five tiles with their links and figures", () => { |
| 12 | 12 | const out = kpiRowFor( |
| 13 | { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 }, | |
| 13 | { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null }, | |
| 14 | 14 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 15 | 15 | ).toString(); |
| 16 | 16 | expect(out).toContain('<span class="sha">abcdef0</span>'); |
| @@ -25,7 +25,7 @@ describe("home kpi row", () => { | ||
| 25 | 25 | |
| 26 | 26 | test("the whole figure is in the title and the link's label, rounded and in studio words", () => { |
| 27 | 27 | const out = kpiRowFor( |
| 28 | { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 }, | |
| 28 | { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null }, | |
| 29 | 29 | { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" }, |
| 30 | 30 | ).toString(); |
| 31 | 31 | expect(out).toContain('title="0.29"'); |
| @@ -37,13 +37,13 @@ describe("home kpi row", () => { | ||
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | 39 | test("empty figures say so", () => { |
| 40 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null).toString(); | |
| 40 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null }, null).toString(); | |
| 41 | 41 | expect(out).toContain("Nothing live yet"); |
| 42 | 42 | expect(out).toContain("No check finished in 24 h"); |
| 43 | 43 | }); |
| 44 | 44 | |
| 45 | 45 | test("no git words render, not even in a title or label", () => { |
| 46 | const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000 }, null).toString(); | |
| 46 | const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000, backupAt: null }, null).toString(); | |
| 47 | 47 | expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i); |
| 48 | 48 | }); |
| 49 | 49 | |
| @@ -67,7 +67,7 @@ describe("home kpi row", () => { | ||
| 67 | 67 | }); |
| 68 | 68 | |
| 69 | 69 | test("a truncated note carries its whole text as a title", () => { |
| 70 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null).toString(); | |
| 70 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null }, null).toString(); | |
| 71 | 71 | expect(out).toContain('title="No check finished in 24 h"'); |
| 72 | 72 | }); |
| 73 | 73 | |
| @@ -80,7 +80,7 @@ describe("home kpi row", () => { | ||
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | 82 | test("the builders tile says running of slots, titles it, and links to the crew", () => { |
| 83 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 83 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 84 | 84 | expect(out).toContain('href="/settings/crew"'); |
| 85 | 85 | expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<'); |
| 86 | 86 | expect(out).toContain('title="Crew builders running now, of 4 slots"'); |
| @@ -88,14 +88,14 @@ describe("home kpi row", () => { | ||
| 88 | 88 | }); |
| 89 | 89 | |
| 90 | 90 | test("with no slots set the builders tile shows a dash and says so", () => { |
| 91 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 91 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 92 | 92 | expect(out).toContain('class="kpi-value num" title="—">—<'); |
| 93 | 93 | expect(out).toContain('title="No slots set">No slots set<'); |
| 94 | 94 | expect(out).not.toContain("0 of 0"); |
| 95 | 95 | }); |
| 96 | 96 | |
| 97 | 97 | test("the builders note does not repeat the value", () => { |
| 98 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 98 | const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString(); | |
| 99 | 99 | expect(out).toContain(">running now<"); |
| 100 | 100 | }); |
| 101 | 101 | |
| @@ -122,7 +122,7 @@ describe("the builders figure from the database", () => { | ||
| 122 | 122 | }); |
| 123 | 123 | |
| 124 | 124 | describe("the terminal's figures", () => { |
| 125 | const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, spend: { todayUsd: 12, ceilingUsd: 40 }, medianCheckMs: 185_000 }; | |
| 125 | const k = { landedHours: hours(6), landed: 6, live: { sha: "abcdef0123456", at: new Date().toISOString() }, builders: { running: 2, slots: 4 }, spend: { todayUsd: 12, ceilingUsd: 40 }, medianCheckMs: 185_000, backupAt: null }; | |
| 126 | 126 | test("the same five figures in the same words", () => { |
| 127 | 127 | const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" }); |
| 128 | 128 | expect(lines).toHaveLength(5); |
| @@ -145,7 +145,7 @@ describe("the terminal's figures", () => { | ||
| 145 | 145 | |
| 146 | 146 | describe("the spend tile", () => { |
| 147 | 147 | const row = (spend: { todayUsd: number; ceilingUsd: number | null }) => |
| 148 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString(); | |
| 148 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString(); | |
| 149 | 149 | |
| 150 | 150 | test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => { |
| 151 | 151 | const out = row({ todayUsd: 12.34, ceilingUsd: 40 }); |
| @@ -185,3 +185,50 @@ describe("the spend figure from the database", () => { | ||
| 185 | 185 | expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null }); |
| 186 | 186 | }); |
| 187 | 187 | }); |
| 188 | ||
| 189 | describe("home kpi row: the last off-box backup", () => { | |
| 190 | const NOW = new Date("2026-10-20T12:00:00.000Z"); | |
| 191 | const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 192 | const row = (backupAt: string | null) => | |
| 193 | kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null }, null, NOW).toString(); | |
| 194 | const tile = (out: string) => /<a class="card kpi"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0]; | |
| 195 | ||
| 196 | test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => { | |
| 197 | const out = row(hoursAgo(2)); | |
| 198 | expect(out).toContain("2 h ago"); | |
| 199 | expect(out).toContain(`title="${hoursAgo(2)}"`); | |
| 200 | expect(tile(out)).not.toContain("data-tone"); | |
| 201 | expect(tile(out)).not.toContain("Overdue"); | |
| 202 | }); | |
| 203 | ||
| 204 | test("a backup 25 h old is flagged and its label says overdue", () => { | |
| 205 | const t = tile(row(hoursAgo(25))); | |
| 206 | expect(t).toContain('data-tone="attn"'); | |
| 207 | expect(t).toContain("Overdue"); | |
| 208 | }); | |
| 209 | ||
| 210 | test("no backup at all reads never and is flagged", () => { | |
| 211 | const out = row(null); | |
| 212 | expect(out).toContain(">never<"); | |
| 213 | expect(tile(out)).toContain('data-tone="attn"'); | |
| 214 | expect(tile(out)).toContain("Overdue"); | |
| 215 | }); | |
| 216 | ||
| 217 | test("readKpis reads the mirror's newest time from the data directory", async () => { | |
| 218 | const { mkdtempSync } = await import("node:fs"); | |
| 219 | const { join } = await import("node:path"); | |
| 220 | const { tmpdir } = await import("node:os"); | |
| 221 | const { Database } = await import("bun:sqlite"); | |
| 222 | const { recordMirror } = await import("../src/snapshot/mirror.ts"); | |
| 223 | const { readKpis } = await import("../src/flow/kpis.ts"); | |
| 224 | const dir = mkdtempSync(join(tmpdir(), "kpi-backup-")); | |
| 225 | const db = new Database(":memory:"); | |
| 226 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); | |
| 227 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); | |
| 228 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 229 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 230 | expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull(); | |
| 231 | recordMirror(dir, { where: "b2", attemptAt: hoursAgo(1), reachable: true, newest: "s1", newestAt: hoursAgo(3) }); | |
| 232 | expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBe(hoursAgo(3)); | |
| 233 | }); | |
| 234 | }); |
test/home-kpis.test.ts +3 −3
| @@ -131,7 +131,7 @@ describe("home kpis", () => { | ||
| 131 | 131 | |
| 132 | 132 | describe("home kpi row titles", () => { |
| 133 | 133 | const full = kpiRowFor( |
| 134 | { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 }, | |
| 134 | { landedHours: Array.from({ length: 24 }, (_, i) => (i === 0 ? 6 : 0)), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null }, | |
| 135 | 135 | { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" }, |
| 136 | 136 | ).toString(); |
| 137 | 137 | |
| @@ -144,8 +144,8 @@ describe("home kpi row titles", () => { | ||
| 144 | 144 | }); |
| 145 | 145 | |
| 146 | 146 | test("no value or note lacks a title, and none brings back a git word", () => { |
| 147 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(10); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(10); | |
| 147 | expect(full.match(/class="kpi-(value num|note small muted)"/g)).toHaveLength(12); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(12); | |
| 149 | 149 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); |
| 150 | 150 | }); |
| 151 | 151 | }); |
new above
offline
This line runs far past the wrapping of the lines around it; re-wrap the comment.