Public: anyone can read this; the crew can change it.
Home's KPI row shows today's spend of the daily ceiling, linking to the meter #2986
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2968-home-s-kpi-row-shows-today-s-spend-of-th → main 5 commits fork 4 threads · 4 unresolved
10 files
changed +162
−38
README.md +6 −3
| @@ -254,12 +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. | |
| 257 | Home shows five figures first, from `src/flow/kpis.ts`: shipped per hour (with | |
| 258 | a 24 h sparkline), the live version and since when, the median check time, the crew builders | |
| 259 | running of their slots, and the crew's spend today of the forge's daily | |
| 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. | |
| 259 | 262 | They count every repository, so only a site admin sees them, on both doors; |
| 260 | 263 | nobody else does, and neither does the lean client home. On the web each is a |
| 261 | 264 | 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 | |
| 265 | shows the same five as lines above the forge strip (`kpiLines` in | |
| 263 | 266 | `src/flow/kpi_words.ts`). They are read when the page or screen loads and |
| 264 | 267 | do not refresh on their own. |
| 265 | 268 |
src/flow/kpi_words.ts +16 −1
| @@ -31,8 +31,22 @@ export function buildersValue(b: Kpis["builders"]): string { | ||
| 31 | 31 | return b.slots === 0 ? "—" : `${b.running} of ${b.slots}`; |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | /** Cents below ten dollars, so a small spend never reads `$0`; whole dollars above. */ | |
| 35 | const whole = (n: number): string => (n < 10 ? `$${n.toFixed(2)}` : `$${Math.round(n)}`); | |
| 36 | const cents = (n: number): string => `$${n.toFixed(2)}`; | |
| 37 | ||
| 38 | /** Today's spend of the daily ceiling: `$12 of $40`, or the spend alone with no ceiling. */ | |
| 39 | export function spendValue(s: Kpis["spend"]): string { | |
| 40 | return s.ceilingUsd === null ? whole(s.todayUsd) : `${whole(s.todayUsd)} of ${whole(s.ceilingUsd)}`; | |
| 41 | } | |
| 42 | ||
| 43 | /** The same figure to the cent, for the title. */ | |
| 44 | export function spendFull(s: Kpis["spend"]): string { | |
| 45 | return s.ceilingUsd === null ? cents(s.todayUsd) : `${cents(s.todayUsd)} of ${cents(s.ceilingUsd)}`; | |
| 46 | } | |
| 47 | ||
| 34 | 48 | /** |
| 35 | * The terminal's row: the same four figures in the same words, as plain | |
| 49 | * The terminal's row: the same five figures in the same words, as plain | |
| 36 | 50 | * lines. Forge-wide like the web's, so the caller shows them to an admin only. |
| 37 | 51 | */ |
| 38 | 52 | export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] { |
| @@ -41,5 +55,6 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): | ||
| 41 | 55 | `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`, |
| 42 | 56 | `Median check time ${checkTime(k.medianCheckMs)}`, |
| 43 | 57 | `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`, |
| 58 | `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`, | |
| 44 | 59 | ]; |
| 45 | 60 | } |
src/flow/kpis.ts +7 −1
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; | |
| 17 | 18 | import { HOURS, median } from "./throughput.ts"; |
| 18 | 19 | |
| 19 | 20 | const HOUR_MS = 3_600_000; |
| @@ -29,9 +30,11 @@ export interface Kpis { | ||
| 29 | 30 | readonly medianCheckMs: number | null; |
| 30 | 31 | /** Crew builders running now, of the slots the autonomous repositories are set to. */ |
| 31 | 32 | readonly builders: { readonly running: number; readonly slots: number }; |
| 33 | /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */ | |
| 34 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null }; | |
| 32 | 35 | } |
| 33 | 36 | |
| 34 | export function readKpis(db: Database, now: Date): Kpis { | |
| 37 | export function readKpis(db: Database, now: Date, forge: { readonly ceilingUsd: number | null } = { ceilingUsd: null }): Kpis { | |
| 35 | 38 | const end = now.getTime(); |
| 36 | 39 | const to = now.toISOString(); |
| 37 | 40 | const since = new Date(end - HOURS * HOUR_MS).toISOString(); |
| @@ -79,8 +82,11 @@ export function readKpis(db: Database, now: Date): Kpis { | ||
| 79 | 82 | ) |
| 80 | 83 | .get() ?? { running: 0, slots: 0 }; |
| 81 | 84 | |
| 85 | const todayUsd = spentTodayEverywhere(db, now); | |
| 86 | ||
| 82 | 87 | return { |
| 83 | 88 | builders, |
| 89 | spend: { todayUsd, ceilingUsd: forge.ceilingUsd }, | |
| 84 | 90 | landedHours, |
| 85 | 91 | landed: landedHours.reduce((a, b) => a + b, 0), |
| 86 | 92 | live: live ?? null, |
src/tui/screens/dashboard.ts +12 −4
| @@ -96,6 +96,12 @@ import { repoScreen } from "./repo.ts"; | ||
| 96 | 96 | import { local } from "./where.ts"; |
| 97 | 97 | import { signInScreen } from "./signin.ts"; |
| 98 | 98 | |
| 99 | /** Rows the list keeps under the figures strip; below it the strip shows fewer figures. */ | |
| 100 | const FIGURES_ROOM = 6; | |
| 101 | ||
| 102 | /** The forge-wide daily ceiling, from config, for the spend figure. */ | |
| 103 | const kpiForge = (app: { config: { crewCeilingUsd?: number } }) => ({ ceilingUsd: app.config.crewCeilingUsd ?? null }); | |
| 104 | ||
| 99 | 105 | /** |
| 100 | 106 | * The three things the launcher is about. Order is the `tab` order: the |
| 101 | 107 | * repositories, then what is waiting on you, then what is on your machine. |
| @@ -216,7 +222,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 216 | 222 | figures = |
| 217 | 223 | f.user?.isAdmin !== true |
| 218 | 224 | ? [] |
| 219 | : kpiLines(readKpis(f.db, now), commit === null ? null : { sha: commit, since: ago(BOOTED) }); | |
| 225 | : kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }); | |
kpiForge(f.app) is untested at the screen level; add a test that the admin's figures show the configured ceiling. |
||
| 220 | 226 | // "Yours" is ownership plus the orgs you are on a team of; typing in the |
| 221 | 227 | // filter reaches every repository you can see. |
| 222 | 228 | const orgs = f.user |
| @@ -777,9 +783,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 777 | 783 | ); |
| 778 | 784 | body = rest!; |
| 779 | 785 | } |
| 780 | if (figures.length > 0) { | |
| 781 | const [strip, rest] = split(body, "v", [len(figures.length), fill(1)]); | |
| 782 | figures.forEach((line, i) => | |
| 786 | // A short terminal drops the last figures before it drops the list. | |
| 787 | const shown = figures.slice(0, Math.max(0, body.h - FIGURES_ROOM)); | |
| 788 | if (shown.length > 0) { | |
| 789 | const [strip, rest] = split(body, "v", [len(shown.length), fill(1)]); | |
| 790 | shown.forEach((line, i) => | |
| 783 | 791 | s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w), |
| 784 | 792 | ); |
| 785 | 793 | body = rest!; |
src/web/studio/home.ts +3 −1
| @@ -245,8 +245,10 @@ export async function studioHome( | ||
| 245 | 245 | } |
| 246 | 246 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 247 | 247 | const figures = user.isAdmin && steps === undefined; |
| 248 | const ceilingUsd = ctx.app.config.crewCeilingUsd; | |
| 249 | const kpiOpts = ceilingUsd === undefined ? {} : { ceilingUsd }; | |
outdated
This ceiling wiring is untested. Add a page test with and without crewCeilingUsd. Also compute the opts object once above the html instead of inline in the template, so the line stays readable. |
||
| 248 | 250 | const body = html`<h1 class="sr-only">Home</h1> |
| 249 | ${ask} ${address} ${figures ? kpiRow(ctx.app.db) : liveLine(ctx)} | |
| 251 | ${ask} ${address} ${figures ? kpiRow(ctx.app.db, kpiOpts) : liveLine(ctx)} | |
| 250 | 252 | ${when( |
| 251 | 253 | steps, |
| 252 | 254 | () => html`<p class="small muted" id="lean-home"> |
src/web/studio/kpi_row.ts +16 −7
| @@ -1,13 +1,14 @@ | ||
| 1 | 1 | /** |
| 2 | * Home's row of four figures (#2871), from `flow/kpis.ts`: shipped per hour | |
| 2 | * Home's row of five 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 | * time, and the crew builders running of their slots. Server-rendered, one | |
| 5 | * fixed-height card each, so nothing moves when it paints. Only the studio's | |
| 6 | * words: shipped, change, check time. | |
| 4 | * time, the crew builders running of their slots, and the crew's spend today | |
outdated
Rewrap this comment. It is now far over the line width used elsewhere in this file. |
||
| 5 | * of the daily ceiling. Server-rendered, one fixed-height card each, so | |
| 6 | * nothing moves when it paints. Only the studio's words: shipped, change, | |
| 7 | * check time. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import type { Database } from "bun:sqlite"; |
| 10 | import { buildersValue, checkTime } from "../../flow/kpi_words.ts"; | |
| 11 | import { buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts"; | |
| 11 | 12 | import { readKpis, type Kpis } from "../../flow/kpis.ts"; |
| 12 | 13 | import { html, raw, type Html } from "../html.ts"; |
| 13 | 14 | import { BOOTED } from "../../release/gate.ts"; |
| @@ -81,10 +82,18 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht | ||
| 81 | 82 | k.builders.slots === 0 ? "No slots set" : "running now", |
| 82 | 83 | k.builders.slots === 0 ? "No slots set" : `Crew builders running now, of ${k.builders.slots} ${k.builders.slots === 1 ? "slot" : "slots"}`, |
| 83 | 84 | )} |
| 85 | ${tile( | |
| 86 | "Crew spend today", | |
| 87 | "/settings/crew", | |
| 88 | spendValue(k.spend), | |
| 89 | spendFull(k.spend), | |
| 90 | k.spend.ceilingUsd === null ? "no ceiling" : "of the daily ceiling", | |
| 91 | k.spend.ceilingUsd === null ? "No daily ceiling is set" : "Crew spend today (UTC), of the daily ceiling", | |
| 92 | )} | |
| 84 | 93 | </section>`; |
| 85 | 94 | } |
| 86 | 95 | |
| 87 | 96 | /** The figures count every repository, so the caller shows this to an admin only. */ |
| 88 | export function kpiRow(db: Database, now = new Date()): Html { | |
| 89 | return kpiRowFor(readKpis(db, now), liveNow()); | |
| 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()); | |
| 90 | 99 | } |
test/home-kpi-page.test.ts +32 −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 four tiles; only an admin sees them", async () => { | |
| 28 | test("home renders the five 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,10 +50,40 @@ test("home renders the four 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(4); | |
| 53 | expect((a.match(/class="card kpi"/g) ?? []).length).toBe(5); | |
| 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. |
| 57 | expect(a).toContain("no ceiling"); | |
| 57 | 58 | expect(await home(bob)).not.toContain('class="kpis"'); |
| 58 | 59 | expect(await home()).not.toContain('class="kpis"'); |
| 59 | 60 | }); |
| 61 | ||
| 62 | test("home passes the forge's daily ceiling to the spend tile", async () => { | |
| 63 | const dir = await mkdtemp(join(tmpdir(), "forge-home-kpi-cap-")); | |
| 64 | const capped = createApp({ dataDir: dir, port: 0, crewCeilingUsd: 40 }); | |
| 65 | const s = await serve(capped); | |
| 66 | try { | |
| 67 | const r = await createUser(capped.db, { | |
| 68 | origin: "seed", | |
| 69 | trusted: true, | |
| 70 | username: "keeper", | |
| 71 | email: "[email protected]", | |
| 72 | password: "a-good-passphrase", | |
| 73 | adminIfFirst: true, | |
| 74 | }); | |
| 75 | if (!r.ok) throw new Error(r.error); | |
| 76 | promote(capped.db, r.value); | |
| 77 | const res = await fetch(`${s.url}/`, { | |
| 78 | headers: { cookie: `forge_session=${createSession(capped.db, r.value.id).token}; forge_mode=studio` }, | |
| 79 | }); | |
| 80 | const body = await res.text(); | |
| 81 | expect(body).toContain("of $40"); | |
| 82 | expect(body).toContain("of the daily ceiling"); | |
| 83 | expect(body).not.toContain("no ceiling"); | |
| 84 | } finally { | |
| 85 | s.stop(); | |
| 86 | capped.close(); | |
| 87 | await rm(dir, { recursive: true, force: true }); | |
| 88 | } | |
| 89 | }); |
test/home-kpi-row.test.ts +59 −14
| @@ -1,4 +1,4 @@ | ||
| 1 | /** Home's figures row (#2871): the four tiles, their links, studio words only, a sparkline of 24 points. */ | |
| 1 | /** Home's figures row (#2871): the five tiles, their links, studio words only, a sparkline of 24 points. */ | |
| 2 | 2 | import { describe, expect, test } from "bun:test"; |
| 3 | 3 | import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts"; |
| 4 | 4 | import { running } from "../src/kernel/version.ts"; |
| @@ -8,9 +8,9 @@ import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts"; | ||
| 8 | 8 | const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0)); |
| 9 | 9 | |
| 10 | 10 | describe("home kpi row", () => { |
| 11 | test("four tiles with their links and figures", () => { | |
| 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 }, medianCheckMs: 185_000 }, | |
| 13 | { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 }, | |
| 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 }, medianCheckMs: 185_000 }, | |
| 28 | { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000 }, | |
| 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 }, 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 }, 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 }, 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 }, 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 }, 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 }, 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 } }, null).toString(); | |
| 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(); | |
| 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 } }, null).toString(); | |
| 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(); | |
| 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 } }, null).toString(); | |
| 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(); | |
| 99 | 99 | expect(out).toContain(">running now<"); |
| 100 | 100 | }); |
| 101 | 101 | |
| @@ -111,7 +111,7 @@ describe("the builders figure from the database", () => { | ||
| 111 | 111 | const { Database } = await import("bun:sqlite"); |
| 112 | 112 | const db = new Database(":memory:"); |
| 113 | 113 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); |
| 114 | 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)"); | |
| 114 | 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)"); | |
| 115 | 115 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); |
| 116 | 116 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); |
| 117 | 117 | db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')"); |
| @@ -122,10 +122,12 @@ 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 }, medianCheckMs: 185_000 }; | |
| 126 | test("the same four figures in the same words", () => { | |
| 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 }; | |
| 126 | test("the same five figures in the same words", () => { | |
| 127 | 127 | const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" }); |
| 128 | expect(lines).toHaveLength(4); | |
| 128 | expect(lines).toHaveLength(5); | |
| 129 | expect(lines[4]).toBe("Crew spend today $12 of $40"); | |
| 130 | expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no ceiling"); | |
| 129 | 131 | expect(lines[3]).toBe("Builders running 2 of 4"); |
| 130 | 132 | expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set"); |
| 131 | 133 | expect(lines[0]).toContain("Shipped per hour 0.3"); |
| @@ -140,3 +142,46 @@ describe("the terminal's figures", () => { | ||
| 140 | 142 | expect(s[20]).toBe("█"); |
| 141 | 143 | }); |
| 142 | 144 | }); |
| 145 | ||
| 146 | describe("the spend tile", () => { | |
| 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(); | |
| 149 | ||
| 150 | test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => { | |
| 151 | const out = row({ todayUsd: 12.34, ceilingUsd: 40 }); | |
| 152 | expect(out).toContain('class="kpi-value num" title="$12.34 of $40.00">$12 of $40<'); | |
| 153 | expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the daily ceiling"'); | |
| 154 | expect(out).toContain(">of the daily ceiling<"); | |
| 155 | }); | |
| 156 | ||
| 157 | test("a small spend shows cents, not $0", () => { | |
| 158 | expect(row({ todayUsd: 0.4, ceilingUsd: 40 })).toContain('>$0.40 of $40<'); | |
| 159 | }); | |
| 160 | ||
| 161 | test("without a ceiling it shows the spend alone and says no ceiling", () => { | |
| 162 | const out = row({ todayUsd: 12.34, ceilingUsd: null }); | |
| 163 | expect(out).toContain('title="$12.34">$12<'); | |
| 164 | expect(out).toContain(">no ceiling<"); | |
| 165 | expect(out).not.toContain(" of $"); | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | describe("the spend figure from the database", () => { | |
| 170 | test("only today's agent runs count, not yesterday's", async () => { | |
| 171 | const { Database } = await import("bun:sqlite"); | |
| 172 | const { readKpis } = await import("../src/flow/kpis.ts"); | |
| 173 | const now = new Date("2026-10-19T15:00:00.000Z"); | |
| 174 | const db = new Database(":memory:"); | |
| 175 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); | |
| 176 | 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)"); | |
| 177 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 178 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 179 | db.run( | |
| 180 | `INSERT INTO run (id, kind, cost_usd, created_at) VALUES | |
| 181 | ('1','agent',5,'2026-10-19T00:00:00.000Z'), ('2','agent',7.5,'2026-10-19T14:00:00.000Z'), | |
| 182 | ('3','agent',100,'2026-10-18T23:59:59.000Z'), ('4','check',9,'2026-10-19T10:00:00.000Z')`, | |
| 183 | ); | |
| 184 | expect(readKpis(db, now, { ceilingUsd: 40 }).spend).toEqual({ todayUsd: 12.5, ceilingUsd: 40 }); | |
| 185 | expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null }); | |
| 186 | }); | |
| 187 | }); |
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 }, 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 }, | |
| 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(8); | |
| 148 | expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(8); | |
| 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); | |
| 149 | 149 | expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i); |
| 150 | 150 | }); |
| 151 | 151 | }); |
test/tui-kpis.test.ts +8 −2
| @@ -40,10 +40,10 @@ afterAll(async () => { | ||
| 40 | 40 | await rm(dir, { recursive: true, force: true }); |
| 41 | 41 | }); |
| 42 | 42 | |
| 43 | async function firstScreen(user?: User): Promise<string> { | |
| 43 | async function firstScreen(user?: User, host: Forge = forge): Promise<string> { | |
| 44 | 44 | const terminal = headlessTerminal(200, 32); |
| 45 | 45 | const { app } = createForgeApp( |
| 46 | forge, | |
| 46 | host, | |
| 47 | 47 | { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) }, |
| 48 | 48 | { terminal, exitHooks: false }, |
| 49 | 49 | ); |
| @@ -82,3 +82,9 @@ test("the live figure is the running commit, as /releases shows it", async () => | ||
| 82 | 82 | const text = await withCommit("1234567abcdef", () => firstScreen(alice)); |
| 83 | 83 | expect(text).toContain("Live version 1234567, live"); |
| 84 | 84 | }); |
| 85 | ||
| 86 | test("the admin's figures carry the configured daily ceiling, and say so when there is none", async () => { | |
| 87 | const capped = { ...forge, config: { ...forge.config, crewCeilingUsd: 40 } }; | |
| 88 | expect(await firstScreen(alice, capped)).toContain("Crew spend today $0.00 of $40"); | |
| 89 | expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no ceiling"); | |
| 90 | }); |
new above
offline
Add the spend line to kpiLines() below, so the terminal shows the same figure as the page, and test it.