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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3186-home-s-visitors-tile-shows-the-last-30-d → main 2 commits fork 5 threads · 5 unresolved
6 files
changed +76
−21
docs/CACHING.md +3 −1
| @@ -204,7 +204,9 @@ and rounds to 0.0): | ||
| 204 | 204 | landings per hour (24 h sparkline), the live version and when it went |
| 205 | 205 | live, the median check time, crew builders active out of slots, today's |
| 206 | 206 | 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. | |
| 208 | 210 | - **Key:** `kpi:{head}:{utcHour}`, a single entry. It is read only **after** |
| 209 | 211 | the request has established `viewer.isAdmin`. The figures add up private |
| 210 | 212 | 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 = "▁▂▃▄▅▆▇█"; | ||
| 20 | 20 | |
| 21 | 21 | /** The terminal's sparkline: the same 24 hours, oldest on the left, one block each. */ |
| 22 | 22 | 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 | |
| 26 | 30 | .map((v) => (v === 0 ? BLOCKS[0]! : BLOCKS[Math.min(BLOCKS.length - 1, Math.ceil((v / top) * (BLOCKS.length - 1)))]!)) |
| 27 | 31 | .join(""); |
| 28 | 32 | } |
| @@ -107,7 +111,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n | ||
| 107 | 111 | `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`, |
| 108 | 112 | `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`, |
| 109 | 113 | `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`, | |
| 111 | 115 | `Accounts ${usersValue(k.reach)} signed in today`, |
| 112 | 116 | `Page speed ${k.reach.loadP75 === null ? "no page views measured yet" : `loads in ${speedWords(k.reach.loadP75)}`}, ${reactNote(k.reach)}`, |
| 113 | 117 | ]; |
src/flow/reach.ts +14 −3
| @@ -12,18 +12,22 @@ | ||
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | |
| 14 | 14 | const DAY_MS = 86_400_000; |
| 15 | /** How many days the visitors sparkline covers. */ | |
| 16 | export const DAYS = 30; | |
| 15 | 17 | |
| 16 | 18 | export interface Reach { |
| 17 | 19 | readonly usersToday: number; |
| 18 | 20 | readonly usersTotal: number; |
| 19 | 21 | readonly visitorsToday: number; |
| 20 | 22 | readonly visitors7d: number; |
| 23 | /** Distinct visitors per UTC day over the last 30 days, oldest first, today last. */ | |
| 24 | readonly visitorDays: readonly number[]; | |
| 21 | 25 | /** Milliseconds; null when no page view in 7 days measured it. */ |
| 22 | 26 | readonly loadP75: number | null; |
| 23 | 27 | readonly inpP75: number | null; |
| 24 | 28 | } |
| 25 | 29 | |
| 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 }; | |
| 27 | 31 | |
| 28 | 32 | /** The 75th percentile, nearest rank, of a column over the days since `fromDay`. */ |
| 29 | 33 | function p75(db: Database, column: "load" | "inp", fromDay: string): number | null { |
| @@ -50,15 +54,22 @@ export function readReach(db: Database, now: Date): Reach { | ||
| 50 | 54 | (SELECT COUNT(*) FROM user) AS total`, |
| 51 | 55 | ) |
| 52 | 56 | .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 | |
| 54 | 59 | .query<{ day: string; n: number }, [string, string]>( |
| 55 | 60 | "SELECT day, COUNT(DISTINCT visitor) AS n FROM page_vital WHERE day >= ?1 AND day <= ?2 GROUP BY day", |
| 56 | 61 | ) |
| 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); | |
| 58 | 68 | return { |
| 59 | 69 | usersToday: users.today, |
| 60 | 70 | usersTotal: users.total, |
| 61 | 71 | visitorsToday: days.find((d) => d.day === today)?.n ?? 0, |
| 72 | visitorDays, | |
|
||
| 62 | 73 | visitors7d: days.reduce((a, d) => a + d.n, 0), |
| 63 | 74 | loadP75: p75(db, "load", weekFrom), |
| 64 | 75 | inpP75: p75(db, "inp", weekFrom), |
src/web/studio/kpi_row.ts +17 −9
| @@ -2,10 +2,9 @@ | ||
| 2 | 2 | * Home's row of nine figures (#2871, #2713), from `flow/kpis.ts`: shipped per |
| 3 | 3 | * hour with a day's sparkline, the live version and since when, the median |
| 4 | 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. | |
| 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. | |
| 9 | 8 | */ |
| 10 | 9 | |
| 11 | 10 | import type { Database } from "bun:sqlite"; |
| @@ -20,19 +19,28 @@ import { sha, time } from "../ui.ts"; | ||
| 20 | 19 | const W = 96; |
| 21 | 20 | const H = 24; |
| 22 | 21 | |
| 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 { | |
| 26 | 24 | const top = Math.max(1, ...series); |
| 27 | 25 | const step = W / Math.max(1, series.length - 1); |
| 28 | 26 | const points = series |
| 29 | 27 | .map((v, i) => `${(i * step).toFixed(1)},${(H - 2 - (v / top) * (H - 4)).toFixed(1)}`) |
| 30 | 28 | .join(" "); |
| 31 | 29 | 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>`, | |
| 33 | 31 | ); |
| 34 | 32 | } |
| 35 | 33 | |
| 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 |
||
| 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 | ||
| 36 | 44 | 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 { |
| 37 | 45 | return html`<a class="card kpi" href="${href}"${tone === null ? "" : raw(` data-tone="${tone}"`)} aria-label="${label}: ${full}, ${title}"> |
| 38 | 46 | <span class="small muted">${label}</span> |
| @@ -103,7 +111,7 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 103 | 111 | null, |
| 104 | 112 | overdue ? "attn" : null, |
| 105 | 113 | )} |
| 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))} | |
| 107 | 115 | ${tile("Accounts", "/boards/reach", usersValue(k.reach), usersValue(k.reach), "signed in today", "Accounts signed in today (UTC), of all accounts")} |
| 108 | 116 | ${tile( |
| 109 | 117 | "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 () => { | ||
| 74 | 74 | |
| 75 | 75 | // The figures count every repository, so a non-admin sees none. |
| 76 | 76 | expect(a).toContain("no ceiling"); |
| 77 | expect(a).toContain("last 30 days"); | |
| 77 | 78 | expect(await home(bob)).not.toContain('class="kpis"'); |
| 79 | expect(await home(bob)).not.toContain("last 30 days"); | |
| 78 | 80 | expect(await home()).not.toContain('class="kpis"'); |
| 81 | expect(await home()).not.toContain("last 30 days"); | |
| 79 | 82 | }); |
| 80 | 83 | |
| 81 | 84 | 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"; | |
| 2 | 3 | import { reachTables } from "./helpers/reach-tables.ts"; |
| 3 | 4 | import { NO_REACH, readReach } from "../src/flow/reach.ts"; |
| 4 | 5 | import { describe, expect, test } from "bun:test"; |
| @@ -242,7 +243,7 @@ describe("home kpi row: the last off-box backup", () => { | ||
| 242 | 243 | |
| 243 | 244 | describe("the reach tiles", () => { |
| 244 | 245 | 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 }; | |
| 246 | 247 | 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 | 248 | 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 | 249 | expect((out.match(/class="card kpi" href="\/boards\/reach"/g) ?? []).length).toBe(3); |
| @@ -250,14 +251,40 @@ describe("the reach tiles", () => { | ||
| 250 | 251 | expect(out).not.toContain("p75"); |
| 251 | 252 | }); |
| 252 | 253 | |
| 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 | ||
| 253 | 261 | 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"); | |
| 256 | 264 | expect(lines).toContain("Accounts 3 of 12 signed in today"); |
| 265 | expect(lines.find((l) => l.startsWith("Visitors"))).toContain("▁".repeat(30)); | |
| 257 | 266 | expect(lines).toContain("Page speed loads in 1.3 s, reacts in 180 ms"); |
| 258 | 267 | }); |
| 259 | 268 | }); |
| 260 | 269 | |
| 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 | ||
| 261 | 288 | describe("readReach", () => { |
| 262 | 289 | const NOW = new Date("2026-10-19T15:00:00.000Z"); |
| 263 | 290 | const make = async () => { |
new above
offline
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.