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

The terminal's milestones screen shows each milestone's pace: issues closed per day as a sparkline and when it lands at this pace #8119

Merged opened this

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

Merged

scout/forge:scout/8058-the-terminal-s-milestones-screen-shows-e → main 2 commits fork

  • Merged. scout/8058-the-terminal-s-milestones-screen-shows-e was merged into main as 686a75a.

Review

Sign in to review this change.
3 files changed +339 −15
src/tui/screens/common.ts +52 −8
@@ -48,6 +48,19 @@ export class ListState {
4848 offset = 0;
4949 /** Rows the last render had room for — what `ctrl+d` pages by. */
5050 height = 10;
51 /** Screen rows each drawn item took, from `offset`, when some item had a note; else undefined. */
52 heights: number[] | undefined;
53
54 /** The item at list row `y`: where a click landed, however tall the items above it. */
55 at(y: number): number {
56 if (this.heights === undefined) return this.offset + y;
57 let top = 0;
58 for (let i = 0; i < this.heights.length; i++) {
59 top += this.heights[i]!;
60 if (y < top) return this.offset + i;
61 }
62 return -1;
63 }
5164
5265 clamp(length: number): void {
5366 this.index = Math.max(0, Math.min(this.index, Math.max(0, length - 1)));
@@ -215,6 +228,8 @@ export interface ListRow {
215228 readonly hollow?: boolean;
216229 readonly secondary?: string;
217230 readonly meta?: string;
231 /** A muted line under the row, indented past its dot; the item is then two rows tall. */
232 readonly note?: string;
218233 }
219234
220235 /** A scrolling list of `row()`s with a scrollbar when it overflows. */
@@ -228,15 +243,44 @@ export function drawList(
228243 ): void {
229244 if (r.w <= 0 || r.h <= 0) return;
230245 const body = { x: r.x, y: r.y, w: Math.max(0, r.w - 1), h: r.h };
231 const from = state.window(r.h, rows.length);
232 for (let i = 0; i < r.h; i++) {
233 const item = rows[from + i];
234 if (item === undefined) break;
235 row(s, { x: body.x, y: body.y + i, w: body.w, h: 1 }, c, {
246 const noted = rows.some((i) => i.note !== undefined && i.note !== "");
247 const tall = (i: number): number => (noted && rows[i]!.note ? 2 : 1);
248 let from: number;
249 if (!noted) {
250 state.heights = undefined;
251 from = state.window(r.h, rows.length);
252 } else {
253 // Scroll by item, so the cursor's whole entry (row and note) is in view.
254 state.clamp(rows.length);
255 from = Math.max(0, Math.min(state.offset, state.index));
256 const span = (a: number, b: number): number => {
257 let h = 0;
258 for (let i = a; i <= b; i++) h += tall(i);
259 return h;
260 };
261 while (from < state.index && span(from, state.index) > r.h) from++;
262 // Never leave a gap under the last item when the ones above would fit.
263 while (from > 0 && span(from - 1, rows.length - 1) <= r.h) from--;
264 state.offset = from;
265 }
266 let y = 0;
267 const heights: number[] = [];
268 for (let i = from; i < rows.length && y < r.h; i++) {
269 const item = rows[i]!;
270 const h = Math.min(tall(i), r.h - y);
271 row(s, { x: body.x, y: body.y + y, w: body.w, h: 1 }, c, {
236272 ...item,
237 selected: focused && from + i === state.index,
238 focused: from + i === state.index,
273 selected: focused && i === state.index,
274 focused: i === state.index,
239275 });
276 if (item.note && h > 1 && body.w > 4)
277 s.text(body.x + 4, body.y + y + 1, truncate(item.note, body.w - 4), style(c.mutedFg, c.bg), body.x + body.w);
278 heights.push(h);
279 y += h;
280 }
281 if (noted) {
282 state.heights = heights;
283 state.height = Math.max(1, heights.length);
240284 }
241285 scrollbar(s, { x: r.x + r.w - 1, y: r.y, w: 1, h: r.h }, c, {
242286 total: rows.length,
@@ -276,7 +320,7 @@ export function hitList(
276320 ): void {
277321 ctx.hit(r, {
278322 click: (e) => {
279 const index = state.offset + e.y;
323 const index = state.at(e.y);
280324 if (index < 0 || index >= count) return;
281325 if (index === state.index || e.double) {
282326 state.index = index;
src/tui/screens/limbs.ts +24 −7
@@ -50,6 +50,7 @@ import { binding } from "../app/keys.ts";
5050 import { fill, len, split } from "../ui/layout.ts";
5151 import { style } from "../term/style.ts";
5252 import { truncate } from "../term/width.ts";
53 import { type Pace } from "../../core/pace.ts";
5354 import type { Session } from "../session.ts";
5455 import { issueScreen } from "./issue.ts";
5556 import { local } from "./where.ts";
@@ -121,6 +122,22 @@ export function replanLines(
121122 return { lines, start, shown };
122123 }
123124
125 const BLOCKS = "▁▂▃▄▅▆▇█";
126
127 /** Issues closed per day as block characters, scaled to the largest day; all quiet is the lowest block. */
128 export function paceSpark(days: readonly number[]): string {
129 const top = Math.max(1, ...days);
130 return days.map((n) => BLOCKS[Math.min(7, Math.floor((Math.max(0, n) / top) * 8))]).join("");
131 }
132
133 /** The pace line for `width` cells: sparkline, then the sentence; the sparkline goes first when narrow. */
134 export function paceText(pace: Pace | undefined, width: number): string {
135 if (pace === undefined || pace.line === "" || width <= 0) return "";
136 const spark = paceSpark(pace.closedPerDay);
137 const both = `${spark} ${pace.line}`;
138 return both.length <= width ? both : truncate(pace.line, width);
139 }
140
124141 const line = (n: number, title: string): string => `#${n} ${title}`;
125142
126143 /** What a preview says before anything is written, as dialog lines. */
@@ -162,7 +179,7 @@ export function limbsScreen(
162179 /** What each limb here cost and landed, by issue number; empty unless owner facts. */
163180 let spends = new Map<number, NonNullable<Milestone["spend"]>>();
164181 /** Each limb's pace line, by issue number: on pace for a date, no movement, or done. */
165 let paces = new Map<number, string>();
182 let paces = new Map<number, Pace>();
166183 /** The receipt of the last move made here, which `u` reverses once. */
167184 let last: string | undefined;
168185 /** The limbs with the planner, by issue id (#761). */
@@ -206,7 +223,7 @@ export function limbsScreen(
206223 if (f.user?.kind === "user" && isWatching(f.db, f.user.id, m.issue.id))
207224 watching.add(m.issue.id);
208225 if (m.spend !== undefined) spends.set(l.issue.number, m.spend);
209 if (m.pace.line !== "") paces.set(l.issue.number, m.pace.line);
226 if (m.pace.line !== "") paces.set(l.issue.number, m.pace);
210227 }
211228 loaded = true;
212229 if (follow !== undefined) {
@@ -361,8 +378,10 @@ export function limbsScreen(
361378 ctx.push(limbsScreen(f, ctx, repo, String(number), limb.name));
362379 };
363380
364 const rows = (): ListRow[] =>
381 /** One row per limb; a limb with a pace says it in a note under the row, `width` cells wide. */
382 const rows = (width: number): ListRow[] =>
365383 limbs.map((l, n) => ({
384 note: paceText(paces.get(l.issue.number), width),
366385 title: `${n + 1}. ${line(l.issue.number, l.name)}`,
367386 secondary: withPlanner.has(l.issue.id) ? "with the planner" : l.deferred ? "deferred" : "",
368387 meta: `${watching.has(l.issue.id) ? "watching · " : ""}${l.weight.done}/${l.weight.total} done`,
@@ -391,9 +410,8 @@ export function limbsScreen(
391410 render(area, s) {
392411 const c = ctx.theme.c;
393412 const spend = current() === undefined ? undefined : spends.get(current()!.issue.number);
394 const pace = current() === undefined ? undefined : paces.get(current()!.issue.number);
395413 const answered = current() === undefined ? undefined : replanned.get(current()!.issue.number);
396 const fixed = (pace === undefined ? 0 : 1) + (spend ? 2 : 0);
414 const fixed = spend ? 2 : 0;
397415 // The foot never takes more than half of what the header leaves.
398416 const block =
399417 answered === undefined
@@ -412,9 +430,8 @@ export function limbsScreen(
412430 ? "Nothing here waits for anything yet, so there is no milestone to show."
413431 : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`;
414432 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
415 drawList(s, list!, c, state, rows());
433 drawList(s, list!, c, state, rows(list!.w - 5));
416434 const lines = [
417 ...(pace === undefined ? [] : [pace]),
418435 ...(spend === undefined ? [] : spendLines(spend)),
419436 ...block.lines,
420437 ];
test/tui-limbs-pace.test.ts added +263
@@ -0,0 +1,263 @@
1 /**
2 * The limbs screen draws each milestone's pace under its row (#8058): a
3 * sparkline of issues closed per day and `pace.line` word for word, the
4 * same line the milestones page draws from the same `readPlan`.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { linkIssue } from "../src/actions/dependencies.ts";
12 import { openIssue, setIssueState } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { getIssueById, type Issue } from "../src/core/issues.ts";
15 import { readPlan } from "../src/core/milestones.ts";
16 import { PACE_DAYS } from "../src/core/pace.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import { paceSpark, paceText } from "../src/tui/screens/limbs.ts";
21 import type { TuiIdentity } from "../src/tui/session.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp } from "./helpers/tui.ts";
25
26 quietLogs();
27
28 const SPARK = /[▁▂▃▄▅▆▇█]{14}/;
29
30 describe("pace under each milestone in the terminal", () => {
31 let dataDir: string;
32 let app: App;
33 let alice: User;
34 let repo: Repo;
35 let manyId = "";
36 let moving: Issue;
37 let still: Issue;
38 const plan = (): ReturnType<typeof readPlan> => readPlan(app.db, getRepoById(app.db, manyId)!, { spend: false });
39 const fresh = () => getRepoById(app.db, repo.id)!;
40 const current = (i: Issue) => getIssueById(app.db, i.id)!;
41 const open = (title: string): Issue => {
42 const made = openIssue(app, alice, fresh(), { title });
43 if (!made.ok) throw new Error(String(made.error));
44 return made.value;
45 };
46
47 function boot(rows = 32, cols = 120, name = "alice/pace") {
48 const terminal = headlessTerminal(cols, rows);
49 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: alice };
50 const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false });
51 tui.start();
52 const press = async (...keys: string[]) => {
53 for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
54 await settleApp(tui);
55 };
56 const toLimbs = async () => {
57 await settleApp(tui);
58 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
59 await settleApp(tui);
60 await press(..."Milestones in a repository".split(""), "enter");
61 await press(...name.split(""), "enter");
62 };
63 const mouse = async (kind: "press" | "wheel-down" | "wheel-up", x: number, y: number) => {
64 tui.dispatch({ type: "mouse", kind, button: 0, x, y, ctrl: false, alt: false, shift: false });
65 await settleApp(tui);
66 };
67 const text = () => tui.screen.toText();
68 /** The screen row holding `needle`. */
69 const rowOf = (needle: string): number => text().split("\n").findIndex((r) => /\d+\. #\d+ /.test(r) && r.includes(needle));
70 /** Click the row that holds `needle`, `below` rows under it. */
71 const click = (needle: string, below = 0) => mouse("press", 12, rowOf(needle) + below);
72 return { text, press, toLimbs, click, mouse, rowOf, stop: () => tui.stop() };
73 }
74
75 beforeAll(async () => {
76 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-pace-"));
77 app = createApp({ dataDir, port: 0 });
78 const user = await createUser(app.db, {
79 origin: "seed",
80 trusted: true,
81 username: "alice",
82 email: "[email protected]",
83 password: "a-good-passphrase",
84 adminIfFirst: true,
85 });
86 if (!user.ok) throw new Error(String(user.error));
87 alice = getUserByName(app.db, "alice")!;
88 const made = await createRepo(app, alice, { name: "pace", visibility: "public" });
89 if (!made.ok) throw new Error(String(made.error));
90 repo = made.value;
91 moving = open("Moving");
92 still = open("Still");
93 const leaves = [1, 2, 3, 4, 5].map((n) => open(`Moving leaf ${n}`));
94 for (const leaf of leaves) linkIssue(app, alice, fresh(), current(moving), current(leaf));
95 linkIssue(app, alice, fresh(), current(still), current(open("Still leaf")));
96 // Three closed on three different days, two left open.
97 for (const [leaf, daysAgo] of [[leaves[0]!, 1], [leaves[1]!, 3], [leaves[2]!, 5]] as const) {
98 expect(setIssueState(app, alice, fresh(), current(leaf), "closed").ok).toBe(true);
99 const at = new Date(Date.now() - daysAgo * 86_400_000).toISOString();
100 app.db.query("UPDATE issue SET closed_at = ?1 WHERE id = ?2").run(at, leaf.id);
101 }
102 });
103
104 /** A repository of `n` milestones, each with a pace line, behind one that says none. */
105 async function manyMilestones(): Promise<void> {
106 const made = await createRepo(app, alice, { name: "many", visibility: "public" });
107 if (!made.ok) throw new Error(String(made.error));
108 const many = made.value;
109 const mk = (title: string): Issue => {
110 const o = openIssue(app, alice, many, { title });
111 if (!o.ok) throw new Error(String(o.error));
112 return o.value;
113 };
114 const fresh2 = () => getRepoById(app.db, many.id)!;
115 const cur = (i: Issue) => getIssueById(app.db, i.id)!;
116 // Quiet: finished, but with no recorded day, so the page has no line to say.
117 const quiet = mk("Quiet");
118 const quietLeaf = mk("Quiet leaf");
119 linkIssue(app, alice, fresh2(), cur(quiet), cur(quietLeaf));
120 expect(setIssueState(app, alice, fresh2(), cur(quietLeaf), "closed").ok).toBe(true);
121 app.db.query("UPDATE issue SET closed_at = NULL WHERE id = ?1").run(quietLeaf.id);
122 for (let n = 1; n <= 8; n++) {
123 const m = mk(`Mile ${n}`);
124 const done = mk(`Mile ${n} done`);
125 linkIssue(app, alice, fresh2(), cur(m), cur(done));
126 const open = mk(`Mile ${n} open`);
127 linkIssue(app, alice, fresh2(), cur(m), cur(open));
128 // Mile 2 has a limb of its own, so `enter` has something to open.
129 if (n === 2) linkIssue(app, alice, fresh2(), cur(open), cur(mk("Mile 2 deep")));
130 expect(setIssueState(app, alice, fresh2(), cur(done), "closed").ok).toBe(true);
131 }
132 manyId = many.id;
133 }
134
135 beforeAll(manyMilestones);
136
137 afterAll(async () => {
138 app.close();
139 await rm(dataDir, { recursive: true, force: true });
140 });
141
142 test("a milestone that closed issues on three days shows the page's sentence and a sparkline of PACE_DAYS cells", async () => {
143 const m = readPlan(app.db, fresh(), { spend: false }).milestone(moving.number)!;
144 expect(m.pace.closedPerDay.filter((n) => n > 0)).toHaveLength(3);
145 expect(m.pace.line).toContain("on pace for");
146 const spark = paceSpark(m.pace.closedPerDay);
147 expect([...spark]).toHaveLength(PACE_DAYS);
148
149 const h = boot();
150 await h.toLimbs();
151 expect(h.text()).toContain(`${spark} ${m.pace.line}`);
152 h.stop();
153 });
154
155 test("a milestone with no pace line draws no pace row and stays one row tall", async () => {
156 const quiet = plan().limbs.find((l) => l.name === "Quiet");
157 expect(quiet).toBeDefined();
158 const m = plan().milestone(quiet!.issue.number)!;
159 expect(m.pace.line).toBe("");
160 const h = boot(32, 120, "alice/many");
161 await h.toLimbs();
162 const y = h.rowOf("Quiet");
163 expect(y).toBeGreaterThan(0);
164 // The row under it is the next milestone, not a pace row.
165 const lines = h.text().split("\n");
166 expect(lines[y + 1]).toContain("Mile 1");
167 expect(lines[y]).not.toMatch(SPARK);
168 expect(lines[y + 1]).not.toMatch(SPARK);
169 h.stop();
170 });
171
172 test("the sparkline of a quiet fortnight is the lowest block; no line is no text", () => {
173 const m = readPlan(app.db, fresh(), { spend: false }).milestone(still.number)!;
174 expect(m.pace.closedPerDay.every((n) => n === 0)).toBe(true);
175 expect(paceSpark(m.pace.closedPerDay)).toBe("▁".repeat(PACE_DAYS));
176 expect(paceText({ ...m.pace, line: "" }, 80)).toBe("");
177 });
178
179 test("a narrow terminal drops the sparkline before the sentence, by the width the list has", async () => {
180 const m = readPlan(app.db, fresh(), { spend: false }).milestone(moving.number)!;
181 const both = PACE_DAYS + 1 + m.pace.line.length;
182 expect(paceText(m.pace, both)).toMatch(SPARK);
183 expect(paceText(m.pace, both - 1)).toBe(m.pace.line);
184 // 40 columns less the scrollbar and the 4-cell indent is 35 cells for the note.
185 expect(35).toBeLessThan(both);
186 expect(35).toBeGreaterThanOrEqual(m.pace.line.length);
187 const h = boot(32, 40);
188 await h.toLimbs();
189 expect(h.text()).toContain(m.pace.line);
190 expect(h.text()).not.toMatch(SPARK);
191 h.stop();
192 // The terminal refuses anything under 40x10, so narrower than the sentence is the helper's.
193 const cut = paceText(m.pace, 10);
194 expect([...cut].length).toBeLessThanOrEqual(10);
195 expect(cut).toContain("on pace");
196 });
197
198 test("a width of nothing or less draws nothing, and the smallest terminal the app accepts does not throw", async () => {
199 const m = readPlan(app.db, fresh(), { spend: false }).milestone(moving.number)!;
200 for (const w of [0, -1, -40]) expect(paceText(m.pace, w)).toBe("");
201 for (const [rows, cols] of [[10, 40], [3, 4]] as const) {
202 const h = boot(rows, cols);
203 await h.toLimbs();
204 expect(typeof h.text()).toBe("string");
205 h.stop();
206 }
207 });
208
209 test("a short terminal scrolls by milestone: the cursor's row and its pace line are both on screen", async () => {
210 const h = boot(10, 120, "alice/many");
211 await h.toLimbs();
212 for (let n = 1; n <= 8; n++) {
213 await h.press("j");
214 const lines = h.text().split("\n");
215 const y = lines.findIndex((r) => new RegExp(`\\d+\\. #\\d+ Mile ${n}\\b`).test(r));
216 expect(y).toBeGreaterThanOrEqual(0);
217 expect(lines[y + 1]).toContain("on pace for");
218 }
219 h.stop();
220 });
221
222 test("a click on a pace row selects its milestone; a second click opens it", async () => {
223 const h = boot(32, 120, "alice/many");
224 await h.toLimbs();
225 // Milestones with leaves of their own open on enter; the cursor moves first.
226 await h.click("Mile 3", 1);
227 await h.press("r");
228 expect(h.text()).toContain("Raise “Mile 3”?");
229 h.stop();
230 });
231
232 test("a double click on a pace row opens that milestone", async () => {
233 const h = boot(32, 120, "alice/many");
234 await h.toLimbs();
235 expect(h.text()).not.toContain("Mile 2 open");
236 await h.click("Mile 2", 1);
237 await h.click("Mile 2", 1);
238 expect(h.text()).toContain("Mile 2 open");
239 h.stop();
240 });
241
242 test("the wheel moves the cursor and keeps its pace line on screen", async () => {
243 const h = boot(10, 120, "alice/many");
244 await h.toLimbs();
245 await h.mouse("wheel-down", 12, 4);
246 await h.mouse("wheel-down", 12, 4);
247 const lines = h.text().split("\n");
248 // Two notches of three rows put the cursor on the seventh limb: Mile 6.
249 const y = lines.findIndex((r) => /\d+\. #\d+ Mile 6\b/.test(r));
250 expect(y).toBeGreaterThanOrEqual(0);
251 expect(lines[y + 1]).toContain("on pace for");
252 h.stop();
253 });
254
255 test("raise and lower still address the row under the cursor", async () => {
256 const h = boot();
257 await h.toLimbs();
258 await h.press("j");
259 await h.press("r");
260 expect(h.text()).toContain("Raise");
261 h.stop();
262 });
263 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs