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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2968-home-s-kpi-row-shows-today-s-spend-of-th → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/2968-home-s-kpi-row-shows-today-s-spend-of-th was merged into main as 530dc65.

Review

Sign in to review this change.
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
254254 comes back. The terminal's first screen opens with the same lines, from the
255255 same query (`src/core/client-apps.ts`).
256256
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.
259262 They count every repository, so only a site admin sees them, on both doors;
260263 nobody else does, and neither does the lean client home. On the web each is a
261264 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
263266 `src/flow/kpi_words.ts`). They are read when the page or screen loads and
264267 do not refresh on their own.
265268
src/flow/kpi_words.ts +16 −1
@@ -31,8 +31,22 @@ export function buildersValue(b: Kpis["builders"]): string {
3131 return b.slots === 0 ? "—" : `${b.running} of ${b.slots}`;
3232 }
3333
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. */
outdated

Add the spend line to kpiLines() below, so the terminal shows the same figure as the page, and test it.

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
3448 /**
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
3650 * lines. Forge-wide like the web's, so the caller shows them to an admin only.
3751 */
3852 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):
4155 `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`,
4256 `Median check time ${checkTime(k.medianCheckMs)}`,
4357 `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" : ""}`,
4459 ];
4560 }
src/flow/kpis.ts +7 −1
@@ -14,6 +14,7 @@
1414 */
1515
1616 import type { Database } from "bun:sqlite";
17 import { spentTodayEverywhere } from "../crew/autonomy.ts";
1718 import { HOURS, median } from "./throughput.ts";
1819
1920 const HOUR_MS = 3_600_000;
@@ -29,9 +30,11 @@ export interface Kpis {
2930 readonly medianCheckMs: number | null;
3031 /** Crew builders running now, of the slots the autonomous repositories are set to. */
3132 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 };
3235 }
3336
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 {
3538 const end = now.getTime();
3639 const to = now.toISOString();
3740 const since = new Date(end - HOURS * HOUR_MS).toISOString();
@@ -79,8 +82,11 @@ export function readKpis(db: Database, now: Date): Kpis {
7982 )
8083 .get() ?? { running: 0, slots: 0 };
8184
85 const todayUsd = spentTodayEverywhere(db, now);
86
8287 return {
8388 builders,
89 spend: { todayUsd, ceilingUsd: forge.ceilingUsd },
8490 landedHours,
8591 landed: landedHours.reduce((a, b) => a + b, 0),
8692 live: live ?? null,
src/tui/screens/dashboard.ts +12 −4
@@ -96,6 +96,12 @@ import { repoScreen } from "./repo.ts";
9696 import { local } from "./where.ts";
9797 import { signInScreen } from "./signin.ts";
9898
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
99105 /**
100106 * The three things the launcher is about. Order is the `tab` order: the
101107 * 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 {
216222 figures =
217223 f.user?.isAdmin !== true
218224 ? []
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.

220226 // "Yours" is ownership plus the orgs you are on a team of; typing in the
221227 // filter reaches every repository you can see.
222228 const orgs = f.user
@@ -777,9 +783,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
777783 );
778784 body = rest!;
779785 }
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) =>
783791 s.text(strip!.x, strip!.y + i, truncate(line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w),
784792 );
785793 body = rest!;
src/web/studio/home.ts +3 −1
@@ -245,8 +245,10 @@ export async function studioHome(
245245 }
246246 const ask = firstSteps ?? everydayCard(ctx, user);
247247 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.

248250 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)}
250252 ${when(
251253 steps,
252254 () => html`<p class="small muted" id="lean-home">
src/web/studio/kpi_row.ts +16 −7
@@ -1,13 +1,14 @@
11 /**
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
33 * 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.
78 */
89
910 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";
1112 import { readKpis, type Kpis } from "../../flow/kpis.ts";
1213 import { html, raw, type Html } from "../html.ts";
1314 import { BOOTED } from "../../release/gate.ts";
@@ -81,10 +82,18 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null): Ht
8182 k.builders.slots === 0 ? "No slots set" : "running now",
8283 k.builders.slots === 0 ? "No slots set" : `Crew builders running now, of ${k.builders.slots} ${k.builders.slots === 1 ? "slot" : "slots"}`,
8384 )}
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 )}
8493 </section>`;
8594 }
8695
8796 /** 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());
9099 }
test/home-kpi-page.test.ts +32 −2
@@ -25,7 +25,7 @@ afterAll(async () => {
2525 await rm(dataDir, { recursive: true, force: true });
2626 });
2727
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 () => {
2929 const mk = async (username: string, first = false): Promise<User> => {
3030 const r = await createUser(app.db, {
3131 origin: "seed",
@@ -50,10 +50,40 @@ test("home renders the four tiles; only an admin sees them", async () => {
5050
5151 const a = await home(admin);
5252 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);
5454 for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`);
5555
5656 // The figures count every repository, so a non-admin sees none.
57 expect(a).toContain("no ceiling");
5758 expect(await home(bob)).not.toContain('class="kpis"');
5859 expect(await home()).not.toContain('class="kpis"');
5960 });
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. */
22 import { describe, expect, test } from "bun:test";
33 import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts";
44 import { running } from "../src/kernel/version.ts";
@@ -8,9 +8,9 @@ import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts";
88 const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n : 0));
99
1010 describe("home kpi row", () => {
11 test("four tiles with their links and figures", () => {
11 test("five tiles with their links and figures", () => {
1212 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 },
1414 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
1515 ).toString();
1616 expect(out).toContain('<span class="sha">abcdef0</span>');
@@ -25,7 +25,7 @@ describe("home kpi row", () => {
2525
2626 test("the whole figure is in the title and the link's label, rounded and in studio words", () => {
2727 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 },
2929 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
3030 ).toString();
3131 expect(out).toContain('title="0.29"');
@@ -37,13 +37,13 @@ describe("home kpi row", () => {
3737 });
3838
3939 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();
4141 expect(out).toContain("Nothing live yet");
4242 expect(out).toContain("No check finished in 24 h");
4343 });
4444
4545 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();
4747 expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i);
4848 });
4949
@@ -67,7 +67,7 @@ describe("home kpi row", () => {
6767 });
6868
6969 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();
7171 expect(out).toContain('title="No check finished in 24 h"');
7272 });
7373
@@ -80,7 +80,7 @@ describe("home kpi row", () => {
8080 });
8181
8282 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();
8484 expect(out).toContain('href="/settings/crew"');
8585 expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<');
8686 expect(out).toContain('title="Crew builders running now, of 4 slots"');
@@ -88,14 +88,14 @@ describe("home kpi row", () => {
8888 });
8989
9090 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();
9292 expect(out).toContain('class="kpi-value num" title="—">—<');
9393 expect(out).toContain('title="No slots set">No slots set<');
9494 expect(out).not.toContain("0 of 0");
9595 });
9696
9797 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();
9999 expect(out).toContain(">running now<");
100100 });
101101
@@ -111,7 +111,7 @@ describe("the builders figure from the database", () => {
111111 const { Database } = await import("bun:sqlite");
112112 const db = new Database(":memory:");
113113 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)");
115115 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
116116 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
117117 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", () => {
122122 });
123123
124124 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", () => {
127127 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");
129131 expect(lines[3]).toBe("Builders running 2 of 4");
130132 expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set");
131133 expect(lines[0]).toContain("Shipped per hour 0.3");
@@ -140,3 +142,46 @@ describe("the terminal's figures", () => {
140142 expect(s[20]).toBe("█");
141143 });
142144 });
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", () => {
131131
132132 describe("home kpi row titles", () => {
133133 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 },
135135 { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" },
136136 ).toString();
137137
@@ -144,8 +144,8 @@ describe("home kpi row titles", () => {
144144 });
145145
146146 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);
149149 expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i);
150150 });
151151 });
test/tui-kpis.test.ts +8 −2
@@ -40,10 +40,10 @@ afterAll(async () => {
4040 await rm(dir, { recursive: true, force: true });
4141 });
4242
43 async function firstScreen(user?: User): Promise<string> {
43 async function firstScreen(user?: User, host: Forge = forge): Promise<string> {
4444 const terminal = headlessTerminal(200, 32);
4545 const { app } = createForgeApp(
46 forge,
46 host,
4747 { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) },
4848 { terminal, exitHooks: false },
4949 );
@@ -82,3 +82,9 @@ test("the live figure is the running commit, as /releases shows it", async () =>
8282 const text = await withCommit("1234567abcdef", () => firstScreen(alice));
8383 expect(text).toContain("Live version 1234567, live");
8484 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs