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

Home says the week's percent left and when it runs out at this pace #9061

Merged opened this

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

Merged

torque/forge:torque/8961-home-says-the-week-s-percent-left-and-wh → main 4 commits fork 4 threads · 4 unresolved

  • Merged. torque/8961-home-says-the-week-s-percent-left-and-wh was merged into main as 319339d.

Review

Sign in to review this change.
7 files changed +166 −33
docs/features/crew-line.md added +13
@@ -0,0 +1,13 @@
1 # The crew line on Home
2
3 One short line under Home's header, for the owner (a site admin) only, on the web and in the terminal's dashboard. Both print the same words from `crewLineWords` (`src/crew/week_read.ts`):
4
5 > 41% of the week left, runs out Thu 14:00 UTC at this pace
6
7 - **Percent**: the share of the subscriptions' week still unspent, as the meter reads it, rounded and held to 0-100.
8 - **Run-out time**: when the pacer's own `runsOutAt` says the current burn empties the week. Always UTC, written `Day HH:MM UTC`.
9 - **"lasts to the reset at this pace"**: the burn would not empty the week before it resets, so there is no run-out time.
10 - **Absent** while the week is unmeasured (no account metered, or the reset is past or over a week off): the line says nothing rather than guess. The sentence below it then says the pace is unknown.
11 - It reads the repository the crew works most, the same one the long week sentence reads (`busiest` in `week_read.ts`), so the two cannot disagree.
12
13 The sentence under it (`weekBlock`, `besideCrewLine` in `src/crew/week.ts`) keeps the pace verdict, burn, slots and efficiency but drops the percent and run-out time, so each figure is said once. Both lines are `small muted break` and read as one block. The Spend tab, which has no crew line, keeps the full sentence. While the week is unmeasured there is no crew line and the full sentence stands alone.
src/crew/week.ts +14 −6
@@ -80,6 +80,8 @@ export interface WeekDecision {
8080 readonly release: boolean;
8181 /** One sentence for home and the log. */
8282 readonly sentence: string;
83 /** The same sentence without the free percent and run-out time, for under the crew line that already states them. */
84 readonly besideCrewLine: string;
8385 }
8486
8587 const utc = (d: Date): string => `${d.toISOString().slice(0, 16).replace("T", " ")} UTC`;
@@ -91,6 +93,7 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
9193 const { meter, now, ceiling } = input;
9294 if (meter === null) {
9395 const slots = Math.min(ceiling, WEEK.unknownSlots);
96 const sentence = `The week's pace is unknown: the provider has not yet reported a measure of the week, so the crew holds at ${slots} of ${ceiling} slots.`;
9497 return {
9598 status: "unknown",
9699 targetPctPerHour: null,
@@ -98,7 +101,8 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
98101 runsOutAt: null,
99102 slots,
100103 release: false,
101 sentence: `The week's pace is unknown: the provider has not yet reported a measure of the week, so the crew holds at ${slots} of ${ceiling} slots.`,
104 sentence,
105 besideCrewLine: sentence,
102106 };
103107 }
104108 const left = meter.resetsAt.getTime() - now.getTime();
@@ -127,17 +131,21 @@ export function weekDecision(input: WeekInput): WeekDecision | null {
127131
128132 const release = status === "behind" && input.idleSlots > 0 && input.startable === 0 && input.gated > 0;
129133 const gone = Math.round((1 - left / WINDOW_MS.week) * 100);
130 const weekLeft = `${Math.round(meter.freePct)}% of the week left with ${gone}% of its time gone, reset ${utc(meter.resetsAt)}`;
134 const timeGone = `${gone}% of its time gone, reset ${utc(meter.resetsAt)}`;
131135 const burnWords = burn === null ? "unread (too few readings)" : `${pct(burn)} an hour`;
132 const sentence =
133 burn === null && !burning
136 // `full`: the free percent and run-out time are said here; otherwise the crew line above says them.
137 const say = (full: boolean): string => {
138 const weekLeft = full ? `${Math.round(meter.freePct)}% of the week left with ${timeGone}` : `the week has ${timeGone}`;
139 const runOut = full && runsOutAt !== null ? `, running out about ${utc(runsOutAt)}` : "";
140 return burn === null && !burning
134141 ? `Burn not known yet: too few readings of the meter; ${weekLeft}; holding at ${slots} of ${ceiling} slots until it has them.`
135142 : status === "on pace"
136143 ? `On pace: ${weekLeft}; burning ${burnWords}, ${pct(allowed)} is allowed.`
137144 : status === "ahead"
138 ? `Ahead of pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed${runsOutAt === null ? "" : `, running out about ${utc(runsOutAt)}`}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.`
145 ? `Ahead of pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed${runOut}; ${slots === 0 ? "holding the crew for the reset" : `slowing to ${slots} of ${ceiling} slots`}.`
139146 : `Behind pace: ${weekLeft}, burning ${burnWords} against ${pct(allowed)} allowed; ${slots === 0 ? "holding the crew for the reset" : `speeding up to ${slots} of ${ceiling} slots`}${release ? " and releasing the next issue" : ""}.`;
140 return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence };
147 };
148 return { status, targetPctPerHour, allowedPctPerHour: allowed, runsOutAt, slots, release, sentence: say(true), besideCrewLine: say(false) };
141149 }
142150
143151 /** What each real landing cost, and how many runs it took, over a window. */
src/crew/week_read.ts +65 −10
@@ -147,20 +147,75 @@ function heldWords(app: App): string {
147147 }
148148
149149 /**
150 * The pace sentence and the efficiency beside it for the autonomous
151 * repository the crew works most this week (the most agent runs; the first by
152 * name on a tie), not whichever sorts first: a quiet repository's "nothing
153 * landed" is not the forge's. With no measurement it says the pace is unknown; null only when no autonomous repository has a pace to state (a reset past or over a week off). Home and the
154 * terminal's dashboard print the same words.
150 * The autonomous repository the crew works most this week (the most agent
151 * runs; the first by name on a tie) among those with a pace to state, and
152 * what `read` makes of it. The one pick `weekWords` and `crewPace` share.
155153 */
156 export function weekWords(app: App, now: Date = new Date()): string | null {
157 let best: { words: string; runs: number } | null = null;
154 function busiest<T>(app: App, now: Date, read: (repo: Repo, week: WeekDecision) => T | null): T | null {
155 let best: { value: T; runs: number } | null = null;
158156 for (const repo of listAutonomousRepos(app.db)) {
159157 if (repo.autonomy !== "autonomous" || repo.archivedAt !== null) continue;
160158 const week = crewWeekFor(app, repo, now);
161159 if (week === null) continue;
162 const efficiency = weekEfficiency(app, repo, now);
163 if (best === null || efficiency.runs > best.runs) best = { words: `${week.sentence} The week allows the whole crew ${week.slots} of ${crewCeiling(app, repo)} slots across every repository. ${efficiencyWords(efficiency)}${heldWords(app)}`, runs: efficiency.runs };
160 const value = read(repo, week);
161 if (value === null) continue;
162 const runs = weekEfficiency(app, repo, now).runs;
163 if (best === null || runs > best.runs) best = { value, runs };
164164 }
165 return best?.words ?? null;
165 return best?.value ?? null;
166 }
167
168 /**
outdated

crewPace repeats the repository selection loop of weekWords. Extract the shared pick so the two cannot drift.

169 * The pace sentence and the efficiency beside it for the busiest autonomous
170 * repository (`busiest`), not whichever sorts first: a quiet repository's "nothing
171 * landed" is not the forge's. With no measurement it says the pace is unknown; null only when no autonomous repository has a pace to state (a reset past or over a week off). Home and the
172 * terminal's dashboard print the same words. `besideCrewLine` drops the free
173 * percent and run-out time, for under the crew line that states them.
174 */
175 export function weekWords(app: App, now: Date = new Date(), besideCrewLine = false): string | null {
176 return busiest(app, now, (repo, week) => `${besideCrewLine ? week.besideCrewLine : week.sentence} The week allows the whole crew ${week.slots} of ${crewCeiling(app, repo)} slots across every repository. ${efficiencyWords(weekEfficiency(app, repo, now))}${heldWords(app)}`);
177 }
178
179 /** The crew line's figures: the week's free percent and when this burn empties it (null: it lasts to the reset). */
180 export interface CrewPace {
181 readonly freePct: number;
182 readonly runsOutAt: Date | null;
183 }
184
185 /**
186 * The same week `weekWords` states, as numbers: the meter's free share and
187 * the pacer's own `runsOutAt`, for the same repository (`busiest`).
188 * Null while the week is unmeasured, past, or has no autonomous repository.
189 */
190 export function crewPace(app: App, now: Date = new Date()): CrewPace | null {
191 return busiest(app, now, (repo, week) => {
192 const meter = weekOf(app, repo, now).meter;
193 return meter === null ? null : { freePct: meter.freePct, runsOutAt: week.runsOutAt };
194 });
195 }
196
197 const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
198
199 /** `Thu 14:00 UTC`, the pacer's run-out moment. */
200 function dayTime(d: Date): string {
201 return `${DAYS[d.getUTCDay()]} ${d.toISOString().slice(11, 16)} UTC`;
202 }
203
204 /** The crew line in a few words: `41% of the week left, runs out Thu 14:00 UTC at this pace`; null while the week is unmeasured. Home and the terminal print it. */
205 export function crewLineWords(app: App, now: Date = new Date()): string | null {
206 const pace = crewPace(app, now);
207 if (pace === null) return null;
208 const left = `${Math.round(Math.min(100, Math.max(0, pace.freePct)))}% of the week left`;
209 return pace.runsOutAt === null ? `${left}, lasts to the reset at this pace` : `${left}, runs out ${dayTime(pace.runsOutAt)} at this pace`;
210 }
211
212 /**
213 * The owner's week on Home and the terminal: the crew line, then the week
214 * sentence without the figures the line already gives, so the percent is
215 * said once. With no crew line (an unmeasured week) the full sentence alone.
216 */
217 export function weekBlock(app: App, now: Date = new Date()): string[] {
218 const crew = crewLineWords(app, now);
219 const words = weekWords(app, now, crew !== null);
220 return [...(crew === null ? [] : [crew]), ...(words === null ? [] : [words])];
166221 }
src/tui/screens/dashboard.ts +2 −8
@@ -119,7 +119,7 @@ import { lastClearedNote } from "../../flow/clog_tick.ts";
119119 import { forgeCeiling } from "../../flow/ceiling.ts";
120120 import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts";
121121 import { idleLine, idleReason } from "../../crew/idle_reason.ts";
122 import { weekWords } from "../../crew/week_read.ts";
122 import { weekBlock } from "../../crew/week_read.ts";
123123 import { noReleaseAlarm } from "../../flow/alarm_no_release.ts";
124124 import { redMainAlarm } from "../../flow/alarm_red_main.ts";
125125 import { alarmStuck } from "../../flow/alarm_stuck.ts";
@@ -400,7 +400,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
400400 ? []
401401 : [
402402 ...idleLines(f.app, now),
403 ...weekLines(f.app, now),
403 ...weekBlock(f.app, now),
404404 ...kpiLines(readKpis(f.db, now, { ...kpiForge(f.app), idle: () => idleReason(f.app, now) }), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
405405 vitalsLine(readVitals(f.db, now)),
406406 ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says),
@@ -1522,12 +1522,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
15221522 return screen;
15231523 }
15241524
1525 /** The week's pace and efficiency, as the line an admin's home reads (#8695); none while no account is metered. */
1526 function weekLines(app: App, now: Date): string[] {
1527 const words = weekWords(app, now);
1528 return words === null ? [] : [words];
1529 }
1530
15311525 /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */
15321526 function idleLines(app: App, now: Date): string[] {
outdated

Drop export: no importer, it is dead surface.

15331527 const reason = idleReason(app, now);
src/web/studio/home.ts +2 −2
@@ -56,7 +56,7 @@ import { heldLine } from "./held.ts";
5656 import { appChips, movingRegion, tellBox } from "./living.ts";
5757 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
5858 import { kpiRow, waitingLine } from "./kpi_row.ts";
59 import { weekLine } from "./week_line.ts";
59 import { homeWeek } from "./week_line.ts";
6060 import { feedCandidateCache } from "../../feed/feed.ts";
6161 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
6262 import { idleReason } from "../../crew/idle_reason.ts";
@@ -239,7 +239,7 @@ export function homeDraws(ctx: Ctx, user: User): Record<HomeRegion, () => Html |
239239 const kpiTime: KpiTimer = (name, fn) => timePhase(request, name, fn);
240240 const kpiOpts = { idle: () => idleReason(ctx.app, new Date()), dataDir: ctx.app.config.dataDir, time: kpiTime, ...(ceilingUsd === null ? {} : { ceilingUsd }) };
241241 return html`${timePhase(request, "kpis", () => kpiRow(db, kpiOpts), () => kpiCache(db))}
242 ${weekLine(ctx.app)}
242 ${homeWeek(ctx.app, user)}
243243 ${alarmLines(cachedAlarms(db, new Date(), ceilingUsd))}`;
244244 },
245245 waiting: () => timePhase(request, "waiting", () => waitingLine(db, user), () => waitingCache(db)),
src/web/studio/week_line.ts +17 −4
@@ -1,14 +1,27 @@
11 /**
2 * Home's week line (#8695): one sentence on whether the crew is spending the
3 * subscriptions' week on pace, and beside it what a real landing costs
4 * (`weekWords`). Empty while no account is metered.
2 * Home's week (#8695, #8961): for the owner, the crew line (percent of the
3 * week left, when it runs out at this pace), then the pace sentence without
4 * those figures and beside it what a real landing costs (`weekWords`). Both
5 * in the same muted small style, read as one block. Empty while no account
6 * is metered.
57 */
68
79 import type { App } from "../../app.ts";
8 import { weekWords } from "../../crew/week_read.ts";
10 import { crewLineWords, weekWords } from "../../crew/week_read.ts";
911 import { html, type Html } from "../html.ts";
1012
13 type Viewer = { readonly isAdmin: boolean } | undefined;
14
15 /** The full pace sentence, for pages without the crew line (Spend). */
outdated

The class differs from the sentence beneath it (no muted). Use the named style from docs/DESIGN.md and make the two lines read as one block, or say why not.

1116 export function weekLine(app: App, now: Date = new Date()): Html {
1217 const words = weekWords(app, now);
1318 return words === null ? html`` : html`<p class="small muted break">${words}</p>`;
1419 }
20
21 /** Home's week: the owner reads the crew line and the sentence under it, the percent said once; anyone else the full sentence. */
22 export function homeWeek(app: App, viewer: Viewer, now: Date = new Date()): Html {
23 const crew = viewer?.isAdmin === true ? crewLineWords(app, now) : null;
24 if (crew === null) return weekLine(app, now);
25 const words = weekWords(app, now, true);
outdated

Only Home renders this. Add the same line to the terminal dashboard in src/tui/ (admin only, using crewLineWords) and a test for it, and add docs/features/ how-to.

26 return html`<p class="small muted break" id="crew-pace">${crew}</p>${words === null ? html`` : html`<p class="small muted break">${words}</p>`}`;
27 }
test/week-wiring.test.ts +53 −3
@@ -16,14 +16,14 @@ import { noteMeasure } from "../src/core/measure.ts";
1616 import { setHeadroom } from "../src/core/standing.ts";
1717 import { addSubscription } from "../src/core/subscriptions.ts";
1818 import { recordUsage } from "../src/core/usage.ts";
19 import { type User } from "../src/core/users.ts";
19 import { createUser, type User } from "../src/core/users.ts";
2020 import { driveRepo, repoPace } from "../src/crew/autonomy.ts";
2121 import { changeCapacity } from "../src/actions/capacity.ts";
2222 import { capacityDefault } from "../src/core/capacity.ts";
23 import { forgetWeek, weekWords } from "../src/crew/week_read.ts";
23 import { crewLineWords, forgetWeek, weekWords } from "../src/crew/week_read.ts";
2424 import { createForgeApp } from "../src/tui/forge.ts";
2525 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { weekLine } from "../src/web/studio/week_line.ts";
26 import { homeWeek, weekLine } from "../src/web/studio/week_line.ts";
2727 import { seedAdmin } from "./helpers/admin.ts";
2828 import { quietLogs } from "./helpers/globals.ts";
2929 import { serve } from "../src/http/server.ts";
@@ -219,6 +219,10 @@ test("with no measurement of the week the pace is unknown and the crew holds at
219219 forgetWeek();
220220 expect(weekWords(app, NOW)).toContain("pace is unknown");
221221 expect(String(weekLine(app, NOW))).toContain("pace is unknown");
222 // Nothing measured: no percent to state, so no crew line on either door.
223 expect(crewLineWords(app, NOW)).toBeNull();
224 expect(String(homeWeek(app, alice, NOW))).not.toContain("crew-pace");
225 expect(String(homeWeek(app, alice, NOW))).toContain("pace is unknown");
222226 expect(repoPace(app, repo, NOW).slots).toBe(2);
223227 });
224228
@@ -298,3 +302,49 @@ test("a week that fell 8 points in 3 hours slows the picks; a flat meter does no
298302 expect(flat.slots).toBe(4);
299303 expect(weekWords(app, NOW)).toContain("burning 0% an hour");
300304 });
305
306 test("home's crew line gives the owner the percent left and when it runs out, escaped, and nobody else", () => {
307 week(70, 6);
308 const runsOut = repoPace(app, repo, NOW);
309 expect(runsOut.reason).toContain("running out about");
310 const words = crewLineWords(app, NOW)!;
311 expect(words).toMatch(/^30% of the week left, runs out (Sun|Mon|Tue|Wed|Thu|Fri|Sat) \d\d:\d\d UTC at this pace$/);
312 const owner = String(homeWeek(app, alice, NOW));
313 expect(owner).toContain(words);
314 expect(owner).toContain('id="crew-pace"');
315 // The sentence under the line does not say the percent or the run-out again.
316 expect(owner.split("30% of the week left").length - 1).toBe(1);
317 expect(owner).not.toContain("running out about");
318 expect(owner).toContain("Ahead of pace: the week has");
319 expect(owner).toContain('<p class="small muted break" id="crew-pace">');
320 const other = { isAdmin: false } as User;
321 for (const viewer of [other, undefined]) {
322 const page = String(homeWeek(app, viewer, NOW));
323 expect(page).not.toContain("crew-pace");
324 expect(page).toContain("Ahead of pace: 30% of the week left");
325 }
326 week(20);
327 expect(crewLineWords(app, NOW)).toBe("80% of the week left, lasts to the reset at this pace");
328 });
329
330 async function terminalFor(user: User): Promise<string> {
331 const terminal = headlessTerminal(240, 32);
332 const { app: tui } = createForgeApp(app, { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, { terminal, exitHooks: false });
333 tui.start();
334 await settleApp(tui);
335 const text = tui.screen.toText();
336 tui.stop();
337 return text;
338 }
339
340 test("the terminal's home carries the crew line for the admin, the same words as the web, and for nobody else", async () => {
341 week(70, 6);
342 const words = crewLineWords(app, NOW)!;
343 expect(words).toContain("30% of the week left, runs out");
344 const admin = await terminalFor(alice);
345 expect(admin).toContain(words);
346 expect(admin.split("30% of the week left").length - 1).toBe(1);
347 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-long-passphrase" });
348 if (!made.ok) throw new Error(made.error);
349 expect(await terminalFor(made.value)).not.toContain("of the week left, ");
350 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs