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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12290-next-opens-with-one-line-of-growth-and-t → main 11 commits fork 4 threads · 4 unresolved
9 files
changed +321
−6
docs/features/next-up.md +2
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | |
| 16 | 16 | **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. |
| 17 | 17 | |
| 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 | ||
| 18 | 20 | ## The week is the sprint (#8704) |
| 19 | 21 | |
| 20 | 22 | 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 | ||
| 156 | 156 | return { days: list, open: { issues: openIssues, pulls: openPulls }, perDay, daysQueued, growingRun, sentences, suggest }; |
| 157 | 157 | } |
| 158 | 158 | |
| 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); | |
| 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 | ||
| 159 | 204 | export interface Aged { |
| 160 | 205 | readonly number: number; |
| 161 | 206 | readonly title: string; |
src/tui/screens/next.ts +31 −2
| @@ -13,7 +13,10 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actions/next.ts"; |
| 15 | 15 | 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"; | |
| 17 | 20 | import { pillarsOf, servedBy } from "../../core/pillars.ts"; |
| 18 | 21 | import { sealedScope } from "../../core/sealed.ts"; |
| 19 | 22 | import { notStartable } from "../../crew/not_startable.ts"; |
| @@ -21,6 +24,7 @@ import { lineAfter, rangeWords, weekView, type WeekView } from "../../flow/week. | ||
| 21 | 24 | import { openPullStages } from "../../flow/read.ts"; |
| 22 | 25 | import type { Stage } from "../../flow/stage.ts"; |
| 23 | 26 | import { countWords, filtered, matches, readFilter, MAX_Q } from "../../web/pages/next_filter.ts"; |
| 27 | import { GROWTH_DAYS, growthWords } from "../../web/pages/next_growth.ts"; | |
| 24 | 28 | import { stateWord } from "../../web/pages/next_state.ts"; |
| 25 | 29 | import type { Ctx, Screen } from "../app/app.ts"; |
| 26 | 30 | import { binding } from "../app/keys.ts"; |
| @@ -40,6 +44,8 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 40 | 44 | let rows: readonly NextRow[] = []; |
| 41 | 45 | let away = 0; |
| 42 | 46 | 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; | |
| 43 | 49 | let stages = new Map<number, Stage>(); |
| 44 | 50 | let loaded = false; |
| 45 | 51 | // 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 { | ||
| 55 | 61 | rows = []; |
| 56 | 62 | away = 0; |
| 57 | 63 | week = undefined; |
| 64 | growing = undefined; | |
| 58 | 65 | } else { |
| 59 | 66 | const scope = sealedScope(f.db, repo, f.user); |
| 60 | 67 | const next = nextUp(f.db, repo, scope, (ready) => notStartable(f.app, repo, ready, scope)); |
| 61 | 68 | rows = next.rows; |
| 62 | 69 | away = next.notNowTotal; |
| 63 | 70 | 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()) }; | |
| 64 | 73 | stages = openPullStages(f.db, repo, scope, new Date(), new Set(rows.map((r) => r.issue.number))); |
| 65 | 74 | } |
| 66 | 75 | loaded = true; |
| @@ -106,6 +115,20 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 106 | 115 | ); |
| 107 | 116 | }; |
| 108 | 117 | |
| 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 | ||
| 109 | 132 | const current = (): NextRow | undefined => rows[shown[state.index] ?? -1]; |
| 110 | 133 | |
| 111 | 134 | const rank = (to: Place | undefined): void => { |
| @@ -160,6 +183,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 160 | 183 | binding(["d"], "down", { disabled: !on || rows.indexOf(current()!) >= rows.length - 1 }), |
| 161 | 184 | binding(["l"], "not now", { disabled: !on }), |
| 162 | 185 | binding(["/"], "filter"), |
| 186 | ...(owns() && growing !== undefined ? [binding(["p"], growing.until === null ? "pause planner" : "planner on")] : []), | |
| 163 | 187 | ]; |
| 164 | 188 | }, |
| 165 | 189 | load(signal) { |
| @@ -169,12 +193,16 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 169 | 193 | }, |
| 170 | 194 | render(area, s) { |
| 171 | 195 | 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)]); | |
| 173 | 197 | const note = !loaded |
| 174 | 198 | ? "Loading…" |
| 175 | 199 | : rows.length === 0 |
| 176 | 200 | ? "Nothing is ready: everything open waits for something." |
| 177 | 201 | : `${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 | } | |
| 178 | 206 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 179 | 207 | drawList(s, list!, c, state, listRows()); |
| 180 | 208 | hitList(ctx, list!, state, shown.length, { open }); |
| @@ -187,6 +215,7 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 187 | 215 | else if (e.key === "u") step(-1); |
| 188 | 216 | else if (e.key === "d") step(1); |
| 189 | 217 | else if (e.key === "/") ask(); |
| 218 | else if (e.key === "p") pause(); | |
| 190 | 219 | else if (e.key === "l") rank({ to: "not_now" }); |
| 191 | 220 | else return false; |
| 192 | 221 | return true; |
src/web/pages/next.ts +5 −1
| @@ -13,7 +13,8 @@ import { estimateLine } from "../../flow/estimate_words.ts"; | ||
| 13 | 13 | import { nextUp, TOP, type NextRow } from "../../core/next_up.ts"; |
| 14 | 14 | import { openPullStages } from "../../flow/read.ts"; |
| 15 | 15 | 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"; | |
| 17 | 18 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 18 | 19 | import { pillarsOf, servedBy } from "../../core/pillars.ts"; |
| 19 | 20 | import { sealedScope } from "../../core/sealed.ts"; |
| @@ -25,6 +26,7 @@ import { backTo, field, readForm } from "../form.ts"; | ||
| 25 | 26 | import { html, raw, type Html } from "../html.ts"; |
| 26 | 27 | import { flashOrJson, page } from "../layout.ts"; |
| 27 | 28 | import { btn, kbd, row } from "../ui.ts"; |
| 29 | import { GROWTH_DAYS, growthLine } from "./next_growth.ts"; | |
| 28 | 30 | import { chaptersSection } from "./next_chapters.ts"; |
| 29 | 31 | import { countWords, filterPath, markBefore, filtered, matches, readFilter } from "./next_filter.ts"; |
| 30 | 32 | |
| @@ -214,6 +216,7 @@ export function nextPage(ctx: Ctx): Response { | ||
| 214 | 216 | const served = pillars !== undefined ? servedBy(ctx.app.db, pillars) : new Map<string, string>(); |
| 215 | 217 | const shows = (r: NextRow): boolean => !isFiltered || matches(filter, r.issue.title, served.get(r.issue.id)); |
| 216 | 218 | const rendering = performance.now(); |
| 219 | const week7 = weekLine(ctx.app.db, repo, GROWTH_DAYS, new Date(), scope); | |
| 217 | 220 | const week = weekView(ctx.app.db, repo.id, rows, new Date()); |
| 218 | 221 | const { priced, cut, capacity, sentence } = week; |
| 219 | 222 | const costs = rowCosts(priced); |
| @@ -253,6 +256,7 @@ export function nextPage(ctx: Ctx): Response { | ||
| 253 | 256 | const body = html`${repoHeader(ctx, repo, "issues")} |
| 254 | 257 | <div data-next> |
| 255 | 258 | ${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)} | |
| 256 | 260 | <p class="error" id="next-flash" role="alert" hidden></p> |
| 257 | 261 | ${rows.length === 0 |
| 258 | 262 | ? 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 |
||
| 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"; | ||
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { html, type Html } from "../html.ts"; |
| 18 | 18 | import { flashTo, page } from "../layout.ts"; |
| 19 | 19 | import { badge, btn, row } from "../ui.ts"; |
| @@ -100,7 +100,7 @@ export async function queueSwitch(ctx: Ctx): Promise<Response> { | ||
| 100 | 100 | if (!repo) return notFoundPage(ctx); |
| 101 | 101 | const form = await readForm(ctx, "rw"); |
| 102 | 102 | if (form instanceof Response) return form; |
| 103 | const back = `${repoBase(repo)}/queue`; | |
| 103 | const back = backTo(form, `${repoBase(repo)}/queue`); | |
| 104 | 104 | const input = switchInput(field(form, "mode"), field(form, "at")); |
| 105 | 105 | if (input === undefined) return flashTo(ctx, back, { tone: "danger", message: "Choose on, a time, the weekly reset or until you switch it on." }); |
| 106 | 106 | 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 }; | ||
| 37 | 37 | /** Pages that cost more today, with the most they may: lower these, never raise them. */ |
| 38 | 38 | const HEAVIER: Record<string, { statements?: number; git?: number }> = { |
| 39 | 39 | "/: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. |
||
| 41 | 41 | "/: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) |
| 42 | 42 | "/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 |
| 43 | 43 | "/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 | }); |
new above
offline
Non-blocking: the
?3 <> '' AND ?2 <> ''guard exists only to use the bound parameters. Say so in a comment or restructure.