From afcb6af8a5950c1c51ce4ad11272c7d98710fed9 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Sun, 26 Jul 2026 06:26:17 +0700 Subject: [PATCH] feat(dashboard): answer "is my plan worth it?", carefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- dashboard/src/content/copy.csv | 7 + dashboard/src/hooks/use-plan-prices.test.ts | 73 +++++++ dashboard/src/hooks/use-plan-prices.ts | 67 ++++++ dashboard/src/lib/plan-value.test.ts | 156 ++++++++++++++ dashboard/src/lib/plan-value.ts | 148 ++++++++++++++ dashboard/src/pages/DashboardPage.jsx | 6 + .../ui/dashboard/components/PlanValueCard.jsx | 139 +++++++++++++ .../__tests__/PlanValueCard.test.jsx | 190 ++++++++++++++++++ .../src/ui/dashboard/views/DashboardView.jsx | 14 ++ test/plan-value-wiring.test.js | 58 ++++++ 11 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/hooks/use-plan-prices.test.ts create mode 100644 dashboard/src/hooks/use-plan-prices.ts create mode 100644 dashboard/src/lib/plan-value.test.ts create mode 100644 dashboard/src/lib/plan-value.ts create mode 100644 dashboard/src/ui/dashboard/components/PlanValueCard.jsx create mode 100644 dashboard/src/ui/dashboard/components/__tests__/PlanValueCard.test.jsx create mode 100644 test/plan-value-wiring.test.js diff --git a/README.md b/README.md index c464bc86..70905f2a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Auto-collect token usage from **20+ AI coding tools**, aggregate it locally, and You can — that is the honest alternative, and for a single tool it is enough. TokenTracker earns its place once you use more than one: - **One number instead of six tabs.** Claude, Codex, Cursor, Gemini and Copilot each bill in their own dashboard, on their own reset schedule, in their own units. Nobody adds them up for you. -- **Subscriptions hide the number entirely.** A flat monthly plan shows you a quota bar, not what your usage would have cost. TokenTracker prices every token against public model rates, so you can see whether the plan is a bargain or a subsidy you have outgrown. +- **Subscriptions hide the number entirely.** A flat monthly plan shows you a quota bar, not what your usage would have cost. TokenTracker prices every token against public model rates, and you can enter what you pay to see the two side by side. The figure is list-price-equivalent for the window on screen, not a bill you would have paid — and when some usage cannot be priced it says so rather than quietly reporting a smaller number. - **Per-project and per-model, not just per-account.** Billing pages answer "what do I owe this month". This answers "which repo, which model, and which hour" — the resolution you need to actually change something. - **Quota chips before you hit the wall.** Live plan-limit usage sits on each provider's card, so a 5-hour window running out is something you see rather than something you discover. diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 884ed1d9..43600f86 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -58,6 +58,13 @@ dashboard.projects.limit_top_10,ui,ProjectUsagePanel,ProjectUsagePanel,limit_top dashboard.projects.empty,ui,ProjectUsagePanel,ProjectUsagePanel,empty,No public repositories,,active dashboard.projects.unattributed,ui,ProjectUsagePanel,ProjectUsagePanel,unattributed,Not counted per repo:,Prefix for the list of sources with usage but no project attribution,active dashboard.projects.unpriced,ui,ProjectUsagePanel,ProjectUsagePanel,unpriced,no model recorded,Shown instead of a cost when every row for this repo predates per-model project attribution,active +dashboard.plan_value.title,ui,PlanValueCard,PlanValueCard,title,Plan vs list price,,active +dashboard.plan_value.empty,ui,PlanValueCard,PlanValueCard,empty,Enter what you pay for a plan to compare it against list price.,Shown when no provider has a plan price set,active +dashboard.plan_value.headline,ui,PlanValueCard,PlanValueCard,headline,{{plan}}/mo of plans · {{list}} of usage at API list price,Deliberately neutral: no "saved" or "wasted",active +dashboard.plan_value.row,ui,PlanValueCard,PlanValueCard,row,{{plan}}/mo · {{list}} at list price,Per-provider line,active +dashboard.plan_value.floor_note,ui,PlanValueCard,PlanValueCard,floor_note,At least this much — some usage could not be priced ({{models}}).,Shown when any model is unpriced or fuzzy-matched,active +dashboard.plan_value.disclaimer,ui,PlanValueCard,PlanValueCard,disclaimer,List-price-equivalent for this window — not a bill you would have paid.,Guards against reading the number as a counterfactual,active +dashboard.plan_value.input_aria,ui,PlanValueCard,PlanValueCard,input_aria,Monthly plan price for {{source}},,active dashboard.widgets.title,dashboard,DashboardPage,WidgetOnboardingCard,title,Desktop Widgets,,active dashboard.widgets.hint,dashboard,DashboardPage,WidgetOnboardingCard,hint,"Right-click your desktop → Edit Widgets → search ""TokenTracker""",,active dashboard.widgets.dismiss_aria,dashboard,DashboardPage,WidgetOnboardingCard,dismiss_aria,Dismiss widget intro,,active diff --git a/dashboard/src/hooks/use-plan-prices.test.ts b/dashboard/src/hooks/use-plan-prices.test.ts new file mode 100644 index 00000000..0b8d38fb --- /dev/null +++ b/dashboard/src/hooks/use-plan-prices.test.ts @@ -0,0 +1,73 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PLAN_PRICES_STORAGE_KEY, usePlanPrices } from "./use-plan-prices"; + +describe("usePlanPrices", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("starts empty when nothing is stored", () => { + const { result } = renderHook(() => usePlanPrices()); + expect(result.current.planPrices).toEqual({}); + }); + + it("round-trips a price through storage", () => { + const { result } = renderHook(() => usePlanPrices()); + act(() => result.current.setPlanPrice("claude", 20)); + expect(result.current.planPrices).toEqual({ claude: 20 }); + expect(JSON.parse(window.localStorage.getItem(PLAN_PRICES_STORAGE_KEY)!)).toEqual({ + claude: 20, + }); + }); + + it("clearing a price REMOVES it rather than storing zero", () => { + // Absent is not zero. A stored 0 would make the card compare usage against + // a $0 plan, which is infinitely over by construction. + window.localStorage.setItem(PLAN_PRICES_STORAGE_KEY, JSON.stringify({ claude: 20 })); + const { result } = renderHook(() => usePlanPrices()); + act(() => result.current.setPlanPrice("claude", null)); + expect(result.current.planPrices).toEqual({}); + expect(JSON.parse(window.localStorage.getItem(PLAN_PRICES_STORAGE_KEY)!)).toEqual({}); + }); + + it("drops stored values that are not positive numbers", () => { + window.localStorage.setItem( + PLAN_PRICES_STORAGE_KEY, + JSON.stringify({ claude: 20, codex: 0, kimi: "abc", zed: -5, goose: null }), + ); + const { result } = renderHook(() => usePlanPrices()); + expect(result.current.planPrices).toEqual({ claude: 20 }); + }); + + it("survives corrupt storage instead of throwing", () => { + window.localStorage.setItem(PLAN_PRICES_STORAGE_KEY, "{not json"); + const { result } = renderHook(() => usePlanPrices()); + expect(result.current.planPrices).toEqual({}); + }); + + it("survives storage that is not an object", () => { + window.localStorage.setItem(PLAN_PRICES_STORAGE_KEY, JSON.stringify([1, 2, 3])); + const { result } = renderHook(() => usePlanPrices()); + expect(result.current.planPrices).toEqual({}); + }); + + it("keeps the value in memory when storage refuses the write", () => { + // Private-mode or quota-exceeded. Silently reverting under the user is + // worse than a preference that does not persist. + const { result } = renderHook(() => usePlanPrices()); + const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + act(() => result.current.setPlanPrice("claude", 20)); + expect(result.current.planPrices).toEqual({ claude: 20 }); + setItem.mockRestore(); + }); + + it("normalises the provider key so CLAUDE and claude are one entry", () => { + const { result } = renderHook(() => usePlanPrices()); + act(() => result.current.setPlanPrice("CLAUDE", 20)); + act(() => result.current.setPlanPrice("claude", 25)); + expect(result.current.planPrices).toEqual({ claude: 25 }); + }); +}); diff --git a/dashboard/src/hooks/use-plan-prices.ts b/dashboard/src/hooks/use-plan-prices.ts new file mode 100644 index 00000000..4d979fa2 --- /dev/null +++ b/dashboard/src/hooks/use-plan-prices.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from "react"; + +// What the user pays each provider, per month, entered by them. +// +// Stored in localStorage like every other preference here and NEVER sent +// anywhere — there is no plan catalogue to maintain and go stale, and a plan +// price is exactly the kind of thing this product promises not to transmit. +// +// The value is a plain map so a provider the user has not priced is simply +// absent. Absent is not zero: treating it as zero would make every unpriced +// provider look infinitely over its plan. + +const STORAGE_KEY = "tokentracker.plan_prices.v1"; + +export type PlanPrices = Record; + +function readStored(): PlanPrices { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: PlanPrices = {}; + for (const [source, value] of Object.entries(parsed)) { + const price = Number(value); + // Anything that is not a positive finite number is dropped rather than + // coerced. A stored "0" or "abc" must not become a plan the card compares + // against. + if (Number.isFinite(price) && price > 0) out[String(source).toLowerCase()] = price; + } + return out; + } catch { + // Corrupt or unreadable storage is the same as none set. + return {}; + } +} + +export function usePlanPrices() { + const [prices, setPrices] = useState({}); + + useEffect(() => { + setPrices(readStored()); + }, []); + + const setPlanPrice = useCallback((source: string, price: number | null) => { + setPrices((current) => { + const key = String(source || "").toLowerCase(); + if (!key) return current; + const next = { ...current }; + const value = Number(price); + if (Number.isFinite(value) && value > 0) next[key] = value; + else delete next[key]; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Storage full or blocked — keep the in-memory value so the card still + // works this session rather than silently reverting under the user. + } + return next; + }); + }, []); + + return { planPrices: prices, setPlanPrice, PLAN_PRICES_STORAGE_KEY: STORAGE_KEY }; +} + +export { STORAGE_KEY as PLAN_PRICES_STORAGE_KEY }; diff --git a/dashboard/src/lib/plan-value.test.ts b/dashboard/src/lib/plan-value.test.ts new file mode 100644 index 00000000..dfd1a2c1 --- /dev/null +++ b/dashboard/src/lib/plan-value.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { computePlanValue, formatUsd, rollUpPlanValues } from "./plan-value"; + +const source = (over: Record = {}) => ({ + source: "claude", + totals: { total_cost_usd: "47.000000" }, + models: [{ model: "claude-sonnet-5", pricing_tier: "litellm:exact" }], + ...over, +}); + +describe("computePlanValue", () => { + it("compares this window's list price against what the user pays", () => { + const value = computePlanValue(source(), 20)!; + expect(value.planPriceUsd).toBe(20); + expect(value.listPriceUsd).toBe(47); + expect(value.direction).toBe("above"); + expect(value.ratio).toBeCloseTo(2.35); + }); + + it("reads the same way when usage is BELOW the plan price", () => { + // Under-usage is a downgrade signal and is exactly as useful as the other + // direction, so nothing here treats it as the bad case. + const value = computePlanValue(source({ totals: { total_cost_usd: "3.00" } }), 20)!; + expect(value.direction).toBe("below"); + expect(value.listPriceUsd).toBe(3); + expect(value.confidence).toBe("exact"); + }); + + it("returns null with no plan price — absent is not zero", () => { + // A card with no number beats a card with a wrong one, and treating an + // unentered price as $0 would make every provider look infinitely over. + expect(computePlanValue(source(), null)).toBeNull(); + expect(computePlanValue(source(), undefined)).toBeNull(); + expect(computePlanValue(source(), 0)).toBeNull(); + expect(computePlanValue(source(), -5)).toBeNull(); + expect(computePlanValue(source(), Number.NaN)).toBeNull(); + }); + + it("marks the figure a FLOOR when some usage could not be priced", () => { + // The caveats already shipped in #92 exist for exactly this. An unpriced + // model means the real list price is higher than the number shown. + const value = computePlanValue( + source({ + models: [ + { model: "claude-sonnet-5", pricing_tier: "litellm:exact" }, + { model: "mystery-model", pricing_tier: "miss" }, + ], + }), + 20, + )!; + expect(value.confidence).toBe("floor"); + expect(value.unpricedModels).toEqual(["mystery-model"]); + }); + + it("treats a fuzzy match as a floor too, and names it separately", () => { + // A substring match can be wrong in either direction. It is still not a + // figure anyone should read as exact. + const value = computePlanValue( + source({ models: [{ model: "some-model-v3", pricing_tier: "litellm:fuzzy" }] }), + 20, + )!; + expect(value.confidence).toBe("floor"); + expect(value.fuzzyModels).toEqual(["some-model-v3"]); + expect(value.unpricedModels).toEqual([]); + }); + + it("counts the `unattributed` tier as unpriced", () => { + // The tier added in #94 for "we recorded this before we recorded models". + const value = computePlanValue( + source({ models: [{ model: "unknown", pricing_tier: "unattributed" }] }), + 20, + )!; + expect(value.confidence).toBe("floor"); + expect(value.unpricedModels).toEqual(["unknown"]); + }); + + it("is exact when every model priced exactly", () => { + const value = computePlanValue( + source({ + models: [ + { model: "a", pricing_tier: "curated:exact" }, + { model: "b", pricing_tier: "litellm:exact-dot" }, + { model: "c", pricing_tier: "curated:alias" }, + ], + }), + 20, + )!; + expect(value.confidence).toBe("exact"); + }); + + it("does not choke on a source with no models or no totals", () => { + const value = computePlanValue({ source: "zed" }, 10)!; + expect(value.listPriceUsd).toBe(0); + expect(value.direction).toBe("below"); + expect(value.confidence).toBe("exact"); + }); + + it("deduplicates and sorts the named models", () => { + const value = computePlanValue( + source({ + models: [ + { model: "zeta", pricing_tier: "miss" }, + { model: "alpha", pricing_tier: "miss" }, + { model: "zeta", pricing_tier: "empty" }, + ], + }), + 20, + )!; + expect(value.unpricedModels).toEqual(["alpha", "zeta"]); + }); +}); + +describe("rollUpPlanValues", () => { + const claude = computePlanValue(source(), 20)!; + const codex = computePlanValue( + source({ source: "codex", totals: { total_cost_usd: "5.00" } }), + 25, + )!; + + it("sums both sides across providers", () => { + const rollup = rollUpPlanValues([claude, codex])!; + expect(rollup.planPriceUsd).toBe(45); + expect(rollup.listPriceUsd).toBe(52); + expect(rollup.direction).toBe("above"); + expect(rollup.providerCount).toBe(2); + }); + + it("one floor anywhere makes the whole headline a floor", () => { + // Averaging confidence would let a well-priced provider vouch for a + // badly-priced one. + const shaky = computePlanValue( + source({ source: "kimi", models: [{ model: "x", pricing_tier: "miss" }] }), + 10, + )!; + expect(rollUpPlanValues([claude, shaky])!.confidence).toBe("floor"); + expect(rollUpPlanValues([claude, codex])!.confidence).toBe("exact"); + }); + + it("returns null when no provider has a plan price", () => { + expect(rollUpPlanValues([])).toBeNull(); + }); + + it("reports `below` for the roll-up too, without treating it as failure", () => { + const cheap = computePlanValue(source({ totals: { total_cost_usd: "1.00" } }), 20)!; + expect(rollUpPlanValues([cheap])!.direction).toBe("below"); + }); +}); + +describe("formatUsd", () => { + it("keeps small numbers from reading as zero", () => { + expect(formatUsd(0.0004)).toBe("$0.0004"); + expect(formatUsd(3)).toBe("$3.00"); + expect(formatUsd(0)).toBe("$0.00"); + expect(formatUsd(Number.NaN)).toBe("$0.00"); + }); +}); diff --git a/dashboard/src/lib/plan-value.ts b/dashboard/src/lib/plan-value.ts new file mode 100644 index 00000000..d86d520d --- /dev/null +++ b/dashboard/src/lib/plan-value.ts @@ -0,0 +1,148 @@ +// "Is my plan worth it?" — the number README:32 already promises. +// +// > Subscriptions hide the number entirely. A flat monthly plan shows you a +// > quota bar, not what your usage would have cost. TokenTracker prices every +// > token against public model rates, so you can see whether the plan is a +// > bargain or a subsidy you have outgrown. +// +// The arithmetic is a subtraction. THE LABELLING IS THE HARD PART, and this +// module exists so it can be tested away from React. +// +// Three rules it enforces, all from issue 106: +// +// 1. It is LIST-PRICE-EQUIVALENT, not a counterfactual bill. Anyone actually on +// the API would use it differently, so nothing here says "you saved". +// 2. It inherits the pricing caveats. If any model in the window is unpriced or +// fuzzy-matched, the comparison is a FLOOR, not a figure — `confidence` +// says which, and the caller must show it. +// 3. It reads NEUTRALLY IN BOTH DIRECTIONS. "$3 of usage on a $20 plan" is a +// downgrade signal and is exactly as useful as the other direction, so +// neither is celebrated. + +export type PlanConfidence = "exact" | "floor"; + +export type PlanValue = { + source: string; + /** What the user pays, per month, as they entered it. */ + planPriceUsd: number; + /** What this window's usage would cost at public list rates. */ + listPriceUsd: number; + /** + * "above" — usage exceeds the plan price. "below" — it does not. + * Deliberately not "saving"/"losing": see rule 1. + */ + direction: "above" | "below" | "even"; + /** listPrice / planPrice. 1 means the plan exactly breaks even. */ + ratio: number; + /** + * "floor" when some usage in the window could not be priced, so the real + * list-price figure is at least this much and possibly more. + */ + confidence: PlanConfidence; + /** Models that made this a floor rather than a figure. */ + unpricedModels: string[]; + fuzzyModels: string[]; +}; + +type SourceTotals = { total_cost_usd?: string | number }; +type SourceModel = { model?: string; model_id?: string; pricing_tier?: string }; +type BreakdownSource = { + source?: string; + totals?: SourceTotals; + models?: SourceModel[]; +}; + +const toNumber = (value: unknown): number => { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +}; + +// Tiers that mean "we did not really price this". `miss` and `empty` produced no +// price at all; the fuzzy tiers matched by substring, which is a guess that can +// be wrong in either direction. Kept as an explicit list rather than a +// "not exact" rule so a NEW tier has to be classified deliberately. +const UNPRICED_TIERS = new Set(["miss", "empty", "unattributed"]); +const FUZZY_TIERS = new Set(["curated:fuzzy", "litellm:fuzzy", "litellm:prefix-strip", "litellm:strip"]); + +function modelName(model: SourceModel): string { + return String(model.model || model.model_id || "").trim(); +} + +/** + * One provider's comparison. Returns null when there is no plan price to compare + * against — an absent price is not zero, and a card with no number is better + * than a card with a wrong one. + */ +export function computePlanValue( + source: BreakdownSource, + planPriceUsd: number | null | undefined, +): PlanValue | null { + const price = Number(planPriceUsd); + if (!Number.isFinite(price) || price <= 0) return null; + + const listPriceUsd = toNumber(source?.totals?.total_cost_usd); + const models = Array.isArray(source?.models) ? source.models : []; + + const unpricedModels = models + .filter((m) => UNPRICED_TIERS.has(String(m.pricing_tier || ""))) + .map(modelName) + .filter(Boolean); + const fuzzyModels = models + .filter((m) => FUZZY_TIERS.has(String(m.pricing_tier || ""))) + .map(modelName) + .filter(Boolean); + + const ratio = listPriceUsd / price; + const direction = ratio > 1 ? "above" : ratio < 1 ? "below" : "even"; + + return { + source: String(source?.source || ""), + planPriceUsd: price, + listPriceUsd, + direction, + ratio, + // Unpriced usage means the real figure is higher than this one. A fuzzy + // match could be wrong in either direction, but it is still not a figure + // anyone should read as exact, so it lands in the same bucket. + confidence: unpricedModels.length > 0 || fuzzyModels.length > 0 ? "floor" : "exact", + unpricedModels: [...new Set(unpricedModels)].sort(), + fuzzyModels: [...new Set(fuzzyModels)].sort(), + }; +} + +export type PlanRollup = { + planPriceUsd: number; + listPriceUsd: number; + direction: "above" | "below" | "even"; + confidence: PlanConfidence; + /** How many providers contributed, so the headline can say what it covers. */ + providerCount: number; +}; + +/** + * One headline across providers. Only providers with a plan price count — a + * provider the user pays nothing for has no plan to evaluate, and folding its + * usage in would inflate the list-price side against a smaller plan side. + */ +export function rollUpPlanValues(values: PlanValue[]): PlanRollup | null { + const priced = values.filter(Boolean); + if (priced.length === 0) return null; + + const planPriceUsd = priced.reduce((sum, v) => sum + v.planPriceUsd, 0); + const listPriceUsd = priced.reduce((sum, v) => sum + v.listPriceUsd, 0); + return { + planPriceUsd, + listPriceUsd, + direction: listPriceUsd > planPriceUsd ? "above" : listPriceUsd < planPriceUsd ? "below" : "even", + // One floor anywhere makes the whole headline a floor. Averaging confidence + // would let a well-priced provider vouch for a badly-priced one. + confidence: priced.some((v) => v.confidence === "floor") ? "floor" : "exact", + providerCount: priced.length, + }; +} + +/** Two decimals above a cent, four below, so small numbers do not read as zero. */ +export function formatUsd(value: number): string { + const n = Number.isFinite(value) ? value : 0; + return n > 0 && n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`; +} diff --git a/dashboard/src/pages/DashboardPage.jsx b/dashboard/src/pages/DashboardPage.jsx index 347987a0..20e2c749 100644 --- a/dashboard/src/pages/DashboardPage.jsx +++ b/dashboard/src/pages/DashboardPage.jsx @@ -6,6 +6,7 @@ import { useUsageData } from "../hooks/use-usage-data.js"; import { useUsageLimits } from "../hooks/use-usage-limits.js"; import { useLimitsDisplayPrefs } from "../hooks/use-limits-display-prefs.js"; import { useUsageModelBreakdown } from "../hooks/use-usage-model-breakdown.js"; +import { usePlanPrices } from "../hooks/use-plan-prices"; import { usePulse } from "../hooks/use-pulse"; import { copy } from "../lib/copy"; import { useLocale } from "../hooks/useLocale.js"; @@ -329,6 +330,8 @@ export function DashboardPage({ } }, [dashboardNow, timeZone]); + // What the user pays each provider. Local only — never sent anywhere. + const { planPrices, setPlanPrice } = usePlanPrices(); const [projectUsageLimit, setProjectUsageLimit] = useState(10); const { entries: projectUsageEntries, @@ -1098,6 +1101,9 @@ export function DashboardPage({ identitySubscriptions={identitySubscriptions} identityScrambleDurationMs={identityScrambleDurationMs} projectUsageEntries={projectUsageEntries} + planPrices={planPrices} + onPlanPriceChange={setPlanPrice} + modelBreakdown={modelBreakdown} projectUnattributedSources={projectUnattributedSources} projectUsageLimit={projectUsageLimit} setProjectUsageLimit={setProjectUsageLimit} diff --git a/dashboard/src/ui/dashboard/components/PlanValueCard.jsx b/dashboard/src/ui/dashboard/components/PlanValueCard.jsx new file mode 100644 index 00000000..cf7735ab --- /dev/null +++ b/dashboard/src/ui/dashboard/components/PlanValueCard.jsx @@ -0,0 +1,139 @@ +import React, { useMemo } from "react"; +import { computePlanValue, formatUsd, rollUpPlanValues } from "../../../lib/plan-value"; + +/** + * "Is my plan worth it?" — the number README:32 already promises and the product + * did not compute. + * + * The arithmetic is a subtraction; the labelling is the hard part, and the rules + * it follows come straight from issue 106: + * + * - It never says "you saved". This is LIST-PRICE-EQUIVALENT, not a + * counterfactual bill — anyone actually on the API would use it differently. + * The disclaimer line is not decoration, it is the claim being kept honest. + * - Under-usage reads the same as over-usage. "$3 of usage on a $20 plan" is a + * downgrade signal and is exactly as useful as the other direction, so neither + * gets a colour or a verdict. + * - When any model in the window could not be priced, the figure is a FLOOR and + * says so, naming the models. The caveats shipped in #92 exist for this. + * + * Renders nothing when no provider has a plan price. A card with no number beats + * a card with a wrong one. + */ +// Module level rather than inline, for two reasons: the arrow function reads +// better away from the JSX, and the ui-hardcode scanner treats the `=>` before a +// `return (` as an opening JSX tag and everything after it as raw text. +function namedFloorModels(values) { + const names = new Set(); + for (const value of values) { + for (const model of value.unpricedModels) names.add(model); + for (const model of value.fuzzyModels) names.add(model); + } + return Array.from(names).sort().join(", "); +} + +export function PlanValueCard({ + sources = [], + planPrices = {}, + onPlanPriceChange, + copy, + className = "", +}) { + // Every provider with usage in this window, whether or not it has a price + // yet — the input has to be reachable before the comparison can exist. + const providers = (Array.isArray(sources) ? sources : []) + .map((s) => String(s?.source || "").toLowerCase()) + .filter(Boolean); + const { values, rollup } = useMemo(() => { + const list = (Array.isArray(sources) ? sources : []) + .map((source) => computePlanValue(source, planPrices?.[String(source?.source || "").toLowerCase()])) + .filter(Boolean); + return { values: list, rollup: rollUpPlanValues(list) }; + }, [sources, planPrices]); + + const title = copy("dashboard.plan_value.title"); + + // The inputs. Rendered in both states, because in the empty state they are + // the only way out of it. + const priceInputs = onPlanPriceChange ? ( +
+ {providers.map((provider) => ( + + ))} +
+ ) : null; + + if (!rollup) { + return ( +
+
{title}
+
+ {copy("dashboard.plan_value.empty")} +
+ {priceInputs} +
+ ); + } + + const floorList = namedFloorModels(values); + + return ( +
+
{title}
+ +
+ {copy("dashboard.plan_value.headline", { + plan: formatUsd(rollup.planPriceUsd), + list: formatUsd(rollup.listPriceUsd), + })} +
+ +
+ {values.map((value) => ( +
+ {value.source} + + {copy("dashboard.plan_value.row", { + plan: formatUsd(value.planPriceUsd), + list: formatUsd(value.listPriceUsd), + })} + +
+ ))} +
+ + {priceInputs} + + {rollup.confidence === "floor" ? ( +
+ {copy("dashboard.plan_value.floor_note", { models: floorList })} +
+ ) : null} + +
+ {copy("dashboard.plan_value.disclaimer")} +
+
+ ); +} diff --git a/dashboard/src/ui/dashboard/components/__tests__/PlanValueCard.test.jsx b/dashboard/src/ui/dashboard/components/__tests__/PlanValueCard.test.jsx new file mode 100644 index 00000000..e4a6ed5c --- /dev/null +++ b/dashboard/src/ui/dashboard/components/__tests__/PlanValueCard.test.jsx @@ -0,0 +1,190 @@ +import { fireEvent, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render } from "../../../../test/test-utils"; +import { PlanValueCard } from "../PlanValueCard.jsx"; + +// Copy that interpolates the way the real registry does, so the assertions see +// the sentence a user would. +function copy(key, vars = {}) { + const text = { + "dashboard.plan_value.title": "Plan vs list price", + "dashboard.plan_value.empty": "Enter what you pay for a plan to compare it against list price.", + "dashboard.plan_value.headline": "{{plan}}/mo of plans · {{list}} of usage at API list price", + "dashboard.plan_value.row": "{{plan}}/mo · {{list}} at list price", + "dashboard.plan_value.floor_note": + "At least this much — some usage could not be priced ({{models}}).", + "dashboard.plan_value.disclaimer": + "List-price-equivalent for this window — not a bill you would have paid.", + "dashboard.plan_value.input_aria": "Monthly plan price for {{source}}", + }[key]; + return String(text ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name) => String(vars[name] ?? "")); +} + +const claudeSource = (over = {}) => ({ + source: "claude", + totals: { total_cost_usd: "47.000000" }, + models: [{ model: "claude-sonnet-5", pricing_tier: "litellm:exact" }], + ...over, +}); + +function card(props = {}) { + return render(); +} + +describe("PlanValueCard", () => { + it("prompts rather than guessing when no plan price is set", () => { + card({ planPrices: {} }); + expect(screen.getByText(/Enter what you pay/)).toBeTruthy(); + }); + + it("shows both sides of the comparison", () => { + card({ planPrices: { claude: 20 } }); + expect(screen.getByText(/\$20\.00\/mo of plans · \$47\.00 of usage/)).toBeTruthy(); + }); + + it("never says saved, wasted or worth it", () => { + // The number is easy to over-claim, and this is the assertion that stops a + // future edit from turning it into "you saved $27". + const { container } = card({ planPrices: { claude: 20 } }); + expect(container.textContent).not.toMatch(/saved|savings|wasted|worth it|bargain/i); + }); + + it("reads identically when usage is BELOW the plan price", () => { + // Under-usage is a downgrade signal, not a failure, so it gets no different + // treatment — same sentence, different numbers. + const { container } = render( + , + ); + expect(screen.getByText(/\$20\.00\/mo of plans · \$3\.00 of usage/)).toBeTruthy(); + expect(container.textContent).not.toMatch(/over|under|too much|too little/i); + }); + + it("always carries the list-price-equivalent disclaimer", () => { + card({ planPrices: { claude: 20 } }); + expect(screen.getByText(/not a bill you would have paid/)).toBeTruthy(); + }); + + it("calls the figure a floor and names the models when pricing is incomplete", () => { + render( + , + ); + expect(screen.getByText(/At least this much/)).toBeTruthy(); + expect(screen.getByText(/mystery-model/)).toBeTruthy(); + }); + + it("omits the floor note when everything priced exactly", () => { + card({ planPrices: { claude: 20 } }); + expect(screen.queryByText(/At least this much/)).toBeNull(); + }); + + it("rolls up across providers and lists each one", () => { + render( + , + ); + expect(screen.getByText(/\$45\.00\/mo of plans · \$52\.00 of usage/)).toBeTruthy(); + expect(screen.getByText("claude")).toBeTruthy(); + expect(screen.getByText("codex")).toBeTruthy(); + }); + + it("ignores a provider the user has not priced", () => { + // Folding its usage in would inflate the list-price side against a smaller + // plan side, which is the flattering direction. + render( + , + ); + expect(screen.getByText(/\$20\.00\/mo of plans · \$47\.00 of usage/)).toBeTruthy(); + expect(screen.queryByText("codex")).toBeNull(); + }); +}); + + +describe("PlanValueCard — entering the price", () => { + it("offers an input in the EMPTY state, which is the only way out of it", () => { + // A card that says "enter a price" with nowhere to enter it is not a + // feature. The empty state is exactly where the input matters most. + render( + {}} />, + ); + expect(screen.getByLabelText("Monthly plan price for claude")).toBeTruthy(); + }); + + it("reports the number the user typed", () => { + const onPlanPriceChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("Monthly plan price for claude"), { + target: { value: "20" }, + }); + expect(onPlanPriceChange).toHaveBeenCalledWith("claude", 20); + }); + + it("clearing the field reports null, not zero", () => { + // Zero is a plan price the comparison would divide by. Absent is not zero. + const onPlanPriceChange = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("Monthly plan price for claude"), { + target: { value: "" }, + }); + expect(onPlanPriceChange).toHaveBeenCalledWith("claude", null); + }); + + it("shows an input for a provider with usage but no price yet", () => { + render( + {}} + />, + ); + expect(screen.getByLabelText("Monthly plan price for codex")).toBeTruthy(); + }); + + it("renders read-only when no handler is supplied", () => { + render(); + expect(screen.queryByLabelText("Monthly plan price for claude")).toBeNull(); + }); +}); diff --git a/dashboard/src/ui/dashboard/views/DashboardView.jsx b/dashboard/src/ui/dashboard/views/DashboardView.jsx index e06659be..81914367 100644 --- a/dashboard/src/ui/dashboard/views/DashboardView.jsx +++ b/dashboard/src/ui/dashboard/views/DashboardView.jsx @@ -2,6 +2,7 @@ import React from "react"; import { Shell } from "../../components"; import { CostAnalysisModal } from "../components/CostAnalysisModal.jsx"; import { DataDetails } from "../components/DataDetails.jsx"; +import { PlanValueCard } from "../components/PlanValueCard.jsx"; import { StatsPanel } from "../components/StatsPanel.jsx"; import { PulseCard } from "../components/PulseCard.jsx"; import { HeroSummary } from "../components/HeroSummary.jsx"; @@ -25,6 +26,9 @@ export function DashboardView(props) { identitySubscriptions, identityScrambleDurationMs, projectUsageEntries, + planPrices = {}, + onPlanPriceChange, + modelBreakdown = null, projectUnattributedSources = [], projectUsageLimit, setProjectUsageLimit, @@ -285,6 +289,16 @@ export function DashboardView(props) { ) : null} + + + + + fs.readFileSync(path.join(__dirname, "..", "dashboard", "src", ...parts), "utf8"); + +test("DashboardView mounts PlanValueCard with the data it needs", () => { + const view = read("ui", "dashboard", "views", "DashboardView.jsx"); + assert.match(view, /import \{ PlanValueCard \} from "\.\.\/components\/PlanValueCard\.jsx"/); + assert.match(view, / { + const page = read("pages", "DashboardPage.jsx"); + assert.match(page, /const \{ planPrices, setPlanPrice \} = usePlanPrices\(\)/); + assert.match(page, /modelBreakdown=\{modelBreakdown\}/); + assert.match(page, /planPrices=\{planPrices\}/); + assert.match(page, /onPlanPriceChange=\{setPlanPrice\}/); +}); + +test("the plan price never leaves the machine", () => { + // The product's headline claim. A plan price is exactly the kind of thing it + // promises not to transmit, so the hook must reach localStorage and nothing + // else — no fetch, no api import. + const hook = read("hooks", "use-plan-prices.ts"); + assert.match(hook, /window\.localStorage/); + assert.doesNotMatch(hook, /\bfetch\s*\(/, "the plan price must not be sent anywhere"); + assert.doesNotMatch(hook, /from "\.\.\/lib\/api"/, "no API client in the storage hook"); +}); + +test("the card's honesty rules are stated in its own source", () => { + // These are the sentences a future edit is most likely to quietly drop, and + // the component tests assert the rendered behaviour. This pins the reasoning + // next to it so the "why" is not lost when the "what" is edited. + const card = read("ui", "dashboard", "components", "PlanValueCard.jsx"); + assert.match(card, /LIST-PRICE-EQUIVALENT|list-price-equivalent/i); + assert.match(card, /FLOOR|floor/); +});