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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11056-the-board-shows-this-month-s-money-in-on → main 3 commits fork 4 threads · 4 unresolved
docs/features/money.md +8 −3
| @@ -4,7 +4,7 @@ Whether the economics slider is set right, read in one line (#510). All | ||
| 4 | 4 | figures are US dollars to the cent; a day is a UTC calendar day, and this |
| 5 | 5 | month runs from the 1st to now. |
| 6 | 6 | |
| 7 | This month: cost $103.50 · revenue $12.00 · gap -$91.50 | |
| 7 | This month · cost $103.50 · revenue $12.00 · -$91.50 | |
| 8 | 8 | Today: gap -$5.00 · Economics slider: 62 |
| 9 | 9 | |
| 10 | 10 | - **cost** — `costOf`: the forge's own server's monthly price spread over |
| @@ -12,13 +12,18 @@ month runs from the 1st to now. | ||
| 12 | 12 | - **revenue** — the sum of every app's statement (`docs/features/statement.md`), |
| 13 | 13 | to the cent. |
| 14 | 14 | - **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 | 19 | |
| 16 | 20 | ## Where to read it |
| 17 | 21 | |
| 18 | 22 | - **The board** (`/board`), for a site admin: the line above, and beside it |
| 19 | 23 | 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. | |
| 22 | 27 | - **An app's page**, for the repository's admins and forge admins: a |
| 23 | 28 | "This month" card with the app's cost, revenue (linking to its statement), |
| 24 | 29 | 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 @@ | ||
| 8 | 8 | * The slot is the same height whatever the numbers, zeros included: the |
| 9 | 9 | * line is two rows (the month; today and the slider), each reserved in the |
| 10 | 10 | * 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). | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | import type { AppMoney, ForgeMoney, MoneyDay, MoneyLine } from "../../actions/money.ts"; |
| @@ -24,7 +26,11 @@ export function signed(n: number): string { | ||
| 24 | 26 | return c > 0 ? `+${dollars(n)}` : c < 0 ? `-${dollars(n)}` : dollars(0); |
| 25 | 27 | } |
| 26 | 28 | |
| 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 | */ | |
| 28 | 34 | export function gapSign(n: number): "up" | "down" | "even" { |
| 29 | 35 | const c = Math.round(n * 100); |
| 30 | 36 | return c > 0 ? "up" : c < 0 ? "down" : "even"; |
| @@ -32,10 +38,17 @@ export function gapSign(n: number): "up" | "down" | "even" { | ||
| 32 | 38 | |
| 33 | 39 | const gap = (n: number): Html => html`<span class="money-gap num" data-sign="${gapSign(n)}">${signed(n)}</span>`; |
| 34 | 40 | |
| 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 | |
| 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>`; | |
| 39 | 52 | } |
| 40 | 53 | |
| 41 | 54 | // ---- the chart ---- |
| @@ -88,14 +101,18 @@ export function moneyChart(days: readonly MoneyDay[]): Html { | ||
| 88 | 101 | |
| 89 | 102 | // ---- the board's line ---- |
| 90 | 103 | |
| 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 | */ | |
| 92 | 109 | export function forgeMoneyLine(m: ForgeMoney): Html { |
| 93 | 110 | return html`<section class="money-line" id="money" aria-label="Money"> |
| 94 | 111 | <p class="num"> |
| 95 | <span>This month: ${figures(m.thisMonth)}</span> | |
| 112 | <span>${monthLine(m.thisMonth)}</span> | |
| 96 | 113 | <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 | |
| 99 | 116 | > |
| 100 | 117 | </p> |
| 101 | 118 | ${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); } | ||
| 225 | 225 | .fg-s5 { fill: var(--series-5); color: var(--series-5); } |
| 226 | 226 | .fg-s6 { fill: var(--series-6); color: var(--series-6); } |
| 227 | 227 | .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. |
||
| 233 | 235 | .money-gap[data-sign="down"] { color: var(--attn); } |
| 236 | a.money-gap[data-sign="up"] { color: var(--accent); } | |
| 234 | 237 | .money-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; } |
| 235 | 238 | .money-line > p { margin: 0; line-height: 24px; min-height: 48px; } |
| 236 | 239 | .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
|
||
| 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", () => { | ||
| 202 | 202 | const html = await page(await login("alice"), "/board"); |
| 203 | 203 | expect(html).toContain('class="money-line"'); |
| 204 | 204 | 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); | |
| 206 | 208 | expect(html).toContain('<svg\n class="money-chart"'); |
| 207 | 209 | expect(html).toMatch(/class="money-gap num" data-sign="(up|down|even)"/); |
| 208 | 210 | const theirs = await page(await login("bob"), "/board"); |
| @@ -329,7 +331,7 @@ describe("money line on an empty forge", () => { | ||
| 329 | 331 | expect(m.value.thisMonth).toEqual({ cost: 0, revenue: 0, gap: 0 }); |
| 330 | 332 | expect(m.value.days).toHaveLength(DAYS); |
| 331 | 333 | 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>'); | |
| 333 | 335 | expect(line).toContain('width="240"'); |
| 334 | 336 | expect(line).toContain('height="48"'); |
| 335 | 337 | } finally { |
test/themes.test.ts +33
| @@ -38,3 +38,36 @@ describe("fixed tokens: colours every theme shares (#2697)", () => { | ||
| 38 | 38 | expect(css).toContain(".fg-s0 { fill: var(--series-0); color: var(--series-0); }"); |
| 39 | 39 | }); |
| 40 | 40 | }); |
| 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 | }); |
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.