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

Merged opened this

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

Merged

scout/forge:scout/3259-home-shows-one-line-n-waiting-on-you-old → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3259-home-shows-one-line-n-waiting-on-you-old was merged into main as 79e3929.

Review

Sign in to review this change.
4 files changed +93 −4
src/flow/kpi_words.ts +9
@@ -99,6 +99,15 @@ export function reactNote(r: Reach): string {
9999 return r.inpP75 === null ? "no taps measured yet" : `reacts in ${speedWords(r.inpP75)}`;
100100 }
101101
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
102111 /**
103112 * The terminal's row: the same figures in the same words, as plain
104113 * 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";
5151 import { needsYouSection } from "./asks_page.ts";
5252 import { flowCards } from "./flow_card.ts";
5353 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
54 import { kpiRow } from "./kpi_row.ts";
54 import { kpiRow, waitingLine } from "./kpi_row.ts";
5555 import { feedCandidateCache } from "../../feed/feed.ts";
5656 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
5757 import { kpiCache, panelCache } from "./home_cache.ts";
@@ -250,6 +250,7 @@ export async function studioHome(
250250 ${ask} ${address} ${figures
251251 ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db))
252252 : liveLine(ctx)}
253 ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user))}
253254 ${figures
254255 ? await timePhaseAsync(ctx.request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(ctx.app.db))
255256 : null}
@@ -289,7 +290,7 @@ export async function studioHome(
289290 body,
290291 scriptJs: `${liveClientJs}\n${liveRegionsJs}\n${feedJs}${ask ? `\n${passkeyJs}` : ""}`,
291292 });
292 notePhaseRest(ctx.request, "render", rendering, ["kpis", "panels", "feed"]);
293 notePhaseRest(ctx.request, "render", rendering, ["kpis", "waiting", "panels", "feed"]);
293294 return res;
294295 }
295296
src/web/studio/kpi_row.ts +16 −1
@@ -8,13 +8,16 @@
88 */
99
1010 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";
1212 import type { Kpis } from "../../flow/kpis.ts";
1313 import { cachedKpis } from "./home_cache.ts";
1414 import { escapeHtml, html, raw, type Html } from "../html.ts";
1515 import { BOOTED } from "../../release/gate.ts";
1616 import { running } from "../../kernel/version.ts";
1717 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";
1821
1922 const W = 96;
2023 const H = 24;
@@ -160,3 +163,15 @@ export function kpiRow(db: Database, opts: { ceilingUsd?: number; dataDir?: stri
160163 const forge = { ceilingUsd: opts.ceilingUsd ?? null, ...(opts.dataDir === undefined ? {} : { dataDir: opts.dataDir }) };
161164 return kpiRowFor(cachedKpis(db, now, forge), liveNow(), now);
162165 }
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);

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.

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";
33 import { reachTables } from "./helpers/reach-tables.ts";
44 import { NO_REACH, readReach } from "../src/flow/reach.ts";
55 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";
715 import { running } from "../src/kernel/version.ts";
816 import { components } from "../src/web/style/components.ts";
917 import { kpiRowFor, liveNow, sparkline } from "../src/web/studio/kpi_row.ts";
@@ -334,3 +342,59 @@ describe("readReach", () => {
334342 expect([r.usersToday, r.usersTotal]).toEqual([1, 3]);
335343 });
336344 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs