Public: anyone can read this; the crew can change it.
The terminal's "on you" screen leads with the week's asks as four numbers: raised, acted on, dismissed, went by themselves #8063
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8055-the-terminal-s-on-you-screen-leads-with → main 6 commits fork 5 threads · 5 unresolved
8 files
changed +230
−22
README.md +2
| @@ -81,6 +81,8 @@ A writer puts a ready issue **First**: the dispatcher starts it before the rest, | ||
| 81 | 81 | |
| 82 | 82 | A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round. |
| 83 | 83 | |
| 84 | The terminal's On you pane leads with the board's strip, the week's asks as four numbers (`Asks, 7 days: 4 raised · 2 acted on · 1 dismissed · 1 went by themselves`, your own rows only, absent when all four are 0). It is read on load and on `r`, not on every paint; `W` opens `/asks` on the web. Both doors share `askMeasureWeek` in `src/core/ask_ledger.ts`. | |
| 85 | ||
| 84 | 86 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`. |
| 85 | 87 | Signed in, `/` paints its frame from memory and swaps in what a write has made stale; a viewer whose access changed is never shown the old copy, and the terminal home is unchanged: `docs/features/home-frame.md`. |
| 86 | 88 |
src/core/ask_ledger.ts +28
| @@ -117,3 +117,31 @@ export function recentEnds( | ||
| 117 | 117 | ) |
| 118 | 118 | .all(viewerId, repoId, limit); |
| 119 | 119 | } |
| 120 | ||
| 121 | /** The window the asks measure covers. */ | |
| 122 | export const ASKS_WINDOW_DAYS = 7; | |
| 123 | ||
| 124 | /** The last `ASKS_WINDOW_DAYS` days of this viewer's asks: the one window both doors read. */ | |
| 125 | export function askMeasureWeek(db: Database, viewerId: string, now = Date.now()): AskMeasure { | |
| 126 | return askMeasure(db, viewerId, new Date(now - ASKS_WINDOW_DAYS * 86_400_000).toISOString()); | |
| 127 | } | |
| 128 | ||
| 129 | /** The four figures of a measure with their labels, in the order they are said. */ | |
| 130 | export function asksMeasureParts(m: AskMeasure): readonly { n: number; label: string }[] { | |
| 131 | return [ | |
| 132 | { n: m.raised, label: "raised" }, | |
| 133 | { n: m.acted, label: "acted on" }, | |
| 134 | { n: m.dismissed, label: "dismissed" }, | |
| 135 | { n: m.expired, label: "went by themselves" }, | |
| 136 | ]; | |
| 137 | } | |
| 138 | ||
| 139 | /** True when nothing has asked and nothing has ended: the line is then not shown. */ | |
| 140 | export function asksMeasureEmpty(m: AskMeasure): boolean { | |
outdated
Fine, but the board and the terminal each also recompute |
||
| 141 | return m.raised + m.acted + m.dismissed + m.expired === 0; | |
| 142 | } | |
| 143 | ||
| 144 | /** "Asks, 7 days: 4 raised · 2 acted on · 1 dismissed · 1 went by themselves" */ | |
| 145 | export function asksMeasureWords(m: AskMeasure): string { | |
| 146 | return `Asks, ${ASKS_WINDOW_DAYS} days: ${asksMeasureParts(m).map((p) => `${p.n} ${p.label}`).join(" · ")}`; | |
| 147 | } |
src/tui/screens/dashboard.ts +20 −1
| @@ -53,6 +53,7 @@ import { | ||
| 53 | 53 | } from "../../actions/asks_bulk.ts"; |
| 54 | 54 | import { reviewSetOf, type ReviewSet } from "../../web/studio/review_set.ts"; |
| 55 | 55 | import { staleWords } from "../../flow/kpi_words.ts"; |
| 56 | import { askMeasureWeek, asksMeasureEmpty, asksMeasureWords } from "../../core/ask_ledger.ts"; | |
| 56 | 57 | import { mutedRepos, removeMutes } from "../../core/interactions.ts"; |
| 57 | 58 | import { chipsOf, KIND_CHIPS, setChip } from "../../core/feed_chips.ts"; |
| 58 | 59 | import type { Operation } from "../../core/fleet_ops.ts"; |
| @@ -172,6 +173,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 172 | 173 | let pins: Pin[] = []; |
| 173 | 174 | let pinned = new Set<string>(); |
| 174 | 175 | let onYou: OnYouItem[] = []; |
| 176 | /** The week's asks line, read on reload; drawing only reads it. */ | |
| 177 | let asksLine: string | undefined; | |
| 175 | 178 | /** Wave proposals that wait for this person's nod, listed after the rows above. */ |
| 176 | 179 | let waves: { proposal: Proposal; repo: Repo }[] = []; |
| 177 | 180 | /** Waves put aside with Later, which `B` brings back. */ |
| @@ -328,6 +331,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 328 | 331 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 329 | 332 | all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 330 | 333 | onYou = assignedToMe(); |
| 334 | asksLine = undefined; | |
| 335 | if (f.user !== undefined && f.user.kind === "user") { | |
| 336 | const m = askMeasureWeek(f.db, f.user.id); | |
| 337 | if (!asksMeasureEmpty(m)) asksLine = asksMeasureWords(m); | |
| 338 | } | |
| 331 | 339 | waitingSet = f.user === undefined ? undefined : reviewSetOf(f.db, f.user, new Date()); |
| 332 | 340 | waves = f.user === undefined ? [] : wavesFor(); |
| 333 | 341 | asideWaves = f.user === undefined ? [] : putAsideWaves(f.db, f.user); |
| @@ -1231,6 +1239,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1231 | 1239 | ctx.push(migrateScreen(f, ctx)); |
| 1232 | 1240 | return true; |
| 1233 | 1241 | } |
| 1242 | if (e.key === "W" && asksLine !== undefined) { | |
| 1243 | openOnWeb(f, ctx, "/asks", "Asks"); | |
| 1244 | return true; | |
| 1245 | } | |
| 1234 | 1246 | if (e.key === "E" && everyday?.asking === true) { |
| 1235 | 1247 | openOnWeb(f, ctx, "/settings", "Making your everyday account"); |
| 1236 | 1248 | return true; |
| @@ -1258,11 +1270,17 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1258 | 1270 | function drawOnYou(s: Surface, r: Rect, c: Tokens, set: IconSet): void { |
| 1259 | 1271 | const on = focus === "onyou"; |
| 1260 | 1272 | const footer = onYouFooter(); |
| 1261 | const inner = card(s, r, c, { | |
| 1273 | const frame = card(s, r, c, { | |
| 1262 | 1274 | title: `${icon("person", set)} ${TITLES.onyou}`, |
| 1263 | 1275 | focused: on, |
| 1264 | 1276 | ...(footer === undefined ? {} : { footer }), |
| 1265 | 1277 | }); |
| 1278 | // The week's asks lead the pane, muted; absent when all four are 0, so the list sits where it did. | |
| 1279 | let inner = frame; | |
| 1280 | if (asksLine !== undefined && frame.h > 1) { | |
| 1281 | s.text(frame.x, frame.y, truncate(asksLine, frame.w), style(c.mutedFg, c.bg), frame.x + frame.w); | |
| 1282 | inner = { ...frame, y: frame.y + 1, h: frame.h - 1 }; | |
| 1283 | } | |
| 1266 | 1284 | if (onYou.length + waves.length === 0) { |
| 1267 | 1285 | note(s, inner, c, [ |
| 1268 | 1286 | silenced.size > 0 |
| @@ -1332,6 +1350,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1332 | 1350 | : "1-4 chips · h hide author", |
| 1333 | 1351 | ); |
| 1334 | 1352 | } |
| 1353 | if (asksLine !== undefined) parts.push("W asks on the web"); | |
| 1335 | 1354 | if (marked.size > 0) parts.push(`${marked.size} marked`); |
| 1336 | 1355 | if (silenced.size > 0) |
| 1337 | 1356 | parts.push( |
src/web/pages/board.ts +6 −9
| @@ -18,7 +18,7 @@ import { usd } from "../../core/money.ts"; | ||
| 18 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 19 | 19 | import { sentinelNotice } from "../../crew/sentinel.ts"; |
| 20 | 20 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; |
| 21 | import { askMeasure } from "../../core/ask_ledger.ts"; | |
| 21 | import { ASKS_WINDOW_DAYS, askMeasureWeek, asksMeasureEmpty, asksMeasureParts } from "../../core/ask_ledger.ts"; | |
| 22 | 22 | import { declinedWaits, reviewWaits } from "../../actions/owner_waits.ts"; |
| 23 | 23 | import { tokenReturns } from "../../core/returns.ts"; |
| 24 | 24 | import { forgeVitals, needLine } from "../../actions/health.ts"; |
| @@ -447,9 +447,6 @@ function reviewNotes(ctx: Ctx, scope: BoardScope): Note[] { | ||
| 447 | 447 | |
| 448 | 448 | // ---- what asked you, and what came of it ---- |
| 449 | 449 | |
| 450 | /** The window the asks measure covers. */ | |
| 451 | export const ASKS_WINDOW_DAYS = 7; | |
| 452 | ||
| 453 | 450 | /** |
| 454 | 451 | * The viewer's asks over the last week, as four figures: raised, acted on, |
| 455 | 452 | * dismissed, and gone by themselves. Many dismissed against few acted on |
| @@ -459,12 +456,12 @@ export const ASKS_WINDOW_DAYS = 7; | ||
| 459 | 456 | function asksStrip(ctx: Ctx): Html | null { |
| 460 | 457 | const user = ctx.auth.user; |
| 461 | 458 | if (!user) return null; |
| 462 | const since = new Date(Date.now() - ASKS_WINDOW_DAYS * 86_400_000).toISOString(); | |
| 463 | const m = askMeasure(ctx.app.db, user.id, since); | |
| 464 | if (m.raised + m.acted + m.dismissed + m.expired === 0) return null; | |
| 459 | const m = askMeasureWeek(ctx.app.db, user.id); | |
| 460 | if (asksMeasureEmpty(m)) return null; | |
| 465 | 461 | return html`<a class="muted small" id="asks-measure" href="/asks" |
| 466 | >Asks, ${ASKS_WINDOW_DAYS} days: <b>${m.raised}</b> raised · <b>${m.acted}</b> acted | |
| 467 | on · <b>${m.dismissed}</b> dismissed · <b>${m.expired}</b> went by themselves</a | |
| 462 | >Asks, ${ASKS_WINDOW_DAYS} days: ${asksMeasureParts(m).map( | |
| 463 | (p, i) => html`${i === 0 ? "" : " · "}<b>${p.n}</b> ${p.label}`, | |
| 464 | )}</a | |
| 468 | 465 | >`; |
| 469 | 466 | } |
| 470 | 467 |
src/web/studio/asks.ts +2 −3
| @@ -22,7 +22,7 @@ import { issueTitles, proposalsFor, type Proposal } from "../../core/proposals.t | ||
| 22 | 22 | import { chipsOf, type Chips } from "../../core/feed_chips.ts"; |
| 23 | 23 | import { chipHides } from "../../feed/chips.ts"; |
| 24 | 24 | import { askDismissals, mutedRepos } from "../../core/interactions.ts"; |
| 25 | import { askMeasure, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts"; | |
| 25 | import { ASKS_WINDOW_DAYS, askMeasureWeek, syncAskLedger, type AskEnd } from "../../core/ask_ledger.ts"; | |
| 26 | 26 | import { |
| 27 | 27 | getIssueById, |
| 28 | 28 | labelsForIssues, |
| @@ -1145,14 +1145,13 @@ export function askSentence(count: number): string { | ||
| 1145 | 1145 | * and gone by themselves. `GET /api/v1/asks` and the MCP's `asks` read this. |
| 1146 | 1146 | */ |
| 1147 | 1147 | export function asksAnswer(ctx: Ctx, user: User, chips?: Chips) { |
| 1148 | const since = new Date(Date.now() - 7 * 86_400_000).toISOString(); | |
| 1149 | 1148 | const set = reviewSet(ctx, user); |
| 1150 | 1149 | return { |
| 1151 | 1150 | // The very figures Home's line and /review show: `count` is live asks plus the forge's needs; `stale` are older than 30 days. |
| 1152 | 1151 | waiting: { count: set.count, stale: set.stale.length }, |
| 1153 | 1152 | rows: askLinesFor(ctx.app.db, user, chips), |
| 1154 | 1153 | forge: forgeNeedsFor(ctx, user).map((d) => needLine(d)), |
| 1155 | measure: { days: 7, ...askMeasure(ctx.app.db, user.id, since) }, | |
| 1154 | measure: { days: ASKS_WINDOW_DAYS, ...askMeasureWeek(ctx.app.db, user.id) }, | |
outdated
|
||
| 1156 | 1155 | }; |
| 1157 | 1156 | } |
| 1158 | 1157 |
test/asks-rank.test.ts +3 −3
| @@ -327,9 +327,9 @@ describe("asks that earn their place", () => { | ||
| 327 | 327 | check(security, "failed"); |
| 328 | 328 | recordAskLedger(app.db); |
| 329 | 329 | const board = await page("/board"); |
| 330 | expect(board).toContain('id="asks-measure"'); | |
| 331 | expect(board).toContain( | |
| 332 | "<b>6</b> raised · <b>1</b> acted on · <b>1</b> dismissed · <b>1</b> went by themselves", | |
| 330 | // The whole strip, so a drift in its markup fails here: the link, the window, the bold figures and their separators. | |
| 331 | expect(board).toMatch( | |
| 332 | /<a class="muted small" id="asks-measure" href="\/asks"\s*>Asks, 7 days: <b>6<\/b> raised · <b>1<\/b> acted on · <b>1<\/b> dismissed · <b>1<\/b> went by themselves<\/a\s*>/, | |
| 333 | 333 | ); |
| 334 | 334 | const fromMcp = await mcpAsks(); |
| 335 | 335 | expect(fromMcp.measure.raised).toBe(6); |
test/helpers/chrome.ts +20 −6
| @@ -28,6 +28,19 @@ interface CdpMessage { | ||
| 28 | 28 | error?: { message: string }; |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | /** | |
| 32 | * `promise`, or a rejection naming `what` after `ms`: every wait on chromium | |
| 33 | * is bounded, so a stalled browser fails the test and the `finally` that | |
| 34 | * kills it runs, instead of hanging the shard until it is killed. | |
| 35 | */ | |
| 36 | function within<T>(ms: number, what: string, promise: Promise<T>): Promise<T> { | |
| 37 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 38 | const late = new Promise<never>((_, reject) => { | |
| 39 | timer = setTimeout(() => reject(new Error(`chromium: ${what} took longer than ${ms} ms`)), ms); | |
| 40 | }); | |
| 41 | return Promise.race([promise, late]).finally(() => clearTimeout(timer)); | |
| 42 | } | |
| 43 | ||
| 31 | 44 | /** A CDP session over one page's own devtools socket. */ |
| 32 | 45 | class Cdp { |
| 33 | 46 | private nextId = 1; |
| @@ -51,13 +64,13 @@ class Cdp { | ||
| 51 | 64 | |
| 52 | 65 | send(method: string, params: object = {}): Promise<any> { |
| 53 | 66 | const id = this.nextId++; |
| 54 | return new Promise((resolve, reject) => { | |
| 67 | return within(5_000, method, new Promise((resolve, reject) => { | |
| 55 | 68 | this.pending.set(id, (msg) => { |
| 56 | 69 | if (msg.error !== undefined) reject(new Error(msg.error.message)); |
| 57 | 70 | else resolve(msg.result); |
| 58 | 71 | }); |
| 59 | 72 | this.ws.send(JSON.stringify({ id, method, params })); |
| 60 | }); | |
| 73 | })); | |
| 61 | 74 | } |
| 62 | 75 | |
| 63 | 76 | /** Resolves the next time `method` fires as an event — a one-shot wait. */ |
| @@ -73,7 +86,8 @@ async function findPageTarget( | ||
| 73 | 86 | const httpBase = debuggerWsUrl |
| 74 | 87 | .replace(/^ws:/, "http:") |
| 75 | 88 | .replace(/\/devtools\/browser\/.*$/, ""); |
| 76 | const list = (await (await fetch(`${httpBase}/json/list`)).json()) as { | |
| 89 | const res = await fetch(`${httpBase}/json/list`, { signal: AbortSignal.timeout(5_000) }); | |
| 90 | const list = (await res.json()) as { | |
| 77 | 91 | type: string; |
| 78 | 92 | webSocketDebuggerUrl: string; |
| 79 | 93 | }[]; |
| @@ -141,12 +155,12 @@ export async function evaluateLoaded( | ||
| 141 | 155 | ]); |
| 142 | 156 | const page = await findPageTarget(browserWsUrl); |
| 143 | 157 | ws = new WebSocket(page.webSocketDebuggerUrl); |
| 144 | await new Promise<void>((resolve, reject) => { | |
| 158 | await within(5_000, "opening the devtools socket", new Promise<void>((resolve, reject) => { | |
| 145 | 159 | ws!.addEventListener("open", () => resolve()); |
| 146 | 160 | ws!.addEventListener("error", () => |
| 147 | 161 | reject(new Error("chromium's devtools socket refused the connection")), |
| 148 | 162 | ); |
| 149 | }); | |
| 163 | })); | |
| 150 | 164 | const cdp = new Cdp(ws); |
| 151 | 165 | await cdp.send("Page.enable"); |
| 152 | 166 | await cdp.send("Network.enable"); |
| @@ -171,7 +185,7 @@ export async function evaluateLoaded( | ||
| 171 | 185 | }); |
| 172 | 186 | const loaded = cdp.once("Page.loadEventFired"); |
| 173 | 187 | await cdp.send("Page.navigate", { url }); |
| 174 | await loaded; | |
| 188 | await within(10_000, `loading ${url}`, loaded); | |
| 175 | 189 | // The load event can be the blank start page's, fired late on a busy |
| 176 | 190 | // machine: the page measured is the navigated one, loaded, or none. |
| 177 | 191 | const deadline = Date.now() + 10_000; |
test/tui-yours-asks.test.ts added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | /** | |
| 2 | * The terminal's On you leads with the week's asks as four numbers, as the | |
| 3 | * board's strip does (#8055): the viewer's own rows only, absent when all | |
| 4 | * four are 0. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { signUp } from "../src/actions/users.ts"; | |
| 13 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 14 | import { ASKS_WINDOW_DAYS, askMeasureWeek, asksMeasureWords } from "../src/core/ask_ledger.ts"; | |
| 15 | import type { User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 21 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | describe("terminal On you: the week's asks", () => { | |
| 26 | let dir: string; | |
| 27 | let forge: Forge; | |
| 28 | let alice: User; | |
| 29 | let bob: User; | |
| 30 | let repoId = ""; | |
| 31 | let n = 0; | |
| 32 | ||
| 33 | const row = (viewer: User, how?: "acted" | "dismissed" | "expired", ageMs = 0) => { | |
| 34 | const now = new Date(Date.now() - ageMs).toISOString(); | |
| 35 | forge.db.run( | |
| 36 | "INSERT INTO ask_ledger (id, viewer_id, ask_id, repo_id, kind, raised_at, ended_at, ended_how) VALUES (?,?,?,?,?,?,?,?)", | |
| 37 | [`r${++n}`, viewer.id, `a${n}`, repoId, "review", now, how === undefined ? null : now, how ?? null], | |
| 38 | ); | |
| 39 | }; | |
| 40 | ||
| 41 | const open = async (user: User | undefined, rows = 32) => { | |
| 42 | const { app } = createForgeApp( | |
| 43 | forge, | |
| 44 | { sshUsername: user?.username ?? "t", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", ...(user ? { user } : {}) }, | |
| 45 | { terminal: headlessTerminal(200, rows), exitHooks: false }, | |
| 46 | ); | |
| 47 | app.start(); | |
| 48 | await settleApp(app); | |
| 49 | const key = async (k: string) => { | |
| 50 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 51 | await settleApp(app); | |
| 52 | }; | |
| 53 | return { app, key, text: () => app.screen.toText() }; | |
| 54 | }; | |
| 55 | ||
| 56 | const screen = async (user: User | undefined, rows = 32) => { | |
| 57 | const t = await open(user, rows); | |
| 58 | const text = t.text(); | |
| 59 | t.app.stop(); | |
| 60 | return text; | |
| 61 | }; | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dir = await mkdtemp(join(tmpdir(), "forge-yours-asks-")); | |
| 65 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 66 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 67 | const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "another-long-passphrase" }, { slot: fixtureSlot(forge) }); | |
| 68 | if (!a.ok || !b.ok) throw new Error("signup failed"); | |
| 69 | alice = a.value; | |
| 70 | bob = b.value; | |
| 71 | const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" }); | |
| 72 | if (!r.ok) throw new Error("repo failed"); | |
| 73 | repoId = r.value.id; | |
| 74 | }); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | forge.close(); | |
| 78 | await rm(dir, { recursive: true, force: true }); | |
| 79 | }); | |
| 80 | ||
| 81 | test("an empty ledger shows no line", async () => { | |
| 82 | expect(await screen(alice)).not.toContain("Asks, "); | |
| 83 | }); | |
| 84 | ||
| 85 | test("askMeasureWeek counts a row just inside the window and not one just outside it", () => { | |
| 86 | const now = Date.parse("2026-01-10T12:00:00.000Z"); | |
| 87 | const day = 86_400_000; | |
outdated
This test builds its own |
||
| 88 | const at = (ms: number) => new Date(ms).toISOString(); | |
| 89 | const put = (id: string, ms: number) => | |
| 90 | forge.db.run( | |
| 91 | "INSERT INTO ask_ledger (id, viewer_id, ask_id, repo_id, kind, raised_at, ended_at, ended_how) VALUES (?,?,?,?,?,?,?,?)", | |
| 92 | [id, bob.id, `a-${id}`, repoId, "review", at(ms), at(ms), "dismissed"], | |
| 93 | ); | |
| 94 | put("edge-in", now - ASKS_WINDOW_DAYS * day + 1000); | |
| 95 | put("edge-out", now - ASKS_WINDOW_DAYS * day - 1000); | |
| 96 | expect(askMeasureWeek(forge.db, bob.id, now)).toEqual({ raised: 1, acted: 0, dismissed: 1, expired: 0 }); | |
| 97 | forge.db.run("DELETE FROM ask_ledger WHERE id IN ('edge-in', 'edge-out')"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("the four numbers are the week's, never older and never another viewer's", async () => { | |
| 101 | row(alice, "acted"); | |
| 102 | row(alice, "acted"); | |
| 103 | row(alice, "dismissed"); | |
| 104 | row(alice, "expired"); | |
| 105 | // Eight days old: outside the window, so the line leaves it out. | |
| 106 | row(alice, "acted", 8 * 86_400_000); | |
| 107 | row(bob, "dismissed"); | |
| 108 | row(bob); | |
| 109 | const m = askMeasureWeek(forge.db, alice.id); | |
| 110 | expect(m).toEqual({ raised: 4, acted: 2, dismissed: 1, expired: 1 }); | |
| 111 | expect(asksMeasureWords(m)).toBe( | |
| 112 | "Asks, 7 days: 4 raised · 2 acted on · 1 dismissed · 1 went by themselves", | |
| 113 | ); | |
| 114 | expect(await screen(alice)).toContain(asksMeasureWords(m)); | |
| 115 | }); | |
| 116 | ||
| 117 | test("drawing reads the ledger on load, not on every frame: a later row waits for r", async () => { | |
| 118 | const t = await open(alice); | |
| 119 | try { | |
| 120 | const before = t.text(); | |
| 121 | expect(before).toContain("4 raised"); | |
| 122 | row(alice, "acted"); | |
| 123 | t.app.frame(); | |
| 124 | t.app.screen.toText(); | |
| 125 | expect(t.text()).toContain("4 raised"); | |
| 126 | await t.key("r"); | |
| 127 | expect(t.text()).toContain("5 raised"); | |
| 128 | } finally { | |
| 129 | t.app.stop(); | |
| 130 | } | |
| 131 | }); | |
| 132 | ||
| 133 | test("W offers the asks page on the web, and says so in the footer", async () => { | |
| 134 | const t = await open(alice); | |
| 135 | try { | |
| 136 | expect(t.text()).toContain("W asks on the web"); | |
| 137 | await t.key("W"); | |
| 138 | expect(t.text()).toContain("Asks is on the web at /asks"); | |
| 139 | } finally { | |
| 140 | t.app.stop(); | |
| 141 | } | |
| 142 | }); | |
| 143 | ||
| 144 | test("a signed-out viewer, an agent and a three-row terminal get no line and no throw", async () => { | |
| 145 | expect(await screen(undefined)).not.toContain("Asks, "); | |
| 146 | expect(await screen({ ...alice, kind: "agent" })).not.toContain("Asks, "); | |
| 147 | expect(await screen(alice, 3)).not.toContain("Asks, "); | |
| 148 | }); | |
| 149 | }); |
new above
offline
askMeasureWeekneeds a direct test of the window edge (just inside and just outside 7 days).