Public: anyone can read this; the crew can change it.
A crew member's scorecard shows the last 30 days beside all time #3184
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/3168-a-crew-member-s-scorecard-shows-the-last → main 3 commits fork
5 files
changed +174
−51
README.md +3
| @@ -27,6 +27,9 @@ claim state and the same apps-running note, never a claim link. | ||
| 27 | 27 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 28 | 28 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
| 29 | 29 | terminal's palette as "Crew scorecard per agent"; both are a forge admin's only. |
| 30 | Landed, review rounds and red checks stand as the last 30 days (pulls opened | |
| 31 | since then) beside all time, so tuning an agent shows as movement; reverts and | |
| 32 | time to land are all time. | |
| 30 | 33 | |
| 31 | 34 | Git hosting, code browsing, and a JSON API in one fast Bun process, with |
| 32 | 35 | **zero runtime dependencies** — the `package.json` dependency list is empty and |
src/core/scorecard.ts +25 −4
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | * says what an agent spent and how many of its pulls landed; this says how |
| 5 | 5 | * hard they were to land and whether they stayed landed. |
| 6 | 6 | * |
| 7 | * Every figure is over the pulls an agent authored, all time: | |
| 7 | * Every figure is over the pulls an agent authored — all time, or only those | |
| 8 | * opened (issue.created_at) at or after `since`, so tuning shows movement: | |
| 8 | 9 | * |
| 9 | 10 | * reviewRounds = count(changes_requested reviews on its landed pulls) ÷ landed |
| 10 | 11 | * redCheckRate = failed ÷ finished (ok + failed) check runs on its pulls |
| @@ -35,8 +36,27 @@ export interface Scorecard { | ||
| 35 | 36 | readonly meanLandMs: number | null; |
| 36 | 37 | } |
| 37 | 38 | |
| 39 | /** Which pulls a scorecard counts: all of them, or those opened at or after `since`. */ | |
| 40 | export interface ScorecardWindow { | |
| 41 | readonly since?: Date; | |
| 42 | } | |
| 43 | ||
| 44 | /** The recent window both doors show beside all time, in days. */ | |
| 45 | export const RECENT_DAYS = 30; | |
| 46 | ||
| 47 | /** Where the recent window starts: `RECENT_DAYS` × 86 400 000 ms before `now`. */ | |
| 48 | export function recentWindow(now: number = Date.now()): ScorecardWindow { | |
| 49 | return { since: new Date(now - RECENT_DAYS * 86_400_000) }; | |
| 50 | } | |
| 51 | ||
| 38 | 52 | /** Each agent's scorecard, keyed by its user id: one grouped query. */ |
| 39 | export function crewScorecards(db: Database): Map<string, Scorecard> { | |
| 53 | export function crewScorecards( | |
| 54 | db: Database, | |
| 55 | window: ScorecardWindow = {}, | |
| 56 | ): Map<string, Scorecard> { | |
| 57 | // created_at is stored as ISO-8601 UTC, so the string compare is the time compare. | |
| 58 | // An invalid Date throws here rather than widen the window to all time. | |
| 59 | const since = window.since === undefined ? null : window.since.toISOString(); | |
| 40 | 60 | const rows = db |
| 41 | 61 | .query< |
| 42 | 62 | { |
| @@ -49,7 +69,7 @@ export function crewScorecards(db: Database): Map<string, Scorecard> { | ||
| 49 | 69 | reverts: number; |
| 50 | 70 | mean_land_days: number | null; |
| 51 | 71 | }, |
| 52 | [] | |
| 72 | [string | null] | |
| 53 | 73 | >( |
| 54 | 74 | `SELECT issue.author_id AS author_id, |
| 55 | 75 | COUNT(*) AS opened, |
| @@ -74,9 +94,10 @@ export function crewScorecards(db: Database): Map<string, Scorecard> { | ||
| 74 | 94 | FROM issue |
| 75 | 95 | JOIN pull ON pull.issue_id = issue.id |
| 76 | 96 | JOIN user ON user.id = issue.author_id AND user.is_agent = 1 |
| 97 | WHERE ?1 IS NULL OR issue.created_at >= ?1 | |
| 77 | 98 | GROUP BY issue.author_id`, |
| 78 | 99 | ) |
| 79 | .all(); | |
| 100 | .all(since); | |
| 80 | 101 | return new Map( |
| 81 | 102 | rows.map((r) => { |
| 82 | 103 | const checks = r.checks_ok + r.checks_failed; |
src/tui/screens/scorecard.ts +31 −8
| @@ -1,13 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The crew's scorecard in the terminal, a forge admin's (#2677): each |
| 3 | * agent's pulls landed of opened, review rounds, red checks, reverts and | |
| 4 | * time to land, as Settings → Crew shows them (`crewScorecards`). The gate | |
| 3 | * agent's pulls landed of opened, review rounds and red checks over the | |
| 4 | * last `RECENT_DAYS` days beside all time, then reverts and time to land | |
| 5 | * over all time, as Settings → Crew shows them (`crewScorecards`). The gate | |
| 5 | 6 | * is the page's: a forge admin; anyone else sees nothing. |
| 6 | 7 | */ |
| 7 | 8 | |
| 8 | 9 | import { listAllAgents } from "../../actions/crew.ts"; |
| 9 | 10 | import { |
| 10 | 11 | crewScorecards, |
| 12 | RECENT_DAYS, | |
| 13 | recentWindow, | |
| 11 | 14 | spanText, |
| 12 | 15 | type Scorecard, |
| 13 | 16 | } from "../../core/scorecard.ts"; |
| @@ -20,21 +23,34 @@ import type { Session } from "../session.ts"; | ||
| 20 | 23 | import { fill, len, split } from "../ui/layout.ts"; |
| 21 | 24 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 22 | 25 | |
| 23 | /** One row per agent; one with no pull yet says so rather than a row of zeros. */ | |
| 26 | const landed = (s: Scorecard | undefined) => | |
| 27 | s === undefined ? "—" : `${s.landed} of ${s.opened}`; | |
| 28 | const rounds = (s: Scorecard | undefined) => | |
| 29 | s?.reviewRounds == null ? "—" : s.reviewRounds.toFixed(1); | |
| 30 | const red = (s: Scorecard | undefined) => | |
| 31 | s?.redCheckRate == null ? "—" : `${Math.round(100 * s.redCheckRate)}%`; | |
| 32 | ||
| 33 | /** | |
| 34 | * One row per agent; one with no pull yet says so rather than a row of | |
| 35 | * zeros. Each windowed figure reads "<recent>d … · all …", the page's order. | |
| 36 | */ | |
| 24 | 37 | export function scorecardRows( |
| 25 | 38 | agents: readonly Agent[], |
| 26 | 39 | cards: ReadonlyMap<string, Scorecard>, |
| 40 | recent: ReadonlyMap<string, Scorecard>, | |
| 27 | 41 | ): ListRow[] { |
| 42 | const d = `${RECENT_DAYS}d`; | |
| 28 | 43 | return agents.map((agent) => { |
| 29 | 44 | const s = cards.get(agent.user.id); |
| 30 | 45 | if (s === undefined) |
| 31 | 46 | return { title: agent.user.username, secondary: "no pull yet" }; |
| 47 | const r = recent.get(agent.user.id); | |
| 32 | 48 | return { |
| 33 | 49 | title: agent.user.username, |
| 34 | secondary: `${s.landed} landed of ${s.opened}`, | |
| 50 | secondary: `landed ${d} ${landed(r)} · all ${landed(s)}`, | |
| 35 | 51 | meta: [ |
| 36 | `review rounds ${s.reviewRounds === null ? "—" : s.reviewRounds.toFixed(1)}`, | |
| 37 | `red checks ${s.redCheckRate === null ? "—" : `${Math.round(100 * s.redCheckRate)}%`}`, | |
| 52 | `review rounds ${d} ${rounds(r)} · all ${rounds(s)}`, | |
| 53 | `red checks ${d} ${red(r)} · all ${red(s)}`, | |
| 38 | 54 | `reverts ${s.reverts}`, |
| 39 | 55 | `time to land ${s.meanLandMs === null ? "—" : spanText(s.meanLandMs)}`, |
| 40 | 56 | ].join(" "), |
| @@ -48,7 +64,13 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 48 | 64 | // Read once when the screen opens, as the page reads it once per request. |
| 49 | 65 | const listed = allowed ? listAllAgents(f.app, f.user) : undefined; |
| 50 | 66 | const rows = |
| 51 | listed?.ok === true ? scorecardRows(listed.value, crewScorecards(f.db)) : []; | |
| 67 | listed?.ok === true | |
| 68 | ? scorecardRows( | |
| 69 | listed.value, | |
| 70 | crewScorecards(f.db), | |
| 71 | crewScorecards(f.db, recentWindow()), | |
| 72 | ) | |
| 73 | : []; | |
| 52 | 74 | return { |
| 53 | 75 | title: "Crew scorecard per agent", |
| 54 | 76 | keys: [binding(["j", "k"], "move cursor")], |
| @@ -59,7 +81,8 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen { | ||
| 59 | 81 | ? "The crew's scorecard is read by a forge admin." |
| 60 | 82 | : rows.length === 0 |
| 61 | 83 | ? "No agents yet." |
| 62 | : "Over every pull each agent opened, as Settings → Crew shows it."; | |
| 84 | : `Over the last ${RECENT_DAYS} days (${RECENT_DAYS}d) and all time, ` + | |
| 85 | "as Settings → Crew shows it."; | |
| 63 | 86 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 64 | 87 | drawList(s, list!, c, state, rows); |
| 65 | 88 | }, |
src/web/pages/crew.ts +46 −26
| @@ -26,6 +26,8 @@ import { | ||
| 26 | 26 | } from "../../core/runs.ts"; |
| 27 | 27 | import { |
| 28 | 28 | crewScorecards, |
| 29 | RECENT_DAYS, | |
| 30 | recentWindow, | |
| 29 | 31 | spanText, |
| 30 | 32 | type Scorecard, |
| 31 | 33 | } from "../../core/scorecard.ts"; |
| @@ -229,25 +231,49 @@ function totalsCells(t: AgentTotals | undefined): Html { | ||
| 229 | 231 | |
| 230 | 232 | /** |
| 231 | 233 | * The scorecard: how each agent's pulls fare once opened — so the owner |
| 232 | * can tune who does what. What they cost is the table above's ("Per | |
| 233 | * landed"), not repeated here. An agent with no pull yet is a row of | |
| 234 | * dashes, never a row of misleading zeros. The agent's name stays in view | |
| 235 | * while a phone scrolls the figures sideways. | |
| 234 | * can tune who does what. Landed, review rounds and red checks stand as the | |
| 235 | * last 30 days beside all time, so a change to an agent shows as movement. | |
| 236 | * What they cost is the table above's ("Per landed"), not repeated here. An | |
| 237 | * agent with no pull yet is a row of dashes, never a row of misleading | |
| 238 | * zeros. The agent's name stays in view while a phone scrolls the figures | |
| 239 | * sideways. | |
| 236 | 240 | */ |
| 237 | 241 | function scorecardCard( |
| 238 | 242 | agents: readonly Agent[], |
| 239 | 243 | cards: ReadonlyMap<string, Scorecard>, |
| 244 | recent: ReadonlyMap<string, Scorecard>, | |
| 240 | 245 | ): Html { |
| 241 | 246 | const dash = html`<td class="num muted">—</td>`; |
| 247 | const lately = `${RECENT_DAYS} days`; | |
| 248 | const windowHead = (label: string, span: string) => | |
| 249 | html`<th>${label}<br /><span class="muted small">${span}</span></th>`; | |
| 250 | const landed = (s: Scorecard | undefined) => | |
| 251 | s === undefined | |
| 252 | ? dash | |
| 253 | : html`<td class="num"> | |
| 254 | ${s.landed} | |
| 255 | <span class="muted small">of ${s.opened}</span> | |
| 256 | </td>`; | |
| 257 | const rounds = (s: Scorecard | undefined) => | |
| 258 | s?.reviewRounds == null | |
| 259 | ? dash | |
| 260 | : html`<td class="num">${s.reviewRounds.toFixed(1)}</td>`; | |
| 261 | const red = (s: Scorecard | undefined) => | |
| 262 | s?.redCheckRate == null | |
| 263 | ? dash | |
| 264 | : html`<td class="num">${Math.round(100 * s.redCheckRate)}%</td>`; | |
| 242 | 265 | return card( |
| 243 | 266 | html`<div class="table-scroll"> |
| 244 | 267 | <table class="table" id="crew-scorecard" data-sticky-lead> |
| 245 | 268 | <thead> |
| 246 | 269 | <tr> |
| 247 | 270 | <th>Agent</th> |
| 248 | <th>Pulls landed</th> | |
| 249 | <th>Review rounds</th> | |
| 250 | <th>Red checks</th> | |
| 271 | ${windowHead("Pulls landed", lately)} | |
| 272 | ${windowHead("Pulls landed", "all time")} | |
| 273 | ${windowHead("Review rounds", lately)} | |
| 274 | ${windowHead("Review rounds", "all time")} | |
| 275 | ${windowHead("Red checks", lately)} | |
| 276 | ${windowHead("Red checks", "all time")} | |
| 251 | 277 | <th>Reverts</th> |
| 252 | 278 | <th>Time to land</th> |
| 253 | 279 | </tr> |
| @@ -255,6 +281,7 @@ function scorecardCard( | ||
| 255 | 281 | <tbody> |
| 256 | 282 | ${agents.map((agent) => { |
| 257 | 283 | const s = cards.get(agent.user.id); |
| 284 | const r = recent.get(agent.user.id); | |
| 258 | 285 | return html`<tr> |
| 259 | 286 | <td> |
| 260 | 287 | ${userLink(agent.user.username, { |
| @@ -262,21 +289,10 @@ function scorecardCard( | ||
| 262 | 289 | mark: markOf(agent.user), |
| 263 | 290 | })} |
| 264 | 291 | </td> |
| 292 | ${landed(r)}${landed(s)}${rounds(r)}${rounds(s)}${red(r)}${red(s)} | |
| 265 | 293 | ${s === undefined |
| 266 | ? html`${dash}${dash}${dash}${dash}${dash}` | |
| 267 | : html`<td class="num"> | |
| 268 | ${s.landed} | |
| 269 | <span class="muted small">of ${s.opened}</span> | |
| 270 | </td> | |
| 271 | ${s.reviewRounds === null | |
| 272 | ? dash | |
| 273 | : html`<td class="num">${s.reviewRounds.toFixed(1)}</td>`} | |
| 274 | ${s.redCheckRate === null | |
| 275 | ? dash | |
| 276 | : html`<td class="num"> | |
| 277 | ${Math.round(100 * s.redCheckRate)}% | |
| 278 | </td>`} | |
| 279 | <td class="num">${s.reverts}</td> | |
| 294 | ? html`${dash}${dash}` | |
| 295 | : html`<td class="num">${s.reverts}</td> | |
| 280 | 296 | ${s.meanLandMs === null |
| 281 | 297 | ? dash |
| 282 | 298 | : html`<td class="num">${spanText(s.meanLandMs)}</td>`}`} |
| @@ -291,10 +307,11 @@ function scorecardCard( | ||
| 291 | 307 | head: html`${icon("activity")} |
| 292 | 308 | <h2>Scorecard</h2> |
| 293 | 309 | <span class="muted small" |
| 294 | >Over every pull each agent opened: review rounds are change | |
| 295 | requests per landed pull, red checks the share of finished checks | |
| 296 | that failed, reverts the landed pulls a merged pull took back, time | |
| 297 | to land the mean from opening to merge.</span | |
| 310 | >Over the pulls each agent opened in the last ${RECENT_DAYS} days | |
| 311 | and over all time: review rounds are change requests per landed pull, red checks | |
| 312 | the share of finished checks that failed; reverts (the landed pulls | |
| 313 | a merged pull took back) and time to land (the mean from opening to | |
| 314 | merge) are all time.</span | |
| 298 | 315 | >`, |
| 299 | 316 | }, |
| 300 | 317 | ); |
| @@ -363,6 +380,7 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 363 | 380 | const agents = listed.ok ? listed.value : []; |
| 364 | 381 | const totals = agentTotals(ctx.app.db); |
| 365 | 382 | const scorecards = crewScorecards(ctx.app.db); |
| 383 | const recentScorecards = crewScorecards(ctx.app.db, recentWindow()); | |
| 366 | 384 | // Where the money goes today: every app running on its own, its spend |
| 367 | 385 | // against its ceiling. The ceilings are the one control on the drain, and |
| 368 | 386 | // this is the page where the crew is wielded, so they are read here. |
| @@ -460,7 +478,9 @@ function crewView(ctx: Ctx, values: AgentValues, error?: string): Html { | ||
| 460 | 478 | </div>`, |
| 461 | 479 | { pad: false }, |
| 462 | 480 | )} |
| 463 | ${when(agents.length > 0, () => scorecardCard(agents, scorecards))} | |
| 481 | ${when(agents.length > 0, () => | |
| 482 | scorecardCard(agents, scorecards, recentScorecards), | |
| 483 | )} | |
| 464 | 484 | ${splitsCard( |
| 465 | 485 | "Per model", |
| 466 | 486 | "Model", |
test/crew-scorecard.test.ts +69 −13
| @@ -10,7 +10,7 @@ import { | ||
| 10 | 10 | createRun, |
| 11 | 11 | finishRun, |
| 12 | 12 | } from "../src/core/runs.ts"; |
| 13 | import { crewScorecards } from "../src/core/scorecard.ts"; | |
| 13 | import { crewScorecards, recentWindow } from "../src/core/scorecard.ts"; | |
| 14 | 14 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| 15 | 15 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 16 | 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -97,14 +97,10 @@ describe("a scorecard per crew member", () => { | ||
| 97 | 97 | headers: { cookie }, |
| 98 | 98 | }).then((r) => r.text()); |
| 99 | 99 | expect(page).toContain(`id="crew-scorecard"`); |
| 100 | for (const head of [ | |
| 101 | "Pulls landed", | |
| 102 | "Review rounds", | |
| 103 | "Red checks", | |
| 104 | "Reverts", | |
| 105 | "Time to land", | |
| 106 | ]) | |
| 107 | expect(page).toContain(`<th>${head}</th>`); | |
| 100 | for (const head of ["Pulls landed", "Review rounds", "Red checks"]) | |
| 101 | for (const span of ["30 days", "all time"]) | |
| 102 | expect(page).toContain(`<th>${head}<br /><span class="muted small">${span}</span></th>`); | |
| 103 | for (const head of ["Reverts", "Time to land"]) expect(page).toContain(`<th>${head}</th>`); | |
| 108 | 104 | // What a landed pull costs is the agent table's "Per landed"; one figure, one column. |
| 109 | 105 | expect(page).not.toContain("<th>Landed per $</th>"); |
| 110 | 106 | // The agent's name stays in view while a phone scrolls the figures. |
| @@ -166,6 +162,53 @@ describe("a scorecard per crew member", () => { | ||
| 166 | 162 | expect(table).toContain("3h"); |
| 167 | 163 | }); |
| 168 | 164 | |
| 165 | test("the last 30 days and all time are different figures over old and new pulls", async () => { | |
| 166 | const old = (n: number) => `2020-01-0${n}T00:00:00.000Z`; | |
| 167 | await fetch(`${base}/api/v1/agents`, { | |
| 168 | method: "POST", | |
| 169 | headers: { cookie, "content-type": "application/json" }, | |
| 170 | body: JSON.stringify({ username: "jones", model: "opus" }), | |
| 171 | }); | |
| 172 | const jones = getUserByName(app.db, "jones")!.id; | |
| 173 | // Long ago: two landed, three change requests between them, one red check of one. | |
| 174 | const a = pull(jones, "Old one", { openedAt: old(1), mergedAt: old(2) }); | |
| 175 | const b = pull(jones, "Old two", { openedAt: old(3), mergedAt: old(4) }); | |
| 176 | for (const issueId of [a, a, b]) | |
| 177 | submitReview(app.db, { issueId, authorId: adminId, state: "changes_requested", body: "", commitOid: "b" }); | |
| 178 | check(a, "failed"); | |
| 179 | // Lately: one landed with no change request and a green check, one still open. | |
| 180 | const hour = 3_600_000; | |
| 181 | const recent = pull(jones, "New one", { | |
| 182 | openedAt: new Date(Date.now() - 2 * hour).toISOString(), | |
| 183 | mergedAt: new Date(Date.now() - hour).toISOString(), | |
| 184 | }); | |
| 185 | check(recent, "ok"); | |
| 186 | pull(jones, "New, open"); | |
| 187 | ||
| 188 | // The window both doors share: 30 days of 86 400 000 ms before now. | |
| 189 | expect(recentWindow(Date.UTC(2026, 9, 31)).since).toEqual(new Date(Date.UTC(2026, 9, 1))); | |
| 190 | const all = crewScorecards(app.db).get(jones)!; | |
| 191 | const month = crewScorecards(app.db, recentWindow()).get(jones)!; | |
| 192 | expect([all.opened, all.landed]).toEqual([4, 3]); | |
| 193 | expect(all.reviewRounds).toBe(1); // 3 change requests ÷ 3 landed | |
| 194 | expect(all.redCheckRate).toBe(0.5); // 1 failed ÷ 2 finished | |
| 195 | expect([month.opened, month.landed]).toEqual([2, 1]); | |
| 196 | expect(month.reviewRounds).toBe(0); // 0 ÷ 1 landed | |
| 197 | expect(month.redCheckRate).toBe(0); // 0 failed ÷ 1 finished | |
| 198 | // An agent with nothing in the window is absent from it, not zeros. | |
| 199 | const later = new Date(Date.now() + 86_400_000); | |
| 200 | expect(crewScorecards(app.db, { since: later }).has(jones)).toBe(false); | |
| 201 | ||
| 202 | const page = await fetch(`${base}/settings/crew`, { headers: { cookie } }).then((r) => r.text()); | |
| 203 | const table = page.slice(page.indexOf(`id="crew-scorecard"`)); | |
| 204 | const row = table.slice(table.indexOf("jones"), table.indexOf("</tr>", table.indexOf("jones"))); | |
| 205 | const cells = [...row.matchAll(/<td class="num">([\s\S]*?)<\/td>/g)].map((m) => | |
| 206 | m[1]!.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim(), | |
| 207 | ); | |
| 208 | // 30 days beside all time: landed, review rounds, red checks; then reverts. | |
| 209 | expect(cells.slice(0, 7)).toEqual(["1 of 2", "3 of 4", "0.0", "1.0", "0%", "50%", "0"]); | |
| 210 | }); | |
| 211 | ||
| 169 | 212 | /** The forge's terminal, signed in as one account, on a headless screen. */ |
| 170 | 213 | function terminal(username: string) { |
| 171 | 214 | const user = getUserByName(app.db, username)!; |
| @@ -198,14 +241,27 @@ describe("a scorecard per crew member", () => { | ||
| 198 | 241 | await t.enter(); |
| 199 | 242 | const text = t.text(); |
| 200 | 243 | expect(text).toContain("smith"); |
| 201 | expect(text).toContain("2 landed of 3"); | |
| 202 | expect(text).toContain("review rounds 1.0"); | |
| 203 | expect(text).toContain("red checks 25%"); | |
| 244 | expect(text).toContain("· all 2 of 3"); | |
| 245 | expect(text).toContain("· all 1.0"); | |
| 246 | expect(text).toContain("· all 25%"); | |
| 204 | 247 | expect(text).toContain("reverts 1"); |
| 205 | 248 | expect(text).toContain("time to land 3h"); |
| 206 | 249 | t.stop(); |
| 207 | 250 | }); |
| 208 | 251 | |
| 252 | test("the terminal shows the last 30 days beside all time, as the page does", async () => { | |
| 253 | // jones: two landed pulls from 2020 and two opened within the hour (see above). | |
| 254 | const t = terminal("ada"); | |
| 255 | await t.palette("Crew scorecard per agent"); | |
| 256 | await t.enter(); | |
| 257 | const text = t.text(); | |
| 258 | expect(text).toContain("Over the last 30 days (30d) and all time"); | |
| 259 | expect(text).toContain("landed 30d 1 of 2 · all 3 of 4"); | |
| 260 | expect(text).toContain("review rounds 30d 0.0 · all 1.0"); | |
| 261 | expect(text).toContain("red checks 30d 0% · all 50%"); | |
| 262 | t.stop(); | |
| 263 | }); | |
| 264 | ||
| 209 | 265 | test("someone who is not an admin has no scorecard in the terminal", async () => { |
| 210 | 266 | const made = await createUser(app.db, { |
| 211 | 267 | origin: "seed", |
| @@ -219,7 +275,7 @@ describe("a scorecard per crew member", () => { | ||
| 219 | 275 | await t.palette("Crew scorecard per agent"); |
| 220 | 276 | expect(t.text()).not.toContain("review rounds, red checks"); |
| 221 | 277 | await t.enter(); |
| 222 | expect(t.text()).not.toContain("2 landed of 3"); | |
| 278 | expect(t.text()).not.toContain("all 2 of 3"); | |
| 223 | 279 | t.stop(); |
| 224 | 280 | }); |
| 225 | 281 | }); |
new above
offline