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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11102-the-board-s-money-line-names-the-economi → main 3 commits fork 2 threads · 2 unresolved
11 files
changed +130
−64
docs/design/areas/chart.md +2 −1
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | Drawn by `src/web/studio/money_line.ts`; styled in `src/web/style/content.ts`. |
| 16 | 16 | |
| 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. | |
| 18 | 18 | - `.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. | |
| 19 | 20 | - `.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 | ||
| 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 · -$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 | |
| 9 | 9 | |
| 10 | 10 | - **cost** — `costOf`: the forge's own server's monthly price spread over |
| 11 | 11 | the days so far, cloud awake-hours, storage, and crew spend. |
| 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 | - **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". | |
| 19 | 21 | |
| 20 | 22 | ## Where to read it |
| 21 | 23 | |
| 22 | 24 | - **The board** (`/board`), for a site admin: the line above, and beside it |
| 23 | 25 | the last 30 days' cost (muted) and revenue (text colour) as one small |
| 24 | 26 | 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. | |
| 27 | 29 | - **An app's page**, for the repository's admins and forge admins: a |
| 28 | 30 | "This month" card with the app's cost, revenue (linking to its statement), |
| 29 | 31 | gap, and how many hours it was awake on the forge's own server against |
forge.json +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | ], |
| 6 | 6 | "app": { |
| 7 | 7 | "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"], | |
| 9 | 9 | "run": "bun run src/main.ts", |
| 10 | 10 | "install": "", |
| 11 | 11 | "health": "/healthz", |
src/core/slider.ts +18
| @@ -237,3 +237,21 @@ export const parseSlots = (input: unknown): number | undefined => | ||
| 237 | 237 | export function usdOfCents(cents: number): string { |
| 238 | 238 | return `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`; |
| 239 | 239 | } |
| 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; | |
| 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"; | ||
| 21 | 21 | import { ListState, drawList, type ListRow } from "./common.ts"; |
| 22 | 22 | import type { PaletteEntry } from "./entries.ts"; |
| 23 | 23 | import { dollars, signed } from "../../web/studio/money_line.ts"; |
| 24 | import { positionWord } from "../../core/slider.ts"; | |
| 24 | 25 | |
| 25 | 26 | const figures = (m: MoneyLine): string => `cost ${dollars(m.cost)} · revenue ${dollars(m.revenue)} · gap ${signed(m.gap)}`; |
| 26 | 27 | const hours = (s: number): string => `${(s / 3600).toFixed(1)} h`; |
| 27 | 28 | const pct = (part: number, whole: number): string => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : "0%"); |
| 28 | 29 | |
| 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 | */ | |
| 30 | 34 | export function forgeMoneyRows(m: ForgeMoney): ListRow[] { |
| 31 | 35 | return [ |
| 32 | 36 | { title: `This month (${m.month})`, meta: figures(m.thisMonth) }, |
| 33 | 37 | { 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) }, | |
| 35 | 39 | ...[...m.days].reverse().map((d) => ({ title: d.day, meta: figures(d) })), |
| 36 | 40 | ]; |
| 37 | 41 | } |
src/web/studio/money_line.ts +24 −25
| @@ -1,20 +1,22 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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`). | |
| 7 | 8 | * |
| 8 | 9 | * 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 | |
| 10 | 11 | * 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). | |
| 15 | 16 | */ |
| 16 | 17 | |
| 17 | 18 | import type { AppMoney, ForgeMoney, MoneyDay, MoneyLine } from "../../actions/money.ts"; |
| 19 | import { positionWord } from "../../core/slider.ts"; | |
| 18 | 20 | import { html, type Html } from "../html.ts"; |
| 19 | 21 | |
| 20 | 22 | /** US dollars to the cent, as on a statement. */ |
| @@ -39,16 +41,17 @@ export function gapSign(n: number): "up" | "down" | "even" { | ||
| 39 | 41 | const gap = (n: number): Html => html`<span class="money-gap num" data-sign="${gapSign(n)}">${signed(n)}</span>`; |
| 40 | 42 | |
| 41 | 43 | /** |
| 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. | |
| 46 | 49 | */ |
| 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); | |
| 49 | 52 | 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>`; | |
| 52 | 55 | } |
| 53 | 56 | |
| 54 | 57 | // ---- the chart ---- |
| @@ -102,18 +105,14 @@ export function moneyChart(days: readonly MoneyDay[]): Html { | ||
| 102 | 105 | // ---- the board's line ---- |
| 103 | 106 | |
| 104 | 107 | /** |
| 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. | |
| 108 | 110 | */ |
| 109 | 111 | export function forgeMoneyLine(m: ForgeMoney): Html { |
| 110 | 112 | return html`<section class="money-line" id="money" aria-label="Money"> |
| 111 | 113 | <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> | |
| 117 | 116 | </p> |
| 118 | 117 | ${moneyChart(m.days)} |
| 119 | 118 | </section>`; |
src/web/style/content.ts +9 −8
| @@ -225,15 +225,16 @@ 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, #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. */ | |
| 235 | 236 | .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; } | |
| 237 | 238 | .money-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; } |
| 238 | 239 | .money-line > p { margin: 0; line-height: 24px; min-height: 48px; } |
| 239 | 240 | .money-line > p > span { display: block; } |
test/board-money-line.test.ts +42 −15
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 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. | |
| 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). | |
| 6 | 6 | * |
| 7 | 7 | * The board reads the real clock, so the prices that spread over the month |
| 8 | 8 | * (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"; | ||
| 28 | 28 | import { createUser, type User } from "../src/core/users.ts"; |
| 29 | 29 | import { serve, type Servers } from "../src/http/server.ts"; |
| 30 | 30 | import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts"; |
| 31 | import { positionWord } from "../src/core/slider.ts"; | |
| 31 | 32 | |
| 32 | 33 | describe("the board's money line", () => { |
| 33 | 34 | let dataDir: string; |
| @@ -62,8 +63,9 @@ describe("the board's money line", () => { | ||
| 62 | 63 | if (!m) throw new Error("no money line on the board"); |
| 63 | 64 | return m[0].replace(/\s+/g, " "); |
| 64 | 65 | }; |
| 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>`; | |
| 67 | 69 | const words = (section: string): string => section.replace(/<svg[\s\S]*<\/svg>/, "").replace(/<[^>]*>/g, "").replace(/\s+/g, " "); |
| 68 | 70 | |
| 69 | 71 | beforeAll(async () => { |
| @@ -95,7 +97,6 @@ describe("the board's money line", () => { | ||
| 95 | 97 | }, |
| 96 | 98 | alice.id, |
| 97 | 99 | ); |
| 98 | setEconomics(app.db, { position: 62, marginPct: 20 }, alice.id); | |
| 99 | 100 | const res = await fetch(`${servers.url}/signin`, { |
| 100 | 101 | method: "POST", |
| 101 | 102 | redirect: "manual", |
| @@ -109,18 +110,44 @@ describe("the board's money line", () => { | ||
| 109 | 110 | await rm(dataDir, { recursive: true, force: true }); |
| 110 | 111 | }); |
| 111 | 112 | |
| 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 () => { | |
| 113 | 114 | 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")); | |
| 116 | 118 | }); |
| 117 | 119 | |
| 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 () => { | |
| 119 | 145 | spend(notesId, 10); |
| 120 | 146 | 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"); | |
| 124 | 151 | expect(section.match(/<a /g)?.length).toBe(1); |
| 125 | 152 | // The same function as the statement's sum: the board says what readForgeMoney says. |
| 126 | 153 | const m = readForgeMoney(app, alice); |
| @@ -135,8 +162,8 @@ describe("the board's money line", () => { | ||
| 135 | 162 | closeAwake(app.db, { repoId: notesId, env: "pr-1", at: new Date(Date.parse(start) + 60_000).toISOString() }); |
| 136 | 163 | const section = await moneySection(); |
| 137 | 164 | 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"); | |
| 140 | 167 | expect(section.match(/<a /g)?.length).toBe(1); |
| 141 | 168 | }); |
| 142 | 169 | }); |
test/money.test.ts +3 −2
| @@ -202,7 +202,7 @@ 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("Economics slider: 62"); | |
| 205 | expect(html).toContain("spend: responsive"); | |
| 206 | 206 | const line = html.match(/<section class="money-line"[\s\S]*?<\/section>/)?.[0] ?? ""; |
| 207 | 207 | expect(line.match(/href="\/settings\/economics"/g)?.length).toBe(1); |
| 208 | 208 | expect(html).toContain('<svg\n class="money-chart"'); |
| @@ -331,7 +331,8 @@ describe("money line on an empty forge", () => { | ||
| 331 | 331 | expect(m.value.thisMonth).toEqual({ cost: 0, revenue: 0, gap: 0 }); |
| 332 | 332 | expect(m.value.days).toHaveLength(DAYS); |
| 333 | 333 | 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>'); | |
| 335 | 336 | expect(line).toContain('width="240"'); |
| 336 | 337 | expect(line).toContain('height="48"'); |
| 337 | 338 | } finally { |
test/themes.test.ts +1 −1
| @@ -65,7 +65,7 @@ describe("the accent as text", () => { | ||
| 65 | 65 | expect(contrast("#2563eb", "#2563eb")).toBe(1); |
| 66 | 66 | }); |
| 67 | 67 | |
| 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. | |
| 69 | 69 | test.each(THEME_NAMES)("%s: the accent on the ground holds 4.5:1", (name) => { |
| 70 | 70 | const t = THEMES[name].tokens; |
| 71 | 71 | 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", () => { | ||
| 87 | 87 | // Box 300 × 9.5 / 30 = 95.00 against 10 h × $0.10 = 1.00 of revenue. |
| 88 | 88 | expect(rows[0]).toEqual({ title: "This month (2026-11)", meta: "cost $95.00 · revenue $1.00 · gap -$94.00" }); |
| 89 | 89 | 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" }); | |
| 91 | 91 | // Thirty days, newest first. |
| 92 | 92 | expect(rows).toHaveLength(3 + 30); |
| 93 | 93 | expect(rows[3]!.title).toBe("2026-11-10"); |
| 94 | 94 | expect(rows[32]!.title).toBe("2026-10-12"); |
| 95 | 95 | }); |
| 96 | 96 | |
| 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 | ||
| 97 | 110 | test("an app's rows: its month, where it was awake, its share of the server", () => { |
| 98 | 111 | const m = readAppMoney(app, admin, notes, NOW); |
| 99 | 112 | if (!m.ok) throw new Error(m.error); |
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.