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

Home's visitors tile shows the last 30 days as a sparkline, linking to its board #3187

Merged opened this

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

Merged

scout/forge:scout/3186-home-s-visitors-tile-shows-the-last-30-d → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/3186-home-s-visitors-tile-shows-the-last-30-d was merged into main as c6eb581.

Review

Sign in to review this change.
6 files changed +76 −21
docs/CACHING.md +3 −1
@@ -204,7 +204,9 @@ and rounds to 0.0):
204204 landings per hour (24 h sparkline), the live version and when it went
205205 live, the median check time, crew builders active out of slots, today's
206206 spend against the ceiling, and the age of the last good off-box backup.
207 Numbers only, rendered for each request.
207 Numbers only, rendered for each request. The visitors tile also carries
208 30 days of distinct visitors per day (#3186), part of the same entry and
209 so admin-only by the same gate.
208210 - **Key:** `kpi:{head}:{utcHour}`, a single entry. It is read only **after**
209211 the request has established `viewer.isAdmin`. The figures add up private
210212 repositories, so the entry is admin-scoped by its gate, and a viewer who
src/flow/kpi_words.ts +8 −4
@@ -20,9 +20,13 @@ const BLOCKS = "▁▂▃▄▅▆▇█";
2020
2121 /** The terminal's sparkline: the same 24 hours, oldest on the left, one block each. */
2222 export function sparkText(hours: readonly number[]): string {
23 const top = Math.max(1, ...hours);
24 return [...hours]
25 .reverse()
23 return trendText([...hours].reverse());
24 }
25
26 /** One block per value, oldest on the left; the series is already in that order. */
27 export function trendText(series: readonly number[]): string {
28 const top = Math.max(1, ...series);
29 return series
2630 .map((v) => (v === 0 ? BLOCKS[0]! : BLOCKS[Math.min(BLOCKS.length - 1, Math.ceil((v / top) * (BLOCKS.length - 1)))]!))
2731 .join("");
2832 }
@@ -107,7 +111,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n
107111 `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`,
108112 `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`,
109113 `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`,
110 `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`,
114 `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`,

The Visitors line is long now and may wrap at 80 columns in the terminal. Check it, or move the trend to its own line.

111115 `Accounts ${usersValue(k.reach)} signed in today`,
112116 `Page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${reactNote(k.reach)}`,
113117 ];
src/flow/reach.ts +14 −3
@@ -12,18 +12,22 @@
1212 import type { Database } from "bun:sqlite";
1313
1414 const DAY_MS = 86_400_000;
15 /** How many days the visitors sparkline covers. */
16 export const DAYS = 30;
1517
1618 export interface Reach {
1719 readonly usersToday: number;
1820 readonly usersTotal: number;
1921 readonly visitorsToday: number;
2022 readonly visitors7d: number;
23 /** Distinct visitors per UTC day over the last 30 days, oldest first, today last. */
24 readonly visitorDays: readonly number[];
2125 /** Milliseconds; null when no page view in 7 days measured it. */
2226 readonly loadP75: number | null;
2327 readonly inpP75: number | null;
2428 }
2529
26 export const NO_REACH: Reach = { usersToday: 0, usersTotal: 0, visitorsToday: 0, visitors7d: 0, loadP75: null, inpP75: null };
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 };
2731
2832 /** The 75th percentile, nearest rank, of a column over the days since `fromDay`. */
2933 function p75(db: Database, column: "load" | "inp", fromDay: string): number | null {
@@ -50,15 +54,22 @@ export function readReach(db: Database, now: Date): Reach {
5054 (SELECT COUNT(*) FROM user) AS total`,
5155 )
5256 .get(`${today}T00:00:00Z`) ?? { today: 0, total: 0 };
53 const days = db
57 const monthFrom = new Date(now.getTime() - (DAYS - 1) * DAY_MS).toISOString().slice(0, 10);
58 const month = db
5459 .query<{ day: string; n: number }, [string, string]>(
5560 "SELECT day, COUNT(DISTINCT visitor) AS n FROM page_vital WHERE day >= ?1 AND day <= ?2 GROUP BY day",
5661 )
57 .all(weekFrom, today);
62 .all(monthFrom, today);
63 const perDay = new Map(month.map((d) => [d.day, d.n]));
64 const visitorDays = Array.from({ length: DAYS }, (_, i) =>
65 perDay.get(new Date(now.getTime() - (DAYS - 1 - i) * DAY_MS).toISOString().slice(0, 10)) ?? 0,
66 );
67 const days = month.filter((d) => d.day >= weekFrom);
5868 return {
5969 usersToday: users.today,
6070 usersTotal: users.total,
6171 visitorsToday: days.find((d) => d.day === today)?.n ?? 0,
72 visitorDays,

visitorDays reaches the page but not the terminal. Add a trend line to kpiLines so both doors show it.

6273 visitors7d: days.reduce((a, d) => a + d.n, 0),
6374 loadP75: p75(db, "load", weekFrom),
6475 inpP75: p75(db, "inp", weekFrom),
src/web/studio/kpi_row.ts +17 −9
@@ -2,10 +2,9 @@
22 * Home's row of nine figures (#2871, #2713), from `flow/kpis.ts`: shipped per
33 * hour with a day's sparkline, the live version and since when, the median
44 * 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.
5 * today of the daily ceiling, the age of the last off-box backup, visitors with 30 days as a sparkline, accounts signed in, and page speed.

This header line runs far past the wrapping of its neighbours; rewrap it.

6 * Server-rendered, one fixed-height card each, so nothing moves when it
7 * paints. Only the studio's words: shipped, change, check time.
98 */
109
1110 import type { Database } from "bun:sqlite";
@@ -20,19 +19,28 @@ import { sha, time } from "../ui.ts";
2019 const W = 96;
2120 const H = 24;
2221
23 /** The last 24 hours, oldest on the left, as an SVG polyline; the numbers are the only input. */
24 export function sparkline(hours: readonly number[]): Html {
25 const series = [...hours].reverse();
22 /** A series oldest first as a fixed-size SVG polyline; the numbers are the only input. */
23 function spark(series: readonly number[], label: string): Html {
2624 const top = Math.max(1, ...series);
2725 const step = W / Math.max(1, series.length - 1);
2826 const points = series
2927 .map((v, i) => `${(i * step).toFixed(1)},${(H - 2 - (v / top) * (H - 4)).toFixed(1)}`)
3028 .join(" ");
3129 return raw(
32 `<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Shipped each hour over the last 24 hours"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`,
30 `<svg class="kpi-spark" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="${label}"><polyline points="${points}" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>`,
3331 );
3432 }
3533
34 /** The last 24 hours, newest first in, oldest on the left out. */
35 export function sparkline(hours: readonly number[]): Html {
36 return spark([...hours].reverse(), "Shipped each hour over the last 24 hours");
37 }
outdated

Near-duplicate of sparkline above. Extract a shared spark(series, label) and call it from both, instead of copying the polyline maths.

38
39 /** Distinct visitors per day, oldest first, today on the right. */
40 export function visitorsSpark(days: readonly number[]): Html {
41 return spark(days, `Distinct visitors each day over the last ${days.length} days`);
42 }
43
3644 function tile(label: string, href: string, value: Html | string, full: string, note: string | Html, title: string, extra: Html | null = null, tone: "attn" | null = null): Html {
3745 return html`<a class="card kpi" href="${href}"${tone === null ? "" : raw(` data-tone="${tone}"`)} aria-label="${label}: ${full}, ${title}">
3846 <span class="small muted">${label}</span>
@@ -103,7 +111,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now
103111 null,
104112 overdue ? "attn" : null,
105113 )}
106 ${tile("Visitors", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), "Distinct browsers per day, summed over 7 days")}
114 ${tile("Visitors", "/boards/reach", visitorsValue(k.reach), `${visitorsValue(k.reach)}, ${visitorsNote(k.reach)}`, visitorsNote(k.reach), "Distinct browsers per day, summed over 7 days", visitorsSpark(k.reach.visitorDays))}
107115 ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "signed in today", "Accounts signed in today (UTC), of all accounts")}
108116 ${tile(
109117 "Page speed",
test/home-kpi-page.test.ts +3
@@ -74,8 +74,11 @@ test("home renders the nine tiles; only an admin sees them", async () => {
7474
7575 // The figures count every repository, so a non-admin sees none.
7676 expect(a).toContain("no ceiling");
77 expect(a).toContain("last 30 days");
7778 expect(await home(bob)).not.toContain('class="kpis"');
79 expect(await home(bob)).not.toContain("last 30 days");
7880 expect(await home()).not.toContain('class="kpis"');
81 expect(await home()).not.toContain("last 30 days");
7982 });
8083
8184 test("home passes the forge's daily ceiling to the spend tile", async () => {
test/home-kpi-row.test.ts +31 −4
@@ -1,4 +1,5 @@
1 /** Home's figures row (#2871): the five tiles, their links, studio words only, a sparkline of 24 points. */
1 /** Home's figures row (#2871): the tiles, their links, studio words only, a sparkline of 24 points and one of 30 days of visitors (#3186). */
outdated

The import now sits above the docblock. Move it down with the other imports.

2 import { Database } from "bun:sqlite";
23 import { reachTables } from "./helpers/reach-tables.ts";
34 import { NO_REACH, readReach } from "../src/flow/reach.ts";
45 import { describe, expect, test } from "bun:test";
@@ -242,7 +243,7 @@ describe("home kpi row: the last off-box backup", () => {
242243
243244 describe("the reach tiles", () => {
244245 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 reach = { usersToday: 3, usersTotal: 12, visitorsToday: 5, visitors7d: 31, visitorDays: new Array<number>(30).fill(0), loadP75: 1250, inpP75: 180 };
246247 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();
247248 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}<`);
248249 expect((out.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3);
@@ -250,14 +251,40 @@ describe("the reach tiles", () => {
250251 expect(out).not.toContain("p75");
251252 });
252253
254 test("the terminal draws the 30 days as one block per day", () => {
255 const days = Array.from({ length: 30 }, (_, i) => (i === 29 ? 8 : 0));
256 const reach = { usersToday: 0, usersTotal: 1, visitorsToday: 8, visitors7d: 8, visitorDays: days, loadP75: null, inpP75: null };
257 const line = kpiLines({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).find((l) => l.startsWith("Visitors"))!;
258 expect(line).toContain("▁".repeat(29) + "█");
259 });
260
253261 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");
262 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 }, spend: { todayUsd: 0, ceilingUsd: null } }, null);
263 expect(lines.find((l) => l.startsWith("Visitors"))).toContain("Visitors 5 today, 31 in 7 days");
256264 expect(lines).toContain("Accounts 3 of 12 signed in today");
265 expect(lines.find((l) => l.startsWith("Visitors"))).toContain("▁".repeat(30));
257266 expect(lines).toContain("Page speed loads in 1.3 s, reacts in 180 ms");
258267 });
259268 });
260269
270 describe("the visitors sparkline", () => {
271 test("is 30 points, oldest first, and ends at today", () => {
272 const db = new Database(":memory:");
273 reachTables(db);
274 const NOW = new Date("2026-10-19T15:00:00.000Z");
275 db.run("INSERT INTO page_vital (day, visitor) VALUES ('2026-10-19','a'),('2026-10-19','b'),('2026-10-19','a'),('2026-09-20','c'),('2026-09-19','old')");
276 const r = readReach(db, NOW);
277 expect(r.visitorDays).toHaveLength(30);
278 expect(r.visitorDays[29]).toBe(2);
279 expect(r.visitorDays[0]).toBe(1); // 2026-09-20 is 29 days back; 09-19 is out
280 expect(r.visitorDays.reduce((a, b) => a + b, 0)).toBe(3);
281 const out = kpiRowFor({ landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: r, medianCheckMs: null, builders: { running: 0, slots: 0 }, spend: { todayUsd: 0, ceilingUsd: null } }, null).toString();
282 const svg = /<svg[^>]*last 30 days[^>]*><polyline points="([^"]+)"/.exec(out)!;
283 expect(svg[1]!.split(" ")).toHaveLength(30);
284 expect(out).toContain('href="/boards/reach"');
285 });
286 });
287
261288 describe("readReach", () => {
262289 const NOW = new Date("2026-10-19T15:00:00.000Z");
263290 const make = async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs