Skip to content

feat(dashboard): answer "is my plan worth it?", carefully - #122

Merged
pitimon merged 1 commit into
mainfrom
feat/106-plan-value-card
Jul 25, 2026
Merged

feat(dashboard): answer "is my plan worth it?", carefully#122
pitimon merged 1 commit into
mainfrom
feat/106-plan-value-card

Conversation

@pitimon

@pitimon pitimon commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #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 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. 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.

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 no fetch and imports no API client.

Absent is never zero

Three places, because zero is the flattering direction every time:

  • no stored price → no comparison, rather than a $0 plan (infinitely over by construction)
  • clearing the field → stores nothing, not 0
  • a provider the user has not priced → left out of the roll-up entirely, rather than inflating the list-price side against a smaller plan 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

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.

Test plan

  • ci:local exit 0 — 952 root tests, 302 dashboard
  • 14 on the pure comparison: both directions, null/0/negative/NaN plan prices, floor from unpriced, floor from fuzzy, unattributed counted, exact when all tiers are exact, missing models/totals, dedup + sort, roll-up sums, one-floor-poisons-the-roll-up
  • 8 on storage: round-trip, clearing removes rather than stores zero, non-positive and non-numeric stored values dropped, corrupt JSON, a stored array, a write that throws (value survives in memory), key case-normalised
  • 14 on the card, including the four input tests and read-only when no handler is supplied
  • A wiring guard — a source-fact test, not a render test, because there is no DashboardView harness. 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 a return ( as an opening JSX tag so everything after it becomes text. Both fixed by structure, not by widening the baseline.

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.
@pitimon
pitimon merged commit c6c8c87 into main Jul 25, 2026
1 check passed
@pitimon
pitimon deleted the feat/106-plan-value-card branch July 25, 2026 23:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: answer the question the README already promises — "is my plan worth it?"

1 participant