Public: anyone can read this; the crew can change it.
Home shows one line, "N waiting on you, oldest Xh", linking to /review, and nothing at zero #4487
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3259-home-shows-one-line-n-waiting-on-you-old → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +93
−4
src/flow/kpi_words.ts +9
| @@ -99,6 +99,15 @@ export function reactNote(r: Reach): string { | ||
| 99 | 99 | return r.inpP75 === null ? "no taps measured yet" : `reacts in ${speedWords(r.inpP75)}`; |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | /** Home's one line over /review: `3 waiting on you, oldest 50 h`; null at zero, so the line is absent. */ | |
| 103 | export function waitingWords(count: number, oldestAt: string | null, now: Date): string | null { | |
| 104 | if (count <= 0) return null; | |
| 105 | const t = oldestAt === null ? NaN : Date.parse(oldestAt); | |
| 106 | if (Number.isNaN(t)) return `${count} waiting on you`; | |
| 107 | const h = Math.max(0, Math.floor((now.getTime() - t) / 3_600_000)); | |
| 108 | return `${count} waiting on you, oldest ${h < 1 ? "under 1" : h} h`; | |
| 109 | } | |
| 110 | ||
| 102 | 111 | /** |
| 103 | 112 | * The terminal's row: the same figures in the same words, as plain |
| 104 | 113 | * lines. Forge-wide like the web's, so the caller shows them to an admin only. |
src/web/studio/home.ts +3 −2
| @@ -51,7 +51,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 51 | 51 | import { needsYouSection } from "./asks_page.ts"; |
| 52 | 52 | import { flowCards } from "./flow_card.ts"; |
| 53 | 53 | import { composer, feedJs, feedNote, feedRegion } from "./feed.ts"; |
| 54 | import { kpiRow } from "./kpi_row.ts"; | |
| 54 | import { kpiRow, waitingLine } from "./kpi_row.ts"; | |
| 55 | 55 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 56 | 56 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 57 | 57 | import { kpiCache, panelCache } from "./home_cache.ts"; |
| @@ -250,6 +250,7 @@ export async function studioHome( | ||
| 250 | 250 | ${ask} ${address} ${figures |
| 251 | 251 | ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db)) |
| 252 | 252 | : liveLine(ctx)} |
| 253 | ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user))} | |
| 253 | 254 | ${figures |
| 254 | 255 | ? await timePhaseAsync(ctx.request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(ctx.app.db)) |
| 255 | 256 | : null} |
| @@ -289,7 +290,7 @@ export async function studioHome( | ||
| 289 | 290 | body, |
| 290 | 291 | scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}${ask ? `\n${passkeyJs}` : ""}`, |
| 291 | 292 | }); |
| 292 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "panels", "feed"]); | |
| 293 | notePhaseRest(ctx.request, "render", rendering, ["kpis", "waiting", "panels", "feed"]); | |
| 293 | 294 | return res; |
| 294 | 295 | } |
| 295 | 296 |
src/web/studio/kpi_row.ts +16 −1
| @@ -8,13 +8,16 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue } from "../../flow/kpi_words.ts"; | |
| 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; | |
| 12 | 12 | import type { Kpis } from "../../flow/kpis.ts"; |
| 13 | 13 | import { cachedKpis } from "./home_cache.ts"; |
| 14 | 14 | import { escapeHtml, html, raw, type Html } from "../html.ts"; |
| 15 | 15 | import { BOOTED } from "../../release/gate.ts"; |
| 16 | 16 | import { running } from "../../kernel/version.ts"; |
| 17 | 17 | import { sha, stat, time } from "../ui.ts"; |
| 18 | import { listReposVisibleTo } from "../../core/repos.ts"; | |
| 19 | import type { User } from "../../core/users.ts"; | |
| 20 | import { waitingOn } from "./asks.ts"; | |
| 18 | 21 | |
| 19 | 22 | const W = 96; |
| 20 | 23 | const H = 24; |
| @@ -160,3 +163,15 @@ export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: stri | ||
| 160 | 163 | const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }) }; |
| 161 | 164 | return kpiRowFor(cachedKpis(db, now, forge), liveNow(), now); |
| 162 | 165 | } |
| 166 | ||
| 167 | /** | |
| 168 | * One `.small .muted` line over `/review`: how many asks wait on the viewer | |
| 169 | * (the very rows /review lists) and how long the oldest has. Empty at zero. | |
| 170 | */ | |
| 171 | export function waitingLine(db: Database, user: User, now: Date = new Date()): Html { | |
| 172 | const repos = listReposVisibleTo(db, user, { limit: 200, withoutArchived: true }); | |
| 173 | const asks = waitingOn(db, user, repos); | |
| 174 | const oldest = asks.reduce<string | null>((o, a) => (o === null || Date.parse(a.since) < Date.parse(o) ? a.since : o), null); | |
| 175 | const words = waitingWords(asks.length, oldest, now); | |
| 176 | return words === null ? html`` : html`<p class="small muted"><a href="/review">${words}</a></p>`; | |
| 177 | } |
test/home-kpi-row.test.ts +65 −1
| @@ -3,7 +3,15 @@ import { Database } from "bun:sqlite"; | ||
| 3 | 3 | import { reachTables } from "./helpers/reach-tables.ts"; |
| 4 | 4 | import { NO_REACH, readReach } from "../src/flow/reach.ts"; |
| 5 | 5 | import { describe, expect, test } from "bun:test"; |
| 6 | import { checkTime, kpiLines, sparkText } from "../src/flow/kpi_words.ts"; | |
| 6 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp } from "../src/app.ts"; | |
| 10 | import { createRepo as createRepoRow } from "../src/core/repos.ts"; | |
| 11 | import { createUser } from "../src/core/users.ts"; | |
| 12 | import { serve } from "../src/http/server.ts"; | |
| 13 | import { id } from "../src/kernel/id.ts"; | |
| 14 | import { checkTime, kpiLines, sparkText, waitingWords } from "../src/flow/kpi_words.ts"; | |
| 7 | 15 | import { running } from "../src/kernel/version.ts"; |
| 8 | 16 | import { components } from "../src/web/style/components.ts"; |
| 9 | 17 | import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts"; |
| @@ -334,3 +342,59 @@ describe("readReach", () => { | ||
| 334 | 342 | expect([r.usersToday, r.usersTotal]).toEqual([1, 3]); |
| 335 | 343 | }); |
| 336 | 344 | }); |
| 345 | ||
| 346 | describe("home waiting line", () => { | |
| 347 | test("words: absent at zero, count and oldest hours otherwise", () => { | |
| 348 | const now = new Date("2026-10-21T12:00:00.000Z"); | |
| 349 | expect(waitingWords(0, null, now)).toBeNull(); | |
| 350 | expect(waitingWords(3, "2026-10-19T10:00:00.000Z", now)).toBe("3 waiting on you, oldest 50 h"); | |
| 351 | expect(waitingWords(1, "2026-10-21T11:40:00.000Z", now)).toBe("1 waiting on you, oldest under 1 h"); | |
| 352 | }); | |
| 353 | ||
| 354 | async function homeWith(stale: number[]): Promise<string> { | |
| 355 | const root = mkdtempSync(join(tmpdir(), "forge-home-wait-")); | |
| 356 | const app = createApp({ dataDir: root, port: 0 }); | |
| 357 | const servers = await serve(app); | |
| 358 | try { | |
| 359 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 360 | if (!made.ok) throw new Error(made.error); | |
| 361 | const repo = await createRepoRow(app.db, app.reposRoot, { ownerId: made.value.id, name: "wait", visibility: "public", description: "wait" }); | |
| 362 | if (!repo.ok) throw new Error(repo.error); | |
| 363 | stale.forEach((h, i) => { | |
| 364 | const issueId = id(); | |
| 365 | const number = app.db.query<{ issue_seq: number }, [string]>("UPDATE repo SET issue_seq = issue_seq + 1 WHERE id = ?1 RETURNING issue_seq").get(repo.value.id)!.issue_seq; | |
| 366 | app.db | |
| 367 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'open', ?6)`) | |
| 368 | .run(issueId, repo.value.id, number, `Stale ${i}`, made.value.id, new Date(Date.now() - h * 3_600_000).toISOString()); | |
| 369 | app.db | |
| 370 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, ?3, ?3, ?4)`) | |
| 371 | .run(issueId, `b/${i}`, "0".repeat(40), repo.value.id); | |
| 372 | }); | |
| 373 | const signin = await fetch(`${servers.url}/signin`, { | |
| 374 | method: "POST", | |
| 375 | headers: { origin: servers.url }, | |
| 376 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 377 | redirect: "manual", | |
| 378 | }); | |
| 379 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 380 | const review = await (await fetch(`${servers.url}/review`, { headers: { cookie } })).text(); | |
| 381 | expect(review.split("<li").filter((r) => r.includes('class="row"')).length).toBe(stale.length); | |
| 382 | return await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); | |
| 383 | } finally { | |
| 384 | await servers.stop(); | |
| 385 | app.close(); | |
| 386 | rmSync(root, { recursive: true, force: true }); | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | test("three asks show one line linking to /review", async () => { | |
| 391 | const body = await homeWith([30, 40, 50]); | |
| 392 | expect(body).toMatch(/<p class="small muted"><a href="\/review">3 waiting on you, oldest 5\d h<\/a><\/p>/); | |
| 393 | }, 30_000); | |
| 394 | ||
| 395 | test("with none, the line is absent", async () => { | |
| 396 | const body = await homeWith([]); | |
| 397 | expect(body).not.toContain("waiting on you"); | |
| 398 | expect(body).not.toContain('href="/review"'); | |
| 399 | }, 30_000); | |
| 400 | }); |
new above
offline
This reruns the visible-repos query and the scoring that /review, needsYouSection and flowCards already do in the same request. Reuse the repos and asks already computed for home, or cache per request, once the timing shows a cost.