Public: anyone can read this; the crew can change it.
Home KPIs show p75 LCP and INP from the first-party beacon #10766
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10738-home-kpis-show-p75-lcp-and-inp-from-the → main 8 commits fork 4 threads · 4 unresolved
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 | ||
| 101 | 101 | `data-tone="attn"` and says overdue in its label. |
| 102 | 102 | The last three (`src/flow/reach.ts`, the `reach` board source's rows) are |
| 103 | 103 | 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 | |
| 106 | 106 | the Reach starting board (`/boards/reach`). The visitor hash rotates daily, so |
| 107 | 107 | the 7-day reporting browsers is each day's distinct count summed. |
| 108 | 108 | 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 | ||
| 26 | 26 | - **Just shipped** titles are cut at a word (`shortTitle`); the row's hover and screen-reader title carry the whole one. |
| 27 | 27 | - **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. |
| 28 | 28 | - 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 = { | ||
| 22 | 22 | 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.", |
| 23 | 23 | 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.", |
| 24 | 24 | 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.", | |
| 26 | 26 | } as const; |
| 27 | 27 | |
| 28 | 28 | /** A duration the way a person reads a check: `45s`, `3m 05s`, `1h 02m`. */ |
| @@ -132,9 +132,10 @@ export function visitorsNote(r: Reach): string { | ||
| 132 | 132 | return `${r.visitors7d} in 7 days`; |
| 133 | 133 | } |
| 134 | 134 | |
| 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); | |
| 138 | return parts.length === 0 ? "no visitors yet" : parts.join(" · "); | |
| 138 | 139 | } |
| 139 | 140 | |
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. |
||
| 140 | 141 | /** Shipped per hour, as the tile shows it. */ |
| @@ -230,6 +231,6 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n | ||
| 230 | 231 | k.backup ? k.backup.line : `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`, |
| 231 | 232 | `Reporting browsers ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`, |
| 232 | 233 | `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)}`, | |
| 234 | 235 | ]; |
| 235 | 236 | } |
src/flow/reach.ts +12 −7
| @@ -25,9 +25,11 @@ export interface Reach { | ||
| 25 | 25 | /** Milliseconds; null when no page view in 7 days measured it. */ |
| 26 | 26 | readonly loadP75: number | null; |
| 27 | 27 | readonly inpP75: number | null; |
| 28 | /** Largest contentful paint, the same window; null when none was measured. */ | |
| 29 | readonly lcpP75: number | null; | |
| 28 | 30 | } |
| 29 | 31 | |
| 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 }; | |
| 31 | 33 | |
| 32 | 34 | /** The 75th percentile, nearest rank: the value at rank ⌈0.75·n⌉ of `values` sorted. */ |
| 33 | 35 | export function p75(values: number[]): number | null { |
| @@ -40,19 +42,21 @@ export function p75(values: number[]): number | null { | ||
| 40 | 42 | * Page load and INP over the days since `fromDay`, read in one pass (#3693): |
| 41 | 43 | * two counts and two sorted OFFSET reads cost home 70 ms on a month of views. |
| 42 | 44 | */ |
| 43 | function vitals(db: Database, fromDay: string): { load: number[]; inp: number[] } { | |
| 45 | function vitals(db: Database, fromDay: string): { load: number[]; inp: number[]; lcp: number[] } { | |
| 44 | 46 | const load: number[] = []; |
| 45 | 47 | const inp: number[] = []; |
| 48 | const lcp: number[] = []; | |
| 46 | 49 | // 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)", | |
| 50 | 53 | ) |
| 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); | |
| 52 | 56 | if (l !== null) load.push(l); |
| 53 | 57 | if (i !== null) inp.push(i); |
| 54 | 58 | } |
| 55 | return { load, inp }; | |
| 59 | return { load, inp, lcp }; | |
| 56 | 60 | } |
| 57 | 61 | |
| 58 | 62 | export function readReach(db: Database, now: Date): Reach { |
| @@ -84,5 +88,6 @@ export function readReach(db: Database, now: Date): Reach { | ||
| 84 | 88 | visitors7d: days.reduce((a, d) => a + d.n, 0), |
| 85 | 89 | loadP75: p75(week.load), |
| 86 | 90 | inpP75: p75(week.inp), |
| 91 | lcpP75: p75(week.lcp), | |
| 87 | 92 | }; |
| 88 | 93 | } |
src/web/studio/kpi_row.ts +2 −2
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | 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"; | |
| 15 | 15 | import { perfRecord } from "../../perf/live.ts"; |
| 16 | 16 | import type { TrafficClass, TrafficMinute, TrafficTotals } from "../../perf/traffic.ts"; |
| 17 | 17 | import type { IdleReason } from "../../crew/idle_reason.ts"; |
| @@ -188,7 +188,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 188 | 188 | "/boards/reach", |
| 189 | 189 | speedWords(k.reach.loadP75), |
| 190 | 190 | speedWords(k.reach.loadP75), |
| 191 | reactNote(k.reach), | |
| 191 | vitalsNote(k.reach), | |
| 192 | 192 | KPI_COUNTS.speed, |
| 193 | 193 | )} |
| 194 | 194 | </section>`; |
test/helpers/reach-tables.ts +1 −1
| @@ -4,7 +4,7 @@ import type { Database } from "bun:sqlite"; | ||
| 4 | 4 | export function reachTables(db: Database): void { |
| 5 | 5 | db.run("CREATE TABLE session (user_id TEXT, last_seen_at TEXT)"); |
| 6 | 6 | 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)"); | |
| 8 | 8 | } |
| 9 | 9 | |
| 10 | 10 | /** 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 () => { | ||
| 69 | 69 | // 2 accounts, the admin alone with a session today; visitors 3 today and 4 over 7 days; |
| 70 | 70 | // load p75 of [100,200,300,400,1900] is 400 ms, INP p75 of [40,80,120,160] is 120 ms. |
| 71 | 71 | expect(a).toContain(">400 ms<"); |
| 72 | expect(a).toContain(">reacts in 120 ms<"); | |
| 72 | expect(a).toContain(">LCP 100 ms · INP 120 ms<"); | |
| 73 | 73 | expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(1); |
| 74 | 74 | |
| 75 | 75 | // 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", () => { | ||
| 254 | 254 | |
| 255 | 255 | describe("the reach tiles", () => { |
| 256 | 256 | 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 }; | |
| 258 | 258 | 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}<`); | |
| 260 | 260 | // Reporting browsers and Accounts feed no decision: only the page speed stays (#10440). |
| 261 | 261 | for (const gone of ["Reporting browsers", "Accounts", "Traffic"]) expect(out).not.toContain(gone); |
| 262 | 262 | 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. | |
| 264 | 264 | expect(out).not.toContain("p75"); |
| 265 | 265 | }); |
| 266 | 266 | |
| 267 | 267 | test("the terminal draws the 30 days as one block per day", () => { |
| 268 | 268 | 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 }; | |
| 270 | 270 | 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"))!; |
| 271 | 271 | expect(line).toContain("▁".repeat(29) + "█"); |
| 272 | 272 | }); |
| 273 | 273 | |
| 274 | 274 | 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); | |
| 276 | 276 | expect(lines.find((l) => l.startsWith("Reporting browsers"))).toContain("Reporting browsers 5 today, 31 in 7 days"); |
| 277 | 277 | expect(lines).toContain("Accounts 3 of 12 seen today"); |
| 278 | 278 | 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"); | |
| 280 | 280 | }); |
| 281 | 281 | }); |
| 282 | 282 | |
| @@ -303,7 +303,7 @@ describe("readReach", () => { | ||
| 303 | 303 | return db; |
| 304 | 304 | }; |
| 305 | 305 | 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]); | |
| 307 | 307 | |
| 308 | 308 | test("nothing seeded: zeros and no speed", async () => { |
| 309 | 309 | 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 |
||
| 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
|
||
| 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 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { NO_REACH, type Reach } from "../src/flow/reach.ts"; |
| 4 | 4 | |
| 5 | 5 | const NOW = new Date("2026-10-27T12:00:00.000Z"); |
| @@ -108,26 +108,22 @@ describe("reach words", () => { | ||
| 108 | 108 | expect(usersValue(NO_REACH)).toBe("0 of 0"); |
| 109 | 109 | expect(visitorsValue(NO_REACH)).toBe("0 today"); |
| 110 | 110 | expect(visitorsNote(NO_REACH)).toBe("0 in 7 days"); |
| 111 | expect(reactNote(NO_REACH)).toBe("no taps measured yet"); | |
| 112 | 111 | }); |
| 113 | 112 | test("one", () => { |
| 114 | 113 | const r = reach({ usersToday: 1, usersTotal: 1, visitorsToday: 1, visitors7d: 1, inpP75: 1 }); |
| 115 | 114 | expect(usersValue(r)).toBe("1 of 1"); |
| 116 | 115 | expect(visitorsValue(r)).toBe("1 today"); |
| 117 | 116 | expect(visitorsNote(r)).toBe("1 in 7 days"); |
| 118 | expect(reactNote(r)).toBe("reacts in 1 ms"); | |
| 119 | 117 | }); |
| 120 | 118 | test("many, and react speed crossing a second", () => { |
| 121 | 119 | const r = reach({ usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, inpP75: 180 }); |
| 122 | 120 | expect(usersValue(r)).toBe("3 of 12"); |
| 123 | 121 | expect(visitorsValue(r)).toBe("5 today"); |
| 124 | 122 | 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"); | |
| 127 | 123 | }); |
| 128 | 124 | test("none of them is dirty", () => { |
| 129 | 125 | 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); | |
| 131 | 127 | } |
| 132 | 128 | }); |
| 133 | 129 | }); |
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.