Public: anyone can read this; the crew can change it.
On /crew, a member's Returned count opens the pulls returned to them this week in place #10814
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10788-on-crew-a-member-s-returned-count-opens → main 5 commits fork 4 threads · 4 unresolved
docs/features/crew-week-returned.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # The crew's week shows which pulls came back (#10788) | |
| 2 | ||
| 3 | On the crew week card, a member's **Returned** count above zero opens onto the pulls returned to them in the last 7 days. Zero stays a plain `0`. | |
| 4 | ||
| 5 | - **On the web:** Settings → Crew → *This week*. The number links to a fold in a full-width row under the member's ("N returned to …"); open it for each pull: repository (muted), `#number` (the one link), title and the first line of the change request. No JavaScript is needed, and the numeric column keeps only the count, so the table holds at 360px. | |
| 6 | - **In the terminal:** the *Crew this week* screen (palette: "Crew this week") lists the same pulls under the member's row. | |
| 7 | - **Both doors are admin only**, as the card itself is; anyone else sees no figures. | |
| 8 | - **Cap:** 10 pulls per member, newest first, then a muted "and N more". The list is one entry per change request, so it adds up to the count. | |
| 9 | ||
| 10 | Read by `weekReturns` in `src/crew/week_card.ts`: one query for the whole page. The API and MCP week answers are unchanged. |
src/crew/week_card.ts +46
| @@ -59,3 +59,49 @@ export function weekCard(db: Database, now: Date = new Date()): WeekLine[] { | ||
| 59 | 59 | return { username: u.username, merged: m, returned: r, spentUsd: s, idle: m === 0 && r === 0 && s === 0 }; |
| 60 | 60 | }); |
| 61 | 61 | } |
| 62 | ||
| 63 | export interface ReturnedPull { | |
| 64 | readonly repo: string; | |
| 65 | readonly number: number; | |
| 66 | readonly title: string; | |
| 67 | /** The first line of the change request, clipped; empty when the review had no words. */ | |
| 68 | readonly why: string; | |
| 69 | } | |
| 70 | ||
| 71 | /** Most pulls listed per member; the rest are counted, not listed. */ | |
| 72 | export const RETURNED_CAP = 10; | |
| 73 | ||
| 74 | /** | |
| 75 | * The pulls behind each member's Returned count, newest first, at most | |
| 76 | * RETURNED_CAP per member (by username). One read for the whole page; it | |
| 77 | * counts the same reviews as `weekCard`, one entry per change request. | |
| 78 | */ | |
| 79 | export function weekReturns(db: Database, now: Date = new Date()): Map<string, ReturnedPull[]> { | |
| 80 | const since = new Date(now.getTime() - WEEK_DAYS * 86_400_000).toISOString(); | |
| 81 | const rows = db | |
| 82 | .query<{ username: string; repo: string; number: number; title: string; body: string }, [string, number]>( | |
| 83 | `SELECT username, repo, number, title, body FROM ( | |
| 84 | SELECT author.username AS username, owner.username || '/' || repo.name AS repo, | |
| 85 | issue.number AS number, issue.title AS title, review.body AS body, | |
| 86 | ROW_NUMBER() OVER (PARTITION BY issue.author_id ORDER BY review.created_at DESC, review.id) AS rn | |
| 87 | FROM review | |
| 88 | JOIN issue ON issue.id = review.issue_id | |
| 89 | JOIN pull ON pull.issue_id = issue.id | |
| 90 | JOIN repo ON repo.id = issue.repo_id | |
| 91 | JOIN user AS owner ON owner.id = repo.owner_id | |
| 92 | JOIN user AS author ON author.id = issue.author_id | |
| 93 | WHERE review.state = 'changes_requested' AND review.author_id <> issue.author_id | |
| 94 | AND review.created_at >= ?1 AND author.is_agent = 1 | |
| 95 | ) WHERE rn <= ?2 ORDER BY username, rn`, | |
| 96 | ) | |
| 97 | .all(since, RETURNED_CAP); | |
| 98 | const out = new Map<string, ReturnedPull[]>(); | |
| 99 | for (const r of rows) { | |
| 100 | const first = (r.body.split("\n").find((l) => l.trim() !== "") ?? "").trim(); | |
| 101 | const why = first.length > 140 ? `${first.slice(0, 139)}…` : first; | |
| 102 | const list = out.get(r.username) ?? []; | |
| 103 | list.push({ repo: r.repo, number: r.number, title: r.title, why }); | |
| 104 | out.set(r.username, list); | |
| 105 | } | |
| 106 | return out; | |
| 107 | } |
src/tui/screens/weekcard.ts +17 −7
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * admin; anyone else sees one sentence and no figures. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts"; | |
| 8 | import { weekCard, weekReturns, WEEK_DAYS, type ReturnedPull, type WeekLine } from "../../crew/week_card.ts"; | |
| 9 | 9 | import type { Ctx, Screen } from "../app/app.ts"; |
| 10 | 10 | import { binding } from "../app/keys.ts"; |
| 11 | 11 | import { style } from "../term/style.ts"; |
| @@ -16,17 +16,27 @@ import { ListState, drawList, type ListRow } from "./common.ts"; | ||
| 16 | 16 | import type { PaletteEntry } from "./entries.ts"; |
| 17 | 17 | |
| 18 | 18 | /** One row per member; one with nothing in the week reads "idle", not zeros. */ |
| 19 | export function weekRows(lines: readonly WeekLine[]): ListRow[] { | |
| 20 | return lines.map((l) => ({ | |
| 21 | title: l.username, | |
| 22 | secondary: l.idle ? "idle" : `merged ${l.merged} · returned ${l.returned} · spent $${l.spentUsd.toFixed(2)}`, | |
| 23 | })); | |
| 19 | export function weekRows( | |
| 20 | lines: readonly WeekLine[], | |
| 21 | returns: ReadonlyMap<string, readonly ReturnedPull[]> = new Map(), | |
| 22 | ): ListRow[] { | |
| 23 | return lines.map((l) => { | |
| 24 | const row = { | |
| 25 | title: l.username, | |
| 26 | secondary: l.idle ? "idle" : `merged ${l.merged} · returned ${l.returned} · spent $${l.spentUsd.toFixed(2)}`, | |
| 27 | }; | |
| 28 | const pulls = returns.get(l.username) ?? []; | |
| 29 | if (l.returned === 0 || pulls.length === 0) return row; | |
| 30 | const more = l.returned - pulls.length; | |
| 31 | const below = pulls.map((p) => `${p.repo}#${p.number} ${p.title}${p.why ? ` — ${p.why}` : ""}`); | |
| 32 | return { ...row, below: more > 0 ? [...below, `and ${more} more`] : below }; | |
| 33 | }); | |
| 24 | 34 | } |
| 25 | 35 | |
| 26 | 36 | export function weekCardScreen(f: Session, ctx: Ctx): Screen { |
| 27 | 37 | const state = new ListState(); |
| 28 | 38 | const allowed = f.user?.isAdmin === true; |
| 29 | const rows = allowed ? weekRows(weekCard(f.db)) : []; | |
| 39 | const rows = allowed ? weekRows(weekCard(f.db), weekReturns(f.db)) : []; | |
| 30 | 40 | return { |
| 31 | 41 | title: "Crew this week", |
| 32 | 42 | keys: [binding(["j", "k"], "move cursor")], |
src/web/pages/crew.ts +34 −5
| @@ -37,7 +37,7 @@ import { | ||
| 37 | 37 | type SplitScorecard, |
| 38 | 38 | } from "../../core/scorecard.ts"; |
| 39 | 39 | import { keeperDays, type KeeperDay } from "../../flow/keeper_rate.ts"; |
| 40 | import { weekCard, WEEK_DAYS, type WeekLine } from "../../crew/week_card.ts"; | |
| 40 | import { weekCard, weekReturns, WEEK_DAYS, type ReturnedPull, type WeekLine } from "../../crew/week_card.ts"; | |
| 41 | 41 | import { knobsCard } from "./knobs.ts"; |
| 42 | 42 | import { decideChange, readReview } from "../../actions/model_review.ts"; |
| 43 | 43 | import { wastedDays, type WastedDay } from "../../core/wasted_days.ts"; |
| @@ -58,6 +58,7 @@ import { flashTo, page } from "../layout.ts"; | ||
| 58 | 58 | import { |
| 59 | 59 | btn, |
| 60 | 60 | confirmAction, |
| 61 | disclosure, | |
| 61 | 62 | icon, |
| 62 | 63 | time, |
| 63 | 64 | field as uiField, |
| @@ -391,8 +392,35 @@ function scorecardCard( | ||
| 391 | 392 | ); |
| 392 | 393 | } |
| 393 | 394 | |
| 395 | /** A Returned count above zero links to its fold below; zero stays a bare 0. Only the count sits in the numeric cell. */ | |
| 396 | function returnedCount(l: WeekLine, pulls: readonly ReturnedPull[]): Html | number { | |
outdated
Principle 7 (rows, not cramped cells) and the layout: this puts a multi-line list (repo, title, reason) inside |
||
| 397 | if (l.returned === 0 || pulls.length === 0) return l.returned; | |
| 398 | return html`<a href="#returned-${l.username}">${l.returned}</a>`; | |
| 399 | } | |
| 400 | ||
| 401 | /** The pulls that came back and why, folded in a full-width row under the member's: one link per pull. */ | |
| 402 | function returnedRow(l: WeekLine, pulls: readonly ReturnedPull[]): Html | null { | |
| 403 | if (l.returned === 0 || pulls.length === 0) return null; | |
| 404 | const more = l.returned - pulls.length; | |
| 405 | return html`<tr data-returned="${l.username}"> | |
| 406 | <td colspan="4">${disclosure( | |
| 407 | `${l.returned} returned to ${l.username}`, | |
| 408 | html`<ul class="list"> | |
outdated
Link target is the first repo only, not the member's other returned pulls. Use plain text or a real destination. |
||
| 409 | ${pulls.map( | |
| 410 | (p) => html`<li> | |
| 411 | <span class="muted">${p.repo}</span> | |
| 412 | <a href="/${p.repo}/pulls/${p.number}">#${p.number}</a> ${p.title}${p.why ? html` <span class="muted">— ${p.why}</span>` : null} | |
| 413 | </li>`, | |
| 414 | )} | |
| 415 | ${more > 0 ? html`<li class="muted">and ${more} more</li>` : null} | |
| 416 | </ul>`, | |
| 417 | { id: `returned-${l.username}`, small: true }, | |
| 418 | )}</td> | |
| 419 | </tr>`; | |
| 420 | } | |
| 421 | ||
| 394 | 422 | /** The week at a glance: one dense row per member; a member with nothing reads "idle". */ |
| 395 | function weekCardPanel(lines: readonly WeekLine[]): Html { | |
| 423 | function weekCardPanel(lines: readonly WeekLine[], returns: ReadonlyMap<string, readonly ReturnedPull[]>): Html { | |
| 396 | 424 | return panel( |
| 397 | 425 | html`<div class="table-scroll"> |
| 398 | 426 | <table class="table" id="crew-week" data-sticky-lead> |
| @@ -409,9 +437,10 @@ function weekCardPanel(lines: readonly WeekLine[]): Html { | ||
| 409 | 437 | : html`<tr> |
| 410 | 438 | <td>${userLink(l.username, { avatar: false })}</td> |
| 411 | 439 | <td class="num">${l.merged}</td> |
| 412 | <td class="num">${l.returned}</td> | |
| 440 | <td class="num">${returnedCount(l, returns.get(l.username) ?? [])}</td> | |
| 413 | 441 | <td class="num">$${l.spentUsd.toFixed(2)}</td> |
| 414 | </tr>`, | |
| 442 | </tr> | |
| 443 | ${returnedRow(l, returns.get(l.username) ?? [])}`, | |
| 415 | 444 | )} |
| 416 | 445 | </tbody> |
| 417 | 446 | </table> |
| @@ -720,7 +749,7 @@ function crewView( | ||
| 720 | 749 | </div>`, |
| 721 | 750 | { title: "Agents", id: "crew-agents" }, |
| 722 | 751 | )} |
| 723 | ${when(agents.length > 0 && ctx.auth.user?.isAdmin === true, () => weekCardPanel(weekCard(ctx.app.db)))} | |
| 752 | ${when(agents.length > 0 && ctx.auth.user?.isAdmin === true, () => weekCardPanel(weekCard(ctx.app.db), weekReturns(ctx.app.db)))} | |
| 724 | 753 | ${when(agents.length > 0, () => |
| 725 | 754 | scorecardCard( |
| 726 | 755 | agents, |
test/crew-week-card.test.ts +64 −2
| @@ -15,7 +15,7 @@ import { createRepo } from "../src/actions/repos.ts"; | ||
| 15 | 15 | import { createSession } from "../src/core/sessions.ts"; |
| 16 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 17 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| 18 | import { weekCard } from "../src/crew/week_card.ts"; | |
| 18 | import { weekCard, weekReturns, RETURNED_CAP } from "../src/crew/week_card.ts"; | |
| 19 | 19 | import { serve, type Servers } from "../src/http/server.ts"; |
| 20 | 20 | import { seedAdmin } from "./helpers/admin.ts"; |
| 21 | 21 | import { statements } from "./helpers/statements.ts"; |
| @@ -117,7 +117,7 @@ test("the crew page: a dense row for the busy one, the word idle for the rest", | ||
| 117 | 117 | expect(res.status).toBe(200); |
| 118 | 118 | const page = await res.text(); |
| 119 | 119 | const table = page.slice(page.indexOf('id="crew-week"'), page.indexOf("</table>", page.indexOf('id="crew-week"'))); |
| 120 | const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1); | |
| 120 | const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1).filter((r) => !r.startsWith("<tr data-returned")); | |
| 121 | 121 | expect(rows).toHaveLength(4); |
| 122 | 122 | expect(rows[3]).toMatch(/smith[\s\S]*>2<[\s\S]*>1<[\s\S]*\$1\.25/); |
| 123 | 123 | expect(rows[3]).not.toContain("idle"); |
| @@ -126,6 +126,68 @@ test("the crew page: a dense row for the busy one, the word idle for the rest", | ||
| 126 | 126 | expect(rows[1]).toContain("idle"); |
| 127 | 127 | }); |
| 128 | 128 | |
| 129 | test("a Returned count opens onto the pulls and why; zero stays a bare 0", async () => { | |
| 130 | const smith = app.db.query<{ id: string }, []>(`SELECT id FROM user WHERE username = 'smith'`).get()!; | |
| 131 | const i = createIssue(app.db, { repoId, authorId: smith.id, title: "Second <b>pull</b>", kind: "pull" }); | |
| 132 | if (!i.ok) throw new Error(String(i.error)); | |
| 133 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'h2', 'a', 'b')`).run(i.value.id); | |
| 134 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, body, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4, ?5)`) | |
| 135 | .run(crypto.randomUUID(), i.value.id, reviewer.id, "Needs <script>x</script>\nsecond line", daysAgo(0.5)); | |
| 136 | const map = weekReturns(app.db); | |
| 137 | expect(map.get("smith")!.length).toBe(2); | |
| 138 | expect(map.has("quiet")).toBe(false); | |
| 139 | const page = await (await fetch(`${servers.url}/settings/crew`, { headers: { cookie } })).text(); | |
| 140 | const table = page.slice(page.indexOf('id="crew-week"'), page.indexOf("</table>", page.indexOf('id="crew-week"'))); | |
| 141 | const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/g)!.slice(1); | |
| 142 | const rowOf = (name: string) => rows.find((r) => r.includes(`>${name}<`))!; | |
| 143 | const countRow = rowOf("smith"); | |
| 144 | const cell = countRow.match(/<td class="num">[\s\S]*?<\/td>/g)![1]!; | |
| 145 | expect(cell).toMatch(/^<td class="num"><a href="#returned-smith">\d+<\/a><\/td>$/); | |
| 146 | expect(countRow).not.toContain("<details"); | |
outdated
rows[3] is positional; select the row by username. Also test the cap and the overflow line. |
||
| 147 | const smithRow = rows.find((r) => r.includes('data-returned="smith"'))!; | |
| 148 | expect(smithRow).toContain('<td colspan="4">'); | |
| 149 | expect(smithRow).toContain('<details id="returned-smith"'); | |
| 150 | expect(smithRow).not.toContain('class="num"'); | |
| 151 | expect(smithRow).not.toContain('href="/ada/hello/pulls"'); | |
| 152 | expect(rows.some((r) => r.includes('data-returned="quiet"'))).toBe(false); | |
| 153 | expect(smithRow).toContain("ada/hello"); | |
| 154 | expect(smithRow).toContain("Second <b>pull</b>"); | |
| 155 | expect(smithRow).toContain("Needs <script>x</script>"); | |
| 156 | expect(smithRow).not.toContain("second line"); | |
| 157 | expect(smithRow.match(/\/ada\/hello\/pulls\/\d+/g)!.length).toBe(2); | |
| 158 | expect(rowOf("quiet")).not.toContain("<details"); | |
| 159 | expect(smithRow).not.toContain("more</li>"); | |
| 160 | app.db.query("DELETE FROM review WHERE issue_id = ?1").run(i.value.id); | |
| 161 | app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(i.value.id); | |
| 162 | }); | |
| 163 | ||
| 164 | test("past the cap the list stops at RETURNED_CAP and says and N more, on the page and in the terminal", async () => { | |
| 165 | const smith = app.db.query<{ id: string }, []>(`SELECT id FROM user WHERE username = 'smith'`).get()!; | |
| 166 | const made: string[] = []; | |
| 167 | for (let k = 0; k < RETURNED_CAP + 2; k++) { | |
| 168 | const i = createIssue(app.db, { repoId, authorId: smith.id, title: `bulk ${k}`, kind: "pull" }); | |
| 169 | if (!i.ok) throw new Error(String(i.error)); | |
| 170 | made.push(i.value.id); | |
| 171 | app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', 'hb', 'a', 'b')`).run(i.value.id); | |
| 172 | app.db.query(`INSERT INTO review (id, issue_id, author_id, state, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4)`) | |
| 173 | .run(crypto.randomUUID(), i.value.id, reviewer.id, daysAgo(0.1)); | |
| 174 | } | |
| 175 | const line = weekCard(app.db).find((l) => l.username === "smith")!; | |
| 176 | expect(weekReturns(app.db).get("smith")!.length).toBe(RETURNED_CAP); | |
| 177 | const more = line.returned - RETURNED_CAP; | |
| 178 | expect(more).toBeGreaterThan(0); | |
| 179 | const page = await (await fetch(`${servers.url}/settings/crew`, { headers: { cookie } })).text(); | |
| 180 | expect(page).toContain(`and ${more} more</li>`); | |
| 181 | const { weekRows } = await import("../src/tui/screens/weekcard.ts"); | |
| 182 | const row = weekRows(weekCard(app.db), weekReturns(app.db)).find((r) => r.title === "smith")!; | |
| 183 | expect(row.below!.length).toBe(RETURNED_CAP + 1); | |
| 184 | expect(row.below!.at(-1)).toBe(`and ${more} more`); | |
| 185 | for (const id of made) { | |
| 186 | app.db.query("DELETE FROM review WHERE issue_id = ?1").run(id); | |
| 187 | app.db.query("DELETE FROM pull WHERE issue_id = ?1").run(id); | |
| 188 | } | |
| 189 | }); | |
| 190 | ||
| 129 | 191 | test("the API and the MCP crew tool answer the same numbers", async () => { |
| 130 | 192 | const api = await fetch(`${servers.url}/api/v1/crew/week`, { headers: { authorization: `Bearer ${token}` } }); |
| 131 | 193 | expect(api.status).toBe(200); |
test/keys-contribute.test.ts +2 −2
| @@ -52,8 +52,8 @@ test("each welcomed issue is a row: j/k focus it, Enter opens it, Esc on the iss | ||
| 52 | 52 | } |
| 53 | 53 | const html = await page(); |
| 54 | 54 | const hrefs = await navHrefs(html); |
| 55 | expect(hrefs.sort()).toEqual(["/alice/garden/issues/1", "/alice/garden/issues/2"]); | |
| 56 | await expectListKeys({ path: "/contribute", html, hrefs: await navHrefs(html), opened: { path: hrefs[0]!, body: "<main>issue</main>" } }); | |
| 55 | expect([...hrefs].sort()).toEqual(["/alice/garden/issues/1", "/alice/garden/issues/2"]); | |
| 56 | await expectListKeys({ path: "/contribute", html, hrefs, opened: { path: hrefs[0]!, body: "<main>issue</main>" } }); | |
| 57 | 57 | const p = await shellPage("/contribute", html); |
| 58 | 58 | p.key("j"); |
| 59 | 59 | expect(p.doc.activeElement.getAttribute("tabindex")).toBe("-1"); |
The terminal week screen does not read this; add it there (both doors) with docs/features/ how-to.