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

The board's money line names the Economics slider's position, linked to Settings → Economics #11146

Merged opened this

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

Merged

scout/forge:scout/11102-the-board-s-money-line-names-the-economi → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/11102-the-board-s-money-line-names-the-economi was merged into main as ad21652.

Review

Sign in to review this change.
11 files changed +130 −64
docs/design/areas/chart.md +2 −1
@@ -14,6 +14,7 @@
1414
1515 Drawn by `src/web/studio/money_line.ts`; styled in `src/web/style/content.ts`.
1616
17 - `.money-line` — the board's line: this month's cost, revenue and gap on one row, today's gap and the economics slider (the one link, in the accent) on a second, and the chart beside them. Two 24px rows are reserved, three at 480px and under where the first wraps once; the chart is a fixed size, zeros included, so nothing shifts.
17 - `.money-line` — the board's line: this month's cost, revenue, gap and the slider's word (`spend: balanced`) on one row, today's gap on a second, and the chart beside them. Two 24px rows are reserved, three at 480px and under where the first wraps once; the chart is a fixed size, zeros included, so nothing shifts.
1818 - `.money-gap` — the gap, signed with + or −, in the text colour; `data-sign="down"` (a loss) alone takes `--attn`. The accent is never a gap's: it is for the link. The app page's month card wears it too.
19 - `.money-spend` — the slider's position in words (#11102), the line's one link to Settings → Economics, in the accent; it never breaks inside itself, so the row wraps between its parts.
1920 - `.money-chart` — the thirty-day SVG, 240 × 48, server-rendered, no library. `.money-cost` (muted) and `.money-revenue` (text colour) are its two polylines; `.money-band` holds one polygon per pair of days, `data-sign` tinting the gap between them: a faint text-colour wash for a profit, `--attn` for a loss.
docs/features/money.md +10 −8
@@ -4,26 +4,28 @@ Whether the economics slider is set right, read in one line (#510). All
44 figures are US dollars to the cent; a day is a UTC calendar day, and this
55 month runs from the 1st to now.
66
7 This month · cost $103.50 · revenue $12.00 · -$91.50
8 Today: gap -$5.00 · Economics slider: 62
7 This month · cost $103.50 · revenue $12.00 · -$91.50 · spend: responsive
8 Today: gap -$5.00
99
1010 - **cost** — `costOf`: the forge's own server's monthly price spread over
1111 the days so far, cloud awake-hours, storage, and crew spend.
1212 - **revenue** — the sum of every app's statement (`docs/features/statement.md`),
1313 to the cent.
1414 - **gap** — revenue − cost, with its sign. A loss is in the warning tint.
15 On the board the month's gap is a link to Settings → Economics, in the
16 accent for a profit, the warning tint for a loss and the text colour for
17 an even month; its accessible name reads "Month gap -$91.50, open
18 Economics settings".
15 - **spend** — the Economics slider's position in words (#11102):
16 `cheapest` at 0, `lean` below 40, `balanced` from 40 to 60 (the default,
17 50, included), `responsive` above 60, `most responsive` at 100. On the
18 board it is the line's one link to Settings → Economics, in the accent;
19 its accessible name reads "Spend slider: balanced, open Economics
20 settings".
1921
2022 ## Where to read it
2123
2224 - **The board** (`/board`), for a site admin: the line above, and beside it
2325 the last 30 days' cost (muted) and revenue (text colour) as one small
2426 chart, the space between them shaded faintly for a profit and in the
25 warning tint for a loss. The month's gap is the one link to Settings →
26 Economics; the slider's position beside it is plain text.
27 warning tint for a loss. The slider's word ("spend: balanced") is the one
28 link to Settings → Economics.
2729 - **An app's page**, for the repository's admins and forge admins: a
2830 "This month" card with the app's cost, revenue (linking to its statement),
2931 gap, and how many hours it was awake on the forge's own server against
forge.json +1 −1
@@ -5,7 +5,7 @@
55 ],
66 "app": {
77 "looks": ["/", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/new", "/pinned", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
8 "looksAsOwner": ["/", "/flow", "/talk", "/system", "/resilience", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
8 "looksAsOwner": ["/", "/board", "/flow", "/talk", "/system", "/resilience", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets", "/preview-admin/sample/waves", "/preview-admin/sample/milestones"],
99 "run": "bun run src/main.ts",
1010 "install": "",
1111 "health": "/healthz",
src/core/slider.ts +18
@@ -237,3 +237,21 @@ export const parseSlots = (input: unknown): number | undefined =>
237237 export function usdOfCents(cents: number): string {
238238 return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
239239 }
240
241 /**
242 * The slider's position in plain words, for a line that names it (#11102):
243 * the ends are the Economics page's own words ("cheapest", "most
244 * responsive"); 40–60 is "balanced", 50 the default among them; between,
245 * "lean" toward cheap and "responsive" toward warm. Out of range or not a
246 * number reads as the default's word, like a stored value that does not
247 * parse.
248 */
249 export function positionWord(position: number): string {
250 const p = Number.isFinite(position) ? Math.round(position) : POSITION_DEFAULT;

Reused by the page only; the terminal screen src/tui/screens/money.ts still prints the raw number. Call positionWord there too so both doors say the same.

251 if (p < POSITION_MIN || p > POSITION_MAX) return positionWord(POSITION_DEFAULT);
252 if (p === POSITION_MIN) return "cheapest";
253 if (p === POSITION_MAX) return "most responsive";
254 if (p < 40) return "lean";
255 if (p <= 60) return "balanced";
256 return "responsive";
257 }
src/tui/screens/money.ts +6 −2
@@ -21,17 +21,21 @@ import { fill, len, split } from "../ui/layout.ts";
2121 import { ListState, drawList, type ListRow } from "./common.ts";
2222 import type { PaletteEntry } from "./entries.ts";
2323 import { dollars, signed } from "../../web/studio/money_line.ts";
24 import { positionWord } from "../../core/slider.ts";
2425
2526 const figures = (m: MoneyLine): string => `cost ${dollars(m.cost)} · revenue ${dollars(m.revenue)} · gap ${signed(m.gap)}`;
2627 const hours = (s: number): string => `${(s / 3600).toFixed(1)} h`;
2728 const pct = (part: number, whole: number): string => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : "0%");
2829
29 /** The month, today, the slider, then each of the thirty days, newest first. */
30 /**
31 * The month, today, the slider in the board's words (`positionWord`,
32 * #11102: "balanced", not "50"), then each of the thirty days, newest first.
33 */
3034 export function forgeMoneyRows(m: ForgeMoney): ListRow[] {
3135 return [
3236 { title: `This month (${m.month})`, meta: figures(m.thisMonth) },
3337 { title: `Today (${m.today.day})`, meta: figures(m.today) },
34 { title: "Economics slider", meta: String(m.position) },
38 { title: "Economics slider", meta: positionWord(m.position) },
3539 ...[...m.days].reverse().map((d) => ({ title: d.day, meta: figures(d) })),
3640 ];
3741 }
src/web/studio/money_line.ts +24 −25
@@ -1,20 +1,22 @@
11 /**
22 * The money line (#510): "This month · cost $359.34 · revenue $412.00 ·
3 * +$52.66", the slider's position beside it, and thirty days of daily cost
4 * and revenue drawn as a server-rendered SVG — two polylines with the gap
5 * shaded between them, no chart library. The board shows the forge's; an
6 * app's card shows the same three numbers (`moneyRows`).
3 * +$52.66 · spend: balanced", the slider's position in words beside it,
outdated

Comment line is overlong after the edit; rewrap.

4 * and thirty days of daily cost and revenue drawn as a server-rendered SVG
5 * — two polylines with the gap shaded between them, no chart library. The
6 * board shows the forge's; an app's card shows the same three numbers
7 * (`moneyRows`).
78 *
89 * The slot is the same height whatever the numbers, zeros included: the
9 * line is two rows (the month; today and the slider), each reserved in the
10 * line is two rows (the month and the slider; today), each reserved in the
1011 * CSS, and the SVG has a fixed width and height, so nothing moves when the
11 * figures arrive (no layout shift). On the board the month's gap is the one
12 * link to Settings → Economics, where the slider it judges is set (#11056):
13 * a profit takes the accent, a loss the warning tint, an even month the text
14 * colour — the accent stays on something you can press (docs/DESIGN.md §5).
12 * figures arrive (no layout shift). On the board the slider's word is the
13 * one link to Settings → Economics, where it is set (#11102); the gap beside
14 * it takes the warning tint for a loss and the text colour otherwise — the
15 * accent stays on something you can press (docs/DESIGN.md §5).
1516 */
1617
1718 import type { AppMoney, ForgeMoney, MoneyDay, MoneyLine } from "../../actions/money.ts";
19 import { positionWord } from "../../core/slider.ts";
1820 import { html, type Html } from "../html.ts";
1921
2022 /** US dollars to the cent, as on a statement. */
@@ -39,16 +41,17 @@ export function gapSign(n: number): "up" | "down" | "even" {
3941 const gap = (n: number): Html => html`<span class="money-gap num" data-sign="${gapSign(n)}">${signed(n)}</span>`;
4042
4143 /**
42 * The board's month, in one line (#11056): "This month · cost $359.34 ·
43 * revenue $412.00 · +$52.66", the gap the board's one link to Settings →
44 * Economics. Its text is a bare figure, so it carries an accessible name
45 * that says what the figure is and where it goes.
44 * The board's month, in one line (#11056, #11102): "This month · cost
45 * $359.34 · revenue $412.00 · +$52.66 · spend: balanced", the gap read
46 * against the slider's position in words, and that word the board's one
47 * link to Settings → Economics, where it is set. Its accessible name says
48 * what the word is and where it goes.
4649 */
47 export function monthLine(m: MoneyLine): Html {
48 const g = signed(m.gap);
50 export function monthLine(m: MoneyLine, position: number): Html {
51 const word = positionWord(position);
4952 return html`This month · cost <span class="num">${dollars(m.cost)}</span> · revenue
50 <span class="num">${dollars(m.revenue)}</span> ·
51 <a class="money-gap num" data-sign="${gapSign(m.gap)}" href="/settings/economics" aria-label="Month gap ${g}, open Economics settings">${g}</a>`;
53 <span class="num">${dollars(m.revenue)}</span> · ${gap(m.gap)} ·
54 <a class="money-spend" href="/settings/economics" aria-label="Spend slider: ${word}, open Economics settings">spend: ${word}</a>`;
5255 }
5356
5457 // ---- the chart ----
@@ -102,18 +105,14 @@ export function moneyChart(days: readonly MoneyDay[]): Html {
102105 // ---- the board's line ----
103106
104107 /**
105 * The forge's line for the board, with the slider's position and the chart.
106 * The slider's position is text: the month's gap above it is already the
107 * link to where it is set, and one link to a page is enough (§1).
108 * The forge's line for the board: the month with the slider's word, today's
109 * gap, and the chart.
108110 */
109111 export function forgeMoneyLine(m: ForgeMoney): Html {
110112 return html`<section class="money-line" id="money" aria-label="Money">
111113 <p class="num">
112 <span>${monthLine(m.thisMonth)}</span>
113 <span
114 class="muted"
115 >Today: gap ${signed(m.today.gap)} · Economics slider: ${m.position}</span
116 >
114 <span>${monthLine(m.thisMonth, m.position)}</span>
115 <span class="muted">Today: gap ${signed(m.today.gap)}</span>
117116 </p>
118117 ${moneyChart(m.days)}
119118 </section>`;
src/web/style/content.ts +9 −8
@@ -225,15 +225,16 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
225225 .fg-s5 { fill: var(--series-5); color: var(--series-5); }
226226 .fg-s6 { fill: var(--series-6); color: var(--series-6); }
227227 .fg-s7 { fill: var(--series-7); color: var(--series-7); }
228 /* The money line (studio/money_line.ts, #510, #11056): the board's gap is
229 its one link to Settings → Economics, so a profit takes the accent; a
230 loss takes the warning tint, an even month the text colour. The accent
231 on --bg holds 4.5:1 in every theme (test/themes.test.ts), so the small
232 figure reads. Two rows of 24px, the first wrapping once on a phone, so
233 the reserved height is the real one; the chart a fixed box, so nothing
234 shifts when the figures arrive. */
228 /* The money line (studio/money_line.ts, #510, #11056, #11102): the
229 slider's word ("spend: balanced") is the board's one link to Settings →
230 Economics, in the accent; the gap takes the warning tint for a loss and
231 the text colour otherwise. The accent on --bg holds 4.5:1 in every theme
232 (test/themes.test.ts), so the small text reads. The word never breaks
233 inside itself, so the line wraps between its parts. Two rows of 24px,
234 the first wrapping once on a phone, so the reserved height is the real
235 one; the chart a fixed box, so nothing shifts when the figures arrive. */
235236 .money-gap[data-sign="down"] { color: var(--attn); }
236 a.money-gap[data-sign="up"] { color: var(--accent); }
237 .money-spend { color: var(--accent); white-space: nowrap; }
237238 .money-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; }
238239 .money-line > p { margin: 0; line-height: 24px; min-height: 48px; }
239240 .money-line > p > span { display: block; }
test/board-money-line.test.ts +42 −15
@@ -1,8 +1,8 @@
11 /**
22 * The board's money line (#11056): one line, "This month · cost $10.00 ·
3 * revenue $12.00 · +$2.00", the gap the one link to Settings → Economics,
4 * marked by its sign and named for a screen reader, and the slider's
5 * position beside it as text.
3 * revenue $12.00 · +$2.00 · spend: balanced", the gap marked by its sign,
4 * and the slider's position in words the one link to Settings → Economics,
5 * named for a screen reader (#11102).
66 *
77 * The board reads the real clock, so the prices that spread over the month
88 * (the owned box, storage, awake time) are set to zero, and only crew spend
@@ -28,6 +28,7 @@ import { recordUsage, type UsageEntry } from "../src/core/usage.ts";
2828 import { createUser, type User } from "../src/core/users.ts";
2929 import { serve, type Servers } from "../src/http/server.ts";
3030 import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts";
31 import { positionWord } from "../src/core/slider.ts";
3132
3233 describe("the board's money line", () => {
3334 let dataDir: string;
@@ -62,8 +63,9 @@ describe("the board's money line", () => {
6263 if (!m) throw new Error("no money line on the board");
6364 return m[0].replace(/\s+/g, " ");
6465 };
65 const gapLink = (sign: string, figure: string): string =>
66 `<a class="money-gap num" data-sign="${sign}" href="/settings/economics" aria-label="Month gap ${figure}, open Economics settings">${figure}</a>`;
66 const gapSpan = (sign: string, figure: string): string => `<span class="money-gap num" data-sign="${sign}">${figure}</span>`;
67 const spendLink = (word: string): string =>
68 `<a class="money-spend" href="/settings/economics" aria-label="Spend slider: ${word}, open Economics settings">spend: ${word}</a>`;
6769 const words = (section: string): string => section.replace(/<svg[\s\S]*<\/svg>/, "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ");
6870
6971 beforeAll(async () => {
@@ -95,7 +97,6 @@ describe("the board's money line", () => {
9597 },
9698 alice.id,
9799 );
98 setEconomics(app.db, { position: 62, marginPct: 20 }, alice.id);
99100 const res = await fetch(`${servers.url}/signin`, {
100101 method: "POST",
101102 redirect: "manual",
@@ -109,18 +110,44 @@ describe("the board's money line", () => {
109110 await rm(dataDir, { recursive: true, force: true });
110111 });
111112
112 test("with nothing spent the slot is there, even, at zero", async () => {
113 test("with nothing spent and no position saved: even, at zero, the default's word", async () => {
113114 const section = await moneySection();
114 expect(words(section)).toContain("This month · cost $0.00 · revenue $0.00 · $0.00");
115 expect(section).toContain(gapLink("even", "$0.00"));
115 expect(words(section)).toContain("This month · cost $0.00 · revenue $0.00 · $0.00 · spend: balanced");
116 expect(section).toContain(gapSpan("even", "$0.00"));
117 expect(section).toContain(spendLink("balanced"));
116118 });
117119
118 test("a profit: the line, the gap the one link, the slider beside it as text", async () => {
120 test("each slider position reads as its word, linked to Settings → Economics", async () => {
121 for (const [position, word] of [
122 [10, "lean"],
123 [0, "cheapest"],
124 [80, "responsive"],
125 [100, "most responsive"],
126 ] as const) {
127 setEconomics(app.db, { position, marginPct: 20 }, alice.id);
128 const section = await moneySection();
129 expect(words(section)).toContain(`spend: ${word}`);
130 expect(section).toContain(spendLink(word));
131 expect(section.match(/<a /g)?.length).toBe(1);
132 }
133 expect([positionWord(39), positionWord(40), positionWord(60), positionWord(61), positionWord(-1), positionWord(NaN)]).toEqual([
134 "lean",
135 "balanced",
136 "balanced",
137 "responsive",
138 "balanced",
139 "balanced",
140 ]);
141 setEconomics(app.db, { position: 62, marginPct: 20 }, alice.id);
142 });
143
144 test("a profit: the line, the gap marked up, the slider's word the one link", async () => {
119145 spend(notesId, 10);
120146 const section = await moneySection();
121 expect(words(section)).toContain("This month · cost $10.00 · revenue $12.00 · +$2.00");
122 expect(section).toContain(gapLink("up", "+$2.00"));
123 expect(words(section)).toContain("Today: gap +$2.00 · Economics slider: 62");
147 expect(words(section)).toContain("This month · cost $10.00 · revenue $12.00 · +$2.00 · spend: responsive");
148 expect(section).toContain(gapSpan("up", "+$2.00"));
149 expect(section).toContain(spendLink("responsive"));
150 expect(words(section)).toContain("Today: gap +$2.00");
124151 expect(section.match(/<a /g)?.length).toBe(1);
125152 // The same function as the statement's sum: the board says what readForgeMoney says.
126153 const m = readForgeMoney(app, alice);
@@ -135,8 +162,8 @@ describe("the board's money line", () => {
135162 closeAwake(app.db, { repoId: notesId, env: "pr-1", at: new Date(Date.parse(start) + 60_000).toISOString() });
136163 const section = await moneySection();
137164 expect(words(section)).toContain("This month · cost $15.00 · revenue $12.00 · -$3.00");
138 expect(section).toContain(gapLink("down", "-$3.00"));
139 expect(words(section)).toContain("Economics slider: 62");
165 expect(section).toContain(gapSpan("down", "-$3.00"));
166 expect(words(section)).toContain("· -$3.00 · spend: responsive");
140167 expect(section.match(/<a /g)?.length).toBe(1);
141168 });
142169 });
test/money.test.ts +3 −2
@@ -202,7 +202,7 @@ describe("money line", () => {
202202 const html = await page(await login("alice"), "/board");
203203 expect(html).toContain('class="money-line"');
204204 expect(html).toContain("This month");
205 expect(html).toContain("Economics slider: 62");
205 expect(html).toContain("spend: responsive");
206206 const line = html.match(/<section class="money-line"[\s\S]*?<\/section>/)?.[0] ?? "";
207207 expect(line.match(/href="\/settings\/economics"/g)?.length).toBe(1);
208208 expect(html).toContain('<svg\n class="money-chart"');
@@ -331,7 +331,8 @@ describe("money line on an empty forge", () => {
331331 expect(m.value.thisMonth).toEqual({ cost: 0, revenue: 0, gap: 0 });
332332 expect(m.value.days).toHaveLength(DAYS);
333333 const line = forgeMoneyLine(m.value).text;
334 expect(line).toContain('data-sign="even" href="/settings/economics" aria-label="Month gap $0.00, open Economics settings">$0.00</a>');
334 expect(line).toContain('<span class="money-gap num" data-sign="even">$0.00</span>');
335 expect(line).toContain('href="/settings/economics" aria-label="Spend slider: balanced, open Economics settings">spend: balanced</a>');
335336 expect(line).toContain('width="240"');
336337 expect(line).toContain('height="48"');
337338 } finally {
test/themes.test.ts +1 −1
@@ -65,7 +65,7 @@ describe("the accent as text", () => {
6565 expect(contrast("#2563eb", "#2563eb")).toBe(1);
6666 });
6767
68 // The board's month gap is small text in the accent (#11056): it must read on the ground.
68 // The board's "spend: balanced" link is small text in the accent (#11056, #11102): it must read on the ground.
6969 test.each(THEME_NAMES)("%s: the accent on the ground holds 4.5:1", (name) => {
7070 const t = THEMES[name].tokens;
7171 expect(contrast(t.accent, t.bg)).toBeGreaterThanOrEqual(4.5);
test/tui-money.test.ts +14 −1
@@ -87,13 +87,26 @@ describe("the terminal's money screen", () => {
8787 // Box 300 × 9.5 / 30 = 95.00 against 10 h × $0.10 = 1.00 of revenue.
8888 expect(rows[0]).toEqual({ title: "This month (2026-11)", meta: "cost $95.00 · revenue $1.00 · gap -$94.00" });
8989 expect(rows[1]).toEqual({ title: "Today (2026-11-10)", meta: "cost $5.00 · revenue $0.00 · gap -$5.00" });
90 expect(rows[2]).toEqual({ title: "Economics slider", meta: "62" });
90 expect(rows[2]).toEqual({ title: "Economics slider", meta: "responsive" });
9191 // Thirty days, newest first.
9292 expect(rows).toHaveLength(3 + 30);
9393 expect(rows[3]!.title).toBe("2026-11-10");
9494 expect(rows[32]!.title).toBe("2026-10-12");
9595 });
9696
97 test("the slider row says the board's word for each position (#11102)", () => {
98 const m = readForgeMoney(app, admin, NOW);
99 if (!m.ok) throw new Error(m.error);
100 const word = (position: number): string | undefined => forgeMoneyRows({ ...m.value, position })[2]?.meta;
101 expect([word(0), word(20), word(50), word(80), word(100)]).toEqual([
102 "cheapest",
103 "lean",
104 "balanced",
105 "responsive",
106 "most responsive",
107 ]);
108 });
109
97110 test("an app's rows: its month, where it was awake, its share of the server", () => {
98111 const m = readAppMoney(app, admin, notes, NOW);
99112 if (!m.ok) throw new Error(m.error);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs