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

The board's money line: cost, revenue and the gap, per forge and per app, today and this month #10983

Merged opened this

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

Merged

torque/forge:torque/510-the-board-s-money-line-cost-revenue-and → main 11 commits fork 2 threads · 2 unresolved

  • Merged. torque/510-the-board-s-money-line-cost-revenue-and was merged into main as ec391ad.

Review

Sign in to review this change.
23 files changed +1265 −95
docs/PROFIT.md +9
@@ -77,6 +77,15 @@ $67.85. Read it at `GET /api/v1/repos/:owner/:name/statement?month=YYYY-MM`
7777 (JSON, or `format=csv`) or print it from the app's settings
7878 (`/:owner/:name/settings/app/statement`); see `docs/features/statement.md`.
7979
80 `src/actions/money.ts` is the gap (#510): revenue is the sum of the apps'
81 statements — both start from `monthUse` and `chargeOf`, so there is no second
82 sum — and cost is `costOf` over the month so far. A day's cost is `costOf`
83 over that day, and its revenue the sum of each app's `chargeOf`, rounded to
84 the cent per app; one meter pass (`usageBins`) and one ledger read
85 (`usageDays`) give all thirty days. One app's cost carries the owned box in
86 proportion to its share of the forge's owned awake time this month, so the
87 forge's cost is the apps' costs plus the box's unowned share. How to read it: `docs/features/money.md`.
88
8089 ## The order (what waits for what)
8190
8291 | issue | what | waits for |
docs/design/areas/chart.md +8
@@ -9,3 +9,11 @@
99 - `.fg-s5` — series 5.
1010 - `.fg-s6` — series 6.
1111 - `.fg-s7` — series 7, the folded "others" series (grey).
12
13 ## The money line (#510)
14
15 Drawn by `src/web/studio/money_line.ts`; styled in `src/web/style/content.ts`.
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.
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-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 added +38
@@ -0,0 +1,38 @@
1 # The money line — cost, revenue and the gap
2
3 Whether the economics slider is set right, read in one line (#510). All
4 figures are US dollars to the cent; a day is a UTC calendar day, and this
5 month runs from the 1st to now.
6
7 This month: cost $103.50 · revenue $12.00 · gap -$91.50
8 Today: gap -$5.00 · Economics slider: 62
9
10 - **cost** — `costOf`: the forge's own server's monthly price spread over
11 the days so far, cloud awake-hours, storage, and crew spend.
12 - **revenue** — the sum of every app's statement (`docs/features/statement.md`),
13 to the cent.
14 - **gap** — revenue − cost, with its sign. A loss is in the warning tint.
15
16 ## Where to read it
17
18 - **The board** (`/board`), for a site admin: the line above, and beside it
19 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.
22 - **An app's page**, for the repository's admins and forge admins: a
23 "This month" card with the app's cost, revenue (linking to its statement),
24 gap, and how many hours it was awake on the forge's own server against
25 cloud. A brand-new app's page shows its progress instead, without the card.
26 - **At the terminal**, a site admin picks `Money` from the `:` palette for
27 the month, today, the slider and each of the 30 days. With a repository
28 open, its admins pick `owner/name — Money` for that app's month.
29
30 ## The one assumption
31
32 An app's cost takes the forge's own server in proportion to its share of
33 the time apps were awake there this month. An app awake only on cloud takes
34 none of it. So the forge's cost is the apps' costs, plus the part of the
35 server's month no app was awake on.
36
37 Anyone else is told nothing: the board leaves the line out, the app page
38 leaves the card out, and the terminal offers neither screen.
src/actions/money.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * The money line (#510; docs/PROFIT.md): what the forge, or one app, cost
3 * and earned this month and today, and the gap between them — the signal
4 * that says whether the slider is set right.
5 *
6 * revenue = Σ over apps of each app's statement total (`statementFor`,
7 * built from `monthUse` as `readStatement` builds it), so the
8 * board's revenue is the sum of the statements, to the cent
9 * cost = `costOf` over every app's meter and the whole crew ledger,
10 * the owned box spread over the month so far
11 * gap = revenue − cost, in cents, then dollars
12 *
13 * A day is a UTC calendar day; today runs to `now`. Each day's revenue is
14 * Σ over apps of `chargeOf` rounded to the cent, and its cost is `costOf`
15 * for that day — the same two functions, never a second sum.
16 *
17 * For one app, the cost charges its own cloud time, storage and crew spend
18 * in full, and the owned box in proportion to its share of the forge's
19 * owned awake time this month — an assumption the app page states. An app
20 * awake nowhere owned pays no share of the box.
21 *
22 * Forge figures are for a site admin who is a person (as Settings →
23 * Economics); an app's are for whoever may read its statement. Everyone
24 * else is told `not_found`.
25 */
26
27 import type { App } from "../app.ts";
28 import { costOf, monthSoFar, type Range } from "../core/cost.ts";
29 import { usageBins, type AppUsage } from "../core/meter.ts";
30 import type { Repo } from "../core/repos.ts";
31 import { chargeOf, monthOf, statementFor } from "../core/revenue.ts";
32 import { usageDays } from "../core/usage.ts";
33 import type { User } from "../core/users.ts";
34 import { err, ok, type Result } from "../kernel/result.ts";
35 import type { Prices } from "../core/slider.ts";
36 import type { Rates } from "../core/revenue.ts";
37 import { getEconomicsAll } from "../settings/economics.ts";
38 import { appUse, mayReadStatement, monthUse, ratesFrom } from "./statement.ts";
39
40 /** US dollars, each rounded to the cent. */
41 export interface MoneyLine {
42 readonly cost: number;
43 readonly revenue: number;
44 /** revenue − cost. */
45 readonly gap: number;
46 }
47
48 export interface MoneyDay extends MoneyLine {
49 /** `YYYY-MM-DD`, UTC. */
50 readonly day: string;
51 }
52
53 export interface ForgeMoney {
54 /** `YYYY-MM`, UTC. */
55 readonly month: string;
56 readonly thisMonth: MoneyLine;
57 readonly today: MoneyDay;
58 /** The last `DAYS` UTC days, oldest first, today last. */
59 readonly days: readonly MoneyDay[];
60 /** The slider, 0–100. */
61 readonly position: number;
62 }
63
64 export interface AppMoney {
65 readonly month: string;
66 readonly thisMonth: MoneyLine;
67 /** Awake seconds this month, by where they ran. */
68 readonly ownedSeconds: number;
69 readonly cloudSeconds: number;
70 /** The app's fraction of the forge's owned awake time this month, 0–1. */
71 readonly ownedShare: number;
72 }
73
74 /** Days the board's chart spans. */
75 export const DAYS = 30;
76
77 const DAY_MS = 86_400_000;
78 const cents = (usd: number): number => Math.round(usd * 100);
79 const line = (costCents: number, revenueCents: number): MoneyLine => ({
80 cost: costCents / 100,
81 revenue: revenueCents / 100,
82 gap: (revenueCents - costCents) / 100,
83 });
84
85 /** The forge's whole month so far: Σ statements against `costOf`. */
86 function forgeMonth(app: App, now: string, prices: Prices, rates: Rates): { month: string; line: MoneyLine } {
87 const month = monthOf(now);
88 const use = monthUse(app, month, now);
89 const repos = new Set<string>(use.meter.map((u) => u.repoId));
90 for (const t of use.crew) if (t.key !== null) repos.add(t.key);
91 let revenueCents = 0;
92 for (const id of repos) revenueCents += Math.round(chargeOf(appUse(use, id), rates).totalCents);
93 const cost = costOf(use.meter, use.crew, prices, monthSoFar(now));
94 return { month, line: line(cents(cost.total), revenueCents) };
95 }
96
97 /** The last `DAYS` UTC days: one meter pass, one ledger read. */
98 function forgeDays(app: App, now: string, prices: Prices, rates: Rates): MoneyDay[] {
99 const clock = Date.parse(now);
100 const today = Math.floor(clock / DAY_MS) * DAY_MS;
101 const starts = Array.from({ length: DAYS }, (_, i) => today - (DAYS - 1 - i) * DAY_MS);
102 const edges = [...starts.map((ms) => new Date(ms).toISOString()), new Date(clock).toISOString()];
103 const meter = usageBins(app.db, edges, now);
104 const crew = usageDays(app.db, edges[0]!, edges[DAYS]!);
105 return starts.map((_, i) => {
106 const range: Range = { from: edges[i]!, to: edges[i + 1]! };
107 const day = range.from.slice(0, 10);
108 const used: readonly AppUsage[] = meter[i] ?? [];
109 const spent = crew.filter((c) => c.day === day);
110 const repos = new Set<string>(used.map((u) => u.repoId));
111 for (const c of spent) if (c.repoId !== null) repos.add(c.repoId);
112 let revenueCents = 0;
113 for (const id of repos) {
114 const m = used.find((u) => u.repoId === id);
115 revenueCents += Math.round(
116 chargeOf(
117 {
118 awakeSeconds: m?.awakeSeconds ?? 0,
119 gbHours: m?.gbHours ?? 0,
120 crewUsd: spent.filter((c) => c.repoId === id).reduce((s, c) => s + c.costUsd, 0),
121 },
122 rates,
123 ).totalCents,
124 );
125 }
126 const cost = costOf(used, spent, prices, range);
127 return { day, ...line(cents(cost.total), revenueCents) };
128 });
129 }
130
131 function forgeAdmin(actor: User | undefined): boolean {
132 return actor?.isAdmin === true && actor.kind !== "agent";
133 }
134
135 /** The forge's money line and its thirty days. `now` is the clock unless a test pins it. */
136 export function readForgeMoney(
137 app: App,
138 actor: User | undefined,
139 now: string = new Date().toISOString(),
140 ): Result<ForgeMoney, "not_found"> {
141 if (!forgeAdmin(actor)) return err("not_found");
142 // The settings in one read (`getEconomicsAll`): the board's statement budget counts every point read.
143 const { prices, economics } = getEconomicsAll(app.db);
144 const rates = ratesFrom(prices, economics);
145 const month = forgeMonth(app, now, prices, rates);
146 const days = forgeDays(app, now, prices, rates);
147 return ok({
148 month: month.month,
149 thisMonth: month.line,
150 today: days[days.length - 1]!,
151 days,
152 position: economics.position,
153 });
154 }
155
156 /** One app's month: its statement's total, its cost, and where it was awake. */
157 export function readAppMoney(
158 app: App,
159 actor: User | undefined,
160 repo: Repo,
161 now: string = new Date().toISOString(),
162 ): Result<AppMoney, "not_found"> {
163 if (!mayReadStatement(app, repo, actor)) return err("not_found");
164 const month = monthOf(now);
165 const use = monthUse(app, month, now);
166 const { prices, economics } = getEconomicsAll(app.db);
167 const statement = statementFor({ app: `${repo.ownerName}/${repo.name}`, ...appUse(use, repo.id) }, month, ratesFrom(prices, economics));
168 const mine = use.meter.find((u) => u.repoId === repo.id);
169 const ownedSeconds = mine?.awakeByPlacement.owned ?? 0;
170 const cloudSeconds = mine?.awakeByPlacement.cloud ?? 0;
171 const forgeOwned = use.meter.reduce((s, u) => s + u.awakeByPlacement.owned, 0);
172 const ownedShare = forgeOwned > 0 ? ownedSeconds / forgeOwned : 0;
173 const cost = costOf(
174 mine === undefined ? [] : [mine],
175 use.crew.filter((t) => t.key === repo.id),
176 { ...prices, ownedMonthCents: prices.ownedMonthCents * ownedShare },
177 monthSoFar(now),
178 );
179 return ok({
180 month,
181 thisMonth: line(cents(cost.total), cents(statement.total)),
182 ownedSeconds,
183 cloudSeconds,
184 ownedShare,
185 });
186 }
src/actions/statement.ts +62 −24
@@ -10,12 +10,22 @@
1010
1111 import type { App } from "../app.ts";
1212 import { canAdmin, type Repo } from "../core/repos.ts";
13 import { usageBetween } from "../core/meter.ts";
14 import { monthOf, monthRange, parseMonth, statementFor, type Statement } from "../core/revenue.ts";
15 import { usageTotals } from "../core/usage.ts";
13 import { usageBetween, type AppUsage } from "../core/meter.ts";
14 import type { Range } from "../core/cost.ts";
15 import {
16 monthOf,
17 monthRange,
18 parseMonth,
19 statementFor,
20 type AppMonthUse,
21 type Rates,
22 type Statement,
23 } from "../core/revenue.ts";
24 import { usageTotals, type UsageTotals } from "../core/usage.ts";
1625 import type { User } from "../core/users.ts";
1726 import { err, ok, type Result } from "../kernel/result.ts";
18 import { getEconomics, getPrices } from "../settings/economics.ts";
27 import type { Prices } from "../core/slider.ts";
28 import { getEconomicsAll, type Economics } from "../settings/economics.ts";
1929
2030 export type StatementError = "not_found" | "bad_month";
2131
@@ -40,26 +50,54 @@ export function readStatement(
4050 if (!mayReadStatement(app, repo, actor)) return err("not_found");
4151 const m = month === undefined ? monthOf(now) : parseMonth(month);
4252 if (m === undefined) return err("bad_month");
43 const range = monthRange(m);
44 const meter = usageBetween(app.db, range.from, range.to, now).find((u) => u.repoId === repo.id);
53 const use = monthUse(app, m, now);
54 return ok(statementFor({ app: `${repo.ownerName}/${repo.name}`, ...appUse(use, repo.id) }, m, ratesOf(app)));
55 }
56
57 /** What every app used in one month: the meter's sums and the crew ledger's, by repository. */
58 export interface MonthUse {
59 readonly month: string;
60 readonly range: Range;
61 readonly meter: readonly AppUsage[];
62 /** Crew spend summed by repository; the forge's own (no repository) has key null. */
63 readonly crew: readonly UsageTotals[];
64 }
65
66 /**
67 * The two reads behind every statement of a month, for all apps at once:
68 * one app's statement and the board's sum of them (#510) both start here,
69 * so the board cannot count a second way.
70 */
71 export function monthUse(app: App, month: string, now: string): MonthUse {
72 const range = monthRange(month);
73 const meter = usageBetween(app.db, range.from, range.to, now);
4574 // The ledger's `until` is inclusive; the month is half-open, so stop a millisecond short.
4675 const until = new Date(Date.parse(range.to) - 1).toISOString();
47 const crew = usageTotals(app.db, "repo", { since: range.from, until, repoId: repo.id });
48 const prices = getPrices(app.db);
49 return ok(
50 statementFor(
51 {
52 app: `${repo.ownerName}/${repo.name}`,
53 awakeSeconds: meter?.awakeSeconds ?? 0,
54 gbHours: meter?.gbHours ?? 0,
55 crewUsd: crew.reduce((sum, t) => sum + t.costUsd, 0),
56 },
57 m,
58 {
59 awakePriceHourCents: prices.awakePriceHourCents,
60 storagePriceGbMonthCents: prices.storagePriceGbMonthCents,
61 marginPct: getEconomics(app.db).marginPct,
62 },
63 ),
64 );
76 const crew = usageTotals(app.db, "repo", { since: range.from, until });
77 return { month, range, meter, crew };
78 }
79
80 /** One repository's share of a month's use, as `statementFor` reads it. */
81 export function appUse(use: MonthUse, repoId: string): Omit<AppMonthUse, "app"> {
82 const meter = use.meter.find((u) => u.repoId === repoId);
83 return {
84 awakeSeconds: meter?.awakeSeconds ?? 0,
85 gbHours: meter?.gbHours ?? 0,
86 crewUsd: use.crew.filter((t) => t.key === repoId).reduce((sum, t) => sum + t.costUsd, 0),
87 };
88 }
89
90 /** The forge's list prices and margin, as `core/revenue.ts` reads them. */
91 export function ratesOf(app: App): Rates {
92 const { prices, economics } = getEconomicsAll(app.db);
93 return ratesFrom(prices, economics);
94 }
95
96 /** The rates out of numbers already read (`getEconomicsAll`), for a caller that also needs the prices. */
97 export function ratesFrom(prices: Prices, economics: Economics): Rates {
98 return {
99 awakePriceHourCents: prices.awakePriceHourCents,
100 storagePriceGbMonthCents: prices.storagePriceGbMonthCents,
101 marginPct: economics.marginPct,
102 };
65103 }
src/core/meter.ts +72 −53
@@ -162,15 +162,30 @@ export function usageBetween(
162162 to: string,
163163 now: string = new Date().toISOString(),
164164 ): AppUsage[] {
165 const lo = instant(from);
166 const hi = instant(to);
165 return usageBins(db, [from, to], now)[0] ?? [];
166 }
167
168 /**
169 * What each app used in each of the consecutive ranges [edges[i],
170 * edges[i+1]) — the board's thirty days (#510) in one pass over the two
171 * ledgers, with the same arithmetic as `usageBetween`, which is the one-bin
172 * case: so a day's seconds and GB-hours are exactly what `usageBetween`
173 * would say for that day. Edges must ascend; fewer than two is no bins.
174 */
175 export function usageBins(
176 db: Database,
177 edges: readonly string[],
178 now: string = new Date().toISOString(),
179 ): AppUsage[][] {
180 const at = edges.map(instant);
167181 const clock = instant(now);
168 if (!(hi > lo)) return [];
169 const apps = new Map<
170 string,
171 { owned: number; cloud: number; gbHours: number }
172 >();
173 const entry = (repoId: string) => {
182 const bins = Math.max(0, at.length - 1);
183 if (bins === 0) return [];
184 const lo = at[0]!;
185 const hi = at[bins]!;
186 const tallies = Array.from({ length: bins }, () => new Map<string, { owned: number; cloud: number; gbHours: number }>());
187 const entry = (bin: number, repoId: string) => {
188 const apps = tallies[bin]!;
174189 let e = apps.get(repoId);
175190 if (e === undefined) {
176191 e = { owned: 0, cloud: 0, gbHours: 0 };
@@ -178,52 +193,56 @@ export function usageBetween(
178193 }
179194 return e;
180195 };
196 /** Each bin's share of [start, end), in milliseconds, handed to `add`. */
197 const spread = (start: number, end: number, add: (bin: number, ms: number) => void) => {
198 for (let b = 0; b < bins; b++) {
199 const s = Math.max(start, at[b]!);
200 const e = Math.min(end, at[b + 1]!);
201 if (e > s) add(b, e - s);
202 }
203 };
204 if (hi > lo) {
205 const intervals = db
206 .query<IntervalRow, [string, string]>(
207 `SELECT repo_id, placement, started_at, ended_at FROM meter
208 WHERE started_at < ?2 AND (ended_at IS NULL OR ended_at > ?1)`,
209 )
210 .all(iso(lo), iso(hi));
211 for (const row of intervals) {
212 const end = row.ended_at === null ? clock : instant(row.ended_at);
213 spread(instant(row.started_at), end, (b, ms) => {
214 entry(b, row.repo_id)[row.placement] += ms / 1000;
215 });
216 }
181217
182 const intervals = db
183 .query<IntervalRow, [string, string]>(
184 `SELECT repo_id, placement, started_at, ended_at FROM meter
185 WHERE started_at < ?2 AND (ended_at IS NULL OR ended_at > ?1)`,
186 )
187 .all(iso(lo), iso(hi));
188 for (const row of intervals) {
189 const start = Math.max(lo, instant(row.started_at));
190 const end = Math.min(
191 hi,
192 row.ended_at === null ? clock : instant(row.ended_at),
193 );
194 if (end > start) entry(row.repo_id)[row.placement] += (end - start) / 1000;
218 // A sample before `from` may still stand for part of the range.
219 const samples = db
220 .query<SampleRow, [string, string]>(
221 `SELECT repo_id, data_bytes, repo_bytes, at FROM storage
222 WHERE at >= ?1 AND at < ?2 ORDER BY repo_id, at`,
223 )
224 .all(iso(lo - SAMPLE_SPAN_MS), iso(hi));
225 samples.forEach((row, i) => {
226 const taken = instant(row.at);
227 const next = samples[i + 1];
228 const until = Math.min(
229 taken + SAMPLE_SPAN_MS,
230 next !== undefined && next.repo_id === row.repo_id ? instant(next.at) : Infinity,
231 );
232 spread(taken, until, (b, ms) => {
233 entry(b, row.repo_id).gbHours += ((row.data_bytes + row.repo_bytes) * (ms / SAMPLE_SPAN_MS)) / 1e9;
234 });
235 });
195236 }
196237
197 // A sample before `from` may still stand for part of the range.
198 const samples = db
199 .query<SampleRow, [string, string]>(
200 `SELECT repo_id, data_bytes, repo_bytes, at FROM storage
201 WHERE at >= ?1 AND at < ?2 ORDER BY repo_id, at`,
202 )
203 .all(iso(lo - SAMPLE_SPAN_MS), iso(hi));
204 samples.forEach((row, i) => {
205 const at = instant(row.at);
206 const next = samples[i + 1];
207 const until = Math.min(
208 at + SAMPLE_SPAN_MS,
209 next !== undefined && next.repo_id === row.repo_id
210 ? instant(next.at)
211 : Infinity,
212 );
213 const start = Math.max(lo, at);
214 const end = Math.min(hi, until);
215 if (end <= start) return;
216 const hours = (end - start) / SAMPLE_SPAN_MS;
217 entry(row.repo_id).gbHours +=
218 ((row.data_bytes + row.repo_bytes) * hours) / 1e9;
219 });
220
221 return [...apps.entries()]
222 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
223 .map(([repoId, e]) => ({
224 repoId,
225 awakeSeconds: e.owned + e.cloud,
226 awakeByPlacement: { owned: e.owned, cloud: e.cloud },
227 gbHours: e.gbHours,
228 }));
238 return tallies.map((apps) =>
239 [...apps.entries()]
240 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
241 .map(([repoId, e]) => ({
242 repoId,
243 awakeSeconds: e.owned + e.cloud,
244 awakeByPlacement: { owned: e.owned, cloud: e.cloud },
245 gbHours: e.gbHours,
246 })),
247 );
229248 }
src/core/revenue.ts +41 −8
@@ -104,8 +104,25 @@ const amount = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0);
104104 const six = (n: number): number => Math.round(n * 1e6) / 1e6;
105105 const dollars = (cents: number): number => Math.round(cents) / 100;
106106
107 export function statementFor(use: AppMonthUse, month: string, rates: Rates): Statement {
108 const range = monthRange(month);
107 /** What one app's use charges, each line in cents, unrounded. */
108 export interface Charge {
109 readonly hours: number;
110 readonly gbMonths: number;
111 readonly crew: number;
112 readonly margin: number;
113 readonly awakeCents: number;
114 readonly storageCents: number;
115 readonly crewCents: number;
116 readonly marginCents: number;
117 readonly totalCents: number;
118 }
119
120 /**
121 * The statement's arithmetic for any range, unrounded: `statementFor` is
122 * this for one month, and the board's daily line (#510) is this for one
123 * day — one formula, never a second sum.
124 */
125 export function chargeOf(use: Omit<AppMonthUse, "app">, rates: Rates): Charge {
109126 const hours = amount(use.awakeSeconds) / 3600;
110127 const gbMonths = amount(use.gbHours) / HOURS_PER_GB_MONTH;
111128 const crew = amount(use.crewUsd);
@@ -114,20 +131,36 @@ export function statementFor(use: AppMonthUse, month: string, rates: Rates): Sta
114131 const storageCents = gbMonths * rates.storagePriceGbMonthCents;
115132 const crewCents = crew * 100;
116133 const marginCents = (crew * 100 * margin) / 100;
134 return {
135 hours,
136 gbMonths,
137 crew,
138 margin,
139 awakeCents,
140 storageCents,
141 crewCents,
142 marginCents,
143 totalCents: awakeCents + storageCents + crewCents + marginCents,
144 };
145 }
146
147 export function statementFor(use: AppMonthUse, month: string, rates: Rates): Statement {
148 const range = monthRange(month);
149 const c = chargeOf(use, rates);
117150 const lines: StatementLine[] = [
118 { what: "Awake time", quantity: six(hours), unit: "awake-hour", price: rates.awakePriceHourCents / 100, amount: dollars(awakeCents) },
119 { what: "Storage", quantity: six(gbMonths), unit: "GB-month", price: rates.storagePriceGbMonthCents / 100, amount: dollars(storageCents) },
120 { what: "Crew spend at cost", quantity: six(crew), unit: "USD of crew spend", price: 1, amount: dollars(crewCents) },
121 { what: `Margin on crew spend (${margin}%)`, quantity: six(crew), unit: "USD of crew spend", price: margin / 100, amount: dollars(marginCents) },
151 { what: "Awake time", quantity: six(c.hours), unit: "awake-hour", price: rates.awakePriceHourCents / 100, amount: dollars(c.awakeCents) },
152 { what: "Storage", quantity: six(c.gbMonths), unit: "GB-month", price: rates.storagePriceGbMonthCents / 100, amount: dollars(c.storageCents) },
153 { what: "Crew spend at cost", quantity: six(c.crew), unit: "USD of crew spend", price: 1, amount: dollars(c.crewCents) },
154 { what: `Margin on crew spend (${c.margin}%)`, quantity: six(c.crew), unit: "USD of crew spend", price: c.margin / 100, amount: dollars(c.marginCents) },
122155 ];
123156 return {
124157 app: use.app,
125158 month,
126159 ...range,
127160 currency: "USD",
128 marginPct: margin,
161 marginPct: c.margin,
129162 lines,
130 total: dollars(awakeCents + storageCents + crewCents + marginCents),
163 total: dollars(c.totalCents),
131164 };
132165 }
133166
src/core/usage.ts +26
@@ -334,6 +334,32 @@ export function usageBuckets(
334334 return out;
335335 }
336336
337 /** One repository's crew spend on one UTC day. */
338 export interface UsageDay {
339 readonly repoId: string | null;
340 /** `YYYY-MM-DD`, UTC. */
341 readonly day: string;
342 readonly costUsd: number;
343 }
344
345 /**
346 * The ledger summed per repository per UTC day in [since, until] — the
347 * board's thirty-day money line (#510), every repository in one read
348 * walking `usage_at`. The day is the instant's ISO
349 * prefix, as `usageBuckets` names it.
350 */
351 export function usageDays(db: Database, since: string, until: string): UsageDay[] {
352 return db
353 .query<{ repo_id: string | null; day: string; cost_usd: number }, [string, string]>(
354 `SELECT repo_id, substr(at, 1, 10) AS day, SUM(cost_usd) AS cost_usd
355 FROM usage
356 WHERE at >= ?1 AND at <= ?2
357 GROUP BY repo_id, day`,
358 )
359 .all(since, until)
360 .map((r) => ({ repoId: r.repo_id, day: r.day, costUsd: r.cost_usd }));
361 }
362
337363 /** Every repository with a line in the ledger since an instant, by id. */
338364 export function reposWithUsage(db: Database, since: string): string[] {
339365 return db
src/settings/economics.ts +36 −5
@@ -50,14 +50,45 @@ const STORE: Record<EconomicsKey, { key: string; min: number; max: number; fallb
5050
5151 const KEYS = Object.keys(STORE) as EconomicsKey[];
5252
53 function stored(db: Database, name: EconomicsKey): number {
53 /** A stored text as the number it stands for, or the default when it does not parse or is out of range. */
54 function parsed(name: EconomicsKey, value: string | undefined): number {
5455 const def = STORE[name];
56 if (value === undefined || !/^\d{1,9}$/.test(value)) return def.fallback;
57 const n = Number(value);
58 return n >= def.min && n <= def.max ? n : def.fallback;
59 }
60
61 function stored(db: Database, name: EconomicsKey): number {
5562 const row = db
5663 .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1")
57 .get(def.key);
58 if (row === null || !/^\d{1,9}$/.test(row.value)) return def.fallback;
59 const n = Number(row.value);
60 return n >= def.min && n <= def.max ? n : def.fallback;
64 .get(STORE[name].key);
65 return parsed(name, row?.value);
66 }
67
68 /**
69 * Every economics number in one read — for a page that needs both the
70 * slider and the prices (the board's money line, #510) and would otherwise
71 * pay eight point reads per use. Each value is read exactly as `stored` reads it.
72 */
73 export function getEconomicsAll(db: Database): { economics: Economics; prices: Prices } {
74 const rows = db
75 .query<{ key: string; value: string }, string[]>(
76 `SELECT key, value FROM forge_setting WHERE key IN (${KEYS.map((_, i) => `?${i + 1}`).join(",")})`,
77 )
78 .all(...KEYS.map((k) => STORE[k].key));
79 const byKey = new Map(rows.map((r) => [r.key, r.value]));
80 const n = (name: EconomicsKey) => parsed(name, byKey.get(STORE[name].key));
81 return {
82 economics: { position: n("position"), marginPct: n("marginPct") },
83 prices: {
84 ownedMonthCents: n("ownedMonthCents"),
85 ownedSlots: n("ownedSlots"),
86 cloudHourCents: n("cloudHourCents"),
87 storageGbMonthCents: n("storageGbMonthCents"),
88 awakePriceHourCents: n("awakePriceHourCents"),
89 storagePriceGbMonthCents: n("storagePriceGbMonthCents"),
90 },
91 };
6192 }
6293
6394 /** Both numbers as they stand now: two point reads, on every use. */
src/tui/screens/entries.ts +2
@@ -45,6 +45,7 @@ import { entry as claimStart } from "./claim_start.ts";
4545 import { entry as invites } from "./invites.ts";
4646 import { entry as limits } from "./limits.ts";
4747 import { entry as economics } from "./economics.ts";
48 import { entry as money } from "./money.ts";
4849 import { entry as loopStalls } from "./loopstalls.ts";
4950 import { entry as slowPagesEntry } from "./slowpages.ts";
5051 import { entry as maintenance } from "./maintenance.ts";
@@ -126,6 +127,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
126127 quotas,
127128 limits,
128129 economics,
130 money,
129131 appTemplates,
130132 devices,
131133 flowsAll,
src/tui/screens/money.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The money line in the terminal (#510; docs/features/money.md): the board's
3 * line and an app's month card from the same reads, `actions/money.ts`
4 * `readForgeMoney` and `readAppMoney`, so the same gates hold — a site admin
5 * who is a person for the forge's, the repository's admins and forge admins
6 * for an app's. The forge's is a palette entry; an app's is a command while
7 * its repository is open. Anyone else is offered neither and, were a screen
8 * reached, sees no figures. Read-only; US dollars, UTC days.
9 */
10
11 import { mayReadStatement } from "../../actions/statement.ts";
12 import { readAppMoney, readForgeMoney, type AppMoney, type ForgeMoney, type MoneyLine } from "../../actions/money.ts";
13 import type { Repo } from "../../core/repos.ts";
14 import type { Ctx, Screen } from "../app/app.ts";
15 import { binding } from "../app/keys.ts";
16 import type { Command } from "../app/palette.ts";
17 import { style } from "../term/style.ts";
18 import { truncate } from "../term/width.ts";
19 import type { Session } from "../session.ts";
20 import { fill, len, split } from "../ui/layout.ts";
21 import { ListState, drawList, type ListRow } from "./common.ts";
22 import type { PaletteEntry } from "./entries.ts";
23 import { dollars, signed } from "../../web/studio/money_line.ts";
24
25 const figures = (m: MoneyLine): string => `cost ${dollars(m.cost)} · revenue ${dollars(m.revenue)} · gap ${signed(m.gap)}`;
26 const hours = (s: number): string => `${(s / 3600).toFixed(1)} h`;
27 const pct = (part: number, whole: number): string => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : "0%");
28
29 /** The month, today, the slider, then each of the thirty days, newest first. */
30 export function forgeMoneyRows(m: ForgeMoney): ListRow[] {
31 return [
32 { title: `This month (${m.month})`, meta: figures(m.thisMonth) },
33 { title: `Today (${m.today.day})`, meta: figures(m.today) },
34 { title: "Economics slider", meta: String(m.position) },
35 ...[...m.days].reverse().map((d) => ({ title: d.day, meta: figures(d) })),
36 ];
37 }
38
39 /** The app card's rows: the three numbers, where it was awake, and its share of the forge's own server. */
40 export function appMoneyRows(m: AppMoney): ListRow[] {
41 const awake = m.ownedSeconds + m.cloudSeconds;
42 return [
43 { title: `This month (${m.month})`, meta: figures(m.thisMonth) },
44 {
45 title: "Awake",
46 meta: `${hours(awake)} · ${pct(m.ownedSeconds, awake)} on the forge's own server · ${pct(m.cloudSeconds, awake)} on cloud`,
47 },
48 { title: "Share of the forge's own server", meta: pct(m.ownedShare, 1) },
49 ];
50 }
51
52 export function moneyScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
53 const state = new ListState();
54 const rows = (): ListRow[] | undefined => {
55 if (repo === undefined) {
56 const m = readForgeMoney(f.app, f.user);
57 return m.ok ? forgeMoneyRows(m.value) : undefined;
58 }
59 const m = readAppMoney(f.app, f.user, repo);
60 return m.ok ? appMoneyRows(m.value) : undefined;
61 };
62 return {
63 title: repo === undefined ? "Money" : `${repo.ownerName}/${repo.name} — Money`,
64 keys: [binding(["j", "k"], "move cursor")],
65 render(area, s) {
66 const c = ctx.theme.c;
67 const r = rows();
68 const [head, list] = split(area, "v", [len(1), fill(1)]);
69 const said = r === undefined ? "" : "US dollars · days are UTC";
70 s.text(head!.x, head!.y, truncate(said, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
71 drawList(s, list!, c, state, r ?? []);
72 },
73 key: (e) => {
74 const r = rows();
75 return r !== undefined && state.key(e, r.length);
76 },
77 };
78 }
79
80 /** The board's money line: a site admin who is a person, as `readForgeMoney` holds. */
81 export const entry: PaletteEntry = {
82 id: "money",
83 title: "Money",
84 hint: "this month's cost, revenue and gap, and the last 30 days",
85 after: ["economics"],
86 offered: (f) => f.user?.isAdmin === true && f.user.kind !== "agent",
87 run: (f, ctx) => ctx.push(moneyScreen(f, ctx)),
88 };
89
90 /** "owner/name — Money" while a repository is open, to whoever may read its statement. */
91 export function moneyCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
92 if (repo === undefined || !mayReadStatement(f.app, repo, f.user)) return [];
93 return [
94 {
95 id: "app-money",
96 title: `${repo.ownerName}/${repo.name} — Money`,
97 hint: "this app's cost, revenue and gap this month",
98 run: () => ctx.push(moneyScreen(f, ctx, repo)),
99 },
100 ];
101 }
src/tui/screens/workspace.ts +2
@@ -157,6 +157,7 @@ import { autonomyCommands } from "./autonomy.ts";
157157 import { trafficCommands } from "./trafficview.ts";
158158 import { blockedCommands } from "./blocked.ts";
159159 import { statementCommands } from "./statement.ts";
160 import { moneyCommands } from "./money.ts";
160161 import { adapterCommands } from "./adapter.ts";
161162 import { atlasCommands } from "./atlas.ts";
162163 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
@@ -1668,6 +1669,7 @@ export function workspaceScreen(
16681669 ...trafficCommands(f, ctx, repo),
16691670 ...blockedCommands(f, ctx, repo),
16701671 ...statementCommands(f, ctx, repo),
1672 ...moneyCommands(f, ctx, repo),
16711673 ...adapterCommands(f, ctx, repo),
16721674 ...atlasCommands(f, ctx, repo),
16731675 ],
src/web/pages/board.ts +17 −1
@@ -5,7 +5,9 @@
55 * change cost over the week — each a live region that swaps itself when a
66 * matching event lands. A site admin also gets two strips above them: the
77 * forge's health, and each subscription's windows; whoever owns an app gets
8 * a line for each thing its crew waits on them for. Nothing on it
8 * a line for each thing its crew waits on them for. A site admin's board
9 * opens on the money line: this month's cost, revenue and gap, and thirty
10 * days of them drawn (`studio/money_line.ts`). Nothing on it
911 * needs a click, and everything on it links somewhere. It reads the same
1012 * tables as the studio and adds no state of its own — nor words: every
1113 * run, change and decision on it is worded by `runWord`/`pullWord`/
@@ -105,6 +107,8 @@ import {
105107 tokenCount,
106108 } from "../ui.ts";
107109 import { noteLine } from "../studio/note.ts";
110 import { readForgeMoney } from "../../actions/money.ts";
111 import { forgeMoneyLine } from "../studio/money_line.ts";
108112 import { tellWord } from "../studio/tell.ts";
109113 import { voiceFor } from "../studio/voice.ts";
110114 import { repoDir } from "../../git/repos.ts";
@@ -265,6 +269,7 @@ export async function boardPage(ctx: Ctx): Promise<Response> {
265269 const repos = scope.repos;
266270 const wall = ctx.url.searchParams.has("wall");
267271 const forge = timePhase(rq, "forge", () => forgeNotes(ctx));
272 const money = timePhase(rq, "money", () => moneyStrip(ctx));
268273 const declined = timePhase(rq, "declined", () => declinedNotes(ctx, scope));
269274 const review = timePhase(rq, "review", () => reviewNotes(ctx, scope));
270275 const asks = timePhase(rq, "asks", () => asksStrip(ctx));
@@ -301,6 +306,7 @@ export async function boardPage(ctx: Ctx): Promise<Response> {
301306 >
302307 <time class="num small muted" id="board-clock"></time>
303308 </header>
309 ${money}
304310 ${boardNotes(forge, declined, review)}
305311 <div class="board-grid">
306312 ${workingNow} ${landedNow} ${decidedNow}
@@ -402,6 +408,16 @@ export function bigRedirect(ctx: Ctx): Response {
402408
403409 // ---- the forge ----
404410
411 /**
412 * The money line (#510): this month's cost, revenue and gap with thirty
413 * days drawn beside it, for whoever may read Settings → Economics. Nobody
414 * else pays for the reads.
415 */
416 function moneyStrip(ctx: Ctx): Html | null {
417 const money = readForgeMoney(ctx.app, ctx.auth.user);
418 return money.ok ? forgeMoneyLine(money.value) : null;
419 }
420
405421 /**
406422 * The forge's health as notes, for a site admin only: each thing that needs
407423 * them, the lane when checks are queued, and that bursts queue while no
src/web/pages/board_style.ts +1
@@ -11,6 +11,7 @@ export const boardCss = /* css */ `
1111 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; }
1212 .board > header, .board > .stack { padding: 12px var(--pad) 0; }
1313 .board > #notes { padding: 8px var(--pad) 0; }
14 .board > .money-line { padding: 8px var(--pad) 0; }
1415 #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; }
1516 #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; }
1617 #notes li > a { display: block; padding-block: 12px; min-height: 0; }
src/web/studio/app.ts +14 −1
@@ -68,6 +68,8 @@ import {
6868 timePhaseAsync,
6969 } from "../../core/route_timing.ts";
7070 import { noteAppOpened } from "../../core/next-steps.ts";
71 import { readAppMoney } from "../../actions/money.ts";
72 import { moneyRows } from "./money_line.ts";
7173
7274 /**
7375 * What asks the viewer in this repository, muted or not: a mute quiets the
@@ -454,6 +456,17 @@ export async function studioApp(
454456 extra: "about",
455457 },
456458 );
459 // This month's money (#510): the statement's total against what the app
460 // cost, for whoever may read its statement — the same numbers, one door.
461 const money = fresh ? null : timePhase(req, "money", () => readAppMoney(ctx.app, ctx.auth.user, repo));
462 const moneyCard =
463 money === null || !money.ok
464 ? null
465 : card(moneyRows(money.value, `${base}/settings/app/statement`), {
466 head: html`${icon("activity")}
467 <h2>This month</h2>`,
468 extra: "about",
469 });
457470 // The rest of the app's environment — its variables, hostname, access and
458471 // domains — is the admin's, on the app's settings page.
459472 // Issues, Pulls and the map are the head's own tabs; only what it lacks is here.
@@ -523,7 +536,7 @@ export async function studioApp(
523536 ${notesCard(ctx, repo, record)}
524537 </div>
525538 <aside class="stack">
526 ${adapter} ${crewCard} ${weekCard} ${ownCard} ${more}
539 ${adapter} ${crewCard} ${weekCard} ${moneyCard} ${ownCard} ${more}
527540 </aside>
528541 </div>
529542 ${liveMount({ repo: base, app: true })}`;
src/web/studio/money_line.ts added +133
@@ -0,0 +1,133 @@
1 /**
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`).
7 *
8 * 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 * 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.
13 */
14
15 import type { AppMoney, ForgeMoney, MoneyDay, MoneyLine } from "../../actions/money.ts";
16 import { html, type Html } from "../html.ts";
17
18 /** US dollars to the cent, as on a statement. */
19 export const dollars = (n: number): string => `$${Math.abs(n).toFixed(2)}`;
20
21 /** The gap with its sign: "+$52.66", "-$3.10", "$0.00". */
22 export function signed(n: number): string {
23 const c = Math.round(n * 100);
24 return c > 0 ? `+${dollars(n)}` : c < 0 ? `-${dollars(n)}` : dollars(0);
25 }
26
27 /** "up" for a profit, "down" for a loss, "even" at zero; only "down" is tinted. */
28 export function gapSign(n: number): "up" | "down" | "even" {
29 const c = Math.round(n * 100);
30 return c > 0 ? "up" : c < 0 ? "down" : "even";
31 }
outdated

Principle 5: the gap must not take --accent (see content.ts .money-gap[data-sign=up] and the band fill). Accent is for interaction; the Slider link is already one. Use the text colour for a profit and --attn for a loss.

32
33 const gap = (n: number): Html => html`<span class="money-gap num" data-sign="${gapSign(n)}">${signed(n)}</span>`;
34
35 /** The three numbers, inline. */
outdated

Principle 8: plain words. This line plus 'Slider N' reads as shorthand; say what the number is.

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)}`;
39 }
40
41 // ---- the chart ----
42
43 /** The SVG's drawing box, in its own units; the CSS keeps the same size. */
44 export const CHART_W = 240;
45 export const CHART_H = 48;
46 const PAD = 2;
47
48 /**
49 * Thirty days as two polylines (cost, revenue) over a shared y scale from
50 * 0 to the largest figure, and between each pair of days a band shaded by
51 * whether revenue was above cost (`up`) or below (`down`). All zeros draws
52 * two flat lines on the baseline, in the same box.
53 */
54 export function moneyChart(days: readonly MoneyDay[]): Html {
55 const top = Math.max(0, ...days.map((d) => Math.max(d.cost, d.revenue)));
56 const scale = top > 0 ? (CHART_H - 2 * PAD) / top : 0;
57 const step = days.length > 1 ? (CHART_W - 2 * PAD) / (days.length - 1) : 0;
58 const x = (i: number): number => Math.round((PAD + i * step) * 10) / 10;
59 const y = (v: number): number => Math.round((CHART_H - PAD - v * scale) * 10) / 10;
60 const points = (pick: (d: MoneyDay) => number): string =>
61 days.map((d, i) => `${x(i)},${y(pick(d))}`).join(" ");
62 const bands: Html[] = [];
63 for (let i = 0; i + 1 < days.length; i++) {
64 const a = days[i]!;
65 const b = days[i + 1]!;
66 const sign = gapSign(a.gap + b.gap);
67 if (sign === "even") continue;
68 bands.push(
69 html`<polygon data-sign="${sign}" points="${x(i)},${y(a.revenue)} ${x(i + 1)},${y(b.revenue)} ${x(i + 1)},${y(b.cost)} ${x(i)},${y(a.cost)}"></polygon>`,
70 );
71 }
72 const first = days[0]?.day ?? "";
73 const last = days[days.length - 1]?.day ?? "";
74 return html`<svg
75 class="money-chart"
76 viewBox="0 0 ${CHART_W} ${CHART_H}"
77 width="${CHART_W}"
78 height="${CHART_H}"
79 role="img"
80 aria-labelledby="money-chart-t"
81 >
82 <title id="money-chart-t">Daily cost and revenue, ${first} to ${last} (UTC)</title>
83 <g class="money-band">${bands}</g>
84 <polyline class="money-cost" points="${points((d) => d.cost)}"></polyline>
85 <polyline class="money-revenue" points="${points((d) => d.revenue)}"></polyline>
86 </svg>`;
87 }
88
89 // ---- the board's line ----
90
91 /** The forge's line for the board, with the slider's position and the chart. */
92 export function forgeMoneyLine(m: ForgeMoney): Html {
93 return html`<section class="money-line" id="money" aria-label="Money">
94 <p class="num">
95 <span>This month: ${figures(m.thisMonth)}</span>
96 <span
97 ><span class="muted">Today: gap ${signed(m.today.gap)}</span> ·
98 <a href="/settings/economics">Economics slider: ${m.position}</a></span
99 >
100 </p>
101 ${moneyChart(m.days)}
102 </section>`;
103 }
104
105 // ---- one app ----
106
107 const hours = (s: number): string => `${(s / 3600).toFixed(1)} h`;
108 const pct = (part: number, whole: number): string => (whole > 0 ? `${Math.round((part / whole) * 100)}%` : "0%");
109
110 /**
111 * An app's month as `dl` rows: the same three numbers, then where it was
112 * awake (the forge's own server against cloud), and in one sentence the
113 * assumption its cost rests on.
114 */
115 export function moneyRows(m: AppMoney, statementHref: string): Html {
116 const awake = m.ownedSeconds + m.cloudSeconds;
117 return html`<dl>
118 <dt>Cost</dt>
119 <dd class="num">${dollars(m.thisMonth.cost)}</dd>
120 <dt>Revenue</dt>
121 <dd class="num"><a href="${statementHref}">${dollars(m.thisMonth.revenue)}</a></dd>
122 <dt>Gap</dt>
123 <dd>${gap(m.thisMonth.gap)}</dd>
124 <dt>Awake</dt>
125 <dd>
126 <span class="num">${hours(awake)}</span> · ${pct(m.ownedSeconds, awake)} on the forge's own server ·
127 ${pct(m.cloudSeconds, awake)} on cloud
128 </dd>
129 </dl>
130 <p class="muted small">
131 Its cost includes ${pct(m.ownedShare * 100, 100)} of the forge's own server for the month, its share of the time apps ran there.
132 </p>`;
133 }
src/web/style/content.ts +16
@@ -225,6 +225,22 @@ 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. */
233 .money-gap[data-sign="down"] { color: var(--attn); }
234 .money-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; }
235 .money-line > p { margin: 0; line-height: 24px; min-height: 48px; }
236 .money-line > p > span { display: block; }
237 @media (max-width: 480px) { .money-line > p { min-height: 72px; } }
238 .money-chart { display: block; width: 240px; height: 48px; flex: none; }
239 .money-chart polyline { fill: none; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
240 .money-cost { stroke: var(--muted-fg); }
241 .money-revenue { stroke: var(--fg); }
242 .money-band [data-sign="up"] { fill: color-mix(in srgb, var(--fg) 10%, transparent); }
243 .money-band [data-sign="down"] { fill: color-mix(in srgb, var(--attn) 22%, transparent); }
228244 .restore-command { margin: 8px 0 0; padding: 8px 10px; font-size: 12.5px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); }
229245 form.inline { display: inline; margin-left: auto; }
230246
test/economics.test.ts +23 −1
@@ -15,7 +15,7 @@ import { createAgent } from "../src/actions/crew.ts";
1515 import { createToken } from "../src/core/tokens.ts";
1616 import { createUser, type User } from "../src/core/users.ts";
1717 import { serve, type Servers } from "../src/http/server.ts";
18 import { economicsChanges, getEconomics, getOwnedNodeLabel, getPrices } from "../src/settings/economics.ts";
18 import { economicsChanges, getEconomics, getEconomicsAll, getOwnedNodeLabel, getPrices } from "../src/settings/economics.ts";
1919
2020 describe("economics", () => {
2121 let dataDir: string;
@@ -234,6 +234,28 @@ describe("economics", () => {
234234 expect(listed).toContain('value="0.30"');
235235 });
236236
237 test("getEconomicsAll reads all eight numbers in one statement, each exactly as its point read does (#510)", () => {
238 // A hand-edited value out of range reads as the default, as `stored` reads it.
239 app.db.query("INSERT OR REPLACE INTO forge_setting (key, value, updated_by, updated_at) VALUES ('economics.owned_slots', '99999999', ?1, ?2)").run(alice.id, new Date().toISOString());
240 let statements = 0;
241 const query = app.db.query.bind(app.db);
242 app.db.query = ((sql: string) => {
243 statements++;
244 return query(sql);
245 }) as typeof app.db.query;
246 let all: ReturnType<typeof getEconomicsAll>;
247 try {
248 all = getEconomicsAll(app.db);
249 } finally {
250 app.db.query = query;
251 }
252 expect(statements).toBe(1);
253 expect(all.prices).toEqual(getPrices(app.db));
254 expect(all.economics).toEqual(getEconomics(app.db));
255 expect(all.prices.ownedSlots).toBe(0);
256 expect(all.prices.awakePriceHourCents).toBe(12);
257 });
258
237259 test("the owned-node label is a setting: shown with the queue line, set by API and form, validated (#509)", async () => {
238260 const token = createToken(app.db, alice.id, "t", "admin").token;
239261 const got = (await (await api(token, "GET")).json()) as { placement: Record<string, unknown> };
test/money.test.ts added +340
@@ -0,0 +1,340 @@
1 /**
2 * The money line (#510): the board's cost, revenue and gap for the month
3 * and today, worked by hand; the board's revenue is the sum of the apps'
4 * statements, to the cent; an app's month card; the thirty-day SVG's
5 * points; and the board and app page showing them to those who may see.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { readAppMoney, readForgeMoney, DAYS } from "../src/actions/money.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { readStatement } from "../src/actions/statement.ts";
16 import { createApp as makeAppRecord } from "../src/core/apps.ts";
17 import { closeAwake, openAwake, usageBetween, usageBins } from "../src/core/meter.ts";
18 import type { Repo } from "../src/core/repos.ts";
19 import { createRun } from "../src/core/runs.ts";
20 import { recordUsage } from "../src/core/usage.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts";
24 import { forgeMoneyLine, moneyChart, signed } from "../src/web/studio/money_line.ts";
25
26 const NOW = "2026-11-10T12:00:00.000Z";
27
28 describe("money line", () => {
29 let dataDir: string;
30 let app: App;
31 let servers: Servers;
32 let alice: User;
33 let bob: User;
34 let notes: Repo;
35 let other: Repo;
36
37 const spend = (repoId: string, usd: number, at: string) =>
38 recordUsage(app.db, {
39 runId: null,
40 repoId,
41 issueId: null,
42 agentId: null,
43 subscriptionId: null,
44 tokensIn: 1,
45 tokensOut: 1,
46 costUsd: usd,
47 wallMs: 1,
48 outcome: "ok",
49 at,
50 } as never);
51 const login = async (username: string) => {
52 const res = await fetch(`${servers.url}/signin`, {
53 method: "POST",
54 redirect: "manual",
55 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
56 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
57 });
58 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
59 };
60 const page = async (cookie: string, path: string) =>
61 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
62
63 beforeAll(async () => {
64 dataDir = await mkdtemp(join(tmpdir(), "forge-money-"));
65 app = createApp({ dataDir, port: 0 });
66 servers = await serve(app);
67 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
68 const made = await createUser(app.db, {
69 origin: "seed",
70 trusted: true,
71 username,
72 email: `${username}@forge.local`,
73 password: "a-good-passphrase",
74 adminIfFirst: first,
75 });
76 if (!made.ok) throw new Error(made.error);
77 if (username === "alice") alice = made.value;
78 else bob = made.value;
79 }
80 const a = await createRepo(app, alice, { name: "notes", visibility: "public" });
81 const b = await createRepo(app, alice, { name: "other", visibility: "public" });
82 if (!a.ok || !b.ok) throw new Error("repo");
83 notes = a.value;
84 other = b.value;
85 makeAppRecord(app.db, { repoId: notes.id, slug: "notes", access: "public" });
86 // A run in its past: the app page shows a running app's rail, not a fresh app's progress.
87 createRun(app.db, { repoId: notes.id, kind: "agent", summary: "a past run" });
88
89 // November 2026 to the 10th at noon, by hand:
90 // notes: 10 h owned on the 2nd, $5 of crew on the 3rd;
91 // other: 20 h cloud on the 5th, $2.50 of crew that day.
92 openAwake(app.db, { repoId: notes.id, env: "production", placement: "owned", at: "2026-11-02T00:00:00.000Z" });
93 closeAwake(app.db, { repoId: notes.id, env: "production", at: "2026-11-02T10:00:00.000Z" });
94 openAwake(app.db, { repoId: other.id, env: "pr-1", placement: "cloud", at: "2026-11-05T00:00:00.000Z" });
95 closeAwake(app.db, { repoId: other.id, env: "pr-1", at: "2026-11-05T20:00:00.000Z" });
96 spend(notes.id, 5, "2026-11-03T09:00:00.000Z");
97 spend(other.id, 2.5, "2026-11-05T09:00:00.000Z");
98
99 setPrices(
100 app.db,
101 {
102 ...getPrices(app.db),
103 ownedMonthCents: 30_000,
104 cloudHourCents: 5,
105 storageGbMonthCents: 2,
106 awakePriceHourCents: 10,
107 storagePriceGbMonthCents: 25,
108 },
109 alice.id,
110 );
111 setEconomics(app.db, { position: 62, marginPct: 20 }, alice.id);
112 });
113 afterAll(async () => {
114 await servers.stop();
115 await rm(dataDir, { recursive: true, force: true });
116 });
117
118 test("the month, today and thirty days match a hand calculation", () => {
119 const m = readForgeMoney(app, alice, NOW);
120 if (!m.ok) throw new Error(m.error);
121 // Revenue: notes 10 h × $0.10 + $5 + 20% = 7.00; other 20 h × $0.10 + $2.50 + 20% = 5.00.
122 // Cost: the box for 9.5 of November's 30 days, 300 × 9.5 / 30 = 95.00; cloud 20 h × $0.05 = 1.00;
123 // crew 5 + 2.50 = 7.50. Total 103.50; gap 12.00 − 103.50 = −91.50.
124 expect(m.value.month).toBe("2026-11");
125 expect(m.value.thisMonth).toEqual({ cost: 103.5, revenue: 12, gap: -91.5 });
126 // Today is half a day of the box: 300 × 0.5 / 30.
127 expect(m.value.today).toEqual({ day: "2026-11-10", cost: 5, revenue: 0, gap: -5 });
128 expect(m.value.position).toBe(62);
129 expect(m.value.days).toHaveLength(DAYS);
130 expect(m.value.days[0]!.day).toBe("2026-10-12");
131 // An October day is a 31st of the box: 300 / 31 = 9.677… → 9.68.
132 expect(m.value.days[0]).toEqual({ day: "2026-10-12", cost: 9.68, revenue: 0, gap: -9.68 });
133 // The 5th: a 30th of the box, an hour of cloud × 20, and $2.50 of crew; revenue other's 5.00.
134 expect(m.value.days.find((d) => d.day === "2026-11-05")).toEqual({ day: "2026-11-05", cost: 13.5, revenue: 5, gap: -8.5 });
135 // November's days add up to the month.
136 const nov = m.value.days.filter((d) => d.day.startsWith("2026-11"));
137 expect(Math.round(nov.reduce((s, d) => s + d.revenue, 0) * 100)).toBe(1200);
138 expect(Math.round(nov.reduce((s, d) => s + d.cost, 0) * 100)).toBe(10350);
139 });
140
141 test("the board's revenue is the sum of the apps' statements, to the cent", () => {
142 const m = readForgeMoney(app, alice, NOW);
143 if (!m.ok) throw new Error(m.error);
144 let cents = 0;
145 for (const repo of [notes, other]) {
146 const s = readStatement(app, alice, repo, "2026-11", NOW);
147 if (!s.ok) throw new Error(s.error);
148 cents += Math.round(s.value.total * 100);
149 }
150 expect(cents).toBe(1200);
151 expect(Math.round(m.value.thisMonth.revenue * 100)).toBe(cents);
152 });
153
154 test("an app's month: its statement, its cost with its share of the box, and where it was awake", () => {
155 const n = readAppMoney(app, alice, notes, NOW);
156 if (!n.ok) throw new Error(n.error);
157 // All the owned time was notes': the whole 95.00 of box, and its $5 of crew.
158 expect(n.value.thisMonth).toEqual({ cost: 100, revenue: 7, gap: -93 });
159 expect([n.value.ownedSeconds, n.value.cloudSeconds, n.value.ownedShare]).toEqual([36_000, 0, 1]);
160 const o = readAppMoney(app, alice, other, NOW);
161 if (!o.ok) throw new Error(o.error);
162 // No owned time, no box: 20 h × $0.05 + $2.50.
163 expect(o.value.thisMonth).toEqual({ cost: 3.5, revenue: 5, gap: 1.5 });
164 expect([o.value.ownedSeconds, o.value.cloudSeconds, o.value.ownedShare]).toEqual([0, 72_000, 0]);
165 });
166
167 test("only a forge admin reads the forge's; only the app's admins read the app's", () => {
168 expect(readForgeMoney(app, bob, NOW)).toEqual({ ok: false, error: "not_found" });
169 expect(readForgeMoney(app, undefined, NOW)).toEqual({ ok: false, error: "not_found" });
170 expect(readForgeMoney(app, { ...alice, kind: "agent" } as User, NOW)).toEqual({ ok: false, error: "not_found" });
171 expect(readAppMoney(app, bob, notes, NOW)).toEqual({ ok: false, error: "not_found" });
172 });
173
174 test("each day's meter bin is what usageBetween says for that day", () => {
175 const edges = ["2026-11-01", "2026-11-02", "2026-11-03", "2026-11-05", "2026-11-06"].map((d) => `${d}T00:00:00.000Z`);
176 const bins = usageBins(app.db, edges, NOW);
177 expect(bins).toHaveLength(4);
178 bins.forEach((bin, i) => expect(bin).toEqual(usageBetween(app.db, edges[i]!, edges[i + 1]!, NOW)));
179 });
180
181 test("the chart: two polylines and the shaded gap, at hand-worked points", () => {
182 const svg = moneyChart([
183 { day: "2026-11-01", cost: 0, revenue: 0, gap: 0 },
184 { day: "2026-11-02", cost: 1, revenue: 2, gap: 1 },
185 ]).text;
186 // Box 240 × 48, 2 of padding: x 2 and 238; y scale 44 / 2 = 22 per dollar from a baseline at 46.
187 expect(svg).toContain('class="money-cost" points="2,46 238,24"');
188 expect(svg).toContain('class="money-revenue" points="2,46 238,2"');
189 expect(svg).toContain('data-sign="up" points="2,46 238,2 238,24 2,46"');
190 expect(svg).toContain('viewBox="0 0 240 48"');
191 // All zeros: the same box, flat on the baseline, no bands.
192 const flat = moneyChart([
193 { day: "2026-11-01", cost: 0, revenue: 0, gap: 0 },
194 { day: "2026-11-02", cost: 0, revenue: 0, gap: 0 },
195 ]).text;
196 expect(flat).toContain('class="money-cost" points="2,46 238,46"');
197 expect(flat).not.toContain("<polygon");
198 expect([signed(52.66), signed(-3.1), signed(0), signed(-0.001)]).toEqual(["+$52.66", "-$3.10", "$0.00", "$0.00"]);
199 });
200
201 test("the board shows the line and the chart to a forge admin, and to nobody else", async () => {
202 const html = await page(await login("alice"), "/board");
203 expect(html).toContain('class="money-line"');
204 expect(html).toContain("This month");
205 expect(html).toContain('<a href="/settings/economics">Economics slider: 62</a>');
206 expect(html).toContain('<svg\n class="money-chart"');
207 expect(html).toMatch(/class="money-gap num" data-sign="(up|down|even)"/);
208 const theirs = await page(await login("bob"), "/board");
209 expect(theirs).not.toContain('class="money-line"');
210 });
211
212 test("the app page has the month card for its admin, and not for a stranger", async () => {
213 const html = await page(await login("alice"), "/alice/notes");
214 expect(html).toContain("<h2>This month</h2>");
215 expect(html).toContain('href="/alice/notes/settings/app/statement"');
216 expect(html).toContain("own server for the month, its share");
217 const theirs = await page(await login("bob"), "/alice/notes");
218 expect(theirs).not.toContain("<h2>This month</h2>");
219 });
220
221 test("a fresh app's page shows its progress, not the month card", async () => {
222 const made = await createRepo(app, alice, { name: "fresh", visibility: "public" });
223 if (!made.ok) throw new Error("repo");
224 makeAppRecord(app.db, { repoId: made.value.id, slug: "fresh", access: "public" });
225 const html = await page(await login("alice"), "/alice/fresh");
226 expect(html).not.toContain("<h2>This month</h2>");
227 expect(await page(await login("alice"), "/alice/notes")).toContain("<h2>This month</h2>");
228 });
229 });
230
231 describe("the forge's cost is the apps' costs plus what no app carries", () => {
232 test("Σ app costs + the box's unowned share = the board's cost", async () => {
233 const dataDir = await mkdtemp(join(tmpdir(), "forge-money-sum-"));
234 const app = createApp({ dataDir, port: 0 });
235 try {
236 const made = await createUser(app.db, {
237 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
238 password: "a-good-passphrase", adminIfFirst: true,
239 });
240 if (!made.ok) throw new Error(made.error);
241 const alice = made.value;
242 const repos: Repo[] = [];
243 for (const name of ["a", "b", "c"]) {
244 const r = await createRepo(app, alice, { name, visibility: "public" });
245 if (!r.ok) throw new Error("repo");
246 repos.push(r.value);
247 }
248 const [a, b, c] = repos as [Repo, Repo, Repo];
249 const awake = (repo: Repo, placement: "owned" | "cloud", from: string, to: string) => {
250 openAwake(app.db, { repoId: repo.id, env: "production", placement, at: from });
251 closeAwake(app.db, { repoId: repo.id, env: "production", at: to });
252 };
253 // a 6 h owned, b 2 h owned (shares 3/4 and 1/4), c 10 h on cloud and $1 of crew.
254 awake(a, "owned", "2026-11-02T00:00:00.000Z", "2026-11-02T06:00:00.000Z");
255 awake(b, "owned", "2026-11-03T00:00:00.000Z", "2026-11-03T02:00:00.000Z");
256 awake(c, "cloud", "2026-11-04T00:00:00.000Z", "2026-11-04T10:00:00.000Z");
257 recordUsage(app.db, {
258 runId: null, repoId: c.id, issueId: null, agentId: null, subscriptionId: null,
259 tokensIn: 1, tokensOut: 1, costUsd: 1, wallMs: 1, outcome: "ok", at: "2026-11-04T09:00:00.000Z",
260 } as never);
261 setPrices(app.db, { ...getPrices(app.db), ownedMonthCents: 30_000, cloudHourCents: 5, storageGbMonthCents: 0 }, alice.id);
262 const forge = readForgeMoney(app, alice, NOW);
263 if (!forge.ok) throw new Error(forge.error);
264 const apps = repos.map((r) => {
265 const m = readAppMoney(app, alice, r, NOW);
266 if (!m.ok) throw new Error(m.error);
267 return m.value;
268 });
269 // By hand: box 300 × 9.5 / 30 = 95.00, split 71.25 (a) and 23.75 (b); c 10 h × $0.05 + $1 = 1.50.
270 expect(apps.map((m) => m.thisMonth.cost)).toEqual([71.25, 23.75, 1.5]);
271 expect(apps.map((m) => m.ownedShare)).toEqual([0.75, 0.25, 0]);
272 // Every owned hour was an app's, so nothing of the box is unowned: the sum is the apps' alone.
273 const box = 95;
274 const unowned = box * (1 - apps.reduce((s, m) => s + m.ownedShare, 0));
275 const sum = apps.reduce((s, m) => s + Math.round(m.thisMonth.cost * 100), 0) + Math.round(unowned * 100);
276 expect(unowned).toBe(0);
277 expect(forge.value.thisMonth.cost).toBe(96.5);
278 expect(Math.round(forge.value.thisMonth.cost * 100)).toBe(sum);
279 } finally {
280 app.db.close();
281 await rm(dataDir, { recursive: true, force: true });
282 }
283 });
284
285 test("with no app awake on the forge's own server, the whole box is the forge's alone", async () => {
286 const dataDir = await mkdtemp(join(tmpdir(), "forge-money-unowned-"));
287 const app = createApp({ dataDir, port: 0 });
288 try {
289 const made = await createUser(app.db, {
290 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
291 password: "a-good-passphrase", adminIfFirst: true,
292 });
293 if (!made.ok) throw new Error(made.error);
294 const r = await createRepo(app, made.value, { name: "c", visibility: "public" });
295 if (!r.ok) throw new Error("repo");
296 openAwake(app.db, { repoId: r.value.id, env: "production", placement: "cloud", at: "2026-11-04T00:00:00.000Z" });
297 closeAwake(app.db, { repoId: r.value.id, env: "production", at: "2026-11-04T10:00:00.000Z" });
298 setPrices(app.db, { ...getPrices(app.db), ownedMonthCents: 30_000, cloudHourCents: 5, storageGbMonthCents: 0 }, made.value.id);
299 const forge = readForgeMoney(app, made.value, NOW);
300 const one = readAppMoney(app, made.value, r.value, NOW);
301 if (!forge.ok || !one.ok) throw new Error("read");
302 // The app pays its cloud alone, 0.50; the box's 95.00 is unowned, so the forge's cost is 95.50.
303 expect(one.value.thisMonth.cost).toBe(0.5);
304 expect(forge.value.thisMonth.cost).toBe(95.5);
305 } finally {
306 app.db.close();
307 await rm(dataDir, { recursive: true, force: true });
308 }
309 });
310 });
311
312 describe("money line on an empty forge", () => {
313 test("the slot is there with zeros, so nothing shifts when figures arrive", async () => {
314 const dataDir = await mkdtemp(join(tmpdir(), "forge-money-empty-"));
315 const app = createApp({ dataDir, port: 0 });
316 try {
317 const made = await createUser(app.db, {
318 origin: "seed",
319 trusted: true,
320 username: "alice",
321 email: "[email protected]",
322 password: "a-good-passphrase",
323 adminIfFirst: true,
324 });
325 if (!made.ok) throw new Error(made.error);
326 setPrices(app.db, { ...getPrices(app.db), ownedMonthCents: 0 }, made.value.id);
327 const m = readForgeMoney(app, made.value, NOW);
328 if (!m.ok) throw new Error(m.error);
329 expect(m.value.thisMonth).toEqual({ cost: 0, revenue: 0, gap: 0 });
330 expect(m.value.days).toHaveLength(DAYS);
331 const line = forgeMoneyLine(m.value).text;
332 expect(line).toContain('data-sign="even">$0.00');
333 expect(line).toContain('width="240"');
334 expect(line).toContain('height="48"');
335 } finally {
336 app.db.close();
337 await rm(dataDir, { recursive: true, force: true });
338 }
339 });
340 });
test/pages-budget.test.ts +1 −1
@@ -36,7 +36,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = {
3636 "/:owner/:name/issues/:number": { statements: 70 },
3737 "/:owner/:name/next": { statements: 64 },
3838 "/:owner/:name/pulls/:number": { statements: 110 }, // +1: the header Talk count (#10327); +1: the landing line (#10860)
39 "/board": { statements: 64 },
39 "/board": { statements: 70 }, // +6: the money line (#510) — one settings read, then the month's and the 30 days' meter, storage and ledger reads; constant in the number of apps
4040 "/settings/capacity": { statements: 71 },
4141 };
4242
test/palette-roster.test.ts +1
@@ -149,6 +149,7 @@ test("a site-admin person is offered the whole roster", () => {
149149 ["requests", "Waiting", "strangers who asked for an account: allow or decline"],
150150 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
151151 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
152 ["money", "Money", "this month's cost, revenue and gap, and the last 30 days"],
152153 ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"],
153154 ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"],
154155 ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"],
test/traffic-truth.test.ts +3 −1
@@ -73,7 +73,9 @@ beforeAll(async () => {
7373 if (!repo.ok) throw new Error(String(repo.error));
7474 const put = await commitToBranch(app, alice, getRepo(app.db, "alice", name)!, { message: "Start", files: [{ path: "a.txt", content: "hi\n" }] });
7575 if (!put.ok) throw new Error(String(put.error));
76 const oid = Bun.spawnSync(["git", "--git-dir", join(dataDir, "repos", "alice", `${name}.git`), "rev-parse", "HEAD"]).stdout.toString().trim();
76 // The commit's own id, from the write that made it: no second git process to fail quietly under load.
77 const oid = put.value.commitOid;
78 if (!/^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(oid)) throw new Error(`no full id for ${name}: ${oid}`);
7779 if (name === "open") sha = oid;
7880 else shutSha = oid;
7981 }
test/tui-money.test.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * The terminal's money screen (src/tui/screens/money.ts, #510): a site
3 * admin opens "Money" from the palette and reads the month, today, the
4 * slider and the thirty days — the board's figures from the same read; a
5 * repository's admin opens "owner/name — Money" for the app's month. A
6 * member and a guest are offered neither and, were the screen reached, see
7 * no figures. Figures come from `readForgeMoney`/`readAppMoney`, worked by
8 * hand in test/money.test.ts; here the rows are checked against them.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { readAppMoney, readForgeMoney } from "../src/actions/money.ts";
16 import { createRepo } from "../src/actions/repos.ts";
17 import { closeAwake, openAwake } from "../src/core/meter.ts";
18 import type { Repo } from "../src/core/repos.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { appMoneyRows, entry, forgeMoneyRows, moneyCommands, moneyScreen } from "../src/tui/screens/money.ts";
23 import { repoScreen } from "../src/tui/screens/repo.ts";
24 import { local } from "../src/tui/screens/where.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { settleApp } from "./helpers/tui.ts";
27
28 const NOW = "2026-11-10T12:00:00.000Z";
29
30 let dir: string;
31 let app: App;
32 let admin: User;
33 let bob: User;
34 let notes: Repo;
35
36 async function drive(user: User | undefined, keys: string[], start: "home" | "repo" | "forge" | "app"): Promise<string> {
37 const terminal = headlessTerminal(140, 50);
38 const { app: tui, session } = createForgeApp(
39 app,
40 { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
41 { terminal, exitHooks: false },
42 );
43 tui.start();
44 if (start === "repo") tui.ctx.push(repoScreen(session, tui.ctx, local(session, notes)));
45 if (start === "forge") tui.ctx.push(moneyScreen(session, tui.ctx));
46 if (start === "app") tui.ctx.push(moneyScreen(session, tui.ctx, notes));
47 await settleApp(tui);
48 for (const k of keys) {
49 terminal.feed(k);
50 await settleApp(tui);
51 }
52 const text = tui.screen.toText();
53 tui.stop?.();
54 return text;
55 }
56
57 beforeAll(async () => {
58 dir = await mkdtemp(join(tmpdir(), "forge-tui-money-"));
59 app = createApp({ dataDir: dir, port: 0 });
60 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
61 const made = await createUser(app.db, {
62 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
63 password: "a-good-passphrase", adminIfFirst: first,
64 });
65 if (!made.ok) throw new Error(made.error);
66 if (username === "alice") admin = made.value;
67 else bob = made.value;
68 }
69 const made = await createRepo(app, admin, { name: "notes", visibility: "public" });
70 if (!made.ok) throw new Error("repo");
71 notes = made.value;
72 openAwake(app.db, { repoId: notes.id, env: "production", placement: "owned", at: "2026-11-02T00:00:00.000Z" });
73 closeAwake(app.db, { repoId: notes.id, env: "production", at: "2026-11-02T10:00:00.000Z" });
74 setPrices(app.db, { ...getPrices(app.db), ownedMonthCents: 30_000, awakePriceHourCents: 10 }, admin.id);
75 setEconomics(app.db, { position: 62, marginPct: 20 }, admin.id);
76 });
77 afterAll(async () => {
78 app.db.close();
79 await rm(dir, { recursive: true, force: true });
80 });
81
82 describe("the terminal's money screen", () => {
83 test("the forge's rows say the board's figures, in plain words", () => {
84 const m = readForgeMoney(app, admin, NOW);
85 if (!m.ok) throw new Error(m.error);
86 const rows = forgeMoneyRows(m.value);
87 // Box 300 × 9.5 / 30 = 95.00 against 10 h × $0.10 = 1.00 of revenue.
88 expect(rows[0]).toEqual({ title: "This month (2026-11)", meta: "cost $95.00 · revenue $1.00 · gap -$94.00" });
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" });
91 // Thirty days, newest first.
92 expect(rows).toHaveLength(3 + 30);
93 expect(rows[3]!.title).toBe("2026-11-10");
94 expect(rows[32]!.title).toBe("2026-10-12");
95 });
96
97 test("an app's rows: its month, where it was awake, its share of the server", () => {
98 const m = readAppMoney(app, admin, notes, NOW);
99 if (!m.ok) throw new Error(m.error);
100 expect(appMoneyRows(m.value)).toEqual([
101 { title: "This month (2026-11)", meta: "cost $95.00 · revenue $1.00 · gap -$94.00" },
102 { title: "Awake", meta: "10.0 h · 100% on the forge's own server · 0% on cloud" },
103 { title: "Share of the forge's own server", meta: "100%" },
104 ]);
105 });
106
107 test("a site admin opens Money from the palette; a repository's admin its app's", async () => {
108 const forge = await drive(admin, [":", "Money", "\r"], "home");
109 expect(forge).toContain("This month (");
110 expect(forge).toContain("Economics slider");
111 const one = await drive(admin, [":", "notes — Money", "\r"], "repo");
112 expect(one).toContain("alice/notes — Money");
113 expect(one).toContain("on the forge's own server");
114 });
115
116 test("a member and a guest are offered neither and see no figures", async () => {
117 expect(entry.offered({ app, user: bob } as never)).toBe(false);
118 expect(entry.offered({ app, user: undefined } as never)).toBe(false);
119 expect(entry.offered({ app, user: { ...admin, kind: "agent" } } as never)).toBe(false);
120 expect(entry.offered({ app, user: admin } as never)).toBe(true);
121 const ctx = {} as never;
122 expect(moneyCommands({ app, user: bob } as never, ctx, notes)).toEqual([]);
123 expect(moneyCommands({ app, user: undefined } as never, ctx, notes)).toEqual([]);
124 expect(moneyCommands({ app, user: admin } as never, ctx, notes)).toHaveLength(1);
125 for (const who of [bob, undefined]) {
126 for (const start of ["forge", "app"] as const) {
127 const text = await drive(who, [], start);
128 expect(text).not.toContain("This month");
129 expect(text).not.toContain("$");
130 }
131 }
132 });
133 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs