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

/next opens with one line of growth and the planner's state, so the owner knows when to pause it without leaving the queue #12292

Merged opened this

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

Merged

scout/forge:scout/12290-next-opens-with-one-line-of-growth-and-t → main 11 commits fork 4 threads · 4 unresolved

  • Merged. scout/12290-next-opens-with-one-line-of-growth-and-t was merged into main as 7d7fcff.

Review

Sign in to review this change.
9 files changed +321 −6
docs/features/next-up.md +2
@@ -15,6 +15,8 @@
1515
1616 **Filter (#11867):** a plain GET form above the list narrows `/next` to one pillar (`?pillar=P7` or `Later`, typed in a box) and/or a word (`?q=cache`, matched without regard to case, shown as typed, at most 80 characters). A filter only hides rows: each keeps its real place in the whole ranking, the cut line stays where the whole list puts it (drawn only between shown rows), and a move made under a filter moves in the whole order and returns to the same filter. A line says "N of M shown", and that the week's capacity bar is left out while filtered. An unknown or malformed pillar shows an empty list that says so. **Terminal:** `/` asks for a pillar and/or a word (`P7 cache`); an empty answer clears it; ranks, `t` `u` `d` `l` and the header count behave as on the page.
1717
18 **The growth line (#12290):** under the title, two `dl` rows: *Week* (issues opened in the last seven days, who opened them, and how many closed: "2 issues opened (2 by owner), 0 closed.") and *Planner* (on, paused until a time in UTC, or paused until the owner switches it on). The owner (a person who administers the repository) also gets the switch in the Planner row: a button in a post form to the same `planner_switch` action as the queue page, sent back to `/next`, so the pause is one tap and the owner stays on the list. A reader sees both rows and no button. Both rows come from one read (`weekLine`), sealed issues left out for a viewer who may not see them. **Terminal:** Next up shows the same sentence (`growthWords`, `pauseSentence`) above the list, and the owner presses `p` to pause the planner until they switch it on, and `p` again to switch it on; for a reader and for an agent the key does nothing.
19
1820 ## The week is the sprint (#8704)
1921
2022 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.
src/core/growth.ts +45
@@ -156,6 +156,51 @@ export function growth(db: Database, repo: { id: string; ownerId: string }, days
156156 return { days: list, open: { issues: openIssues, pulls: openPulls }, perDay, daysQueued, growingRun, sentences, suggest };
157157 }
158158
159 /** The week in one read (#12290): issues opened (by who), closed, and when the pause lifts. */
160 export interface WeekLine {
161 readonly opened: number;
162 readonly closed: number;
163 readonly by: Readonly<Record<Who, number>>;
164 /** The raw pause time; null when none is set. The caller decides whether it is past. */
165 readonly pause: string | null;
166 }
167
168 /**
169 * What the line at the top of /next needs, in one statement: opened by who, closed, and the
170 * planner's pause row, joined with UNION ALL, so the page pays one read, not four.
171 */
172 export function weekLine(db: Database, repo: { id: string; ownerId: string }, days: number, now: Date, scope: Scope = {}): WeekLine {
173 const from = `${windowDays(now, days)[0]}T00:00:00.000Z`;
174 const [all, mine] = visibleArgs(scope);
175 const rows = db
176 .query<{ t: string; k: string; c: number }, [string, string, string, number, string]>(
177 `SELECT 'o' AS t, ${WHO_SQL} AS k, COUNT(*) AS c
178 FROM issue i LEFT JOIN repo_crew rc ON rc.repo_id = i.repo_id AND rc.user_id = i.author_id
179 WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.created_at >= ?2 ${visible(4, 5)} GROUP BY k
180 UNION ALL
181 SELECT 'c', '', COUNT(*) FROM issue i
182 WHERE i.repo_id = ?1 AND i.kind = 'issue' AND i.closed_at IS NOT NULL AND i.closed_at >= ?2 ${visible(4, 5)}
183 UNION ALL
184 SELECT 'p', until, 0 FROM planner_pause WHERE repo_id = ?1 AND ?3 <> '' AND ?2 <> ''`,
185 )
186 .all(repo.id, from, repo.ownerId, all, mine);

Non-blocking: the ?3 <> '' AND ?2 <> '' guard exists only to use the bound parameters. Say so in a comment or restructure.

187 const by: Record<Who, number> = { owner: 0, people: 0, planner: 0, builders: 0, watchers: 0 };
188 let opened = 0;
189 let closed = 0;
190 let pause: string | null = null;
191 for (const r of rows) {
192 if (r.t === "o") {
193 by[r.k as Who] += r.c;
194 opened += r.c;
195 } else if (r.t === "c") closed = r.c;
196 else pause = r.k;
197 }
198 return { opened, closed, by, pause };
199 }
200
201 /** The pause as `pausedUntil` reads it: a time already past is no pause. */
202 export const pauseAt = (w: WeekLine, now: Date): string | null => (w.pause !== null && w.pause > now.toISOString() ? w.pause : null);
203
159204 export interface Aged {
160205 readonly number: number;
161206 readonly title: string;
src/tui/screens/next.ts +31 −2
@@ -13,7 +13,10 @@
1313
1414 import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actions/next.ts";
1515 import { nextUp, type NextRow } from "../../core/next_up.ts";
16 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
16 import { setPlannerSwitch } from "../../actions/planner_switch.ts";
17 import { pauseAt, weekLine, type WeekLine } from "../../core/growth.ts";
18 import { pauseSentence } from "../../core/planner_pause.ts";
19 import { canAdmin, canRead, canWrite, type Repo } from "../../core/repos.ts";
1720 import { pillarsOf, servedBy } from "../../core/pillars.ts";
1821 import { sealedScope } from "../../core/sealed.ts";
1922 import { notStartable } from "../../crew/not_startable.ts";
@@ -21,6 +24,7 @@ import { lineAfter, rangeWords, weekView, type WeekView } from "../../flow/week.
2124 import { openPullStages } from "../../flow/read.ts";
2225 import type { Stage } from "../../flow/stage.ts";
2326 import { countWords, filtered, matches, readFilter, MAX_Q } from "../../web/pages/next_filter.ts";
27 import { GROWTH_DAYS, growthWords } from "../../web/pages/next_growth.ts";
2428 import { stateWord } from "../../web/pages/next_state.ts";
2529 import type { Ctx, Screen } from "../app/app.ts";
2630 import { binding } from "../app/keys.ts";
@@ -40,6 +44,8 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4044 let rows: readonly NextRow[] = [];
4145 let away = 0;
4246 let week: WeekView | undefined;
47 // The week's growth and the planner's state: the page's line, from the same read (#12290).
48 let growing: { line: WeekLine; until: string | null } | undefined;
4349 let stages = new Map<number, Stage>();
4450 let loaded = false;
4551 // The filter (#11867): a pillar (`P7`, `Later`) and/or words, as on the page. It hides rows
@@ -55,12 +61,15 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5561 rows = [];
5662 away = 0;
5763 week = undefined;
64 growing = undefined;
5865 } else {
5966 const scope = sealedScope(f.db, repo, f.user);
6067 const next = nextUp(f.db, repo, scope, (ready) => notStartable(f.app, repo, ready, scope));
6168 rows = next.rows;
6269 away = next.notNowTotal;
6370 week = weekView(f.db, repo.id, rows, new Date());
71 const line = weekLine(f.db, repo, GROWTH_DAYS, new Date(), scope);
72 growing = { line, until: pauseAt(line, new Date()) };
6473 stages = openPullStages(f.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number)));
6574 }
6675 loaded = true;
@@ -106,6 +115,20 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
106115 );
107116 };
108117
118 // Only a person who administers the repository flips the planner, as on the page.
119 const owns = (): boolean =>
120 f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user);
121 const pause = (): void => {
122 if (!owns() || growing === undefined) return;
123 const done = setPlannerSwitch(f.app, f.user, repo, growing.until === null ? { mode: "owner" } : { mode: "on" });
124 if (!done.ok) {
125 ctx.toast("Only the owner switches the planner.", "danger");
126 return;
127 }
128 reload();
129 ctx.toast(pauseSentence(done.value), "success");
130 };
131
109132 const current = (): NextRow | undefined => rows[shown[state.index] ?? -1];
110133
111134 const rank = (to: Place | undefined): void => {
@@ -160,6 +183,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
160183 binding(["d"], "down", { disabled: !on || rows.indexOf(current()!) >= rows.length - 1 }),
161184 binding(["l"], "not now", { disabled: !on }),
162185 binding(["/"], "filter"),
186 ...(owns() && growing !== undefined ? [binding(["p"], growing.until === null ? "pause planner" : "planner on")] : []),
163187 ];
164188 },
165189 load(signal) {
@@ -169,12 +193,16 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
169193 },
170194 render(area, s) {
171195 const c = ctx.theme.c;
172 const [head, list] = split(area, "v", [len(1), fill(1)]);
196 const [grow, head, list] = split(area, "v", [len(1), len(1), fill(1)]);
173197 const note = !loaded
174198 ? "Loading…"
175199 : rows.length === 0
176200 ? "Nothing is ready: everything open waits for something."
177201 : `${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` : ""}`;
202 if (growing !== undefined) {
203 const g = `${growthWords(growing.line)} ${pauseSentence(growing.until)}`;
204 s.text(grow!.x, grow!.y, truncate(g, grow!.w), style(c.mutedFg, c.bg), grow!.x + grow!.w);
205 }
178206 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
179207 drawList(s, list!, c, state, listRows());
180208 hitList(ctx, list!, state, shown.length, { open });
@@ -187,6 +215,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
187215 else if (e.key === "u") step(-1);
188216 else if (e.key === "d") step(1);
189217 else if (e.key === "/") ask();
218 else if (e.key === "p") pause();
190219 else if (e.key === "l") rank({ to: "not_now" });
191220 else return false;
192221 return true;
src/web/pages/next.ts +5 −1
@@ -13,7 +13,8 @@ import { estimateLine } from "../../flow/estimate_words.ts";
1313 import { nextUp, TOP, type NextRow } from "../../core/next_up.ts";
1414 import { openPullStages } from "../../flow/read.ts";
1515 import { stateWord, type StateFacts } from "./next_state.ts";
16 import { canWrite, type Repo } from "../../core/repos.ts";
16 import { pauseAt, weekLine } from "../../core/growth.ts";
17 import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
1718 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1819 import { pillarsOf, servedBy } from "../../core/pillars.ts";
1920 import { sealedScope } from "../../core/sealed.ts";
@@ -25,6 +26,7 @@ import { backTo, field, readForm } from "../form.ts";
2526 import { html, raw, type Html } from "../html.ts";
2627 import { flashOrJson, page } from "../layout.ts";
2728 import { btn, kbd, row } from "../ui.ts";
29 import { GROWTH_DAYS, growthLine } from "./next_growth.ts";
2830 import { chaptersSection } from "./next_chapters.ts";
2931 import { countWords, filterPath, markBefore, filtered, matches, readFilter } from "./next_filter.ts";
3032
@@ -214,6 +216,7 @@ export function nextPage(ctx: Ctx): Response {
214216 const served = pillars !== undefined ? servedBy(ctx.app.db, pillars) : new Map<string, string>();
215217 const shows = (r: NextRow): boolean => !isFiltered || matches(filter, r.issue.title, served.get(r.issue.id));
216218 const rendering = performance.now();
219 const week7 = weekLine(ctx.app.db, repo, GROWTH_DAYS, new Date(), scope);
217220 const week = weekView(ctx.app.db, repo.id, rows, new Date());
218221 const { priced, cut, capacity, sentence } = week;
219222 const costs = rowCosts(priced);
@@ -253,6 +256,7 @@ export function nextPage(ctx: Ctx): Response {
253256 const body = html`${repoHeader(ctx, repo, "issues")}
254257 <div data-next>
255258 ${pageHead("Next up", { sub: "The crew builds strictly from the top." })}
259 ${growthLine(week7, pauseAt(week7, new Date()), writer && canAdmin(ctx.app.db, repo, ctx.auth.user!), base, back)}
256260 <p class="error" id="next-flash" role="alert" hidden></p>
257261 ${rows.length === 0
258262 ? html`<p class="muted">Nothing is ready: everything open waits for something. <a href="${base}/map">See the map</a>.</p>`
src/web/pages/next_growth.ts added +32
@@ -0,0 +1,32 @@
1 /**
2 * The rows at the top of /next (#12290): the week's growth in one sentence (issues opened and
3 * closed in seven days, and who opened them, from `core/growth.ts`) and the planner's state (from
4 * `core/planner_pause.ts`), as `dl` rows. For the owner the planner row holds the one switch, the
5 * same `planner_switch` post form as the queue page, sent back here so the owner never leaves the
6 * list. Plain data in, markup out: server-rendered, so nothing moves after the first paint.
7 */
8 import { WHO, type WeekLine } from "../../core/growth.ts";
9 import { pauseSentence } from "../../core/planner_pause.ts";
10 import { html, type Html } from "../html.ts";
11 import { btn } from "../ui.ts";
12
13 export const GROWTH_DAYS = 7;
14
15 /** "This week: 5 issues opened (3 by owner, 2 by planner), 2 closed." Same words in every door. */
16 export function growthWords(w: Pick<WeekLine, "opened" | "closed" | "by">): string {
17 const by = WHO.map((x) => ({ x, n: w.by[x] })).filter((y) => y.n > 0);
18 const who = by.length === 0 ? "" : ` (${by.map((y) => `${y.n} by ${y.x}`).join(", ")})`;
19 return `This week: ${w.opened} ${w.opened === 1 ? "issue" : "issues"} opened${who}, ${w.closed} closed.`;
20 }
21
22 export function growthLine(w: WeekLine, until: string | null, owner: boolean, base: string, back: string = `${base}/next`): Html {
23 return html`<dl class="status-rows" id="next-growth" data-growth-line>
outdated

The <a href=/queue> navigates away; use the planner_switch post form (a button) here. Terminal screen needs the same line.

24 <div class="status-row"><dt>Week</dt><dd>${growthWords(w).replace(/^This week: /, "")}</dd></div>
25 <div class="status-row"><dt>Planner</dt><dd data-planner-state>${pauseSentence(until)}${owner
26 ? html` <form method="post" action="${base}/queue/planner" data-planner-form>
27 <input type="hidden" name="back" value="${back}">
28 <input type="hidden" name="mode" value="${until === null ? "owner" : "on"}">
29 ${btn(until === null ? "Pause the planner" : "Switch the planner on", { size: "sm" })}</form>`
30 : null}</dd></div>
31 </dl>`;
32 }
src/web/pages/queue.ts +2 −2
@@ -13,7 +13,7 @@ import { sealedScope } from "../../core/sealed.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1515 import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, repoHeader, repoPalette } from "../chrome.ts";
16 import { field, readForm } from "../form.ts";
16 import { backTo, field, readForm } from "../form.ts";
1717 import { html, type Html } from "../html.ts";
1818 import { flashTo, page } from "../layout.ts";
1919 import { badge, btn, row } from "../ui.ts";
@@ -100,7 +100,7 @@ export async function queueSwitch(ctx: Ctx): Promise<Response> {
100100 if (!repo) return notFoundPage(ctx);
101101 const form = await readForm(ctx, "rw");
102102 if (form instanceof Response) return form;
103 const back = `${repoBase(repo)}/queue`;
103 const back = backTo(form, `${repoBase(repo)}/queue`);
104104 const input = switchInput(field(form, "mode"), field(form, "at"));
105105 if (input === undefined) return flashTo(ctx, back, { tone: "danger", message: "Choose on, a time, the weekly reset or until you switch it on." });
106106 const done = setPlannerSwitch(ctx.app, ctx.auth.user, repo, input);
test/next-growth.test.ts added +110
@@ -0,0 +1,110 @@
1 /**
2 * /next opens with the week's growth line, the planner's state and, for the owner, the pause
3 * link (#12290).
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { openIssue } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { setPlannerSwitch } from "../src/actions/planner_switch.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, type User } from "../src/core/users.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { growthWords } from "../src/web/pages/next_growth.ts";
17 import { weekLine } from "../src/core/growth.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19
20 let dir: string;
21 let app: App;
22 let servers: Servers;
23 let cookie: string;
24 let alice: User;
25 let repo: Repo;
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-next-growth-"));
29 app = createApp({ dataDir: dir, port: 0 });
30 servers = await serve(app);
31 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
32 if (!made.ok) throw new Error(made.error);
33 alice = made.value;
34 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
35 const r = await createRepo(app, alice, { name: "g", visibility: "public" });
36 if (!r.ok) throw new Error(JSON.stringify(r.error));
37 repo = r.value;
38 for (const t of ["One", "Two"]) if (!openIssue(app, alice, repo, { title: t }).ok) throw new Error("issue");
39 });
40
41 afterAll(async () => {
42 await servers.stop();
43 app.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const get = async (c?: string) => (await fetch(`${servers.url}/alice/g/next`, { headers: c ? { cookie: c } : {} })).text();
48
49 test("the owner sees the growth sentence, the planner on, and the link to pause it", async () => {
50 const page = await get(cookie);
51 expect(page).toContain("<dt>Week</dt><dd>2 issues opened (2 by owner), 0 closed.</dd>");
52 expect(page).toContain("The planner is on.");
53 // The switch is a post form on this page, sent back here, not a link away.
54 expect(page).toMatch(/<form method="post" action="\/alice\/g\/queue\/planner" data-planner-form>/);
55 expect(page).toContain('name="back" value="/alice/g/next"');
56 expect(page).toContain('name="mode" value="owner"');
57 expect(page).toContain("Pause the planner");
58 expect(page).not.toContain("data-planner-link");
59 // First in the page body, before the list.
60 expect(page.indexOf("data-growth-line")).toBeLessThan(page.indexOf('id="next-list"'));
61 });
62
63 test("a paused planner says until when and the link offers switching it on", async () => {
64 expect(setPlannerSwitch(app, alice, repo, { mode: "owner" }).ok).toBe(true);
65 const page = await get(cookie);
66 expect(page).toContain("The planner is paused until you switch it on.");
67 expect(page).toContain("Switch the planner on");
68 expect(page).toContain('name="mode" value="on"');
69 });
70
71 test("a visitor reads the line but gets no switch", async () => {
72 const page = await get();
73 expect(page).toContain("data-growth-line");
74 expect(page).not.toContain("data-planner-link");
75 });
76
77 test("the switch posted from /next lands back on /next and flips the planner", async () => {
78 const res = await fetch(`${servers.url}/alice/g/queue/planner`, {
79 method: "POST",
80 redirect: "manual",
81 headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" },
82 body: new URLSearchParams({ mode: "on", back: "/alice/g/next" }),
83 });
84 expect(res.status).toBe(303);
85 expect(new URL(res.headers.get("location")!, servers.url).pathname).toBe("/alice/g/next");
86 expect(await get(cookie)).toContain("The planner is on.");
87 });
88
89 test("a timed pause says until when, in UTC", async () => {
90 const at = new Date(Date.now() + 2 * 86_400_000);
91 expect(setPlannerSwitch(app, alice, repo, { mode: "until", at: at.toISOString() }).ok).toBe(true);
92 const page = await get(cookie);
93 expect(page).toContain(`The planner is paused until ${at.toISOString().slice(0, 16).replace("T", " ")} UTC.`);
94 expect(setPlannerSwitch(app, alice, repo, { mode: "on" }).ok).toBe(true);
95 });
96
97 test("the sentence counts closes and names a planner opener", () => {
98 const none = { owner: 0, people: 0, planner: 0, builders: 0, watchers: 0 };
99 expect(growthWords({ opened: 5, closed: 2, by: { ...none, owner: 3, planner: 2 } })).toBe("This week: 5 issues opened (3 by owner, 2 by planner), 2 closed.");
100 expect(growthWords({ opened: 1, closed: 1, by: { ...none, planner: 1 } })).toBe("This week: 1 issue opened (1 by planner), 1 closed.");
101 expect(growthWords({ opened: 0, closed: 0, by: none })).toBe("This week: 0 issues opened, 0 closed.");
102 });
103
104 test("weekLine counts the week's closes and leaves out older ones, in one read", () => {
105 const w = weekLine(app.db, repo, 7, new Date());
106 expect(w.opened).toBe(2);
107 expect(w.closed).toBe(0);
108 const later = weekLine(app.db, repo, 7, new Date(Date.now() + 30 * 86_400_000));
109 expect(later.opened).toBe(0);
110 });
test/pages-budget.test.ts +1 −1
@@ -37,7 +37,7 @@ const DEFAULT = { statements: 60, git: 8 };
3737 /** Pages that cost more today, with the most they may: lower these, never raise them. */
3838 const HEAVIER: Record<string, { statements?: number; git?: number }> = {
3939 "/:owner/:name/issues/:number": { statements: 70 },
40 "/:owner/:name/next": { statements: 64 },
40 "/:owner/:name/next": { statements: 65 }, // +1: the growth line, one read for the week and the planner pause (#12290)
outdated

Raised against the rule to lower, never raise. Cut the reads instead.

4141 "/:owner/:name/pulls/:number": { statements: 113 }, // +1: whether a repository is the forge's own copy, one read of the source setting for the tell box (#12037); +1: the paused sentence, one read of a queued paused run on the pull (#12010); +1: the header Talk count (#10327); +1: the landing line (#10860); +1: whether a reviewer is seated, read only when Require review is off and the pull would otherwise wait on one; the page loads no crew besides (#11666)
4242 "/board": { statements: 72 }, // +1: the forge-copy read for the tell box (#12037); +1: the vitals read the database's growth (#12025); +6: the money line (#510) — one settings read, then the month's and the 30 days' meter, storage and ledger reads; constant in the number of apps
4343 "/settings/capacity": { statements: 71 },
test/tui-next-growth.test.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * The terminal's Next up shows the page's growth line and planner state, and the owner pauses it
3 * with `p` (#12290); a reader sees the line and the key does nothing.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { openIssue } from "../src/actions/issues.ts";
10 import { createRepo } from "../src/actions/repos.ts";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { pausedUntil } from "../src/core/planner_pause.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createUser, getUserByName, type User } from "../src/core/users.ts";
15 import { createForgeApp } from "../src/tui/forge.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let forge: Forge;
24 let alice: User;
25 let bob: User;
26 let repo: Repo;
27
28 function boot(user: User) {
29 const terminal = headlessTerminal(140, 30);
30 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
31 app.start();
32 const press = async (...keys: string[]) => {
33 for (const key of keys) app.dispatch({ type: "key", key: key.startsWith("ctrl+") ? key.slice(5) : key, ctrl: key.startsWith("ctrl+"), alt: false, shift: false });
34 await settleApp(app);
35 };
36 const type = async (text: string) => {
37 for (const ch of text) app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
38 await settleApp(app);
39 };
40 const toNext = async () => {
41 await settleApp(app);
42 await press("ctrl+k");
43 await type("Next up in a repository");
44 await press("enter");
45 await type("alice/plan");
46 await press("enter");
47 };
48 return { text: () => app.screen.toText(), press, toNext, stop: () => app.stop() };
49 }
50
51 beforeAll(async () => {
52 dir = await mkdtemp(join(tmpdir(), "forge-tui-next-growth-"));
53 forge = createApp({ dataDir: dir, port: 0 });
54 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
55 const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first });
56 if (!made.ok) throw new Error(made.error);
57 }
58 alice = getUserByName(forge.db, "alice")!;
59 bob = getUserByName(forge.db, "bob")!;
60 const r = await createRepo(forge, alice, { name: "plan", visibility: "public" });
61 if (!r.ok) throw new Error("repo");
62 repo = r.value;
63 for (const t of ["One", "Two"]) if (!openIssue(forge, alice, repo, { title: t }).ok) throw new Error("issue");
64 });
65
66 afterAll(async () => {
67 forge.close();
68 await rm(dir, { recursive: true, force: true });
69 });
70
71 test("the owner sees the line, pauses with p, and switches on with p again", async () => {
72 const h = boot(alice);
73 await h.toNext();
74 expect(h.text()).toContain("This week: 2 issues opened (2 by owner), 0 closed. The planner is on.");
75 expect(h.text()).toContain("pause planner");
76 await h.press("p");
77 expect(pausedUntil(forge.db, repo.id, new Date())).not.toBeNull();
78 expect(h.text()).toContain("The planner is paused until you switch it on.");
79 await h.press("p");
80 expect(pausedUntil(forge.db, repo.id, new Date())).toBeNull();
81 expect(h.text()).toContain("The planner is on.");
82 h.stop();
83 });
84
85 test("a reader sees the line and p does nothing", async () => {
86 const h = boot(bob);
87 await h.toNext();
88 expect(h.text()).toContain("This week: 2 issues opened");
89 expect(h.text()).not.toContain("pause planner");
90 await h.press("p");
91 expect(pausedUntil(forge.db, repo.id, new Date())).toBeNull();
92 h.stop();
93 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs