Public: anyone can read this; the crew can change it.
Next up draws the pace line on the page, the terminal and next_up when the dollar line is unsure #12257
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12134-next-up-draws-the-pace-line-on-the-page → main 6 commits fork 5 threads · 5 unresolved
docs/features/next-up.md +3 −2
| @@ -20,10 +20,11 @@ | ||
| 20 | 20 | The subscription's weekly reset is the sprint boundary; what the crew may still spend before it is the capacity (`flow/week.ts`, from `core/standing.ts`). No ceremony and no locked scope: rerank any time and the line moves on the next paint. |
| 21 | 21 | |
| 22 | 22 | - **Estimates:** each row shows a cost range (the 25th to 75th percentile of what past closed issues of the same size and kind cost, every agent run on them summed, `flow/estimate.ts`). With fewer than three comparable issues it falls back to size alone, then says "too little history". Measured, never asked of an agent: no run is spent. |
| 23 | - **Cut line:** walking from the top, the running median estimate is held against the capacity; the line falls before the first issue that does not fit and everything under it reads as next week. An issue with no estimate counts as the median of the others; with none estimated, or no capacity read, there is no line and the sentence says why. Same list and history, same line. | |
| 23 | - **Cut line:** walking from the top, the running median estimate is held against the capacity; the line falls before the first issue that does not fit and everything under it reads as next week. An issue with no estimate counts as the median of the others; with none estimated, or no capacity read, there is no dollar line and the sentence says why. Same list and history, same line. | |
| 24 | - **Pace line:** when the dollar line is unsure (no capacity read, or nothing estimated), the line is drawn by pace instead (`paceCut`): how many issues landed in the last seven days, carried over the days left to the reset. It is worded "By pace: this week ends here" and never says "fit", because it makes no dollar claim; with fewer than three landed the sentence says the pace is a guess. Once the cost line exists, it replaces the pace line. | |
| 24 | 25 | - **Projection:** one sentence above the list: how many should ship by the reset, as a range from the high and low ends of the estimates, and a confidence (high at eight or more samples on every row). |
| 25 | 26 | - **Retrospective:** once an hour the forge keeps the projection of the open window (`week_projection`) and, for a window that has ended, writes `week_retro` once (`flow/retro.ts`): what shipped against the projection, how many shipped issues cost inside their range, what open work cost most, what is stuck (two failed runs or more, and the class of the last failure, never the run's own output), and at most three proposals. `/:owner/:name/week` and the MCP `week` tool show it. A proposal is one tap (`POST /:owner/:name/week`, or `week { app, take }`) by a writer who is a person; it puts the issue behind the Later gate, the same move as `Not now`, and is taken at most once. Nothing changes without that tap. Only a person who writes can take one: `takeProposal` refuses an agent and a reader for every door. |
| 26 | 27 | - **Not done here:** the line falls between issues, not whole scopes, because bets and scopes (#8713) are not in the forge yet; when they land the line moves to their edges. The health mark per group is left for the same reason. |
| 27 | 28 | - **Live:** a drag posts the move and the page reloads itself from the server, which redraws the line; there is no manual reload, but it is a reload, not a client-side recompute. |
| 28 | - **Terminal:** Next up shows each row's range and a muted "this week ends here" line under the last issue that fits, with the projection in the header. "The week in a repository" (palette) shows the retrospective; `j`/`k` choose a proposal and `enter` or `a` takes it, through the same `takeProposal`. A reader or an agent sees it and the key does nothing; a new retrospective (`week.retro`) or a proposal taken anywhere updates the open screen. MCP `next_up` rows carry `estimate` and `thisWeek`, with a `projection` sentence. | |
| 29 | - **Terminal:** Next up shows each row's range and a muted "this week ends here" line under the last issue that fits (by pace, "by pace: this week ends here"), with the projection in the header. "The week in a repository" (palette) shows the retrospective; `j`/`k` choose a proposal and `enter` or `a` takes it, through the same `takeProposal`. A reader or an agent sees it and the key does nothing; a new retrospective (`week.retro`) or a proposal taken anywhere updates the open screen. MCP `next_up` rows carry `estimate` and `thisWeek` (above the pace line when the line is by pace), with a `projection` sentence. | |
| 29 | 30 | - Sealed issues are left out of history and of the retrospective, so their cost never shapes a visible range. |
src/flow/week.ts +29 −1
| @@ -76,6 +76,8 @@ export const FIT_COUNT_JS = `function fitCount(costs, usd) { | ||
| 76 | 76 | |
| 77 | 77 | /** The cut line's two sentences, one place for the page and its script. */ |
| 78 | 78 | export const CUT_TOP_WORDS = "Nothing here fits what is left of this week: all of this reads as next week."; |
| 79 | /** The pace line makes no dollar claim: it says only that it is by pace. */ | |
| 80 | export const paceWords = "By pace: this week ends here; below this line reads as next week."; | |
| 79 | 81 | export const cutWords = (fit: number | string): string => `This week ends here (${fit} fit): below this line reads as next week.`; |
| 80 | 82 | |
| 81 | 83 | /** What each row counts for against the week: its mid, or the median mid when it has no estimate; null when no line can be drawn. */ |
| @@ -136,14 +138,40 @@ export interface WeekView { | ||
| 136 | 138 | readonly priced: readonly Priced[]; |
| 137 | 139 | readonly cut: Cut; |
| 138 | 140 | readonly capacity: Capacity | null; |
| 141 | /** The line by pace, only when the dollar line is unsure; null when the cost line exists. */ | |
| 142 | readonly pace: PaceCut | null; | |
| 139 | 143 | readonly sentence: string; |
| 140 | 144 | } |
| 141 | 145 | |
| 146 | /** Rows above the line a surface draws: the cost line's, else the pace's, else none. */ | |
| 147 | export function lineAfter(week: Pick<WeekView, "cut" | "pace">): number | null { | |
| 148 | return week.cut.unsure === null ? week.cut.fits.length : (week.pace?.rows ?? null); | |
| 149 | } | |
| 150 | ||
| 151 | const WEEK_DAYS = 7; | |
| 152 | ||
| 153 | /** Issues of this repository closed as done in the last 7 days: one indexed statement. */ | |
| 154 | function landedThisWeek(db: Database, repoId: string, now: Date): number { | |
| 155 | const since = new Date(now.getTime() - WEEK_DAYS * 86_400_000).toISOString(); | |
| 156 | return ( | |
| 157 | db | |
| 158 | .query<{ n: number }, [string, string]>( | |
| 159 | `SELECT COUNT(*) AS n FROM issue INDEXED BY issue_repo_kind_number | |
| 160 | WHERE repo_id = ?1 AND kind = 'issue' AND state = 'closed' AND close_reason = 'done' AND sealed = 0 AND closed_at >= ?2`, | |
| 161 | ) | |
| 162 | .get(repoId, since)?.n ?? 0 | |
| 163 | ); | |
| 164 | } | |
| 165 | ||
| 142 | 166 | export function weekView(db: Database, repoId: string, rows: readonly NextRow[], now: Date): WeekView { |
| 143 | 167 | const capacity = weekCapacity(db, now); |
| 144 | 168 | const priced = priceRows(db, repoId, rows); |
| 145 | 169 | const cut = cutLine(priced, capacity); |
| 146 | return { priced, cut, capacity, sentence: projection(cut, rows.length, capacity) }; | |
| 170 | const sentence = projection(cut, rows.length, capacity); | |
| 171 | if (cut.unsure === null) return { priced, cut, capacity, pace: null, sentence }; | |
| 172 | const landed = landedThisWeek(db, repoId, now); | |
| 173 | const pace = paceCut({ landedPerDay: landed / WEEK_DAYS, daysLeft: daysLeftInWeek(now, capacity?.resetsAt), total: rows.length, landed }); | |
| 174 | return { priced, cut, capacity, pace, sentence: `${sentence} The line is drawn ${pace.reason}.` }; | |
| 147 | 175 | } |
| 148 | 176 | |
| 149 | 177 | const DAY_MS = 86_400_000; |
src/http/mcp/tools/next_up.ts +4 −4
| @@ -1,16 +1,16 @@ | ||
| 1 | 1 | import { placeOf, rankIssue } from "../../../actions/next.ts"; |
| 2 | 2 | import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts"; |
| 3 | 3 | import { notStartable } from "../../../crew/not_startable.ts"; |
| 4 | import { weekView, type WeekView } from "../../../flow/week.ts"; | |
| 4 | import { lineAfter, weekView, type WeekView } from "../../../flow/week.ts"; | |
| 5 | 5 | import { sealedScope } from "../../../core/sealed.ts"; |
| 6 | 6 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts"; |
| 7 | 7 | |
| 8 | const brief = (r: NextRow, week?: WeekView) => ({ | |
| 8 | const brief = (r: NextRow, week?: WeekView, at = 0) => ({ | |
| 9 | 9 | ...(week === undefined |
| 10 | 10 | ? {} |
| 11 | 11 | : { |
| 12 | 12 | estimate: week.priced.find((p) => p.number === r.issue.number)?.estimate, |
| 13 | thisWeek: week.cut.fits.includes(r.issue.number), | |
| 13 | thisWeek: week.cut.unsure === null ? week.cut.fits.includes(r.issue.number) : at < (lineAfter(week) ?? 0), | |
Add a test for |
||
| 14 | 14 | }), |
| 15 | 15 | issue: r.issue.number, |
| 16 | 16 | title: r.issue.title, |
| @@ -56,7 +56,7 @@ export const tool: Tool = { | ||
| 56 | 56 | const week = weekView(ctx.app.db, repo.id, next.rows, new Date()); |
| 57 | 57 | return good({ |
| 58 | 58 | projection: week.sentence, |
| 59 | next: (all ? next.rows : next.rows.slice(0, TOP)).map((r) => brief(r, week)), | |
| 59 | next: (all ? next.rows : next.rows.slice(0, TOP)).map((r, at) => brief(r, week, at)), | |
| 60 | 60 | more: (all ? 0 : Math.max(0, next.rows.length - TOP)) + next.unlisted, |
| 61 | 61 | notNow: next.notNowTotal, |
| 62 | 62 | ...(all ? { notNowRows: next.notNow.map((r) => brief(r)) } : {}), |
src/tui/screens/next.ts +5 −5
| @@ -17,7 +17,7 @@ import { canRead, canWrite, type Repo } from "../../core/repos.ts"; | ||
| 17 | 17 | import { pillarsOf, servedBy } from "../../core/pillars.ts"; |
| 18 | 18 | import { sealedScope } from "../../core/sealed.ts"; |
| 19 | 19 | import { notStartable } from "../../crew/not_startable.ts"; |
| 20 | import { rangeWords, weekView, type WeekView } from "../../flow/week.ts"; | |
| 20 | import { lineAfter, rangeWords, weekView, type WeekView } from "../../flow/week.ts"; | |
| 21 | 21 | import { openPullStages } from "../../flow/read.ts"; |
| 22 | 22 | import type { Stage } from "../../flow/stage.ts"; |
| 23 | 23 | import { countWords, filtered, matches, readFilter, MAX_Q } from "../../web/pages/next_filter.ts"; |
| @@ -136,15 +136,15 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 136 | 136 | shown.map((at, i) => { |
| 137 | 137 | const r = rows[at]!; |
| 138 | 138 | const estimate = week?.priced.find((p) => p.number === r.issue.number)?.estimate; |
| 139 | const cut = week?.cut; | |
| 140 | const ends = cut !== undefined && cut.unsure === null && cut.fits.length > 0 && at < cut.fits.length && (shown[i + 1] ?? cut.fits.length) >= cut.fits.length; | |
| 139 | const line = week === undefined ? null : lineAfter(week); | |
| 140 | const ends = line !== null && line > 0 && at < line && (shown[i + 1] ?? line) >= line; | |
| 141 | 141 | return { |
| 142 | 142 | title: `${at + 1}. #${r.issue.number} ${r.issue.title}`, |
| 143 | 143 | secondary: `${r.byOwner ? "Owner's pick · " : ""}${r.role === "blocker" ? "Blocker" : "Enabler"}`, |
| 144 | 144 | meta: [stateWord(r, { stage: stages.get(r.issue.number) ?? "issue", waits: r.waits, held: r.state === "held", onIt: r.onIt !== null }), r.onIt ?? "next", estimate === undefined ? "" : rangeWords(estimate), r.why ?? ""] |
| 145 | 145 | .filter((x) => x !== "") |
| 146 | 146 | .join(" · "), |
| 147 | ...(ends ? { note: `── this week ends here (${cut.fits.length} fit): below reads as next week ──` } : {}), | |
| 147 | ...(ends ? { note: week!.cut.unsure === null ? `── this week ends here (${line} fit): below reads as next week ──` : "── by pace: this week ends here, below reads as next week ──" } : {}), | |
outdated
The note says '(N fit)' for a pace line too. Use words that say it is by pace. |
||
| 148 | 148 | }; |
| 149 | 149 | }); |
| 150 | 150 | |
| @@ -174,7 +174,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 174 | 174 | ? "Loading…" |
| 175 | 175 | : rows.length === 0 |
| 176 | 176 | ? "Nothing is ready: everything open waits for something." |
| 177 | : `${asked !== "" ? `${countWords(shown.length, rows.length)} shown for "${asked}" · ` : ""}${week?.sentence ?? ""}${may() ? "" : " · you can read this order, not change it"}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 177 | : `${asked !== "" ? `${countWords(shown.length, rows.length)} shown for "${asked}" · ` : ""}${may() ? "" : "you can read this order, not change it · "}${week?.sentence ?? ""}${away > 0 ? ` · ${away} not now` : ""}`; | |
| 178 | 178 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 179 | 179 | drawList(s, list!, c, state, listRows()); |
| 180 | 180 | hitList(ctx, list!, state, shown.length, { open }); |
src/web/pages/next.ts +8 −7
| @@ -17,7 +17,7 @@ import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | ||
| 17 | 17 | import { pillarsOf, servedBy } from "../../core/pillars.ts"; |
| 18 | 18 | import { sealedScope } from "../../core/sealed.ts"; |
| 19 | 19 | import { notStartable } from "../../crew/not_startable.ts"; |
| 20 | import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts"; | |
| 20 | import { CUT_TOP_WORDS, cutWords, paceWords, FIT_COUNT_JS, rangeWords, lineAfter, rowCosts, weekView, type Priced } from "../../flow/week.ts"; | |
| 21 | 21 | import type { Ctx } from "../../http/context.ts"; |
| 22 | 22 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts"; |
| 23 | 23 | import { backTo, field, readForm } from "../form.ts"; |
| @@ -186,10 +186,10 @@ function line( | ||
| 186 | 186 | } |
| 187 | 187 | |
| 188 | 188 | /** The cut line: everything above it fits this week's capacity, everything below reads as next week. */ |
| 189 | function cutMark(cut: Cut, top: boolean): Html { | |
| 189 | function cutMark(fit: number, top: boolean, byPace: boolean): Html { | |
| 190 | 190 | return html`<li class="cut-line" role="separator">${top |
| 191 | 191 | ? CUT_TOP_WORDS |
| 192 | : cutWords(cut.fits.length)}</li>`; | |
| 192 | : byPace ? paceWords : cutWords(fit)}</li>`; | |
outdated
Design §1/§8: for a pace line this still prints cutWords(fit), 'N fit', a dollar claim. Pass the line's kind and use pace wording. |
||
| 193 | 193 | } |
| 194 | 194 | |
| 195 | 195 | export function nextPage(ctx: Ctx): Response { |
| @@ -209,7 +209,8 @@ export function nextPage(ctx: Ctx): Response { | ||
| 209 | 209 | const served = pillars !== undefined ? servedBy(ctx.app.db, pillars) : new Map<string, string>(); |
| 210 | 210 | const shows = (r: NextRow): boolean => !isFiltered || matches(filter, r.issue.title, served.get(r.issue.id)); |
| 211 | 211 | const rendering = performance.now(); |
| 212 | const { priced, cut, capacity, sentence } = weekView(ctx.app.db, repo.id, rows, new Date()); | |
| 212 | const week = weekView(ctx.app.db, repo.id, rows, new Date()); | |
| 213 | const { priced, cut, capacity, sentence } = week; | |
| 213 | 214 | const costs = rowCosts(priced); |
| 214 | 215 | const stages = openPullStages(ctx.app.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number))); |
| 215 | 216 | const promised = currentByNumber(ctx.app.db, repo.id, rows.filter((r) => r.issue.estimated).map((r) => r.issue.number)); |
| @@ -219,13 +220,13 @@ export function nextPage(ctx: Ctx): Response { | ||
| 219 | 220 | // One numbered order, folded: the first TOP rows open, what comes after, then Later. |
| 220 | 221 | // A filter hides rows but keeps each one's place in the whole ranking. |
| 221 | 222 | const ats = rows.map((_, at) => at).filter((at) => shows(rows[at]!)); |
| 222 | const cutAt = cut.fits.length; | |
| 223 | const cutAt = lineAfter(week); | |
| 223 | 224 | const section = (title: string, list: readonly number[], open: boolean, id?: string): Html => { |
| 224 | const mark = markBefore(list, cutAt); | |
| 225 | const mark = cutAt === null ? -1 : markBefore(list, cutAt); | |
| 225 | 226 | return html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary> |
| 226 | 227 | <ul class="list setting-list"${id === undefined ? null : html` id="${id}"`}${id !== undefined && !isFiltered && costs !== null && capacity !== null && cut.unsure === null ? html` data-capacity="${String(capacity.usd)}"` : null}> |
| 227 | 228 | ${list.flatMap((at, i) => [ |
| 228 | ...(i === mark && cut.unsure === null && id !== undefined ? [cutMark(cut, false)] : []), | |
| 229 | ...(i === mark && id !== undefined ? [cutMark(cutAt!, false, cut.unsure !== null)] : []), | |
Add a page test that the pace separator is rendered after |
||
| 229 | 230 | line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner, promise(rows[at]!)), |
| 230 | 231 | ])} |
| 231 | 232 | </ul></details>`; |
test/week-pace.test.ts +213 −2
| @@ -1,6 +1,31 @@ | ||
| 1 | 1 | /** How far this week's pace reaches, as plain data (#12133). */ |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { daysLeftInWeek, paceCut } from "../src/flow/week.ts"; | |
| 2 | import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 8 | import { getRepo } from "../src/core/repos.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { escapeHtml } from "../src/web/html.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 18 | import { createRepo } from "../src/actions/repos.ts"; | |
| 19 | import { getIssue } from "../src/core/issues.ts"; | |
| 20 | import { nextUp } from "../src/core/next_up.ts"; | |
| 21 | import { createRun } from "../src/core/runs.ts"; | |
| 22 | import { noteLimit } from "../src/core/standing.ts"; | |
| 23 | import { addSubscription } from "../src/core/subscriptions.ts"; | |
| 24 | import { recordUsage } from "../src/core/usage.ts"; | |
| 25 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 26 | import { cutWords, daysLeftInWeek, lineAfter, paceWords, paceCut, weekView } from "../src/flow/week.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 4 | 29 | |
| 5 | 30 | const cut = (landedPerDay: number, over: Partial<Parameters<typeof paceCut>[0]> = {}) => |
| 6 | 31 | paceCut({ landedPerDay, daysLeft: 3, total: 10, landed: 5, ...over }); |
| @@ -28,6 +53,14 @@ describe("paceCut", () => { | ||
| 28 | 53 | }); |
| 29 | 54 | }); |
| 30 | 55 | |
| 56 | describe("the pace line's words", () => { | |
| 57 | test("say it is by pace and never claim a fit", () => { | |
| 58 | expect(paceWords).toContain("By pace"); | |
| 59 | expect(paceWords).not.toMatch(/fit/i); | |
| 60 | expect(cutWords(3)).toContain("3 fit"); | |
| 61 | }); | |
| 62 | }); | |
| 63 | ||
| 31 | 64 | describe("daysLeftInWeek", () => { |
| 32 | 65 | const now = new Date("2026-11-04T12:00:00Z"); // Wednesday |
| 33 | 66 | test("days to resetsAt when given", () => { |
| @@ -44,3 +77,181 @@ describe("daysLeftInWeek", () => { | ||
| 44 | 77 | expect(daysLeftInWeek(now, "2026-11-01T00:00:00Z")).toBe(0); |
| 45 | 78 | }); |
| 46 | 79 | }); |
| 80 | ||
| 81 | describe("weekView chooses", () => { | |
| 82 | test("pace while capacity is null, the cost line (and no pace) once it is known", async () => { | |
| 83 | const dataDir = await mkdtemp(join(tmpdir(), "forge-week-pace-")); | |
| 84 | const forge = createApp({ dataDir, port: 0 }); | |
| 85 | try { | |
| 86 | const made = await createUser(forge.db, { | |
| 87 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 88 | password: "a-good-passphrase", adminIfFirst: true, | |
| 89 | }); | |
| 90 | if (!made.ok) throw new Error(made.error); | |
| 91 | const alice = getUserByName(forge.db, "alice")!; | |
| 92 | const r = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 93 | if (!r.ok) throw new Error(String(r.error)); | |
| 94 | const repo = r.value; | |
| 95 | const open = (title: string): number => { | |
| 96 | const o = openIssue(forge, alice, repo, { title }); | |
| 97 | if (!o.ok) throw new Error(String(o.error)); | |
| 98 | return o.value.number; | |
| 99 | }; | |
| 100 | const day = 86_400_000; | |
| 101 | for (const usd of [1, 2, 3, 4, 5]) { | |
| 102 | const n = open(`Past ${usd}`); | |
| 103 | forge.db | |
| 104 | .query(`INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, started_at, summary) VALUES (lower(hex(randomblob(8))), ?1, 'agent', ?2, 'ok', ?3, ?4, '')`) | |
| 105 | .run(repo.id, getIssue(forge.db, repo.id, n)!.id, usd, new Date(Date.now() - 3 * day).toISOString()); | |
| 106 | if (!setIssueState(forge, alice, repo, getIssue(forge.db, repo.id, n)!, "closed").ok) throw new Error("close"); | |
| 107 | } | |
| 108 | for (let i = 0; i < 6; i++) open(`Work ${i}`); | |
| 109 | const view = () => weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()); | |
| 110 | ||
| 111 | const before = view(); | |
| 112 | expect(before.capacity).toBeNull(); | |
| 113 | expect(before.cut.unsure).not.toBeNull(); | |
| 114 | expect(before.pace).not.toBeNull(); | |
| 115 | expect(before.sentence).toContain("by pace"); | |
| 116 | expect(lineAfter(before)).toBe(before.pace!.rows); | |
| 117 | ||
| 118 | const sub = addSubscription(forge.db, dataDir, { name: "acct", token: "sk-ant-oat01-pace-test-1" }); | |
| 119 | if (!sub.ok) throw new Error(sub.error); | |
| 120 | recordUsage(forge.db, { | |
| 121 | runId: createRun(forge.db, { repoId: repo.id, kind: "agent" }).id, repoId: repo.id, issueId: null, agentId: null, | |
| 122 | subscriptionId: sub.value.id, tokensIn: 1, tokensOut: 1, costUsd: 20, wallMs: 1000, outcome: "ok", | |
| 123 | at: new Date(Date.now() - 8.5 * day).toISOString(), | |
| 124 | }); | |
| 125 | noteLimit(forge.db, sub.value.id, { | |
| 126 | window: "week", resetsAt: new Date(Date.now() - 2 * day).toISOString(), | |
| 127 | observedAt: new Date(Date.now() - 8 * day).toISOString(), said: "weekly limit", | |
| 128 | }); | |
| 129 | const after = view(); | |
| 130 | expect(after.capacity).not.toBeNull(); | |
| 131 | expect(after.cut.unsure).toBeNull(); | |
| 132 | expect(after.pace).toBeNull(); | |
| 133 | expect(after.sentence).not.toContain("by pace"); | |
| 134 | expect(lineAfter(after)).toBe(after.cut.fits.length); | |
| 135 | } finally { | |
| 136 | forge.close(); | |
| 137 | await rm(dataDir, { recursive: true, force: true }); | |
| 138 | } | |
| 139 | }); | |
| 140 | }); | |
| 141 | ||
| 142 | describe("the pace line on the three surfaces (#12134)", () => { | |
| 143 | // Wednesday noon: 4.5 days left; 5 landed this week is 5/7 a day, | |
| 144 | // so the pace reaches floor(3.2) = 3 of 6 rows. | |
| 145 | const NOW = new Date("2026-11-04T12:00:00Z"); | |
| 146 | let dataDir: string; | |
| 147 | let forge: App; | |
| 148 | let servers: Servers; | |
| 149 | let cookie: string; | |
| 150 | let token: string; | |
| 151 | const work: number[] = []; | |
| 152 | ||
| 153 | beforeAll(async () => { | |
| 154 | setSystemTime(NOW); | |
| 155 | dataDir = await mkdtemp(join(tmpdir(), "forge-week-pace-surfaces-")); | |
| 156 | forge = createApp({ dataDir, port: 0 }); | |
| 157 | servers = await serve(forge); | |
| 158 | const made = await createUser(forge.db, { | |
| 159 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 160 | password: "a-good-passphrase", adminIfFirst: true, | |
| 161 | }); | |
| 162 | if (!made.ok) throw new Error(made.error); | |
| 163 | const alice = getUserByName(forge.db, "alice")!; | |
| 164 | const r = await createRepo(forge, alice, { name: "plan", visibility: "public" }); | |
| 165 | if (!r.ok) throw new Error(String(r.error)); | |
| 166 | const repo = r.value; | |
| 167 | insertApp(forge.db, { repoId: repo.id, slug: "plan", access: "private" }); | |
| 168 | const open = (title: string): number => { | |
| 169 | const o = openIssue(forge, alice, repo, { title }); | |
| 170 | if (!o.ok) throw new Error(String(o.error)); | |
| 171 | return o.value.number; | |
| 172 | }; | |
| 173 | for (let i = 1; i <= 5; i++) { | |
| 174 | const n = open(`Landed ${i}`); | |
| 175 | if (!setIssueState(forge, alice, repo, getIssue(forge.db, repo.id, n)!, "closed").ok) throw new Error("close"); | |
| 176 | } | |
| 177 | for (const name of ["Ant", "Bee", "Cat", "Dog", "Eel", "Fox"]) work.push(open(`Work ${name}`)); | |
| 178 | token = createToken(forge.db, alice.id, "ro", "ro").token; | |
| 179 | const res = await fetch(`${servers.url}/signin`, { | |
| 180 | method: "POST", | |
| 181 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 182 | redirect: "manual", | |
| 183 | }); | |
| 184 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 185 | }); | |
| 186 | ||
| 187 | afterAll(async () => { | |
| 188 | await servers.stop(); | |
| 189 | forge.close(); | |
| 190 | setSystemTime(); | |
| 191 | await rm(dataDir, { recursive: true, force: true }); | |
| 192 | }); | |
| 193 | ||
| 194 | test("the fixture's line is by pace, after row 3", () => { | |
| 195 | const repo = getRepo(forge.db, "alice", "plan")!; | |
| 196 | const week = weekView(forge.db, repo.id, nextUp(forge.db, repo).rows, new Date()); | |
| 197 | expect(week.capacity).toBeNull(); | |
| 198 | expect(week.pace?.rows).toBe(3); | |
| 199 | expect(lineAfter(week)).toBe(3); | |
| 200 | }); | |
| 201 | ||
| 202 | test("the page draws the pace separator after the third row, in the pace's words", async () => { | |
| 203 | const res = await fetch(`${servers.url}/alice/plan/next`, { headers: { cookie } }); | |
| 204 | expect(res.status).toBe(200); | |
| 205 | const text = await res.text(); | |
| 206 | const sep = `<li class="cut-line" role="separator">${escapeHtml(paceWords)}</li>`; | |
| 207 | expect(text.split(sep).length - 1).toBe(1); | |
| 208 | const at = text.indexOf(sep); | |
| 209 | expect(text.indexOf("Work Cat")).toBeLessThan(at); | |
| 210 | expect(text.indexOf("Work Dog")).toBeGreaterThan(at); | |
| 211 | expect(text).not.toContain("fit): below this line"); | |
| 212 | }); | |
| 213 | ||
| 214 | test("the terminal shows the by-pace note under the third row", async () => { | |
| 215 | const terminal = headlessTerminal(120, 32); | |
| 216 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! }; | |
| 217 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 218 | app.start(); | |
| 219 | try { | |
| 220 | const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 221 | const type = async (s: string) => { | |
| 222 | for (const ch of s) key(ch); | |
| 223 | await settleApp(app); | |
| 224 | }; | |
| 225 | await settleApp(app); | |
| 226 | key("k", true); | |
| 227 | await settleApp(app); | |
| 228 | await type("Next up in a repository"); | |
| 229 | key("enter"); | |
| 230 | await settleApp(app); | |
| 231 | await type("alice/plan"); | |
| 232 | key("enter"); | |
| 233 | await settleApp(app); | |
| 234 | const lines = app.screen.toText().split("\n"); | |
| 235 | const row = (s: string) => lines.findIndex((l) => l.includes(s)); | |
| 236 | const note = row("── by pace: this week ends here"); | |
| 237 | expect(note).toBeGreaterThan(row("Work Cat")); | |
| 238 | expect(note).toBeLessThan(row("Work Dog")); | |
| 239 | expect(lines.filter((l) => l.includes("── by pace:")).length).toBe(1); | |
| 240 | } finally { | |
| 241 | app.stop(); | |
| 242 | } | |
| 243 | }); | |
| 244 | ||
| 245 | test("next_up marks thisWeek true above the pace line and false below it", async () => { | |
| 246 | const res = await fetch(`${servers.url}/mcp`, { | |
| 247 | method: "POST", | |
| 248 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 249 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "next_up", arguments: { app: "alice/plan", all: true } } }), | |
| 250 | }); | |
| 251 | const body = (await res.json()) as { result: { content: { text: string }[] } }; | |
| 252 | const answer = JSON.parse(body.result.content[0]!.text) as { projection: string; next: { issue: number; thisWeek: boolean }[] }; | |
| 253 | expect(answer.projection).toContain("by pace"); | |
| 254 | expect(answer.next.map((r) => r.issue)).toEqual(work); | |
| 255 | expect(answer.next.map((r) => r.thisWeek)).toEqual([true, true, true, false, false, false]); | |
| 256 | }); | |
| 257 | }); |
test/week-sprint.test.ts +3 −2
| @@ -168,13 +168,14 @@ describe("the week, in a forge", () => { | ||
| 168 | 168 | expect(one.every((p) => p.estimate.known)).toBe(true); |
| 169 | 169 | }); |
| 170 | 170 | |
| 171 | test("the page shows ranges and the projection sentence; no capacity read means no line", async () => { | |
| 171 | test("the page shows ranges and the projection sentence; no capacity read means the line is by pace", async () => { | |
| 172 | 172 | const res = await fetch(`${servers.url}/alice/plan/next`, { headers: { cookie } }); |
| 173 | 173 | expect(res.status).toBe(200); |
| 174 | 174 | const text = await res.text(); |
| 175 | 175 | expect(text).toContain("$2.00–$4.00"); |
| 176 | 176 | expect(text).toContain("No projection yet: the weekly capacity has not been read yet."); |
| 177 | expect(text).not.toContain('role="separator"'); | |
| 177 | // No cost line without capacity (#12134): the line that is drawn is the pace's, and says so. | |
| 178 | expect(text).toContain("The line is drawn by pace"); | |
| 178 | 179 | }); |
| 179 | 180 | |
| 180 | 181 | test("capacity is what the weekly window leaves, read from the ledger", () => { |
docs/features/next-up.md still says that with no capacity there is no line. Update the how-to for the pace line.