Public: anyone can read this; the crew can change it.

Home's KPI row shows users, visitors and p75 page load and INP #3037

Merged opened this

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

Merged

scout/forge:scout/2713-home-s-kpi-row-shows-users-visitors-and → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2713-home-s-kpi-row-shows-users-visitors-and was merged into main as 4976a91.

Review

Sign in to review this change.
13 files changed +277 −37
README.md +9 −3
@@ -254,7 +254,7 @@ 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 six figures first, from `src/flow/kpis.ts`: shipped per hour (with
257 Home shows nine figures first, from `src/flow/kpis.ts`: shipped per hour (with
258258 a 24 h sparkline), the live version and since when, the median check time, the crew builders
259259 running of their slots, and the crew's spend today of the forge's daily
260260 ceiling (`FORGE_CREW_CEILING_USD`; without one, the spend alone and
@@ -263,10 +263,16 @@ snapshot, `mirror/state.json`; `3 h ago`, the exact time in its title), linking
263263 `/settings/backups`. The spend tile links to `/settings/crew`, where the meter is.
264264 The backup tile past 24 hours, or with none (`never`), takes the card's existing
265265 `data-tone="attn"` and says overdue in its label.
266 The last three (`src/flow/reach.ts`, the `reach` board source's rows) are
267 visitors (`5 today`, `31 in 7 days`), accounts (`1 of 2`, signed in today) and
268 page speed (the slowest quarter's page load over 7 days, and `reacts in 180 ms`,
269 the same for the delay after a tap), from the page-view beacon. Each links to
270 the Reach starting board (`/boards/reach`). The visitor hash rotates daily, so
271 the 7-day visitors is each day's distinct count summed.
266272 They count every repository, so only a site admin sees them, on both doors;
267273 nobody else does, and neither does the lean client home. On the web each is a
268 card linking to `/board`, `/releases` or `/boards`. The terminal's first screen
269 shows the first five as lines above the forge strip (`kpiLines` in
274 card linking to `/board`, `/releases`, `/boards` or `/boards/reach`. The terminal's first screen
275 shows the same nine as lines above the forge strip (`kpiLines` in
270276 `src/flow/kpi_words.ts`). They are read when the page or screen loads and
271277 do not refresh on their own.
272278
src/boards/sources.ts +2 −1
@@ -230,6 +230,7 @@ export const SOURCES: Readonly<Record<string, Source>> = {
230230 owner: "admin",
231231 select: [
232232 `SELECT 'visitors' AS metric, '' AS route, ${DAY_AT} AS at, COUNT(DISTINCT visitor) AS value FROM page_vital GROUP BY day`,
233 percentile("load_p75", "load", 75),
233234 percentile("lcp_p75", "lcp", 75),
234235 percentile("inp_p75", "inp", 75),
235236 percentile("server_p50", "ttfb", 50),
@@ -242,7 +243,7 @@ export const SOURCES: Readonly<Record<string, Source>> = {
242243 columns: { metric: "text", route: "text", at: "time", value: "number" },
243244 docs: {
244245 metric:
245 "visitors (distinct browsers that day), lcp_p75 and inp_p75 (ms), server_p50 and server_p95 " +
246 "visitors (distinct browsers that day), load_p75 (page load), lcp_p75 and inp_p75 (ms), server_p50 and server_p95 " +
246247 "(ms, time to first byte), accounts_active_1d (signed in today), accounts_active_7d, accounts_total",
247248 route: "the route pattern, never the path; empty for visitors and accounts",
248249 at: "the day (UTC) for visitors and the percentiles; now for the account counts, which are not kept per day",
src/boards/starting.ts +18 −1
@@ -1,5 +1,5 @@
11 /**
2 * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security, Throughput and Forges. They are plain
2 * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security, Throughput, Forges and Reach. They are plain
33 * boards in `forge/boards` once written, so a person edits or deletes them
44 * like any other; `.offered` (a JSON list of slugs, committed with the
55 * boards) records what was offered so a deletion sticks and an edit is
@@ -35,6 +35,11 @@ const failed = {
3535 filter: [{ column: "pattern", op: "eq", value: "/signin" }, { column: "method", op: "eq", value: "POST" }, { column: "status", op: "eq", value: "4xx" }],
3636 };
3737
38 const reach = (metric: string, aggregate: string, groupBy?: Record<string, unknown>) => ({
39 source: "reach", aggregate, column: "value", ...(groupBy === undefined ? {} : { groupBy }),
40 filter: [{ column: "metric", op: "eq", value: metric }],
41 });
42
3843 export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
3944 usage: {
4045 title: "Usage",
@@ -112,6 +117,18 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = {
112117 panel("Stages by hour", "heat", { ...STAGES, groupBy: HOUR, series: "stage" }, 2),
113118 ],
114119 },
120 reach: {
121 title: "Reach",
122 range: "30d",
123 refresh: "1m",
124 panels: [
125 panel("Visitors per day", "bar", reach("visitors", "max", DAY), 2),
126 panel("Accounts signed in today", "number", reach("accounts_active_1d", "max")),
127 panel("All accounts", "number", reach("accounts_total", "max")),
128 panel("Page load per day (ms, slowest quarter)", "line", reach("load_p75", "max", DAY), 2),
129 panel("Slow to react per day (ms, slowest quarter)", "line", reach("inp_p75", "max", DAY), 2),
130 ],
131 },
115132 forges: {
116133 title: "Forges",
117134 range: "30d",
src/flow/kpi_words.ts +33 −2
@@ -4,6 +4,7 @@
44 */
55
66 import type { Kpis } from "./kpis.ts";
7 import type { Reach } from "./reach.ts";
78
89 /** A duration the way a person reads a check: `45s`, `3m 05s`, `1h 02m`. */
910 export function checkTime(ms: number | null): string {
@@ -68,16 +69,46 @@ export function backupFull(at: string | null): string {
6869 return at === null || Number.isNaN(Date.parse(at)) ? "No off-box backup yet" : at;
6970 }
7071
72 /** A page speed the way a person reads it: `850 ms`, `1.2 s`, or an em dash for none. */
73 export function speedWords(ms: number | null): string {
74 if (ms === null) return "—";
75 return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
76 }
77
78 /** Accounts signed in today of all accounts: `3 of 12`. */
79 export function usersValue(r: Reach): string {
80 return `${r.usersToday} of ${r.usersTotal}`;
81 }
82
83 /** Visitors today, in words: `5 today`. */
84 export function visitorsValue(r: Reach): string {
85 return `${r.visitorsToday} today`;
86 }
87
88 /** The week behind it: `31 in 7 days`. */
89 export function visitorsNote(r: Reach): string {
90 return `${r.visitors7d} in 7 days`;
91 }
92
93 /** How quick the pages feel, over 7 days: `reacts in 180 ms`, or why there is no figure. */
94 export function reactNote(r: Reach): string {
95 return r.inpP75 === null ? "no taps measured yet" : `reacts in ${speedWords(r.inpP75)}`;
96 }
97
7198 /**
72 * The terminal's row: the same five figures in the same words, as plain
99 * The terminal's row: the same figures in the same words, as plain
73100 * lines. Forge-wide like the web's, so the caller shows them to an admin only.
74101 */
75 export function kpiLines(k: Kpis, live: { sha: string; since: string } | null): string[] {
102 export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, now: Date = new Date()): string[] {
76103 return [
77104 `Shipped per hour ${(k.landed / k.landedHours.length).toFixed(1)} ${sparkText(k.landedHours)} ${k.landed} ${k.landed === 1 ? "change" : "changes"} in 24 h`,
78105 `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`,
79106 `Median check time ${checkTime(k.medianCheckMs)}`,
80107 `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`,
81108 `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`,
109 `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`,
110 `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`,
111 `Accounts ${usersValue(k.reach)} signed in today`,
112 `Page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${reactNote(k.reach)}`,
82113 ];
83114 }
src/flow/kpis.ts +4
@@ -16,6 +16,7 @@
1616 import type { Database } from "bun:sqlite";
1717 import { spentTodayEverywhere } from "../crew/autonomy.ts";
1818 import { lastMirror } from "../snapshot/mirror.ts";
19 import { readReach, type Reach } from "./reach.ts";
1920 import { HOURS, median } from "./throughput.ts";
2021
2122 const HOUR_MS = 3_600_000;
@@ -35,6 +36,8 @@ export interface Kpis {
3536 readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null };
3637 /** When the off-box mirror last finished holding a snapshot; null when it never has. */
3738 readonly backupAt: string | null;
39 /** Accounts, visitors and page speed (`reach.ts`). */
40 readonly reach: Reach;
3841 }
3942
4043 export function readKpis(
@@ -96,6 +99,7 @@ export function readKpis(
9699 return {
97100 backupAt,
98101 builders,
102 reach: readReach(db, now),
99103 spend: { todayUsd, ceilingUsd: forge.ceilingUsd },
100104 landedHours,
101105 landed: landedHours.reduce((a, b) => a + b, 0),
src/flow/reach.ts added +66
@@ -0,0 +1,66 @@
1 /**

The terminal does not render these figures (kpiLines is unchanged). Both doors must show them, and the README should match.

2 * Who comes and how fast it feels, as the home row's four figures (#2713):
3 * accounts active today of all accounts, visitors today and over 7 days, and
4 * the 75th percentile page load and INP over 7 days. Same rows as the `reach`
5 * board source: `session` for accounts, the beacon's `page_vital` for the rest.
6 *
7 * The visitor hash rotates daily and cannot be joined across days, so the
8 * 7-day figure is the sum of each day's distinct visitors (a browser seen on
9 * three days counts three times). Percentiles are nearest-rank, as the board's.
10 */
11
12 import type { Database } from "bun:sqlite";
13
14 const DAY_MS = 86_400_000;
15
16 export interface Reach {
17 readonly usersToday: number;
18 readonly usersTotal: number;
19 readonly visitorsToday: number;
20 readonly visitors7d: number;
21 /** Milliseconds; null when no page view in 7 days measured it. */
22 readonly loadP75: number | null;
23 readonly inpP75: number | null;
24 }
25
26 export const NO_REACH: Reach = { usersToday: 0, usersTotal: 0, visitorsToday: 0, visitors7d: 0, loadP75: null, inpP75: null };
27
28 /** The 75th percentile, nearest rank, of a column over the days since `fromDay`. */
29 function p75(db: Database, column: "load" | "inp", fromDay: string): number | null {

OFFSET scan per home load, for load and inp. Fine at this size; cache briefly before page_vital gets large.

30 const n = db
31 .query<{ n: number }, [string]>(`SELECT COUNT(${column}) AS n FROM page_vital WHERE day >= ?1`)
32 .get(fromDay)?.n ?? 0;
33 if (n === 0) return null;
34 const rank = Math.ceil(n * 0.75);
35 return (
36 db
37 .query<{ v: number }, [string, number]>(
38 `SELECT ${column} AS v FROM page_vital WHERE day >= ?1 AND ${column} IS NOT NULL ORDER BY ${column} LIMIT 1 OFFSET ?2`,
39 )
40 .get(fromDay, rank - 1)?.v ?? null
41 );
42 }
43
44 export function readReach(db: Database, now: Date): Reach {
45 const today = now.toISOString().slice(0, 10);
46 const weekFrom = new Date(now.getTime() - 6 * DAY_MS).toISOString().slice(0, 10);
47 const users = db
48 .query<{ today: number; total: number }, [string]>(
49 `SELECT (SELECT COUNT(DISTINCT user_id) FROM session WHERE last_seen_at >= ?1) AS today,
50 (SELECT COUNT(*) FROM user) AS total`,
51 )
52 .get(`${today}T00:00:00Z`) ?? { today: 0, total: 0 };
53 const days = db
54 .query<{ day: string; n: number }, [string, string]>(
55 "SELECT day, COUNT(DISTINCT visitor) AS n FROM page_vital WHERE day >= ?1 AND day <= ?2 GROUP BY day",
56 )
57 .all(weekFrom, today);
58 return {
59 usersToday: users.today,
60 usersTotal: users.total,
61 visitorsToday: days.find((d) => d.day === today)?.n ?? 0,
62 visitors7d: days.reduce((a, d) => a + d.n, 0),
63 loadP75: p75(db, "load", weekFrom),
64 inpP75: p75(db, "inp", weekFrom),
65 };
66 }
src/web/studio/kpi_row.ts +18 −7
@@ -1,14 +1,15 @@
11 /**
2 * Home's row of six figures (#2871), from `flow/kpis.ts`: shipped per hour
3 * with a day's sparkline, the live version and since when, the median check
4 * time, the crew builders running of their slots, and the crew's spend today
5 * of the daily ceiling, and the age of the last off-box backup. Server-rendered, one fixed-height card each, so
6 * nothing moves when it paints. Only the studio's words: shipped, change,
7 * check time.
2 * Home's row of nine figures (#2871, #2713), from `flow/kpis.ts`: shipped per
3 * hour with a day's sparkline, the live version and since when, the median
4 * check time, the crew builders running of their slots, the crew's spend
5 * today of the daily ceiling, the age of the last off-box backup, visitors,
6 * accounts signed in, and page speed. Server-rendered, one fixed-height card
7 * each, so nothing moves when it paints. Only the studio's words: shipped,
8 * change, check time.
89 */
910
1011 import type { Database } from "bun:sqlite";
11 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, spendFull, spendValue } from "../../flow/kpi_words.ts";
12 import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue } from "../../flow/kpi_words.ts";
1213 import { readKpis, type Kpis } from "../../flow/kpis.ts";
1314 import { html, raw, type Html } from "../html.ts";
1415 import { BOOTED } from "../../release/gate.ts";
@@ -101,6 +102,16 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
101102 null,
102103 overdue ? "attn" : null,
103104 )}
105 ${tile("Visitors", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), "Distinct browsers per day, summed over 7 days")}
106 ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "signed in today", "Accounts signed in today (UTC), of all accounts")}
107 ${tile(
108 "Page speed",
109 "/boards/reach",
110 speedWords(k.reach.loadP75),
111 speedWords(k.reach.loadP75),
112 reactNote(k.reach),
113 "Page load, and delay after a tap, for the slowest quarter of views over 7 days",
114 )}
104115 </section>`;
105116 }
106117
test/boards-starting.test.ts +4 −4
@@ -1,5 +1,5 @@
11 /**
2 * The forge offers the seven starting boards on boot: every panel runs, an
2 * The forge offers the eight starting boards on boot: every panel runs, an
33 * edit survives the next boot, and a deletion is not undone.
44 */
55 import { afterAll, beforeAll, expect, test } from "bun:test";
@@ -36,10 +36,10 @@ test("a forge with no person to found the repo writes nothing", async () => {
3636 expect(await offerStartingBoards(bare)).toEqual([]);
3737 });
3838
39 test("a fresh boot writes the seven starting boards, and every panel runs", async () => {
40 expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]);
39 test("a fresh boot writes the eight starting boards, and every panel runs", async () => {
40 expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "reach", "releases", "security", "throughput", "usage"]);
4141 const boards = await listBoards(app.reposRoot);
42 expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]);
42 expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "reach", "releases", "security", "throughput", "usage"]);
4343 let panels = 0;
4444 for (const { board } of boards) {
4545 for (const p of board.panels) {
test/helpers/reach-tables.ts added +8
@@ -0,0 +1,8 @@
1 import type { Database } from "bun:sqlite";
2
3 /** The three tables `readReach` reads, bare, for tests that build their own small database. */
4 export function reachTables(db: Database): void {
5 db.run("CREATE TABLE session (user_id TEXT, last_seen_at TEXT)");
6 db.run("CREATE TABLE user (id TEXT)");
7 db.run("CREATE TABLE page_vital (day TEXT, visitor TEXT, load REAL, inp REAL)");
8 }
test/home-kpi-page.test.ts +21 −2
@@ -25,7 +25,7 @@ afterAll(async () => {
2525 await rm(dataDir, { recursive: true, force: true });
2626 });
2727
28 test("home renders the six tiles; only an admin sees them", async () => {
28 test("home renders the nine 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",
@@ -48,11 +48,30 @@ test("home renders the six tiles; only an admin sees them", async () => {
4848 })
4949 ).text();
5050
51 // Seeded reach: 4 page views by 3 visitors today, 1 yesterday; sessions seen today.
52 const day = new Date().toISOString().slice(0, 10);
53 const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
54 const view = (d: string, visitor: string, load: number | null, inp: number | null) =>
55 app.db.run("INSERT INTO page_vital (day, at, route, visitor, ttfb, load, lcp, inp, cls) VALUES (?, ?, '/', ?, 10, ?, 100, ?, 0)", [d, Date.now(), visitor, load, inp]);
56 view(day, "a", 100, 40);
57 view(day, "b", 200, 80);
58 view(day, "c", 300, 120);
59 view(day, "a", 1900, null);
60 view(yesterday, "z", 400, 160);
5161 const a = await home(admin);
5262 expect(a).toContain('class="kpis"');
53 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(6);
63 expect((a.match(/class="card kpi"/g) ?? []).length).toBe(9);
5464 for (const href of ["/board", "/releases", "/boards", "/settings/crew"]) expect(a).toContain(`class="card kpi" href="${href}"`);
5565
66 // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days;
67 // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms.
68 expect(a).toContain(">1 of 2<");
69 expect(a).toContain(">3 today<");
70 expect(a).toContain(">4 in 7 days<");
71 expect(a).toContain(">400 ms<");
72 expect(a).toContain(">reacts in 120 ms<");
73 expect((a.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3);
74
5675 // The figures count every repository, so a non-admin sees none.
5776 expect(a).toContain("no ceiling");
5877 expect(await home(bob)).not.toContain('class="kpis"');
test/home-kpi-row.test.ts +89 −14
@@ -1,4 +1,6 @@
11 /** Home's figures row (#2871): the five tiles, their links, studio words only, a sparkline of 24 points. */
2 import { reachTables } from "./helpers/reach-tables.ts";
3 import { NO_REACH, readReach } from "../src/flow/reach.ts";
24 import { describe, expect, test } from "bun:test";
35 import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts";
46 import { running } from "../src/kernel/version.ts";
@@ -10,7 +12,7 @@ const hours = (n: number) => Array.from({ length: 24 }, (_, i) => (i === 3 ? n :
1012 describe("home kpi row", () => {
1113 test("five tiles with their links and figures", () => {
1214 const out = kpiRowFor(
13 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null },
15 { landedHours: hours(6), landed: 6, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
1416 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
1517 ).toString();
1618 expect(out).toContain('<span class="sha">abcdef0</span>');
@@ -25,7 +27,7 @@ describe("home kpi row", () => {
2527
2628 test("the whole figure is in the title and the link's label, rounded and in studio words", () => {
2729 const out = kpiRowFor(
28 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null },
30 { landedHours: hours(7), landed: 7, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 185_000, backupAt: null, reach: NO_REACH },
2931 { sha: "abcdef0123456", at: "2026-10-19T11:00:00.000Z" },
3032 ).toString();
3133 expect(out).toContain('title="0.29"');
@@ -37,13 +39,13 @@ describe("home kpi row", () => {
3739 });
3840
3941 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, backupAt: null }, null).toString();
42 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString();
4143 expect(out).toContain("Nothing live yet");
4244 expect(out).toContain("No check finished in 24 h");
4345 });
4446
4547 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, backupAt: null }, null).toString();
48 const out = kpiRowFor({ landedHours: hours(1), landed: 1, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: 1000, backupAt: null, reach: NO_REACH }, null).toString();
4749 expect(out).not.toMatch(/commit|merge|branch|repositor|pull request|checks (passed|failed)/i);
4850 });
4951
@@ -67,7 +69,7 @@ describe("home kpi row", () => {
6769 });
6870
6971 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, backupAt: null }, null).toString();
72 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach: NO_REACH }, null).toString();
7173 expect(out).toContain('title="No check finished in 24 h"');
7274 });
7375
@@ -80,7 +82,7 @@ describe("home kpi row", () => {
8082 });
8183
8284 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, backupAt: null, medianCheckMs: null, builders: { running: 2, slots: 4 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
85 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();
8486 expect(out).toContain('href="/settings/crew"');
8587 expect(out).toContain('class="kpi-value num" title="2 of 4">2 of 4<');
8688 expect(out).toContain('title="Crew builders running now, of 4 slots"');
@@ -88,14 +90,15 @@ describe("home kpi row", () => {
8890 });
8991
9092 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, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
93 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();
9294 expect(out).toContain('class="kpi-value num" title="—">—<');
9395 expect(out).toContain('title="No slots set">No slots set<');
94 expect(out).not.toContain("0 of 0");
96 expect(out).toContain(`aria-label="Builders running: —, No slots set"`);
97 expect(out).not.toContain("Builders running: 0 of 0");
9598 });
9699
97100 test("the builders note does not repeat the value", () => {
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();
101 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();
99102 expect(out).toContain(">running now<");
100103 });
101104
@@ -114,6 +117,7 @@ describe("the builders figure from the database", () => {
114117 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)");
115118 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
116119 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
120 reachTables(db);
117121 db.run("INSERT INTO repo VALUES ('autonomous', 3, 'main', 'a'), ('autonomous', 1, 'main', 'b'), ('off', 8, 'main', 'c')");
118122 db.run("INSERT INTO run (id, kind, state) VALUES ('1','agent','running'), ('2','agent','running'), ('3','agent','queued'), ('4','check','running')");
119123 const { readKpis } = await import("../src/flow/kpis.ts");
@@ -122,10 +126,11 @@ describe("the builders figure from the database", () => {
122126 });
123127
124128 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, backupAt: null };
126 test("the same five figures in the same words", () => {
129 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, reach: NO_REACH };
130 test("the same nine figures in the same words", () => {
127131 const lines = kpiLines(k, { sha: "abcdef0123456", since: "5m ago" });
128 expect(lines).toHaveLength(5);
132 expect(lines).toHaveLength(9);
133 expect(lines).toContain("Last off-box backup never, overdue");
129134 expect(lines[4]).toBe("Crew spend today $12 of $40");
130135 expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no ceiling");
131136 expect(lines[3]).toBe("Builders running 2 of 4");
@@ -145,7 +150,7 @@ describe("the terminal's figures", () => {
145150
146151 describe("the spend tile", () => {
147152 const row = (spend: { todayUsd: number; ceilingUsd: number | null }) =>
148 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString();
153 kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend }, null).toString();
149154
150155 test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => {
151156 const out = row({ todayUsd: 12.34, ceilingUsd: 40 });
@@ -176,6 +181,7 @@ describe("the spend figure from the database", () => {
176181 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)");
177182 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
178183 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
184 reachTables(db);
179185 db.run(
180186 `INSERT INTO run (id, kind, cost_usd, created_at) VALUES
181187 ('1','agent',5,'2026-10-19T00:00:00.000Z'), ('2','agent',7.5,'2026-10-19T14:00:00.000Z'),
@@ -190,7 +196,7 @@ describe("home kpi row: the last off-box backup", () => {
190196 const NOW = new Date("2026-10-20T12:00:00.000Z");
191197 const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
192198 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();
199 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();
194200 const tile = (out: string) => /<a class="card kpi"[^>]*href="\/settings\/backups"[^>]*>/.exec(out)![0];
195201
196202 test("a backup 2 h old reads its age, with the exact time, and is not flagged", () => {
@@ -227,8 +233,77 @@ describe("home kpi row: the last off-box backup", () => {
227233 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)");
228234 db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)");
229235 db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)");
236 reachTables(db);
230237 expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBeNull();
231238 recordMirror(dir, { where: "b2", attemptAt: hoursAgo(1), reachable: true, newest: "s1", newestAt: hoursAgo(3) });
232239 expect(readKpis(db, NOW, { ceilingUsd: null, dataDir: dir }).backupAt).toBe(hoursAgo(3));
233240 });
234241 });
242
243 describe("the reach tiles", () => {
244 test("say what they count in words, each linking to the reach board", () => {
245 const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, loadP75: 1250, inpP75: 180 };
246 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();
247 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}<`);
248 expect((out.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3);
249 expect(out).not.toContain("INP");
250 expect(out).not.toContain("p75");
251 });
252
253 test("the terminal says the same", () => {
254 const lines = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, loadP75: 1250, inpP75: 180 }, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null);
255 expect(lines).toContain("Visitors 5 today, 31 in 7 days");
256 expect(lines).toContain("Accounts 3 of 12 signed in today");
257 expect(lines).toContain("Page speed loads in 1.3 s, reacts in 180 ms");
258 });
259 });
260
261 describe("readReach", () => {
262 const NOW = new Date("2026-10-19T15:00:00.000Z");
263 const make = async () => {
264 const { Database } = await import("bun:sqlite");
265 const db = new Database(":memory:");
266 reachTables(db);
267 return db;
268 };
269 const view = (db: import("bun:sqlite").Database, day: string, visitor: string, load: number | null, inp: number | null) =>
270 db.run("INSERT INTO page_vital VALUES (?, ?, ?, ?)", [day, visitor, load, inp]);
271
272 test("nothing seeded: zeros and no speed", async () => {
273 expect(readReach(await make(), NOW)).toEqual(NO_REACH);
274 });
275
276 test("the 7-day window starts six days back; today is the UTC day", async () => {
277 const db = await make();
278 view(db, "2026-10-19", "a", 100, 10);
279 view(db, "2026-10-13", "a", 100, 10); // first day inside
280 view(db, "2026-10-12", "b", 9999, 9999); // one day outside
281 const r = readReach(db, NOW);
282 expect(r.visitorsToday).toBe(1);
283 expect(r.visitors7d).toBe(2);
284 expect(r.loadP75).toBe(100);
285 // Just after midnight the same rows are a day older.
286 expect(readReach(db, new Date("2026-10-20T00:00:01.000Z")).visitorsToday).toBe(0);
287 expect(readReach(db, new Date("2026-10-20T00:00:01.000Z")).visitors7d).toBe(1);
288 });
289
290 test("p75 is nearest rank and skips nulls", async () => {
291 const db = await make();
292 for (const [i, v] of [10, 20, 30, 40, 50].entries()) view(db, "2026-10-19", `v${i}`, v, null);
293 view(db, "2026-10-19", "n", null, 70);
294 const r = readReach(db, NOW);
295 expect(r.loadP75).toBe(40); // rank ceil(5 * .75) = 4
296 expect(r.inpP75).toBe(70); // one value: itself
297 });
298
299 test("a visitor seen twice in a day counts once; accounts count distinct users since UTC midnight", async () => {
300 const db = await make();
301 view(db, "2026-10-19", "a", 1, 1);
302 view(db, "2026-10-19", "a", 2, 2);
303 db.run("INSERT INTO user VALUES ('u1'), ('u2'), ('u3')");
304 db.run("INSERT INTO session VALUES ('u1', '2026-10-19T00:00:00Z'), ('u1', '2026-10-19T09:00:00Z'), ('u2', '2026-10-18T23:59:59Z')");
305 const r = readReach(db, NOW);
306 expect(r.visitorsToday).toBe(1);
307 expect([r.usersToday, r.usersTotal]).toEqual([1, 3]);
308 });
309 });
test/home-kpis.test.ts +4 −3
@@ -4,6 +4,7 @@
44 * counted, and an empty forge gives zeros and a null median.
55 */
66
7 import { NO_REACH } from "../src/flow/reach.ts";
78 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
89 import { mkdtemp, rm } from "node:fs/promises";
910 import { tmpdir } from "node:os";
@@ -131,7 +132,7 @@ describe("home kpis", () => {
131132
132133 describe("home kpi row titles", () => {
133134 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, backupAt: null },
135 { 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, reach: NO_REACH },
135136 { sha: "abcdef0123456789", at: "2026-10-19T11:00:00.000Z" },
136137 ).toString();
137138
@@ -144,8 +145,8 @@ describe("home kpi row titles", () => {
144145 });
145146
146147 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(12);
148 expect(full.match(/class="kpi-(value num|note small muted)" title=/g)).toHaveLength(12);
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);
149150 expect(full).not.toMatch(/commit|merge|branch|repositor|pull request/i);
150151 });
151152 });
test/tui-kpis.test.ts +1
@@ -87,4 +87,5 @@ test("the admin's figures carry the configured daily ceiling, and say so when th
8787 const capped = { ...forge, config: { ...forge.config, crewCeilingUsd: 40 } };
8888 expect(await firstScreen(alice, capped)).toContain("Crew spend today $0.00 of $40");
8989 expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no ceiling");
90 expect(await firstScreen(alice)).toContain("Visitors 0 today, 0 in 7 days");
9091 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs