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

The board shows this month's money in one line: cost, revenue and the gap, with a link to Economics #11074

Merged opened this

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

Merged

torque/forge:torque/11056-the-board-shows-this-month-s-money-in-on → main 3 commits fork 4 threads · 4 unresolved

  • Merged. torque/11056-the-board-shows-this-month-s-money-in-on was merged into main as 6574312.

Review

Sign in to review this change.
6 files changed +223 −21
docs/features/money.md +8 −3
@@ -4,7 +4,7 @@ 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 · gap -$91.50
7 This month · cost $103.50 · revenue $12.00 · -$91.50
88 Today: gap -$5.00 · Economics slider: 62
99
1010 - **cost** — `costOf`: the forge's own server's monthly price spread over
@@ -12,13 +12,18 @@ month runs from the 1st to now.
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".
1519
1620 ## Where to read it
1721
1822 - **The board** (`/board`), for a site admin: the line above, and beside it
1923 the last 30 days' cost (muted) and revenue (text colour) as one small
20 chart, the space between them shaded faintly for a profit and in the warning tint for a loss. The
21 slider's number links to Settings → Economics.
24 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.
2227 - **An app's page**, for the repository's admins and forge admins: a
2328 "This month" card with the app's cost, revenue (linking to its statement),
2429 gap, and how many hours it was awake on the forge's own server against
src/web/studio/money_line.ts +28 −11
@@ -8,8 +8,10 @@
88 * The slot is the same height whatever the numbers, zeros included: the
99 * line is two rows (the month; today and the slider), each reserved in the
1010 * CSS, and the SVG has a fixed width and height, so nothing moves when the
11 * figures arrive (no layout shift). The gap is the text colour with its
12 * sign; a loss alone takes the warning tint. The accent is the link's.
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).
1315 */
1416
1517 import type { AppMoney, ForgeMoney, MoneyDay, MoneyLine } from "../../actions/money.ts";
@@ -24,7 +26,11 @@ export function signed(n: number): string {
2426 return c > 0 ? `+${dollars(n)}` : c < 0 ? `-${dollars(n)}` : dollars(0);
2527 }
2628
27 /** "up" for a profit, "down" for a loss, "even" at zero; only "down" is tinted. */
29 /**
30 * "up" for a profit, "down" for a loss, "even" at zero. A loss is in the
31 * warning tint everywhere; on the board, where the gap is a link, a profit
32 * takes the accent.
33 */
2834 export function gapSign(n: number): "up" | "down" | "even" {
2935 const c = Math.round(n * 100);
3036 return c > 0 ? "up" : c < 0 ? "down" : "even";
@@ -32,10 +38,17 @@ export function gapSign(n: number): "up" | "down" | "even" {
3238
3339 const gap = (n: number): Html => html`<span class="money-gap num" data-sign="${gapSign(n)}">${signed(n)}</span>`;
3440
35 /** The three numbers, inline. */
36 function figures(m: MoneyLine): Html {
37 return html`cost <span class="num">${dollars(m.cost)}</span> · revenue
38 <span class="num">${dollars(m.revenue)}</span> · gap ${gap(m.gap)}`;
41 /**
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
outdated

Principle 1 and accessibility: the link text is only the signed number, and a second link to the same page sits right beside it in forgeMoneyLine. Give this an accessible name or keep one link only.

45 * that says what the figure is and where it goes.
46 */
47 export function monthLine(m: MoneyLine): Html {
48 const g = signed(m.gap);
49 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>`;
3952 }
4053
4154 // ---- the chart ----
@@ -88,14 +101,18 @@ export function moneyChart(days: readonly MoneyDay[]): Html {
88101
89102 // ---- the board's line ----
90103
91 /** The forge's line for the board, with the slider's position and the chart. */
104 /**
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 */
92109 export function forgeMoneyLine(m: ForgeMoney): Html {
93110 return html`<section class="money-line" id="money" aria-label="Money">
94111 <p class="num">
95 <span>This month: ${figures(m.thisMonth)}</span>
112 <span>${monthLine(m.thisMonth)}</span>
96113 <span
97 ><span class="muted">Today: gap ${signed(m.today.gap)}</span> ·
98 <a href="/settings/economics">Economics slider: ${m.position}</a></span
114 class="muted"
115 >Today: gap ${signed(m.today.gap)} · Economics slider: ${m.position}</span
99116 >
100117 </p>
101118 ${moneyChart(m.days)}
src/web/style/content.ts +8 −5
@@ -225,12 +225,15 @@ 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): the gap in the text colour,
229 its sign the +/- it carries, and the warning tint for a loss alone — the
230 accent stays for the slider's link. Two rows of 24px, the first wrapping
231 once on a phone, so the reserved height is the real one; the chart a
232 fixed box, so nothing shifts when the figures arrive. */
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. */
outdated

Principle 5: the accent now marks a figure, and on a phone it is the only accent in the top row beside another link to the same place. Keep it, but make sure the contrast of --accent on --bg holds for small text. Also, the comment line above runs past the wrapping used elsewhere; rewrap.

233235 .money-gap[data-sign="down"] { color: var(--attn); }
236 a.money-gap[data-sign="up"] { color: var(--accent); }
234237 .money-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; }
235238 .money-line > p { margin: 0; line-height: 24px; min-height: 48px; }
236239 .money-line > p > span { display: block; }
test/board-money-line.test.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * 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.
6 *
7 * The board reads the real clock, so the prices that spread over the month
8 * (the owned box, storage, awake time) are set to zero, and only crew spend
9 * recorded now and one minute of cloud at the month's first instant move
10 * the figures. In the month's first two minutes that cloud minute is not yet
11 * wholly past, so the loss is skipped then rather than read half-counted. By hand, with a 20% margin and cloud at $300 an hour:
12 * positive: $10 of crew → cost 10.00, revenue 10 × 1.2 = 12.00, gap +2.00
13 * negative: and a cloud minute, 300 / 60 = 5.00 of cost that no statement
14 * charges (awake time is priced at zero) → cost 15.00,
15 * revenue 12.00, gap −3.00
16 */
17
18 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
19 import { mkdtemp, rm } from "node:fs/promises";
20 import { tmpdir } from "node:os";
21 import { join } from "node:path";
22 import { createApp, type App } from "../src/app.ts";
23 import { readForgeMoney } from "../src/actions/money.ts";
24 import { createRepo } from "../src/actions/repos.ts";
25 import { closeAwake, openAwake } from "../src/core/meter.ts";
26 import { createRun } from "../src/core/runs.ts";
27 import { recordUsage, type UsageEntry } from "../src/core/usage.ts";
28 import { createUser, type User } from "../src/core/users.ts";
29 import { serve, type Servers } from "../src/http/server.ts";
30 import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts";
31
32 describe("the board's money line", () => {
33 let dataDir: string;
34 let app: App;
35 let servers: Servers;
36 let alice: User;
37 let cookie = "";
38 let notesId = "";
39
40 const monthStart = `${new Date().toISOString().slice(0, 7)}-01T00:00:00.000Z`;
41 const inFirstMinutes = Date.now() - Date.parse(monthStart) < 120_000;
outdated

as never hides the real input type. Build the proper record. The loss test at the month's first minute is also time-dependent.

42 const spend = (repoId: string, usd: number) => {
43 const entry: UsageEntry = {
44 runId: createRun(app.db, { repoId, kind: "agent" }).id,
45 repoId,
46 issueId: null,
47 agentId: null,
48 subscriptionId: null,
49 tokensIn: 1,
50 tokensOut: 1,
51 costUsd: usd,
52 wallMs: 1,
53 outcome: "ok",
54 at: new Date().toISOString(),
55 };
56 recordUsage(app.db, entry);
57 };
58 /** The money section of the board, whitespace folded to single spaces. */
59 const moneySection = async (): Promise<string> => {
60 const html = await (await fetch(`${servers.url}/board`, { headers: { cookie } })).text();
61 const m = html.match(/<section class="money-line"[\s\S]*?<\/section>/);
62 if (!m) throw new Error("no money line on the board");
63 return m[0].replace(/\s+/g, " ");
64 };
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>`;
67 const words = (section: string): string => section.replace(/<svg[\s\S]*<\/svg>/, "").replace(/<[^>]*>/g, "").replace(/\s+/g, " ");
68
69 beforeAll(async () => {
70 dataDir = await mkdtemp(join(tmpdir(), "forge-board-money-"));
71 app = createApp({ dataDir, port: 0 });
72 servers = await serve(app);
73 const made = await createUser(app.db, {
74 origin: "seed",
75 trusted: true,
76 username: "alice",
77 email: "[email protected]",
78 password: "a-good-passphrase",
79 adminIfFirst: true,
80 });
81 if (!made.ok) throw new Error(made.error);
82 alice = made.value;
83 const repo = await createRepo(app, alice, { name: "notes", visibility: "public" });
84 if (!repo.ok) throw new Error("repo");
85 notesId = repo.value.id;
86 setPrices(
87 app.db,
88 {
89 ...getPrices(app.db),
90 ownedMonthCents: 0,
91 cloudHourCents: 30_000,
92 storageGbMonthCents: 0,
93 awakePriceHourCents: 0,
94 storagePriceGbMonthCents: 0,
95 },
96 alice.id,
97 );
98 setEconomics(app.db, { position: 62, marginPct: 20 }, alice.id);
99 const res = await fetch(`${servers.url}/signin`, {
100 method: "POST",
101 redirect: "manual",
102 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
103 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
104 });
105 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
106 });
107 afterAll(async () => {
108 await servers.stop();
109 await rm(dataDir, { recursive: true, force: true });
110 });
111
112 test("with nothing spent the slot is there, even, at zero", async () => {
113 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"));
116 });
117
118 test("a profit: the line, the gap the one link, the slider beside it as text", async () => {
119 spend(notesId, 10);
120 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");
124 expect(section.match(/<a /g)?.length).toBe(1);
125 // The same function as the statement's sum: the board says what readForgeMoney says.
126 const m = readForgeMoney(app, alice);
127 if (!m.ok) throw new Error(m.error);
128 expect(m.value.thisMonth).toEqual({ cost: 10, revenue: 12, gap: 2 });
129 });
130
131 test.skipIf(inFirstMinutes)("a loss: the gap marked down, the slider still beside it", async () => {
outdated

Brittle: asserting exact CSS source text. Test the served stylesheet or drop this test, since the data-sign attribute is already asserted above.

132 // A minute of cloud from the month's first instant, already past.
133 const start = monthStart;
134 openAwake(app.db, { repoId: notesId, env: "pr-1", placement: "cloud", at: start });
135 closeAwake(app.db, { repoId: notesId, env: "pr-1", at: new Date(Date.parse(start) + 60_000).toISOString() });
136 const section = await moneySection();
137 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");
140 expect(section.match(/<a /g)?.length).toBe(1);
141 });
142 });
test/money.test.ts +4 −2
@@ -202,7 +202,9 @@ 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('<a href="/settings/economics">Economics slider: 62</a>');
205 expect(html).toContain("Economics slider: 62");
206 const line = html.match(/<section class="money-line"[\s\S]*?<\/section>/)?.[0] ?? "";
207 expect(line.match(/href="\/settings\/economics"/g)?.length).toBe(1);
206208 expect(html).toContain('<svg\n class="money-chart"');
207209 expect(html).toMatch(/class="money-gap num" data-sign="(up|down|even)"/);
208210 const theirs = await page(await login("bob"), "/board");
@@ -329,7 +331,7 @@ describe("money line on an empty forge", () => {
329331 expect(m.value.thisMonth).toEqual({ cost: 0, revenue: 0, gap: 0 });
330332 expect(m.value.days).toHaveLength(DAYS);
331333 const line = forgeMoneyLine(m.value).text;
332 expect(line).toContain('data-sign="even">$0.00');
334 expect(line).toContain('data-sign="even" href="/settings/economics" aria-label="Month gap $0.00, open Economics settings">$0.00</a>');
333335 expect(line).toContain('width="240"');
334336 expect(line).toContain('height="48"');
335337 } finally {
test/themes.test.ts +33
@@ -38,3 +38,36 @@ describe("fixed tokens: colours every theme shares (#2697)", () => {
3838 expect(css).toContain(".fg-s0 { fill: var(--series-0); color: var(--series-0); }");
3939 });
4040 });
41
42 /**
43 * WCAG 2.2 contrast (§1.4.3): relative luminance of sRGB with the 0.04045
44 * knee, then (L1 + 0.05) / (L2 + 0.05). By hand, Porcelain's #2563eb on
45 * #fafafa: L = 0.1532 and 0.9560, so 1.0060 / 0.2032 ≈ 4.95, the lowest of
46 * the six and above the 4.5 that small text needs.
47 */
48 function luminance(hex: string): number {
49 const n = Number.parseInt(hex.slice(1), 16);
50 const lin = (c: number): number => {
51 const s = c / 255;
52 return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
53 };
54 return 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
55 }
56 function contrast(a: string, b: string): number {
57 const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
58 return (hi + 0.05) / (lo + 0.05);
59 }
60
61 describe("the accent as text", () => {
62 test("black on white is 21:1, a colour on itself 1:1, Porcelain's accent 4.95", () => {
63 expect(contrast("#2563eb", "#fafafa")).toBeCloseTo(4.95, 2);
64 expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 5);
65 expect(contrast("#2563eb", "#2563eb")).toBe(1);
66 });
67
68 // The board's month gap is small text in the accent (#11056): it must read on the ground.
69 test.each(THEME_NAMES)("%s: the accent on the ground holds 4.5:1", (name) => {
70 const t = THEMES[name].tokens;
71 expect(contrast(t.accent, t.bg)).toBeGreaterThanOrEqual(4.5);
72 });
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs