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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/510-the-board-s-money-line-cost-revenue-and → main 11 commits fork 2 threads · 2 unresolved
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` | ||
| 77 | 77 | (JSON, or `format=csv`) or print it from the app's settings |
| 78 | 78 | (`/:owner/:name/settings/app/statement`); see `docs/features/statement.md`. |
| 79 | 79 | |
| 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 | ||
| 80 | 89 | ## The order (what waits for what) |
| 81 | 90 | |
| 82 | 91 | | issue | what | waits for | |
docs/design/areas/chart.md +8
| @@ -9,3 +9,11 @@ | ||
| 9 | 9 | - `.fg-s5` — series 5. |
| 10 | 10 | - `.fg-s6` — series 6. |
| 11 | 11 | - `.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 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | 12 | 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"; | |
| 16 | 25 | import type { User } from "../core/users.ts"; |
| 17 | 26 | 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"; | |
| 19 | 29 | |
| 20 | 30 | export type StatementError = "not_found" | "bad_month"; |
| 21 | 31 | |
| @@ -40,26 +50,54 @@ export function readStatement( | ||
| 40 | 50 | if (!mayReadStatement(app, repo, actor)) return err("not_found"); |
| 41 | 51 | const m = month === undefined ? monthOf(now) : parseMonth(month); |
| 42 | 52 | 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); | |
| 45 | 74 | // The ledger's `until` is inclusive; the month is half-open, so stop a millisecond short. |
| 46 | 75 | 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 | }; | |
| 65 | 103 | } |
src/core/meter.ts +72 −53
| @@ -162,15 +162,30 @@ export function usageBetween( | ||
| 162 | 162 | to: string, |
| 163 | 163 | now: string = new Date().toISOString(), |
| 164 | 164 | ): 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); | |
| 167 | 181 | 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]!; | |
| 174 | 189 | let e = apps.get(repoId); |
| 175 | 190 | if (e === undefined) { |
| 176 | 191 | e = { owned: 0, cloud: 0, gbHours: 0 }; |
| @@ -178,52 +193,56 @@ export function usageBetween( | ||
| 178 | 193 | } |
| 179 | 194 | return e; |
| 180 | 195 | }; |
| 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 | } | |
| 181 | 217 | |
| 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 | }); | |
| 195 | 236 | } |
| 196 | 237 | |
| 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 | ); | |
| 229 | 248 | } |
src/core/revenue.ts +41 −8
| @@ -104,8 +104,25 @@ const amount = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0); | ||
| 104 | 104 | const six = (n: number): number => Math.round(n * 1e6) / 1e6; |
| 105 | 105 | const dollars = (cents: number): number => Math.round(cents) / 100; |
| 106 | 106 | |
| 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 { | |
| 109 | 126 | const hours = amount(use.awakeSeconds) / 3600; |
| 110 | 127 | const gbMonths = amount(use.gbHours) / HOURS_PER_GB_MONTH; |
| 111 | 128 | const crew = amount(use.crewUsd); |
| @@ -114,20 +131,36 @@ export function statementFor(use: AppMonthUse, month: string, rates: Rates): Sta | ||
| 114 | 131 | const storageCents = gbMonths * rates.storagePriceGbMonthCents; |
| 115 | 132 | const crewCents = crew * 100; |
| 116 | 133 | 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); | |
| 117 | 150 | 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) }, | |
| 122 | 155 | ]; |
| 123 | 156 | return { |
| 124 | 157 | app: use.app, |
| 125 | 158 | month, |
| 126 | 159 | ...range, |
| 127 | 160 | currency: "USD", |
| 128 | marginPct: margin, | |
| 161 | marginPct: c.margin, | |
| 129 | 162 | lines, |
| 130 | total: dollars(awakeCents + storageCents + crewCents + marginCents), | |
| 163 | total: dollars(c.totalCents), | |
| 131 | 164 | }; |
| 132 | 165 | } |
| 133 | 166 |
src/core/usage.ts +26
| @@ -334,6 +334,32 @@ export function usageBuckets( | ||
| 334 | 334 | return out; |
| 335 | 335 | } |
| 336 | 336 | |
| 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 | ||
| 337 | 363 | /** Every repository with a line in the ledger since an instant, by id. */ |
| 338 | 364 | export function reposWithUsage(db: Database, since: string): string[] { |
| 339 | 365 | return db |
src/settings/economics.ts +36 −5
| @@ -50,14 +50,45 @@ const STORE: Record<EconomicsKey, { key: string; min: number; max: number; fallb | ||
| 50 | 50 | |
| 51 | 51 | const KEYS = Object.keys(STORE) as EconomicsKey[]; |
| 52 | 52 | |
| 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 { | |
| 54 | 55 | 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 { | |
| 55 | 62 | const row = db |
| 56 | 63 | .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 | }; | |
| 61 | 92 | } |
| 62 | 93 | |
| 63 | 94 | /** 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"; | ||
| 45 | 45 | import { entry as invites } from "./invites.ts"; |
| 46 | 46 | import { entry as limits } from "./limits.ts"; |
| 47 | 47 | import { entry as economics } from "./economics.ts"; |
| 48 | import { entry as money } from "./money.ts"; | |
| 48 | 49 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 49 | 50 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 50 | 51 | import { entry as maintenance } from "./maintenance.ts"; |
| @@ -126,6 +127,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 126 | 127 | quotas, |
| 127 | 128 | limits, |
| 128 | 129 | economics, |
| 130 | money, | |
| 129 | 131 | appTemplates, |
| 130 | 132 | devices, |
| 131 | 133 | 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"; | ||
| 157 | 157 | import { trafficCommands } from "./trafficview.ts"; |
| 158 | 158 | import { blockedCommands } from "./blocked.ts"; |
| 159 | 159 | import { statementCommands } from "./statement.ts"; |
| 160 | import { moneyCommands } from "./money.ts"; | |
| 160 | 161 | import { adapterCommands } from "./adapter.ts"; |
| 161 | 162 | import { atlasCommands } from "./atlas.ts"; |
| 162 | 163 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| @@ -1668,6 +1669,7 @@ export function workspaceScreen( | ||
| 1668 | 1669 | ...trafficCommands(f, ctx, repo), |
| 1669 | 1670 | ...blockedCommands(f, ctx, repo), |
| 1670 | 1671 | ...statementCommands(f, ctx, repo), |
| 1672 | ...moneyCommands(f, ctx, repo), | |
| 1671 | 1673 | ...adapterCommands(f, ctx, repo), |
| 1672 | 1674 | ...atlasCommands(f, ctx, repo), |
| 1673 | 1675 | ], |
src/web/pages/board.ts +17 −1
| @@ -5,7 +5,9 @@ | ||
| 5 | 5 | * change cost over the week — each a live region that swaps itself when a |
| 6 | 6 | * matching event lands. A site admin also gets two strips above them: the |
| 7 | 7 | * 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 | |
| 9 | 11 | * needs a click, and everything on it links somewhere. It reads the same |
| 10 | 12 | * tables as the studio and adds no state of its own — nor words: every |
| 11 | 13 | * run, change and decision on it is worded by `runWord`/`pullWord`/ |
| @@ -105,6 +107,8 @@ import { | ||
| 105 | 107 | tokenCount, |
| 106 | 108 | } from "../ui.ts"; |
| 107 | 109 | import { noteLine } from "../studio/note.ts"; |
| 110 | import { readForgeMoney } from "../../actions/money.ts"; | |
| 111 | import { forgeMoneyLine } from "../studio/money_line.ts"; | |
| 108 | 112 | import { tellWord } from "../studio/tell.ts"; |
| 109 | 113 | import { voiceFor } from "../studio/voice.ts"; |
| 110 | 114 | import { repoDir } from "../../git/repos.ts"; |
| @@ -265,6 +269,7 @@ export async function boardPage(ctx: Ctx): Promise<Response> { | ||
| 265 | 269 | const repos = scope.repos; |
| 266 | 270 | const wall = ctx.url.searchParams.has("wall"); |
| 267 | 271 | const forge = timePhase(rq, "forge", () => forgeNotes(ctx)); |
| 272 | const money = timePhase(rq, "money", () => moneyStrip(ctx)); | |
| 268 | 273 | const declined = timePhase(rq, "declined", () => declinedNotes(ctx, scope)); |
| 269 | 274 | const review = timePhase(rq, "review", () => reviewNotes(ctx, scope)); |
| 270 | 275 | const asks = timePhase(rq, "asks", () => asksStrip(ctx)); |
| @@ -301,6 +306,7 @@ export async function boardPage(ctx: Ctx): Promise<Response> { | ||
| 301 | 306 | > |
| 302 | 307 | <time class="num small muted" id="board-clock"></time> |
| 303 | 308 | </header> |
| 309 | ${money} | |
| 304 | 310 | ${boardNotes(forge, declined, review)} |
| 305 | 311 | <div class="board-grid"> |
| 306 | 312 | ${workingNow} ${landedNow} ${decidedNow} |
| @@ -402,6 +408,16 @@ export function bigRedirect(ctx: Ctx): Response { | ||
| 402 | 408 | |
| 403 | 409 | // ---- the forge ---- |
| 404 | 410 | |
| 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 | ||
| 405 | 421 | /** |
| 406 | 422 | * The forge's health as notes, for a site admin only: each thing that needs |
| 407 | 423 | * 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 */ ` | ||
| 11 | 11 | .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; } |
| 12 | 12 | .board > header, .board > .stack { padding: 12px var(--pad) 0; } |
| 13 | 13 | .board > #notes { padding: 8px var(--pad) 0; } |
| 14 | .board > .money-line { padding: 8px var(--pad) 0; } | |
| 14 | 15 | #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; } |
| 15 | 16 | #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; } |
| 16 | 17 | #notes li > a { display: block; padding-block: 12px; min-height: 0; } |
src/web/studio/app.ts +14 −1
| @@ -68,6 +68,8 @@ import { | ||
| 68 | 68 | timePhaseAsync, |
| 69 | 69 | } from "../../core/route_timing.ts"; |
| 70 | 70 | import { noteAppOpened } from "../../core/next-steps.ts"; |
| 71 | import { readAppMoney } from "../../actions/money.ts"; | |
| 72 | import { moneyRows } from "./money_line.ts"; | |
| 71 | 73 | |
| 72 | 74 | /** |
| 73 | 75 | * What asks the viewer in this repository, muted or not: a mute quiets the |
| @@ -454,6 +456,17 @@ export async function studioApp( | ||
| 454 | 456 | extra: "about", |
| 455 | 457 | }, |
| 456 | 458 | ); |
| 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 | }); | |
| 457 | 470 | // The rest of the app's environment — its variables, hostname, access and |
| 458 | 471 | // domains — is the admin's, on the app's settings page. |
| 459 | 472 | // 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( | ||
| 523 | 536 | ${notesCard(ctx, repo, record)} |
| 524 | 537 | </div> |
| 525 | 538 | <aside class="stack"> |
| 526 | ${adapter} ${crewCard} ${weekCard} ${ownCard} ${more} | |
| 539 | ${adapter} ${crewCard} ${weekCard} ${moneyCard} ${ownCard} ${more} | |
| 527 | 540 | </aside> |
| 528 | 541 | </div> |
| 529 | 542 | ${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 | } | |
| 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); } | ||
| 225 | 225 | .fg-s5 { fill: var(--series-5); color: var(--series-5); } |
| 226 | 226 | .fg-s6 { fill: var(--series-6); color: var(--series-6); } |
| 227 | 227 | .fg-s7 { fill: var(--series-7); color: var(--series-7); } |
| 228 | /* The money line (studio/money_line.ts, #510): the gap in the text colour, | |
| 229 | its sign the +/- it carries, and the warning tint for a loss alone — the | |
| 230 | accent stays for the slider's link. Two rows of 24px, the first wrapping | |
| 231 | once on a phone, so the reserved height is the real one; the chart a | |
| 232 | fixed box, so nothing shifts when the figures arrive. */ | |
| 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); } | |
| 228 | 244 | .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); } |
| 229 | 245 | form.inline { display: inline; margin-left: auto; } |
| 230 | 246 |
test/economics.test.ts +23 −1
| @@ -15,7 +15,7 @@ import { createAgent } from "../src/actions/crew.ts"; | ||
| 15 | 15 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | 16 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 17 | 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"; | |
| 19 | 19 | |
| 20 | 20 | describe("economics", () => { |
| 21 | 21 | let dataDir: string; |
| @@ -234,6 +234,28 @@ describe("economics", () => { | ||
| 234 | 234 | expect(listed).toContain('value="0.30"'); |
| 235 | 235 | }); |
| 236 | 236 | |
| 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 | ||
| 237 | 259 | test("the owned-node label is a setting: shown with the queue line, set by API and form, validated (#509)", async () => { |
| 238 | 260 | const token = createToken(app.db, alice.id, "t", "admin").token; |
| 239 | 261 | 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 }> = { | ||
| 36 | 36 | "/:owner/:name/issues/:number": { statements: 70 }, |
| 37 | 37 | "/:owner/:name/next": { statements: 64 }, |
| 38 | 38 | "/: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 | |
| 40 | 40 | "/settings/capacity": { statements: 71 }, |
| 41 | 41 | }; |
| 42 | 42 |
test/palette-roster.test.ts +1
| @@ -149,6 +149,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 149 | 149 | ["requests", "Waiting", "strangers who asked for an account: allow or decline"], |
| 150 | 150 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 151 | 151 | ["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"], | |
| 152 | 153 | ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"], |
| 153 | 154 | ["devices", "Devices", "LAN printers: model, state, supplies; declare or remove one"], |
| 154 | 155 | ["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 () => { | ||
| 73 | 73 | if (!repo.ok) throw new Error(String(repo.error)); |
| 74 | 74 | const put = await commitToBranch(app, alice, getRepo(app.db, "alice", name)!, { message: "Start", files: [{ path: "a.txt", content: "hi\n" }] }); |
| 75 | 75 | 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}`); | |
| 77 | 79 | if (name === "open") sha = oid; |
| 78 | 80 | else shutSha = oid; |
| 79 | 81 | } |
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 | }); |
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.