feat(dashboard): answer "is my plan worth it?", carefully - #122
Merged
Conversation
Closes issue 106. README:32 already made this claim and the product did not compute it: it showed what usage WOULD cost at list price, and it showed a quota bar, but the comparison — the actual decision — was left as mental arithmetic against a plan price nothing ever asked for. Every input already existed: per-source cost per window and the pricing tier telling you how much to trust it, both already on the model-breakdown endpoint. The arithmetic is a subtraction. THE LABELLING IS THE HARD PART, which is why the logic lives in a pure module with its own tests rather than inside JSX. Three rules, all from the issue, each with a test that fails if a future edit drops it: 1. It never says "you saved". This is LIST-PRICE-EQUIVALENT, not a counterfactual bill — anyone actually on the API would use it differently. A test asserts the rendered card contains no "saved", "savings", "wasted", "worth it" or "bargain", and the disclaimer line always renders. 2. It inherits the pricing caveats from #92. If any model in the window is unpriced or fuzzy-matched, the figure is a FLOOR and says so, naming the models. `unattributed` from #94 counts as unpriced. One floor anywhere makes the whole roll-up a floor — averaging confidence would let a well-priced provider vouch for a badly-priced one. 3. Under-usage reads exactly like over-usage. "$3 of usage on a $20 plan" is a downgrade signal and is as useful as the other direction, so neither gets a colour or a verdict. Tested by asserting the below case renders the same sentence with no "over"/"under" language. The plan price is entered by the user and stored in localStorage. Never sent anywhere — no plan catalogue to maintain and go stale, and a plan price is exactly the kind of thing this product promises not to transmit. A test asserts the storage hook contains no fetch and imports no API client. Absent is never zero, in three places: no stored price means no comparison rather than a $0 plan (which is infinitely over by construction), clearing the field stores nothing rather than 0, and a provider the user has not priced is left out of the roll-up entirely rather than inflating the list-price side. I nearly shipped this without an input. The card rendered "enter what you pay" with nowhere to enter it, which is not a feature. The inputs render in BOTH states because in the empty state they are the only way out of it. README:32 rewritten to match what shipped rather than left overselling it — the issue's own instruction if the honest framing could not be made to fit. It can, so the claim now names the window, the list-price-equivalence, and the floor. 37 new tests: 14 on the pure comparison, 8 on storage (corrupt JSON, non-object, non-positive values, a write that throws), 14 on the card, plus a wiring guard. That last one is a source-fact test rather than a render test because there is no DashboardView harness — and this session already produced the lesson twice: a Vite build stayed green over a deleted identifier, and a notice went into a component nothing mounts. ci:local exit 0: 952 root tests, 302 dashboard.
This was referenced Jul 26, 2026
Merged
pitimon
added a commit
that referenced
this pull request
Jul 26, 2026
15 commits since v0.39.43. Tracker: #125. User-facing: - The Projects panel honours the date range. It read no query parameter at all, so picking "24h" narrowed every other card while Projects kept showing all-time totals with nothing on screen saying so (#118). - Per-repo cost, with rows written before the change named as unpriced rather than shown as a confident $0 (#121). - Sources with no per-repo attribution are named, instead of their absence reading as "that tool cost nothing here" (#118). - A plan-value card answering what README:32 already promised, labelled as list-price-equivalent rather than a saving (#122). - The Codex quota chip no longer vanishes on a 401 (#119). - `sync --compact`, which reclaimed 34,924 lines to 5,636 on a real install (#117). Behind the scenes: outbound-privacy validator hardening (#111, #114), avatar proxy per-hop revalidation, port-aware allowlisting and a real download cap (#109, #112), a parser conformance ratchet (#116, #120), scripts/graph out of the product gate (#115), and a usage-limits fixture capture tool (#124). Version bumped in all four lockstep locations by the `version` hook: package.json, package-lock.json, both TokenTrackerBar targets, and the Windows csproj. validate:version-lockstep passes. WATCH AFTER SHIPPING: #121 migrates cursors.json on every user's first sync after upgrading, re-keying project buckets from project|source|hour to project|source|model|hour. Without that the old bucket strands and its usage is counted twice. Verified read-only against a real 2,015-bucket state — every key migrated, total_tokens preserved exactly at 6,281,653,062, and all 2,015 queuedKey markers survived, the loss of which would re-append every row. That is one machine; a differently-shaped state is the residual risk. ci:local exit 0: 972 root tests, 302 dashboard. Co-authored-by: itarun.p <itarun.p@somapait.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #106.
README:32already made this claim and the product did not compute it — it showed what usage would cost at list price, and it showed a quota bar, but the comparison was left as mental arithmetic against a plan price nothing ever asked for.Every input already existed: per-source cost per window and the pricing tier saying how much to trust it, both already on the model-breakdown endpoint.
The arithmetic is a subtraction. The labelling is the hard part — which is why the logic lives in a pure module with its own tests rather than inside JSX.
Three rules, each with a test that fails if a future edit drops it
1. It never says "you saved." This is list-price-equivalent, not a counterfactual bill — anyone actually on the API would use it differently. A test asserts the rendered card contains no saved / savings / wasted / worth it / bargain, and that the disclaimer always renders.
2. It inherits the pricing caveats from #92. If any model in the window is unpriced or fuzzy-matched, the figure is a floor and says so, naming the models.
unattributedfrom #94 counts as unpriced. One floor anywhere makes the whole roll-up a floor — averaging confidence would let a well-priced provider vouch for a badly-priced one.3. Under-usage reads exactly like over-usage. "$3 of usage on a $20 plan" is a downgrade signal and is as useful as the other direction, so neither gets a colour or a verdict. Tested by asserting the below case renders the same sentence with no over/under language.
Privacy
The plan price is entered by the user and stored in
localStorage. Never sent anywhere — no plan catalogue to maintain and go stale, and a plan price is exactly the kind of thing this product promises not to transmit. A test asserts the storage hook contains nofetchand imports no API client.Absent is never zero
Three places, because zero is the flattering direction every time:
0I nearly shipped this without an input
The card rendered "enter what you pay" with nowhere to enter it, which is not a feature. The inputs render in both states, because in the empty state they are the only way out of it.
README
README:32rewritten to match what shipped rather than left overselling it — the issue's own instruction if the honest framing could not be made to fit. It can, so the claim now names the window, the list-price-equivalence, and the floor.Test plan
ci:localexit 0 — 952 root tests, 302 dashboardunattributedcounted, exact when all tiers are exact, missing models/totals, dedup + sort, roll-up sums, one-floor-poisons-the-roll-upDashboardViewharness. This session already produced the lesson twice: a Vite build stayed green over a deleted identifier, and a notice went into a component nothing mounts.Two guardrail false positives, reworded rather than baselined
The ui-hardcode scanner reads
render(<X/>)in an arrow body as raw text, and treats the=>before areturn (as an opening JSX tag so everything after it becomes text. Both fixed by structure, not by widening the baseline.