console: wire economics.Project into the headline tiles (mtd only)
The spec promises "month-to-date with projection" and "projections are labelled projections", but economics.Project had zero callers. Adds a "project" template func (server.go) wrapping economics.Project, and on /fleet/economics: when Period=="mtd" and CoveredDays>0, each of the revenue/cost/profit tiles (not margin — a ratio doesn't project) gains a second line, "projected $X for the month". The inline knob script's econ_maths_js gains the exact JS transliteration (project(mtd, covered, days)); recompute() updates the same three lines on a knob change, guarded by a Go template (not just JS) mtd check so a non-mtd page's JS source never carries the word "projected" at all, only a non-mtd render's DOM lacking the element (the honesty rule the finding started from). The file header comment's transliteration list now names Project alongside AllocateBox/PriceAccount. New view-level test (economicspage_test.go) renders the template directly against a hand-built EconomicsView (CoveredDays 10/31, known account cost/revenue) rather than a real sweep, so the expected projected figures are exact: asserts the mtd render contains both the word "projected" and the right figures, and a 7d render contains neither. economics_accounts.html shares econ_maths_js but never calls project() - confirmed no name collision, no behavior change to that page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+118
−1
internal/console/economicspage_test.go+74 −0internal/console/server.go+9 −0internal/console/templates/economics.html+35 −1
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go| index 5cc78d4af..9ba7fce25 100644 |
| --- a/internal/console/economicspage_test.go |
| +++ b/internal/console/economicspage_test.go |
| @@ -1,6 +1,8 @@ |
| package console |
| import ( |
| + "bytes" |
| + "fmt" |
| "net/http" |
| "strings" |
| "testing" |
| @@ -114,6 +116,78 @@ func TestEconomicsPageContent(t *testing.T) { |
| } |
| } |
| +// TestEconomicsPageMTDShowsProjection is the spec's "month-to-date with |
| +// projection" promise (F2, unit-economics final review): on "mtd" with at |
| +// least one covered day, each of the three dollar tiles (revenue, cost, |
| +// profit — never margin, a ratio doesn't project) gains a second line |
| +// naming the word "projected" and carrying economics.Project's own figure. |
| +// Rendered directly through templates.ExecuteTemplate against a hand-built |
| +// EconomicsView (the ladder_test.go pattern) rather than a real |
| +// LoadEconomics sweep, so CoveredDays/PeriodDays are exact, known numbers |
| +// instead of whatever today's real calendar date happens to produce. |
| +func TestEconomicsPageMTDShowsProjection(t *testing.T) { |
| + view := &EconomicsView{ |
| + Period: "mtd", |
| + CoveredDays: 10, |
| + PeriodDays: 31, |
| + Accounts: []economics.AccountEconomics{{Account: fixtureAcct, CostUSD: 100, RevenueUSD: 200}}, |
| + } |
| + // No overhead/box-idle line items on the scenario, so the tiles' totals |
| + // are exactly the one account's own numbers: revenue 200, cost 100, |
| + // profit 100 — easy to hand-check economics.Project's 31/10 scale-up |
| + // against. |
| + p := page{Title: "Fleet economics", CSRF: "tok", Economics: view} |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "economics", p); err != nil { |
| + t.Fatalf("render economics: %v", err) |
| + } |
| + html := buf.String() |
| + |
| + wantRevenue := usd(economics.Project(200, 10, 31)) // $620.00 |
| + wantCost := usd(economics.Project(100, 10, 31)) // $310.00 |
| + wantProfit := usd(economics.Project(100, 10, 31)) // $310.00 |
| + for _, want := range []string{ |
| + "projected " + wantRevenue + " for the month", |
| + "projected " + wantCost + " for the month", |
| + "projected " + wantProfit + " for the month", |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("mtd page missing %q in:\n%s", want, html) |
| + } |
| + } |
| + if !strings.Contains(html, "projected") { |
| + t.Fatal("mtd page must contain the word \"projected\" (honesty rule)") |
| + } |
| + |
| + // A non-mtd period must show none of it: the projected line only ever |
| + // exists on "mtd" (economics.html's $showProjection guard). |
| + view7d := &EconomicsView{ |
| + Period: "7d", |
| + CoveredDays: 7, |
| + PeriodDays: 7, |
| + Accounts: []economics.AccountEconomics{{Account: fixtureAcct, CostUSD: 100, RevenueUSD: 200}}, |
| + } |
| + p7d := page{Title: "Fleet economics", CSRF: "tok", Economics: view7d} |
| + var buf7d bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf7d, "economics", p7d); err != nil { |
| + t.Fatalf("render economics (7d): %v", err) |
| + } |
| + if strings.Contains(buf7d.String(), "projected") { |
| + t.Fatalf("?period=7d page must not contain \"projected\":\n%s", buf7d.String()) |
| + } |
| +} |
| + |
| +// usd mirrors server.go's "usd" template func (same rounding, same sign |
| +// handling) so this test's expected strings are computed the exact way the |
| +// page itself renders them, without reaching into the unexported FuncMap |
| +// closure directly. |
| +func usd(v float64) string { |
| + if v < 0 { |
| + return fmt.Sprintf("-$%.2f", -v) |
| + } |
| + return fmt.Sprintf("$%.2f", v) |
| +} |
| + |
| // TestEconomicsPageHonestUnmetered: a box with a metrics day rollup but no |
| // usage/edge day ever written must render "unmetered" for that box's egress |
| // — never a blank or a fabricated zero. See BoxEconomics.EgressMetered and |
diff --git a/internal/console/server.go b/internal/console/server.go| index 1b322b615..cb90ed8d2 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -202,6 +202,15 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| }, |
| "sub": func(a, b float64) float64 { return a - b }, |
| "mul": func(a, b float64) float64 { return a * b }, |
| + // project is economics.Project (compute.go), exposed so the headline |
| + // tiles' month-to-date figures can carry a "projected for the month" |
| + // second line — the spec's "month-to-date with projection" promise. Only |
| + // ever called from economics.html's tiles when Period=="mtd" and |
| + // CoveredDays>0; the JS twin (economics.html's econ_maths_js define) is |
| + // the exact transliteration a knob change re-runs. |
| + "project": func(mtd float64, coveredDays, periodDays int) float64 { |
| + return economics.Project(mtd, coveredDays, periodDays) |
| + }, |
| // marginOf is economics.PriceAccount's margin formula |
| // ((revenue-cost)/revenue, 0 when revenue is 0), applied to the |
| // dashboard's period TOTALS rather than one account — the "achieved |
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html| index 991ca8f4a..b5063445a 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -3,7 +3,7 @@ |
| is already priced under the active scenario (economicsview.go) — the |
| ONE inline <script> at the foot only RE-computes in place when an |
| operator tweaks a knob, transliterating |
| - internal/economics/compute.go's AllocateBox/PriceAccount |
| + internal/economics/compute.go's AllocateBox/PriceAccount/Project |
| function-for-function (same names, JS-cased) so drift between the two |
| is reviewable at a glance. With JS off, every number above stays |
| exactly what the server rendered, and the knob drawer's only working |
| @@ -50,21 +50,33 @@ |
| {{$cost := add (sumAcctCost .Economics.Accounts) (sumBoxIdle .Economics.Boxes) (overheadUSD .Economics.Scenario .Economics.PeriodDays)}} |
| {{$profit := sub $revenue $cost}} |
| {{$margin := marginOf $revenue $cost}} |
| +{{/* $showProjection: the spec's "month-to-date with projection" promise — |
| + only meaningful on "mtd" (the other periods are already whole, |
| + closed ranges with nothing left to project), and only once at least |
| + one day of this month has actually been swept (economics.Project |
| + itself returns 0 for CoveredDays<=0, which would render a false, |
| + confident-looking "$0.00 for the month" rather than simply omitting |
| + the line). The margin tile has no projected line — a ratio doesn't |
| + project the way a dollar total does. */}} |
| +{{$showProjection := and (eq .Economics.Period "mtd") (gt .Economics.CoveredDays 0)}} |
| <section class="tiles" aria-label="Headline economics"> |
| <div class="tile"> |
| <h2>Would-be revenue</h2> |
| <p class="tile-value" id="tile-revenue">{{usd $revenue}}</p> |
| <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + {{if $showProjection}}<p class="tile-detail" id="tile-revenue-projected">projected {{usd (project $revenue .Economics.CoveredDays .Economics.PeriodDays)}} for the month</p>{{end}} |
| </div> |
| <div class="tile"> |
| <h2>Cost</h2> |
| <p class="tile-value" id="tile-cost">{{usd $cost}}</p> |
| <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + {{if $showProjection}}<p class="tile-detail" id="tile-cost-projected">projected {{usd (project $cost .Economics.CoveredDays .Economics.PeriodDays)}} for the month</p>{{end}} |
| </div> |
| <div class="tile"> |
| <h2>Profit</h2> |
| <p class="tile-value" id="tile-profit">{{usd $profit}}</p> |
| <p class="tile-detail">{{.Economics.CoveredDays}}/{{.Economics.PeriodDays}} days metered</p> |
| + {{if $showProjection}}<p class="tile-detail" id="tile-profit-projected">projected {{usd (project $profit .Economics.CoveredDays .Economics.PeriodDays)}} for the month</p>{{end}} |
| </div> |
| <div class="tile"> |
| <h2>Achieved margin</h2> |
| @@ -254,6 +266,19 @@ |
| setText("tile-profit", usd(profitTotal)); |
| setText("tile-margin", pctf(marginTotal)); |
| + {{/* The three lines below are wrapped in a Go template (not just |
| + JS) "mtd" check, deliberately: setText's own `if (el)` guard would |
| + already no-op on a period that never rendered the element, but |
| + leaving the literal word live in every period's JS source would |
| + defeat the honesty rule (a non-mtd page must not carry the word |
| + at all, not merely fail to display it) — see |
| + TestEconomicsPageMTDShowsProjection. */}} |
| + {{if eq .Economics.Period "mtd"}} |
| + setText("tile-revenue-projected", "projected " + usd(project(revenueTotal, payload.coveredDays, payload.periodDays)) + " for the month"); |
| + setText("tile-cost-projected", "projected " + usd(project(costTotal, payload.coveredDays, payload.periodDays)) + " for the month"); |
| + setText("tile-profit-projected", "projected " + usd(project(profitTotal, payload.coveredDays, payload.periodDays)) + " for the month"); |
| + {{end}} |
| + |
| document.querySelectorAll("[data-account]").forEach(function (row) { |
| var a = accounts[row.getAttribute("data-account")]; |
| if (!a) { return; } |
| @@ -496,6 +521,15 @@ |
| return neg ? "-" + s : s; |
| } |
| function pctf(v) { return Math.round(v * 100) + "%"; } |
| + // project is economics.Project (compute.go) transliterated: scale a |
| + // month-to-date figure to covered/period days, 0 when nothing has been |
| + // covered yet (never a divide-by-zero NaN). recompute() only calls this |
| + // on the one period whose tiles carry a second, scaled-up line at all — |
| + // see that call site's own template guard. |
| + function project(mtd, covered, days) { |
| + if (covered <= 0) { return 0; } |
| + return mtd * days / covered; |
| + } |
| function marginMultiple(margin) { |
| if (margin < 0 || margin >= 1) { return "—"; } |
| return (1 / (1 - margin)).toFixed(1) + "×"; |