Public: anyone can read this; the crew can change it.

A milestone's page draws issues closed per day and the date it is on pace for, from one plan read #6893

Merged opened this

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

Merged

scout/forge:scout/6848-a-milestone-s-page-draws-issues-closed-p → main 6 commits fork 6 threads · 6 unresolved

  • Merged. scout/6848-a-milestone-s-page-draws-issues-closed-p was merged into main as 455cf87.

Review

Sign in to review this change.
10 files changed +297 −23
README.md +6
@@ -331,6 +331,12 @@ work here.
331331 The running list of what the forge does, one bullet each, lives in
332332 [`docs/STATUS.md`](docs/STATUS.md).
333333
334 A milestone says how fast it is moving in one line — `on pace for <date>`,
335 `no movement in 7 days` or `done <date>` — with issues closed per day over 14

Nit: this reads as if the closed-per-day sparkline shows on the page, in the terminal and in the MCP. The terminal's Milestones screen shows only the line, for the limb under the cursor. Something like "…drawn beside it on its page; the line alone in the terminal's Milestones screen; both in the MCP's milestones tool" would say it exactly.

336 days drawn beside it, on its page, in the terminal's Milestones screen and in
337 the MCP's `milestones` tool (`closedPerDay`, `paceDate`); `docs/PLANNING.md`
338 has the rule.
339
334340 ## Where the rest is
335341
336342 - [`docs/DEVELOP.md`](docs/DEVELOP.md) — developing the forge: tests, checks, the gate, lint and types.
docs/MCP.md +1 −1
@@ -91,7 +91,7 @@ for a tunnel and a forge that sleeps.
9191 | `make_it_yours` | `{ repo, name?, visibility?, dryRun? }`: a fork of an app you can see, as your own app, following the original; code and history copied, data and secrets not; private unless `visibility` is public (a public original only); at most 5 copies an account a day; plans unless `dryRun` is false | yes |
9292 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is, parked twice and why (`app`); draws the `ui://forge/map` card | |
9393 | `migrate` | `{ url, name?, crew? }` brings a public repository in by URL (history only; nothing reaches back) and answers `{ job, state, url }`; `{ job }` alone reads that job. Same validation, scope (`rw`) and private-address refusal as `POST /api/v1/migrate` | yes |
94 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
94 | `milestones` | `{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, `closedPerDay` (14 days) and `paceDate`, and for an owner `spend`: dollars, changes landed ever, the costliest three issues) | |
9595 | `mint_invite` | `{ days? }`: mint a single-use invite link that expires in `days` (1–30, default 7) and is shown once; a person admin's, with an admin token | yes |
9696 | `mirror_set` | `{ action, endpoint?, bucket?, region?, accessKey?, secretKey? }` (forge admin, admin-scope token): `set` seals the off-box snapshot mirror, `forget` clears it; the Backups page's rules and refusals, a blank field keeps what was sealed; answers `{ place, says }`, never a key | yes |
9797 | `model_ceiling` | `{ username, dailyUsd, perRunUsd, dryRun? }` (a forge admin who is a person): cap an account's daily and per-run model spend in dollars (both `null` clears); planned unless `dryRun` is false, applying takes an admin token | yes |
docs/PLANNING.md +6 −1
@@ -267,7 +267,12 @@ dropped and split issues are neither.
267267 `/:owner/:name/milestones/:number` is one milestone opened: who is building
268268 what, what is blocked and on what, what merged this week, and a plain
269269 estimate — the open issues at the pace of the last 7 days, or a sentence
270 saying there is no pace to estimate from. What it waits for directly shows
270 saying there is no pace to estimate from. Under it, issues closed per day
271 over 14 UTC days as a sparkline and one line from the same numbers:
272 `on pace for <date>` (today plus the estimate's days), `no movement in 7
273 days`, or `done <date>` (the day the last issue closed); a deferred milestone
274 has no line. The terminal's milestone list shows the line for the limb under
275 the cursor, and the MCP gives `closedPerDay`, `paceDate` and `pace`. What it waits for directly shows
271276 as limbs where a child brings a tree of its own, and as leaves where it
272277 brings none (one that only waits for its siblings is a leaf in an order).
273278 Leaves show only on the limb they are under, and each issue shows once:
src/core/milestones.ts +20
@@ -13,6 +13,7 @@
1313 * from it.
1414 */
1515
16 import { paceOf, type Pace } from "./pace.ts";
1617 import type { Database } from "bun:sqlite";
1718 import { writeStamp } from "./write_clock.ts";
1819 import { deferredIn, repoGraph, type Graph } from "./dependencies.ts";
@@ -117,6 +118,8 @@ export interface Milestone extends Limb {
117118 };
118119 /** A plain sentence: when it lands at this week's pace, or why nobody can say. */
119120 readonly estimate: string;
121 /** Issues closed per day and the date it is on pace for, at the `now` the plan was read with. */
122 readonly pace: Pace;
120123 /** What it waits for directly that has a tree of its own. */
121124 readonly limbs: readonly Limb[];
122125 /** What it waits for directly that brings none: at most an order among these. */
@@ -605,6 +608,20 @@ export function readPlan(
605608 }
606609 : {}),
607610 estimate,
611 pace: paceOf(

This adds a SQL query per milestone() call, but under already holds every issue's closedAt in memory. Bucket closedPerDay from under.filter(counted) here and pass the existing days/closedThisWeek/limb.deferred in. That gives one read and one source of truth.

612 {
613 closedAt: under
614 .filter((i) => i.state === "closed" && counted(i) && i.closedAt !== null)
615 .map((i) => i.closedAt!),
616 total: limb.weight.total,
617 open,
618 deferred: limb.deferred,
619 closedInWindow: closedThisWeek,
620 window: PILLAR_DAYS,
621 days,
622 },
623 now,
624 ),
608625 limbs: arranged(issue.id, [...limbIds]).map(limbOf),
609626 leaves: children
610627 .filter((c) => !limbIds.has(c.id))
@@ -665,6 +682,9 @@ export function milestoneJson(
665682 ...limbJson(milestone),
666683 title: milestone.issue.title,
667684 estimate: milestone.estimate,
685 closedPerDay: milestone.pace.closedPerDay,
686 paceDate: milestone.pace.paceDate,
687 pace: milestone.pace.line,
668688 // `building` is the count, as on a limb; this is who is on what.
669689 buildingNow: milestone.building.map((b) => ({
670690 ...brief(b.issue),
src/core/pace.ts added +59
@@ -0,0 +1,59 @@
1 /**
2 * Pace (#6848): how many issues a milestone closed each day over the last
3 * `PACE_DAYS` UTC days, and the date it is on pace to finish. Pure: it is
4 * handed the closing times and the numbers `readPlan` already has for the
5 * milestone's estimate, so the sentence under the estimate and this line
6 * cannot name different days. `now` is given, never read.
7 */
8
9 /** Days drawn, today last. */
10 export const PACE_DAYS = 14;
11 const DAY_MS = 86_400_000;
12
13 export interface Pace {
14 /** Issues closed each day, oldest first, today last: `PACE_DAYS` numbers. */
15 readonly closedPerDay: readonly number[];
16 /** The UTC date (YYYY-MM-DD) it is on pace to finish; null when stalled, done, deferred or empty. */
17 readonly paceDate: string | null;
18 /** `on pace for <date>`, `no movement in 7 days`, `done <date>`, or "" when there is nothing to say. */
19 readonly line: string;
20 }
21
22 /** What the estimate was worked out from. */
outdated

Nothing outside this function reads doneDate, and milestoneJson does not expose it. Drop it, or return it in the JSON beside paceDate.

23 export interface PaceInput {
24 /** When each issue beneath that counts and is closed, closed. */
25 readonly closedAt: readonly string[];
26 readonly total: number;
27 readonly open: number;
28 readonly deferred: boolean;
29 /** Closed in the estimate's window. */
30 readonly closedInWindow: number;
31 /** The window, in days. */
32 readonly window: number;
33 /** Days to finish at that pace, as the estimate says them. */
34 readonly days: number;
35 }
36
37 const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
38
39 export function paceOf(input: PaceInput, now: Date): Pace {
40 const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
41 const index = new Map(
42 Array.from({ length: PACE_DAYS }, (_, i) => [dayOf(today - (PACE_DAYS - 1 - i) * DAY_MS), i]),
43 );
44 const closedPerDay = Array.from({ length: PACE_DAYS }, () => 0);
45 for (const at of input.closedAt) {
46 const i = index.get(at.slice(0, 10));
47 if (i !== undefined) closedPerDay[i]!++;
48 }
49 const none = { closedPerDay, paceDate: null };
50 if (input.total === 0 || input.deferred) return { ...none, line: "" };
51 if (input.open === 0) {
52 const last = input.closedAt.reduce((m, a) => (a > m ? a : m), "");
53 return { ...none, line: last === "" ? "" : `done ${last.slice(0, 10)}` };
54 }
55 if (input.closedInWindow === 0)
56 return { ...none, line: `no movement in ${input.window} days` };
57 const paceDate = dayOf(today + input.days * DAY_MS);
58 return { closedPerDay, paceDate, line: `on pace for ${paceDate}` };
59 }
src/http/mcp/tools/milestones.ts +1 −1
@@ -3,7 +3,7 @@ import { good, bad, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"
33
44 export const tool: Tool = {
55 name: "milestones",
6 doc: "`{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, and for an owner `spend`: dollars, changes landed ever, the costliest three issues)",
6 doc: "`{ app, issue? }`: how far each thing asked for is (and, with `issue`, whether you watch it: `watching`), from the dependency tree: the plan's limbs with their weights, or one milestone opened (who is building, what is blocked and on what, what merged in 7 days, an estimate, `closedPerDay` (14 days) and `paceDate`, and for an owner `spend`: dollars, changes landed ever, the costliest three issues)",
77 description:
88 "How far each ask is. With `app`: top-level limbs, done/total, building, recent merges.",
99 inputSchema: {
src/tui/screens/limbs.ts +23 −14
@@ -29,7 +29,7 @@ import { IN_HAND } from "../../actions/plan_words.ts";
2929 import { pendingReplans } from "../../core/limb_replan.ts";
3030 import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts";
3131 import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
32 import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts";
32 import { readPlan, type Limb, type Milestone, type Plan } from "../../core/milestones.ts";
3333 import type { Ctx, Screen } from "../app/app.ts";
3434 import { binding } from "../app/keys.ts";
3535 import { fill, len, split } from "../ui/layout.ts";
@@ -54,8 +54,7 @@ const LIVE_KINDS = ["limb.moved", "limb.replan", "limb.replan_taken_back", "issu
5454 const SHOWN = 6;
5555
5656 /** The limbs of `group` (`top`, or a milestone's number), in their set order. */
57 function limbsOf(f: Session, repo: Repo, group: string): Limb[] | undefined {
58 const plan = readPlan(f.db, repo);
57 function limbsOf(plan: Plan, group: string): Limb[] | undefined {
5958 if (group === "top") return [...plan.limbs];
6059 const milestone = plan.milestone(Number(group));
6160 return milestone === undefined ? undefined : [...milestone.limbs];
@@ -110,6 +109,8 @@ export function limbsScreen(
110109 let loaded = false;
111110 /** What each limb here cost and landed, by issue number; empty unless owner facts. */
112111 let spends = new Map<number, NonNullable<Milestone["spend"]>>();
112 /** Each limb's pace line, by issue number: on pace for a date, no movement, or done. */
113 let paces = new Map<number, string>();
113114 /** The receipt of the last move made here, which `u` reverses once. */
114115 let last: string | undefined;
115116 /** The limbs with the planner, by issue id (#761). */
@@ -122,19 +123,21 @@ export function limbsScreen(
122123 if (!canRead(f.db, repo, f.user)) {
123124 limbs = [];
124125 spends = new Map();
126 paces = new Map();
125127 loaded = true;
126128 ctx.invalidate();
127129 return;
128130 }
129 limbs = limbsOf(f, repo, group) ?? [];
131 const plan = readPlan(f.db, repo, { spend: canSeeOwnerFacts(f.db, repo, f.user) });
132 limbs = limbsOf(plan, group) ?? [];
130133 withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId));
131134 spends = new Map();
132 if (canSeeOwnerFacts(f.db, repo, f.user)) {
133 const plan = readPlan(f.db, repo, { spend: true });
134 for (const l of limbs) {
135 const spend = plan.milestone(l.issue.number)?.spend;
136 if (spend !== undefined) spends.set(l.issue.number, spend);
137 }
135 paces = new Map();
136 for (const l of limbs) {
outdated

Low (performance): limbsOf just above already calls readPlan, and this is a second uncached plan read on every load. It used to happen for owners only; now every viewer pays it. Fix: read the plan once (with spend) and pass it to limbsOf, so the screen costs one read again.

137 const m = plan.milestone(l.issue.number);
138 if (m === undefined) continue;
139 if (m.spend !== undefined) spends.set(l.issue.number, m.spend);
140 if (m.pace.line !== "") paces.set(l.issue.number, m.pace.line);
138141 }
139142 loaded = true;
140143 if (follow !== undefined) {
@@ -257,7 +260,7 @@ export function limbsScreen(
257260 if (limb === undefined) return;
258261 const number = limb.issue.number;
259262 // Only a limb with limbs of its own has anything to open.
260 if ((limbsOf(f, repo, String(number)) ?? []).length === 0) return;
263 if ((limbsOf(readPlan(f.db, repo), String(number)) ?? []).length === 0) return;
261264 ctx.push(limbsScreen(f, ctx, repo, String(number), limb.name));
262265 };
263266
@@ -289,7 +292,12 @@ export function limbsScreen(
289292 render(area, s) {
290293 const c = ctx.theme.c;
291294 const spend = current() === undefined ? undefined : spends.get(current()!.issue.number);
292 const [head, list, foot] = split(area, "v", [len(1), fill(1), len(spend ? 2 : 0)]);
295 const pace = current() === undefined ? undefined : paces.get(current()!.issue.number);
296 const [head, list, foot] = split(area, "v", [
297 len(1),
298 fill(1),
299 len((pace === undefined ? 0 : 1) + (spend ? 2 : 0)),
300 ]);
293301 const note = !loaded
294302 ? "Loading…"
295303 : limbs.length === 0
@@ -297,8 +305,9 @@ export function limbsScreen(
297305 : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`;
298306 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
299307 drawList(s, list!, c, state, rows());
300 if (spend !== undefined)
301 spendLines(spend).forEach((text, n) =>
308 const lines = [...(pace === undefined ? [] : [pace]), ...(spend === undefined ? [] : spendLines(spend))];
309 if (lines.length > 0)
310 lines.forEach((text, n) =>
302311 s.text(foot!.x, foot!.y + n, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w),
303312 );
304313 hitList(ctx, list!, state, limbs.length, { open });
src/web/pages/milestones.ts +23 −6
@@ -20,6 +20,8 @@ import {
2020 type Milestone,
2121 type Plan,
2222 } from "../../core/milestones.ts";
23 import type { Pace } from "../../core/pace.ts";
24 import { daySpark } from "../studio/kpi_row.ts";
2325 import type { Database } from "bun:sqlite";
2426 import { parseSeat, seating } from "../../core/limb_seats.ts";
2527 import type { Issue } from "../../core/issues.ts";
@@ -198,17 +200,18 @@ function limbLine(base: string, limb: Limb, move: Html | null = null): Html {
198200 }
199201
200202 /** The repository's plan, kept while nothing it reads changes. */
201 const planFor = (ctx: Ctx, repo: Repo, spend: boolean): Plan =>
203 const planFor = (ctx: Ctx, repo: Repo, spend: boolean, now: Date): Plan =>
202204 cachedPlan(
203205 {
204206 repoId: repo.id,
205207 stamp: planStamp(ctx.app.db, repo),
206208 spend,
207 now: new Date(),
209 now,
208210 },
209211 () =>
210212 readPlan(ctx.app.db, repo, {
211213 spend,
214 now,
212215 stretch: (name, ms) => notePhase(ctx.request, `plan-${name}`, ms),
213216 }),
214217 );
@@ -219,7 +222,7 @@ export function milestonesPage(ctx: Ctx): Response {
219222 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
220223 if (!repo) return notFoundPage(ctx);
221224 const spend = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
222 const plan = timePhase(ctx.request, "plan", () => planFor(ctx, repo, spend));
225 const plan = timePhase(ctx.request, "plan", () => planFor(ctx, repo, spend, new Date()));
223226 const base = repoBase(repo);
224227 const move = timePhase(ctx.request, "seats", () =>
225228 moverFor(ctx.app.db, repo, ctx.auth.user, plan.read),
@@ -358,6 +361,18 @@ function watchButton(base: string, m: Milestone, watching: boolean): Html {
358361 </form>`;
359362 }
360363
364 /** Issues closed per day with the design system's sparkline, and one line of what it means. */
365 function paceLine(pace: Pace): Html | null {
366 if (pace.line === "") return null;
367 const spark = daySpark(
368 pace.closedPerDay,
369 `Issues closed each day over the last ${pace.closedPerDay.length} days`,
370 );
371 return html`<div class="bar" id="ms-pace">
372 <span class="muted">${spark}</span><span>${pace.line}</span>
373 </div>`;
374 }
375
361376 function milestoneBody(
362377 repo: Repo,
363378 m: Milestone,
@@ -401,6 +416,7 @@ function milestoneBody(
401416 m.trim === undefined ? {} : { tone: "attn" },
402417 )}
403418 <p id="ms-estimate">${m.estimate}</p>
419 ${paceLine(m.pace)}

This renders right under #ms-estimate. For a deferred milestone it reads "no pace to estimate from" followed by "on pace for <date>". Hide the line when deferred, or make the estimate and the pace line one sentence.

404420 ${when(
405421 m.trim,
406422 (why) => html`<p class="small">A candidate to trim: ${why}.</p>`,
@@ -535,14 +551,14 @@ function milestoneBody(
535551 }
536552
537553 /** GET /:owner/:name/milestones/:number — one milestone, opened. */
538 export function milestonePage(ctx: Ctx): Response {
554 export function milestonePage(ctx: Ctx, now: Date = new Date()): Response {
539555 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
540556 const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx));
541557 if (!repo) return notFoundPage(ctx);
542558 const number = Number(ctx.params.number);
543559 const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
544560 const plan = Number.isInteger(number)
545 ? timePhase(ctx.request, "plan", () => planFor(ctx, repo, owner))
561 ? timePhase(ctx.request, "plan", () => planFor(ctx, repo, owner, now))
546562 : undefined;
547563 const milestone = plan?.milestone(number);
548564 if (plan === undefined || milestone === undefined) return notFoundPage(ctx);
@@ -659,9 +675,10 @@ export async function milestoneData(
659675 ctx: Ctx,
660676 repo: Repo,
661677 number: number,
678 now: Date = new Date(),
662679 ): Promise<Record<string, unknown> | undefined> {
663680 const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
664 const milestone = readPlan(ctx.app.db, repo, { spend: owner }).milestone(
681 const milestone = readPlan(ctx.app.db, repo, { spend: owner, now }).milestone(
665682 number,
666683 );
667684 const viewer = ctx.auth.user;
test/milestone-pace.test.ts added +148
@@ -0,0 +1,148 @@
1 /** A milestone's pace (#6848): closed per day, and the date it is on pace for, on the page and in the MCP. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { linkIssue } from "../src/actions/dependencies.ts";
7 import { openIssue, setIssueState } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { getIssueById, type Issue } from "../src/core/issues.ts";
11 import { paceOf } from "../src/core/pace.ts";
12 import { getRepo, type Repo } from "../src/core/repos.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import type { Ctx } from "../src/http/context.ts";
15 import { milestoneData, milestonePage } from "../src/web/pages/milestones.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17 import { proveAll } from "./helpers/verified.ts";
18
19 quietLogs();
20
21 // One instant for the whole file; every closing time is before it.
22 const NOW = new Date("2026-10-27T12:00:00.000Z");
23 const at = (daysAgo: number, hours = 1): string =>
24 new Date(NOW.getTime() - daysAgo * 86_400_000 - hours * 3_600_000).toISOString();
25
26 let root = "";
27 let app: App;
28 let repo: Repo;
29
30 beforeAll(async () => {
31 root = mkdtempSync(join(tmpdir(), "forge-pace-"));
32 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
33 const u = await createUser(app.db, {
34 origin: "seed",
35 trusted: true,
36 username: "alice",
37 email: "[email protected]",
38 password: "a-good-passphrase",
39 adminIfFirst: true,
40 });
41 if (!u.ok) throw new Error(u.error);
42 proveAll(app);
43 const made = await createRepo(app, u.value as never, { name: "forge" });
44 if (!made.ok) throw new Error(made.error);
45 repo = getRepo(app.db, "alice", "forge")!;
46 });
47 afterAll(() => {
48 app.close();
49 rmSync(root, { recursive: true, force: true });
50 });
51
52 const alice = () => getUserByName(app.db, "alice")!;
53 const make = (title: string): Issue => {
54 const o = openIssue(app, alice(), repo, { title });
55 if (!o.ok) throw new Error(String(o.error));
56 return o.value;
57 };
58
59 /**
60 * A milestone of `open` open leaves and one closed leaf for each entry of
61 * `closedAt`, a closing time (a number is that many days before NOW).
62 */
63 function milestone(name: string, open: number, closedAt: (number | string)[], gate?: Issue): Issue {
64 const top = make(name);
65 for (const [i, ago] of [...closedAt, ...Array(open).fill(null)].entries()) {
66 const leaf = make(`${name} ${i}`);
67 const linked = linkIssue(app, alice(), repo, top, leaf);
68 if (!linked.ok) throw new Error(String(linked.error));
69 if (ago === null) continue;
70 expect(setIssueState(app, alice(), repo, getIssueById(app.db, leaf.id)!, "closed").ok).toBe(true);
71 app.db
72 .query("UPDATE issue SET closed_at = ?1 WHERE id = ?2")
73 .run(typeof ago === "number" ? at(ago) : ago, leaf.id);
74 }
75 if (gate !== undefined && !linkIssue(app, alice(), repo, top, gate).ok) throw new Error("gate");
76 return top;
77 }
78
79 const ctxFor = (number: number): Ctx =>
80 ({
81 app,
82 request: new Request(`http://x/alice/forge/milestones/${number}`),
83 url: new URL(`http://x/alice/forge/milestones/${number}`),
84 params: { owner: "alice", name: "forge", number: String(number) },
85 auth: { user: alice(), scope: undefined, via: "session" },
86 theme: undefined,
87 }) as Ctx;
88
89 /** The page's pace line and the MCP's data, both at NOW. */
90 async function both(top: Issue) {
91 const html = await milestonePage(ctxFor(top.number), NOW).text();
92 const line = /id="ms-pace"[\s\S]*?<span>([^<]*)<\/span>\s*<\/div>/.exec(html)?.[1];
93 const data = (await milestoneData(ctxFor(top.number), repo, top.number, NOW))!;
94 return { html, line, data };
95 }
96
97 test("on pace: 3 closed in 7 days and 2 open lands 5 days out; page and milestoneData, the MCP tool's read, agree", async () => {
98 const top = milestone("Pace", 2, [0, 1, 2, 9]);
99 const { html, line, data } = await both(top);
100 expect(data["closedPerDay"]).toEqual([0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1]);
101 // 3 closed in the last 7 days: ceil(2 / (3 / 7)) = 5 days after 2026-10-27.
102 expect(data["paceDate"]).toBe("2026-11-01");
103 expect(line).toBe(`on pace for ${data["paceDate"]}`);
104 expect(data["pace"]).toBe(line!);
105 expect(html).toContain("<svg");
106 });
107
108 test("stalled: nothing closed in 7 days says so and has no date", async () => {
109 const top = milestone("Stalled", 1, [10]);
110 const { line, data } = await both(top);
111 expect(line).toBe("no movement in 7 days");
112 expect(data["paceDate"]).toBeNull();
113 });
114
115 test("done: nothing open says the day the last one closed", async () => {
116 const top = milestone("Finished", 0, [3, 5]);
117 const { line, data } = await both(top);
118 expect(line).toBe("done 2026-10-24");
119 expect(data["paceDate"]).toBeNull();
120 });
121
122 test("the pace line and the estimate above it use one window: an issue closed 6.75 days ago counts in both", async () => {
123 // Oct 20 18:00 is inside the rolling 7 days, outside seven calendar days ending today.
124 const top = milestone("Edge", 2, ["2026-10-20T18:00:00.000Z"]);
125 const { html, line, data } = await both(top);
126 expect(html).toContain("About 14 days at this week&#39;s pace");
127 expect(line).toBe("on pace for 2026-11-10");
128 expect(data["paceDate"]).toBe("2026-11-10");
129 });
130
131 test("deferred: the estimate gives no pace and the page shows no pace line either", async () => {
132 const gate = make("Later gate: hold");
133 const top = milestone("Held", 2, [1], gate);
134 const { html, data } = await both(top);
135 expect(html).toContain("Deferred");
136 expect(html).not.toContain("ms-pace");
137 expect(data["paceDate"]).toBeNull();
138 expect(data["pace"]).toBe("");
139 });
140
141 test("paceOf reads no database: closing times in, buckets and a line out", () => {
142 const input = { closedAt: [at(0), at(0), at(20)], total: 5, open: 2, deferred: false, closedInWindow: 2, window: 7, days: 7 };
143 const pace = paceOf(input, NOW);
144 expect(pace.closedPerDay.at(-1)).toBe(2);
145 expect(pace.closedPerDay.reduce((a, b) => a + b, 0)).toBe(2);
146 expect(pace.line).toBe("on pace for 2026-11-03");
147 expect(paceOf({ ...input, deferred: true }, NOW).line).toBe("");
148 });
test/tui-limbs.test.ts +10
@@ -229,6 +229,16 @@ describe("raise and lower a limb in the terminal", () => {
229229 }
230230 });
231231
232 test("the limb under the cursor says its pace, to a guest as to an owner", async () => {
233 for (const who of [alice, undefined]) {
234 const h = boot(who);
235 await h.toMilestones("alice/order");
236 // Nothing under Trust has closed.
237 expect(h.text()).toContain("no movement in 7 days");
238 h.stop();
239 }
240 });
241
232242 test("a guest sees a public order, never a private one, and moves nothing", async () => {
233243 const h = boot(undefined);
234244 await h.toMilestones("alice/vault");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs