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

Merged opened this

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

Merged

scout/forge:scout/12134-next-up-draws-the-pace-line-on-the-page → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/12134-next-up-draws-the-pace-line-on-the-page was merged into main as 18f765b.

Review

Sign in to review this change.
7 files changed +265 −23
docs/features/next-up.md +3 −2
@@ -20,10 +20,11 @@
2020 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.
2121
2222 - **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.
2425 - **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).
2526 - **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.
2627 - **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.
2728 - **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.
2930 - 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) {
7676
7777 /** The cut line's two sentences, one place for the page and its script. */
7878 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.";
7981 export const cutWords = (fit: number | string): string => `This week ends here (${fit} fit): below this line reads as next week.`;
8082
8183 /** 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 {
136138 readonly priced: readonly Priced[];
137139 readonly cut: Cut;
138140 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;
139143 readonly sentence: string;
140144 }
141145
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
142166 export function weekView(db: Database, repoId: string, rows: readonly NextRow[], now: Date): WeekView {
143167 const capacity = weekCapacity(db, now);
144168 const priced = priceRows(db, repoId, rows);
145169 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);
outdated

docs/features/next-up.md still says that with no capacity there is no line. Update the how-to for the pace line.

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}.` };
147175 }
148176
149177 const DAY_MS = 86_400_000;
src/http/mcp/tools/next_up.ts +4 −4
@@ -1,16 +1,16 @@
11 import { placeOf, rankIssue } from "../../../actions/next.ts";
22 import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts";
33 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";
55 import { sealedScope } from "../../../core/sealed.ts";
66 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts";
77
8 const brief = (r: NextRow, week?: WeekView) => ({
8 const brief = (r: NextRow, week?: WeekView, at = 0) => ({
99 ...(week === undefined
1010 ? {}
1111 : {
1212 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 thisWeek under a pace line: true above pace.rows, false below. This branch is untested.

1414 }),
1515 issue: r.issue.number,
1616 title: r.issue.title,
@@ -56,7 +56,7 @@ export const tool: Tool = {
5656 const week = weekView(ctx.app.db, repo.id, next.rows, new Date());
5757 return good({
5858 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)),
6060 more: (all ? 0 : Math.max(0, next.rows.length - TOP)) + next.unlisted,
6161 notNow: next.notNowTotal,
6262 ...(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";
1717 import { pillarsOf, servedBy } from "../../core/pillars.ts";
1818 import { sealedScope } from "../../core/sealed.ts";
1919 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";
2121 import { openPullStages } from "../../flow/read.ts";
2222 import type { Stage } from "../../flow/stage.ts";
2323 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 {
136136 shown.map((at, i) => {
137137 const r = rows[at]!;
138138 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;
141141 return {
142142 title: `${at + 1}. #${r.issue.number} ${r.issue.title}`,
143143 secondary: `${r.byOwner ? "Owner's pick · " : ""}${r.role === "blocker" ? "Blocker" : "Enabler"}`,
144144 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 ?? ""]
145145 .filter((x) => x !== "")
146146 .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.

148148 };
149149 });
150150
@@ -174,7 +174,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
174174 ? "Loading…"
175175 : rows.length === 0
176176 ? "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` : ""}`;
178178 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
179179 drawList(s, list!, c, state, listRows());
180180 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";
1717 import { pillarsOf, servedBy } from "../../core/pillars.ts";
1818 import { sealedScope } from "../../core/sealed.ts";
1919 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";
2121 import type { Ctx } from "../../http/context.ts";
2222 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
2323 import { backTo, field, readForm } from "../form.ts";
@@ -186,10 +186,10 @@ function line(
186186 }
187187
188188 /** 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 {
190190 return html`<li class="cut-line" role="separator">${top
191191 ? 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.

193193 }
194194
195195 export function nextPage(ctx: Ctx): Response {
@@ -209,7 +209,8 @@ export function nextPage(ctx: Ctx): Response {
209209 const served = pillars !== undefined ? servedBy(ctx.app.db, pillars) : new Map<string, string>();
210210 const shows = (r: NextRow): boolean => !isFiltered || matches(filter, r.issue.title, served.get(r.issue.id));
211211 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;
213214 const costs = rowCosts(priced);
214215 const stages = openPullStages(ctx.app.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
215216 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 {
219220 // One numbered order, folded: the first TOP rows open, what comes after, then Later.
220221 // A filter hides rows but keeps each one's place in the whole ranking.
221222 const ats = rows.map((_, at) => at).filter((at) => shows(rows[at]!));
222 const cutAt = cut.fits.length;
223 const cutAt = lineAfter(week);
223224 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);
225226 return html`<details class="mt"${open ? raw(" open") : null}><summary><h2>${title} · ${String(list.length)}</h2></summary>
226227 <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}>
227228 ${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 pace.rows rows with the By pace words when there is no capacity. Nothing asserts the separator today.

229230 line(base, rows, at, writer, back, false, priced, costs?.[at], at + 1, stateWord(rows[at]!, facts(rows[at]!)), rows[at]!.byOwner, promise(rows[at]!)),
230231 ])}
231232 </ul></details>`;
test/week-pace.test.ts +213 −2
@@ -1,6 +1,31 @@
11 /** 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();
429
530 const cut = (landedPerDay: number, over: Partial<Parameters<typeof paceCut>[0]> = {}) =>
631 paceCut({ landedPerDay, daysLeft: 3, total: 10, landed: 5, ...over });
@@ -28,6 +53,14 @@ describe("paceCut", () => {
2853 });
2954 });
3055
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
3164 describe("daysLeftInWeek", () => {
3265 const now = new Date("2026-11-04T12:00:00Z"); // Wednesday
3366 test("days to resetsAt when given", () => {
@@ -44,3 +77,181 @@ describe("daysLeftInWeek", () => {
4477 expect(daysLeftInWeek(now, "2026-11-01T00:00:00Z")).toBe(0);
4578 });
4679 });
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", () => {
168168 expect(one.every((p) => p.estimate.known)).toBe(true);
169169 });
170170
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 () => {
172172 const res = await fetch(`${servers.url}/alice/plan/next`, { headers: { cookie } });
173173 expect(res.status).toBe(200);
174174 const text = await res.text();
175175 expect(text).toContain("$2.00–$4.00");
176176 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");
178179 });
179180
180181 test("capacity is what the weekly window leaves, read from the ledger", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs