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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6848-a-milestone-s-page-draws-issues-closed-p → main 6 commits fork 6 threads · 6 unresolved
README.md +6
| @@ -331,6 +331,12 @@ work here. | ||
| 331 | 331 | The running list of what the forge does, one bullet each, lives in |
| 332 | 332 | [](docs/STATUS.md). |
| 333 | 333 | |
| 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 | |
| 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 | ||
| 334 | 340 | ## Where the rest is |
| 335 | 341 | |
| 336 | 342 | - [](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. | ||
| 91 | 91 | | `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 | |
| 92 | 92 | | `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 | | |
| 93 | 93 | | `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) | | | |
| 95 | 95 | | `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 | |
| 96 | 96 | | `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 | |
| 97 | 97 | | `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. | ||
| 267 | 267 | `/:owner/:name/milestones/:number` is one milestone opened: who is building |
| 268 | 268 | what, what is blocked and on what, what merged this week, and a plain |
| 269 | 269 | 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 | |
| 271 | 276 | as limbs where a child brings a tree of its own, and as leaves where it |
| 272 | 277 | brings none (one that only waits for its siblings is a leaf in an order). |
| 273 | 278 | Leaves show only on the limb they are under, and each issue shows once: |
src/core/milestones.ts +20
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | * from it. |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | import { paceOf, type Pace } from "./pace.ts"; | |
| 16 | 17 | import type { Database } from "bun:sqlite"; |
| 17 | 18 | import { writeStamp } from "./write_clock.ts"; |
| 18 | 19 | import { deferredIn, repoGraph, type Graph } from "./dependencies.ts"; |
| @@ -117,6 +118,8 @@ export interface Milestone extends Limb { | ||
| 117 | 118 | }; |
| 118 | 119 | /** A plain sentence: when it lands at this week's pace, or why nobody can say. */ |
| 119 | 120 | 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; | |
| 120 | 123 | /** What it waits for directly that has a tree of its own. */ |
| 121 | 124 | readonly limbs: readonly Limb[]; |
| 122 | 125 | /** What it waits for directly that brings none: at most an order among these. */ |
| @@ -605,6 +608,20 @@ export function readPlan( | ||
| 605 | 608 | } |
| 606 | 609 | : {}), |
| 607 | 610 | estimate, |
| 611 | pace: paceOf( | |
This adds a SQL query per |
||
| 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 | ), | |
| 608 | 625 | limbs: arranged(issue.id, [...limbIds]).map(limbOf), |
| 609 | 626 | leaves: children |
| 610 | 627 | .filter((c) => !limbIds.has(c.id)) |
| @@ -665,6 +682,9 @@ export function milestoneJson( | ||
| 665 | 682 | ...limbJson(milestone), |
| 666 | 683 | title: milestone.issue.title, |
| 667 | 684 | estimate: milestone.estimate, |
| 685 | closedPerDay: milestone.pace.closedPerDay, | |
| 686 | paceDate: milestone.pace.paceDate, | |
| 687 | pace: milestone.pace.line, | |
| 668 | 688 | // `building` is the count, as on a limb; this is who is on what. |
| 669 | 689 | buildingNow: milestone.building.map((b) => ({ |
| 670 | 690 | ...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 |
||
| 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" | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | 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)", | |
| 7 | 7 | description: |
| 8 | 8 | "How far each ask is. With `app`: top-level limbs, done/total, building, recent merges.", |
| 9 | 9 | inputSchema: { |
src/tui/screens/limbs.ts +23 −14
| @@ -29,7 +29,7 @@ import { IN_HAND } from "../../actions/plan_words.ts"; | ||
| 29 | 29 | import { pendingReplans } from "../../core/limb_replan.ts"; |
| 30 | 30 | import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts"; |
| 31 | 31 | 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"; | |
| 33 | 33 | import type { Ctx, Screen } from "../app/app.ts"; |
| 34 | 34 | import { binding } from "../app/keys.ts"; |
| 35 | 35 | import { fill, len, split } from "../ui/layout.ts"; |
| @@ -54,8 +54,7 @@ const LIVE_KINDS = ["limb.moved", "limb.replan", "limb.replan_taken_back", "issu | ||
| 54 | 54 | const SHOWN = 6; |
| 55 | 55 | |
| 56 | 56 | /** 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 { | |
| 59 | 58 | if (group === "top") return [...plan.limbs]; |
| 60 | 59 | const milestone = plan.milestone(Number(group)); |
| 61 | 60 | return milestone === undefined ? undefined : [...milestone.limbs]; |
| @@ -110,6 +109,8 @@ export function limbsScreen( | ||
| 110 | 109 | let loaded = false; |
| 111 | 110 | /** What each limb here cost and landed, by issue number; empty unless owner facts. */ |
| 112 | 111 | 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>(); | |
| 113 | 114 | /** The receipt of the last move made here, which `u` reverses once. */ |
| 114 | 115 | let last: string | undefined; |
| 115 | 116 | /** The limbs with the planner, by issue id (#761). */ |
| @@ -122,19 +123,21 @@ export function limbsScreen( | ||
| 122 | 123 | if (!canRead(f.db, repo, f.user)) { |
| 123 | 124 | limbs = []; |
| 124 | 125 | spends = new Map(); |
| 126 | paces = new Map(); | |
| 125 | 127 | loaded = true; |
| 126 | 128 | ctx.invalidate(); |
| 127 | 129 | return; |
| 128 | 130 | } |
| 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) ?? []; | |
| 130 | 133 | withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId)); |
| 131 | 134 | 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): |
||
| 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); | |
| 138 | 141 | } |
| 139 | 142 | loaded = true; |
| 140 | 143 | if (follow !== undefined) { |
| @@ -257,7 +260,7 @@ export function limbsScreen( | ||
| 257 | 260 | if (limb === undefined) return; |
| 258 | 261 | const number = limb.issue.number; |
| 259 | 262 | // 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; | |
| 261 | 264 | ctx.push(limbsScreen(f, ctx, repo, String(number), limb.name)); |
| 262 | 265 | }; |
| 263 | 266 | |
| @@ -289,7 +292,12 @@ export function limbsScreen( | ||
| 289 | 292 | render(area, s) { |
| 290 | 293 | const c = ctx.theme.c; |
| 291 | 294 | 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 | ]); | |
| 293 | 301 | const note = !loaded |
| 294 | 302 | ? "Loading…" |
| 295 | 303 | : limbs.length === 0 |
| @@ -297,8 +305,9 @@ export function limbsScreen( | ||
| 297 | 305 | : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`; |
| 298 | 306 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 299 | 307 | 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) => | |
| 302 | 311 | s.text(foot!.x, foot!.y + n, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w), |
| 303 | 312 | ); |
| 304 | 313 | hitList(ctx, list!, state, limbs.length, { open }); |
src/web/pages/milestones.ts +23 −6
| @@ -20,6 +20,8 @@ import { | ||
| 20 | 20 | type Milestone, |
| 21 | 21 | type Plan, |
| 22 | 22 | } from "../../core/milestones.ts"; |
| 23 | import type { Pace } from "../../core/pace.ts"; | |
| 24 | import { daySpark } from "../studio/kpi_row.ts"; | |
| 23 | 25 | import type { Database } from "bun:sqlite"; |
| 24 | 26 | import { parseSeat, seating } from "../../core/limb_seats.ts"; |
| 25 | 27 | import type { Issue } from "../../core/issues.ts"; |
| @@ -198,17 +200,18 @@ function limbLine(base: string, limb: Limb, move: Html | null = null): Html { | ||
| 198 | 200 | } |
| 199 | 201 | |
| 200 | 202 | /** 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 => | |
| 202 | 204 | cachedPlan( |
| 203 | 205 | { |
| 204 | 206 | repoId: repo.id, |
| 205 | 207 | stamp: planStamp(ctx.app.db, repo), |
| 206 | 208 | spend, |
| 207 | now: new Date(), | |
| 209 | now, | |
| 208 | 210 | }, |
| 209 | 211 | () => |
| 210 | 212 | readPlan(ctx.app.db, repo, { |
| 211 | 213 | spend, |
| 214 | now, | |
| 212 | 215 | stretch: (name, ms) => notePhase(ctx.request, `plan-${name}`, ms), |
| 213 | 216 | }), |
| 214 | 217 | ); |
| @@ -219,7 +222,7 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 219 | 222 | const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx)); |
| 220 | 223 | if (!repo) return notFoundPage(ctx); |
| 221 | 224 | 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())); | |
| 223 | 226 | const base = repoBase(repo); |
| 224 | 227 | const move = timePhase(ctx.request, "seats", () => |
| 225 | 228 | moverFor(ctx.app.db, repo, ctx.auth.user, plan.read), |
| @@ -358,6 +361,18 @@ function watchButton(base: string, m: Milestone, watching: boolean): Html { | ||
| 358 | 361 | </form>`; |
| 359 | 362 | } |
| 360 | 363 | |
| 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 | ||
| 361 | 376 | function milestoneBody( |
| 362 | 377 | repo: Repo, |
| 363 | 378 | m: Milestone, |
| @@ -401,6 +416,7 @@ function milestoneBody( | ||
| 401 | 416 | m.trim === undefined ? {} : { tone: "attn" }, |
| 402 | 417 | )} |
| 403 | 418 | <p id="ms-estimate">${m.estimate}</p> |
| 419 | ${paceLine(m.pace)} | |
This renders right under |
||
| 404 | 420 | ${when( |
| 405 | 421 | m.trim, |
| 406 | 422 | (why) => html`<p class="small">A candidate to trim: ${why}.</p>`, |
| @@ -535,14 +551,14 @@ function milestoneBody( | ||
| 535 | 551 | } |
| 536 | 552 | |
| 537 | 553 | /** 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 { | |
| 539 | 555 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 540 | 556 | const repo = timePhase(ctx.request, "repo", () => loadRepo(ctx)); |
| 541 | 557 | if (!repo) return notFoundPage(ctx); |
| 542 | 558 | const number = Number(ctx.params.number); |
| 543 | 559 | const owner = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user); |
| 544 | 560 | const plan = Number.isInteger(number) |
| 545 | ? timePhase(ctx.request, "plan", () => planFor(ctx, repo, owner)) | |
| 561 | ? timePhase(ctx.request, "plan", () => planFor(ctx, repo, owner, now)) | |
| 546 | 562 | : undefined; |
| 547 | 563 | const milestone = plan?.milestone(number); |
| 548 | 564 | if (plan === undefined || milestone === undefined) return notFoundPage(ctx); |
| @@ -659,9 +675,10 @@ export async function milestoneData( | ||
| 659 | 675 | ctx: Ctx, |
| 660 | 676 | repo: Repo, |
| 661 | 677 | number: number, |
| 678 | now: Date = new Date(), | |
| 662 | 679 | ): Promise<Record<string, unknown> | undefined> { |
| 663 | 680 | 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( | |
| 665 | 682 | number, |
| 666 | 683 | ); |
| 667 | 684 | 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'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", () => { | ||
| 229 | 229 | } |
| 230 | 230 | }); |
| 231 | 231 | |
| 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 | ||
| 232 | 242 | test("a guest sees a public order, never a private one, and moves nothing", async () => { |
| 233 | 243 | const h = boot(undefined); |
| 234 | 244 | await h.toMilestones("alice/vault"); |
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
milestonestool" would say it exactly.