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

Home KPIs show p75 LCP and INP from the first-party beacon #10766

Merged opened this

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

Merged

scout/forge:scout/10738-home-kpis-show-p75-lcp-and-inp-from-the → main 8 commits fork 4 threads · 4 unresolved

  • Merged. scout/10738-home-kpis-show-p75-lcp-and-inp-from-the was merged into main as 5a1294d.

Review

Sign in to review this change.
10 files changed +107 −31
docs/TOUR.md +2 −2
@@ -101,8 +101,8 @@ The backup tile past 24 hours, or with none (`never`), takes the card's existing
101101 `data-tone="attn"` and says overdue in its label.
102102 The last three (`src/flow/reach.ts`, the `reach` board source's rows) are
103103 reporting browsers (`5 today`, `31 in 7 days`), accounts (`1 of 2`, seen today) and
104 page speed (the slowest quarter's page load over 7 days, and `reacts in 180 ms`,
105 the same for the delay after a tap), from the page-view beacon. Each links to
104 page speed (the slowest quarter's page load over 7 days, and `LCP 2.4 s · INP 180 ms`,
105 LCP and INP, the main content and a tap), from the page-view beacon. Each links to
106106 the Reach starting board (`/boards/reach`). The visitor hash rotates daily, so
107107 the 7-day reporting browsers is each day's distinct count summed.
108108 They count every repository, so only a site admin sees them, on both doors;
docs/features/living-home.md +4
@@ -26,3 +26,7 @@ The two scans behind the lists are read once per write (`writeStamp`), so a warm
2626 - **Just shipped** titles are cut at a word (`shortTitle`); the row's hover and screen-reader title carry the whole one.
2727 - **Dropped on purpose** (the issue named them: no decision uses them): the Traffic, Reporting browsers and Accounts tiles. The same numbers stay on `/settings/traffic`, `/boards/reach` and the KPI probe.
2828 - A stuck approved pull is one line ("… waits for a person to merge it."); the wall it hit is on the pull.
29
30 ## The speed tile's note (#10738)
31
32 "Reported page speed" shows the 75th-percentile page load as its figure and, under it, `LCP … · INP …` over seven days from the first-party beacon (`vitalsNote`): LCP is how long the main content takes to show, INP how long a tap takes to answer. With no report yet it says "no visitors yet". The terminal's "Reported page speed" line (`kpiLines`) uses the same note.
src/flow/kpi_words.ts +6 −5
@@ -22,7 +22,7 @@ export const KPI_COUNTS = {
2222 traffic: "Who used the forge's answering time this minute and the 14 before, as a share of summed time to answer: people and tokens, named crawlers, anonymous readers on deep pages, other anonymous, the forge asking itself. Refused means answered 429.",
2323 visitors: "Browsers whose page sent a report back today (UTC); scrapers don't run the page, so they never count. The 7-day figure adds each day's count, so a browser seen on three days counts three times.",
2424 accounts: "Accounts with a browser session seen since UTC midnight, of every account including organizations and machine accounts; a token alone doesn't count.",
25 speed: "The slowest quarter of page views that reported back over 7 days: page load at the 75th percentile, and the delay after a tap. Scrapers don't report and an outage reports nothing, so this shows neither.",
25 speed: "The slowest quarter of page views that reported back over 7 days: page load at the 75th percentile, and the delay after a tap. LCP is how long the main content takes to show; INP is how long a tap takes to answer. Scrapers don't report and an outage reports nothing, so this shows neither.",
2626 } as const;
2727
2828 /** A duration the way a person reads a check: `45s`, `3m 05s`, `1h 02m`. */
@@ -132,9 +132,10 @@ export function visitorsNote(r: Reach): string {
132132 return `${r.visitors7d} in 7 days`;
133133 }
134134
135 /** How quick the pages feel, over 7 days: `reacts in 180 ms`, or why there is no figure. */
136 export function reactNote(r: Reach): string {
137 return r.inpP75 === null ? "no taps measured yet" : `reacts in ${speedWords(r.inpP75)}`;
135 /** The speed tile's quiet note: p75 LCP and INP over 7 days, or that nobody has reported yet. */
136 export function vitalsNote(r: Reach): string {
137 const parts = [r.lcpP75 == null ? null : `LCP ${speedWords(r.lcpP75)}`, r.inpP75 == null ? null : `INP ${speedWords(r.inpP75)}`].filter((p) => p !== null);

The partial branch (one of LCP or INP measured) has no test. Add LCP-only and INP-only cases to test/home-vitals.test.ts, because the pull promises the note shows only the figures that exist.

138 return parts.length === 0 ? "no visitors yet" : parts.join(" · ");
138139 }
139140
outdated

Principle 8 and both doors: the terminal line in kpiLines still uses reactNote. Switch it to vitalsNote, test it, and delete reactNote once it has no caller.

140141 /** Shipped per hour, as the tile shows it. */
@@ -230,6 +231,6 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n
230231 k.backup ? k.backup.line : `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`,
231232 `Reporting browsers ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`,
232233 `Accounts ${usersValue(k.reach)} seen today`,
233 `Reported page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${reactNote(k.reach)}`,
234 `Reported page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${vitalsNote(k.reach)}`,
234235 ];
235236 }
src/flow/reach.ts +12 −7
@@ -25,9 +25,11 @@ export interface Reach {
2525 /** Milliseconds; null when no page view in 7 days measured it. */
2626 readonly loadP75: number | null;
2727 readonly inpP75: number | null;
28 /** Largest contentful paint, the same window; null when none was measured. */
29 readonly lcpP75: number | null;
2830 }
2931
30 export const NO_REACH: Reach = { usersToday: 0, usersTotal: 0, visitorsToday: 0, visitors7d: 0, visitorDays: new Array<number>(DAYS).fill(0), loadP75: null, inpP75: null };
32 export const NO_REACH: Reach = { usersToday: 0, usersTotal: 0, visitorsToday: 0, visitors7d: 0, visitorDays: new Array<number>(DAYS).fill(0), loadP75: null, inpP75: null, lcpP75: null };
3133
3234 /** The 75th percentile, nearest rank: the value at rank ⌈0.75·n⌉ of `values` sorted. */
3335 export function p75(values: number[]): number | null {
@@ -40,19 +42,21 @@ export function p75(values: number[]): number | null {
4042 * Page load and INP over the days since `fromDay`, read in one pass (#3693):
4143 * two counts and two sorted OFFSET reads cost home 70 ms on a month of views.
4244 */
43 function vitals(db: Database, fromDay: string): { load: number[]; inp: number[] } {
45 function vitals(db: Database, fromDay: string): { load: number[]; inp: number[]; lcp: number[] } {
4446 const load: number[] = [];
4547 const inp: number[] = [];
48 const lcp: number[] = [];
4649 // Rows as arrays: a week of views is tens of thousands, and objects cost.
47 for (const [l, i] of db
48 .query<[number | null, number | null], [string]>(
49 "SELECT load, inp FROM page_vital WHERE day >= ?1 AND (load IS NOT NULL OR inp IS NOT NULL)",
50 for (const [l, i, c] of db
51 .query<[number | null, number | null, number | null], [string]>(
52 "SELECT load, inp, lcp FROM page_vital WHERE day >= ?1 AND (load IS NOT NULL OR inp IS NOT NULL OR lcp IS NOT NULL)",
5053 )
51 .values(fromDay) as [number | null, number | null][]) {
54 .values(fromDay) as [number | null, number | null, number | null][]) {
55 if (c !== null) lcp.push(c);
5256 if (l !== null) load.push(l);
5357 if (i !== null) inp.push(i);
5458 }
55 return { load, inp };
59 return { load, inp, lcp };
5660 }
5761
5862 export function readReach(db: Database, now: Date): Reach {
@@ -84,5 +88,6 @@ export function readReach(db: Database, now: Date): Reach {
8488 visitors7d: days.reduce((a, d) => a + d.n, 0),
8589 loadP75: p75(week.load),
8690 inpP75: p75(week.inp),
91 lcpP75: p75(week.lcp),
8792 };
8893 }
src/web/studio/kpi_row.ts +2 −2
@@ -11,7 +11,7 @@
1111 */
1212
1313 import type { Database } from "bun:sqlite";
14 import { KPI_COUNTS, backupFull, backupOverdue, backupValue, buildersValue, checkTime, reactNote, shippedValue, speedWords, spendCeilingWords, spendFull, spendValue, waitingWords } from "../../flow/kpi_words.ts";
14 import { KPI_COUNTS, backupFull, backupOverdue, backupValue, buildersValue, checkTime, shippedValue, vitalsNote, speedWords, spendCeilingWords, spendFull, spendValue, waitingWords } from "../../flow/kpi_words.ts";
1515 import { perfRecord } from "../../perf/live.ts";
1616 import type { TrafficClass, TrafficMinute, TrafficTotals } from "../../perf/traffic.ts";
1717 import type { IdleReason } from "../../crew/idle_reason.ts";
@@ -188,7 +188,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
188188 "/boards/reach",
189189 speedWords(k.reach.loadP75),
190190 speedWords(k.reach.loadP75),
191 reactNote(k.reach),
191 vitalsNote(k.reach),
192192 KPI_COUNTS.speed,
193193 )}
194194 </section>`;
test/helpers/reach-tables.ts +1 −1
@@ -4,7 +4,7 @@ import type { Database } from "bun:sqlite";
44 export function reachTables(db: Database): void {
55 db.run("CREATE TABLE session (user_id TEXT, last_seen_at TEXT)");
66 db.run("CREATE TABLE user (id TEXT)");
7 db.run("CREATE TABLE page_vital (day TEXT, visitor TEXT, load REAL, inp REAL)");
7 db.run("CREATE TABLE page_vital (day TEXT, visitor TEXT, load REAL, inp REAL, lcp REAL)");
88 }
99
1010 /** The tables `countBugs` reads, bare, beside a small `issue` table of the test's own. */
test/home-kpi-page.test.ts +1 −1
@@ -69,7 +69,7 @@ test("home renders the eight tiles; only an admin sees them", async () => {
6969 // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days;
7070 // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms.
7171 expect(a).toContain(">400 ms<");
72 expect(a).toContain(">reacts in 120 ms<");
72 expect(a).toContain(">LCP 100 ms · INP 120 ms<");
7373 expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(1);
7474
7575 // The figures count every repository, so a non-admin sees none.
test/home-kpi-row.test.ts +7 −7
@@ -254,29 +254,29 @@ describe("home kpi row: the last off-box backup", () => {
254254
255255 describe("the reach tiles", () => {
256256 test("say what they count in words, each linking to the reach board", () => {
257 const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 };
257 const reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180, lcpP75: 2400 };
258258 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
259 for (const v of ["1.3 s", "reacts in 180 ms"]) expect(out).toContain(`>${v}<`);
259 for (const v of ["1.3 s", "LCP 2.4 s · INP 180 ms"]) expect(out).toContain(`>${v}<`);
260260 // Reporting browsers and Accounts feed no decision: only the page speed stays (#10440).
261261 for (const gone of ["Reporting browsers", "Accounts", "Traffic"]) expect(out).not.toContain(gone);
262262 expect((out.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(1);
263 expect(out).not.toContain("INP");
263 // #10738 asks for LCP and INP by name on the speed tile; the p75 jargon stays out of the row.
264264 expect(out).not.toContain("p75");
265265 });
266266
267267 test("the terminal draws the 30 days as one block per day", () => {
268268 const days = Array.from({ length: 30 }, (_, i) => (i === 29 ? 8 : 0));
269 const reach = { usersToday: 0, usersTotal: 1, visitorsToday: 8, visitors7d: 8, visitorDays: days, loadP75: null, inpP75: null };
269 const reach = { usersToday: 0, usersTotal: 1, visitorsToday: 8, visitors7d: 8, visitorDays: days, loadP75: null, inpP75: null, lcpP75: null };
270270 const line = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null).find((l) => l.startsWith("Reporting browsers"))!;
271271 expect(line).toContain("▁".repeat(29) + "█");
272272 });
273273
274274 test("the terminal says the same", () => {
275 const lines = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 }, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null);
275 const lines = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180, lcpP75: 2400 }, medianCheckMs: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null } }, null);
276276 expect(lines.find((l) => l.startsWith("Reporting browsers"))).toContain("Reporting browsers 5 today, 31 in 7 days");
277277 expect(lines).toContain("Accounts 3 of 12 seen today");
278278 expect(lines.find((l) => l.startsWith("Reporting browsers"))).toContain("▁".repeat(30));
279 expect(lines).toContain("Reported page speed loads in 1.3 s, reacts in 180 ms");
279 expect(lines).toContain("Reported page speed loads in 1.3 s, LCP 2.4 s · INP 180 ms");
280280 });
281281 });
282282
@@ -303,7 +303,7 @@ describe("readReach", () => {
303303 return db;
304304 };
305305 const view = (db: import("bun:sqlite").Database, day: string, visitor: string, load: number | null, inp: number | null) =>
306 db.run("INSERT INTO page_vital VALUES (?, ?, ?, ?)", [day, visitor, load, inp]);
306 db.run("INSERT INTO page_vital (day, visitor, load, inp) VALUES (?, ?, ?, ?)", [day, visitor, load, inp]);
307307
308308 test("nothing seeded: zeros and no speed", async () => {
309309 expect(readReach(await make(), NOW)).toEqual(NO_REACH);
test/home-vitals.test.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * Home's quiet speed note (#10738): p75 LCP and INP over seven days from the
3 * first-party beacon's page_vital, "no visitors yet" when empty, and the whole
4 * reach read stays at a fixed number of queries however many views there are.
5 */
6
7 import { Database } from "bun:sqlite";
8 import { describe, expect, test } from "bun:test";
9 import { mkdtemp } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp } from "../src/app.ts";
13 import { readReach, NO_REACH, type Reach } from "../src/flow/reach.ts";
14 import type { Kpis } from "../src/flow/kpis.ts";
15 import { kpiLines, vitalsNote } from "../src/flow/kpi_words.ts";
16 import { expectWork } from "./helpers/work.ts";
17 import { once } from "./helpers/statements.ts";
18 import { kpiRowFor } from "../src/web/studio/kpi_row.ts";
19
20 const NOW = new Date("2026-10-19T12:00:00.000Z");
21 const kpis = (reach: Reach): Kpis => ({ landedHours: Array<number>(24).fill(0), landed: 0, live: null, builders: { running: 0, slots: 0, idle: null }, spend: { todayUsd: 0, ceilingUsd: null }, medianCheckMs: null, backupAt: null, reach });
22
23 describe("home vitals note", () => {
24 test("empty says no visitors yet, without a figure", () => {
outdated

createApp with an as never cast hides the real options. Use the repo test helper. Also add a timing bound for the 1 ms box, not only a query count.

25 expect(vitalsNote(NO_REACH)).toBe("no visitors yet");
26 expect(kpiRowFor(kpis(NO_REACH), null, NOW).toString()).toContain("no visitors yet");
27 });
28
29 test("only the measured figure shows when one of LCP or INP is missing", () => {
30 expect(vitalsNote({ ...NO_REACH, lcpP75: 2500, inpP75: null })).toBe("LCP 2.5 s");
31 expect(vitalsNote({ ...NO_REACH, lcpP75: null, inpP75: 120 })).toBe("INP 120 ms");
32 expect(kpiRowFor(kpis({ ...NO_REACH, lcpP75: 2500, inpP75: null }), null, NOW).toString()).not.toContain("INP —");
33 });
34
35 test("p75 LCP and INP over 7 days, old days left out, one query", async () => {
36 const app = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-vitals-")), port: 0 });
37 const db: Database = app.db;
38 const add = (day: string, lcp: number | null, inp: number | null) =>
39 db.run("INSERT INTO page_vital (day, at, route, visitor, ttfb, load, lcp, inp, cls) VALUES (?, 0, '/', 'v', 1, 100, ?, ?, 0)", [day, lcp, inp]);
40 for (const l of [1000, 2000, 3000, 4000]) add("2026-10-19", l, l / 10);
41 add("2026-10-01", 99999, 99999);
42 const real = db.query.bind(db);
43 let queries = 0;
44 (db as unknown as { query: unknown }).query = (sql: string) => {
45 if (/page_vital/.test(sql)) queries++;
46 return real(sql);
47 };
48 const reach = readReach(db, NOW);
49 expect(queries).toBeLessThanOrEqual(2);
50 expect(reach.lcpP75).toBe(3000);
outdated

kpis(r) as never is still here. Make kpis return the Kpis type, or build the object so that no cast is needed.

51 expect(reach.inpP75).toBe(300);
52 expect(vitalsNote(reach)).toBe("LCP 3.0 s · INP 300 ms");
53 expect(kpiRowFor(kpis(reach), null, NOW).toString()).toContain("LCP 3.0 s · INP 300 ms");
54 });
55
56 test("the terminal line says LCP and INP too, not the old reacts-in words", () => {
57 const line = (r: Reach) => kpiLines(kpis(r), null, NOW).find((l) => l.startsWith("Reported page speed"))!;
58 expect(line({ ...NO_REACH, loadP75: 900, lcpP75: 2500, inpP75: 120 })).toBe("Reported page speed loads in 900 ms, LCP 2.5 s · INP 120 ms");
59 expect(line(NO_REACH)).toBe("Reported page speed no page views measured yet, no visitors yet");
60 });
61
62 test("reading a seeded week of 3000 views stays at a fixed few statements", async () => {
63 const app = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-vitals-")), port: 0 });
64 const db: Database = app.db;
65 const ins = db.prepare("INSERT INTO page_vital (day, at, route, visitor, ttfb, load, lcp, inp, cls) VALUES ('2026-10-19', 0, '/', ?, 1, ?, ?, ?, 0)");
66 db.transaction(() => { for (let i = 0; i < 3000; i++) ins.run(`v${i % 200}`, 100 + i, 1000 + i, 50 + (i % 300)); })();
67 // Count work, not a clock (#7824): a week of 3000 views is read in two statements, whatever the row count.
68 expectWork(await once(db, () => { readReach(db, NOW); }), { max: 4 });
69 });
70 });
test/kpi-words.test.ts +2 −6
@@ -1,5 +1,5 @@
11 import { describe, expect, test } from "bun:test";
2 import { backupAge, backupOverdue, buildersValue, reactNote, speedWords, spendFull, spendValue, trendText, usersValue, visitorsNote, visitorsValue } from "../src/flow/kpi_words.ts";
2 import { backupAge, backupOverdue, buildersValue, speedWords, spendFull, spendValue, trendText, usersValue, visitorsNote, visitorsValue } from "../src/flow/kpi_words.ts";
33 import { NO_REACH, type Reach } from "../src/flow/reach.ts";
44
55 const NOW = new Date("2026-10-27T12:00:00.000Z");
@@ -108,26 +108,22 @@ describe("reach words", () => {
108108 expect(usersValue(NO_REACH)).toBe("0 of 0");
109109 expect(visitorsValue(NO_REACH)).toBe("0 today");
110110 expect(visitorsNote(NO_REACH)).toBe("0 in 7 days");
111 expect(reactNote(NO_REACH)).toBe("no taps measured yet");
112111 });
113112 test("one", () => {
114113 const r = reach({ usersToday: 1, usersTotal: 1, visitorsToday: 1, visitors7d: 1, inpP75: 1 });
115114 expect(usersValue(r)).toBe("1 of 1");
116115 expect(visitorsValue(r)).toBe("1 today");
117116 expect(visitorsNote(r)).toBe("1 in 7 days");
118 expect(reactNote(r)).toBe("reacts in 1 ms");
119117 });
120118 test("many, and react speed crossing a second", () => {
121119 const r = reach({ usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, inpP75: 180 });
122120 expect(usersValue(r)).toBe("3 of 12");
123121 expect(visitorsValue(r)).toBe("5 today");
124122 expect(visitorsNote(r)).toBe("31 in 7 days");
125 expect(reactNote(r)).toBe("reacts in 180 ms");
126 expect(reactNote(reach({ inpP75: 1500 }))).toBe("reacts in 1.5 s");
127123 });
128124 test("none of them is dirty", () => {
129125 for (const r of [NO_REACH, reach({ inpP75: 0 })]) {
130 for (const s of [usersValue(r), visitorsValue(r), visitorsNote(r), reactNote(r)]) clean(s);
126 for (const s of [usersValue(r), visitorsValue(r), visitorsNote(r)]) clean(s);
131127 }
132128 });
133129 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs