console: mtd projection scales to the full month, not elapsed days
6fb8905 wired economics.Project into the mtd headline tiles but passed PeriodDays as the projection's target denominator. On "mtd", PeriodDays is only the number of days elapsed so far this month — so at full coverage (CoveredDays == PeriodDays) the "projected" figure collapsed to plain MTD, a labelled projection that didn't project. Fix: EconomicsView gains DaysInMonth, the current real calendar month's length (buildEconomicsView's own time.Date(y, mo+1, 0, ...).Day() derivation, matching statementsPass's existing pattern in economicspass.go), threaded through economicsPayload as daysInMonth for the JS twin. The three mtd tiles (revenue, cost, profit) and recompute()'s live-knob-change path now call project() with DaysInMonth instead of PeriodDays; project() itself is unchanged. TestEconomicsPageMTDShowsProjection updated to derive its expected DaysInMonth the same way buildEconomicsView does (not hardcoded to 31), so the golden figures never drift from what the page itself computes on any given day.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+52
−20
internal/console/economicspage_test.go+24 −13internal/console/economicsview.go+22 −1internal/console/templates/economics.html+6 −6
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go| index 9ba7fce25..a6523538c 100644 |
| --- a/internal/console/economicspage_test.go |
| +++ b/internal/console/economicspage_test.go |
| @@ -117,25 +117,36 @@ 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. |
| +// projection" promise (F2, unit-economics final review; corrected post-F2: |
| +// the projection's target denominator is the full current month's length, |
| +// EconomicsView.DaysInMonth — NOT PeriodDays, which on "mtd" is only the |
| +// number of days elapsed so far and would make "projected" collapse to |
| +// plain MTD at full coverage): 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. DaysInMonth is still |
| +// derived from the real current date, the same formula buildEconomicsView |
| +// uses, so the expected figure never drifts from what the page itself would |
| +// compute on any given day. |
| func TestEconomicsPageMTDShowsProjection(t *testing.T) { |
| + now := time.Now().UTC() |
| + daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| + |
| view := &EconomicsView{ |
| Period: "mtd", |
| CoveredDays: 10, |
| PeriodDays: 31, |
| + DaysInMonth: daysInMonth, |
| 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. |
| + // profit 100 — easy to hand-check economics.Project's daysInMonth/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 { |
| @@ -143,9 +154,9 @@ func TestEconomicsPageMTDShowsProjection(t *testing.T) { |
| } |
| 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 |
| + wantRevenue := usd(economics.Project(200, 10, daysInMonth)) |
| + wantCost := usd(economics.Project(100, 10, daysInMonth)) |
| + wantProfit := usd(economics.Project(100, 10, daysInMonth)) |
| for _, want := range []string{ |
| "projected " + wantRevenue + " for the month", |
| "projected " + wantCost + " for the month", |
diff --git a/internal/console/economicsview.go b/internal/console/economicsview.go| index 86e83e997..9b6c08de1 100644 |
| --- a/internal/console/economicsview.go |
| +++ b/internal/console/economicsview.go |
| @@ -58,7 +58,17 @@ type EconomicsView struct { |
| Boxes []BoxEconomics |
| Bills []economics.Bill // this month + previous, if present |
| CoveredDays, PeriodDays int |
| - DataJSON template.JS // the embedded payload — see economicsPayload |
| + // DaysInMonth is the CURRENT real calendar month's length (statementsPass's |
| + // own daysInMonth derivation: time.Date(y, mo+1, 0, ...).Day()) — |
| + // deliberately NOT PeriodDays, which on "mtd" is only the number of days |
| + // elapsed so far this month. economics.Project's mtd->full-month |
| + // projection needs the FULL month's length as its target denominator's |
| + // mate; PeriodDays there would make the projected figure equal plain MTD |
| + // whenever coverage is complete, which is a labelled projection that |
| + // doesn't project (the honesty rule's whole point). Populated on every |
| + // view regardless of Period — only "mtd" ever renders it. |
| + DaysInMonth int |
| + DataJSON template.JS // the embedded payload — see economicsPayload |
| } |
| // BoxEconomics is one box, priced under Scenario over the period. |
| @@ -92,6 +102,11 @@ type economicsPayload struct { |
| Bills []economics.Bill `json:"bills"` |
| CoveredDays int `json:"coveredDays"` |
| PeriodDays int `json:"periodDays"` |
| + // DaysInMonth mirrors EconomicsView.DaysInMonth (this struct's doc |
| + // comment above explains why it must NOT be confused with PeriodDays) — |
| + // additive key, the JS twin of the Go-side project() call's third |
| + // argument. |
| + DaysInMonth int `json:"daysInMonth"` |
| } |
| // economicsCacheEntry is one period's cached sweep — the metricsCache |
| @@ -313,6 +328,10 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| now := time.Now().UTC() |
| from, to := economicsPeriodBounds(period, now) |
| + // daysInMonth is the CURRENT real month's length, statementsPass's own |
| + // derivation (economicspass.go) — see EconomicsView.DaysInMonth's doc |
| + // comment for why this must be the full month, not PeriodDays. |
| + daysInMonth := time.Date(now.Year(), now.Month()+1, 0, 0, 0, 0, 0, time.UTC).Day() |
| routes, warning := s.loadRoutesForEconomics(sweepCtx) |
| @@ -429,6 +448,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| Bills: bills, |
| CoveredDays: sweep.CoveredDays, |
| PeriodDays: sweep.PeriodDays, |
| + DaysInMonth: daysInMonth, |
| } |
| raw, err := json.Marshal(payload) |
| if err != nil { |
| @@ -448,6 +468,7 @@ func (s *Server) buildEconomicsView(ctx context.Context, period string) (*Econom |
| Bills: bills, |
| CoveredDays: sweep.CoveredDays, |
| PeriodDays: sweep.PeriodDays, |
| + DaysInMonth: daysInMonth, |
| DataJSON: template.JS(raw), |
| }, nil |
| } |
diff --git a/internal/console/templates/economics.html b/internal/console/templates/economics.html| index b5063445a..f204a2962 100644 |
| --- a/internal/console/templates/economics.html |
| +++ b/internal/console/templates/economics.html |
| @@ -64,19 +64,19 @@ |
| <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}} |
| + {{if $showProjection}}<p class="tile-detail" id="tile-revenue-projected">projected {{usd (project $revenue .Economics.CoveredDays .Economics.DaysInMonth)}} 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}} |
| + {{if $showProjection}}<p class="tile-detail" id="tile-cost-projected">projected {{usd (project $cost .Economics.CoveredDays .Economics.DaysInMonth)}} 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}} |
| + {{if $showProjection}}<p class="tile-detail" id="tile-profit-projected">projected {{usd (project $profit .Economics.CoveredDays .Economics.DaysInMonth)}} for the month</p>{{end}} |
| </div> |
| <div class="tile"> |
| <h2>Achieved margin</h2> |
| @@ -274,9 +274,9 @@ |
| 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"); |
| + setText("tile-revenue-projected", "projected " + usd(project(revenueTotal, payload.coveredDays, payload.daysInMonth)) + " for the month"); |
| + setText("tile-cost-projected", "projected " + usd(project(costTotal, payload.coveredDays, payload.daysInMonth)) + " for the month"); |
| + setText("tile-profit-projected", "projected " + usd(project(profitTotal, payload.coveredDays, payload.daysInMonth)) + " for the month"); |
| {{end}} |
| document.querySelectorAll("[data-account]").forEach(function (row) { |