Usage Dashboard
A period-scoped usage block — headline, quota bar, cumulative burn-up curve and per-dimension breakdown all derived from one bucket array, plus a run-rate projection stated apart from actual usage.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/usage-dashboard.jsonPrompt
Build a React + TypeScript + Tailwind "UsageDashboard" block (recharts for the
trend, lucide-react icons, shadcn's chart wrapper) with zod.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
period: { id, label, start, end }; // absolute ISO instants
periods?: period[]; activePeriodId?;
meterLabel; unit: "count" | "bytes" | "seconds" | "currency";
unitLabel?; currencyCode?;
limit: number | null; // null = unlimited
previousUsed?;
dimensions: { id, label, detail? }[];
buckets: { start, values: Record<dimensionId, number> }[];
breakdownLabel?; metrics?; upgrade?: { label, href, note? } }
- buckets is the ONLY quantity input. There is deliberately no `total` and no
`breakdown` field beside it: the headline figure, the quota percentage, the
last point of the curve and the sum of the breakdown rows are all folds over
that one array, so the four readouts cannot disagree. Three separately
supplied numbers are three numbers that will eventually contradict each
other, and a reader reconciling a bill is exactly the person who notices.
- `period` is required in EVERY state, loading included, and carries its own
label. `activePeriodId` is what the USER has selected. When they differ, the
payload on screen is stale.
- limit is `number | null` where null is a first-class "unlimited": no bar, no
percentage, no projection against a ceiling that doesn't exist.
- upgrade.href is refined to reject "" and "#" — a dead link styled as the
primary action is what that refinement exists to prevent. Props add
onUpgrade?, which swaps the anchor for a real button.
- Props = z.infer of the schema plus now (string | number | Date, REQUIRED and
injected — never Date.now() in render, or nothing is reproducible), locale
("en-US"), timeZone ("UTC"), maxDimensions (5), warnAt (0.75),
criticalAt (0.9), onPeriodChange?, onRetry?, onUpgrade?, heading, className,
rest spread on the root section; forwardRef to that section.
Behavior
- Four first-class branches: loading (skeletons mirroring the real anatomy,
under a heading that already names the period), empty, error (message plus
"Try again" only when onRetry exists), ready. A "ready" payload with nothing
metered DEGRADES to the empty branch, named for its period — a new account's
first day must not render as a wall of zeros and a 0% bar.
- Period switching, and the one lie this block exists to prevent: the heading
prints the label of the period the FIGURES came from, never the one that was
just clicked. So "new period's title over last period's numbers" is
unrepresentable rather than merely unlikely — both are read out of the same
object. While activePeriodId != period.id the data region gets aria-busy, a
visible stale tint, and a role=status strip naming both periods ("Loading
Jan 2026. Everything below is still Feb 2026"). Old values stay readable on
purpose: blanking them throws away the very numbers the reader is comparing
against. Hosts that prefer a full skeleton can just set status="loading".
- Derivation, in one pass: per-dimension totals over every key that appears
(including keys with no declared dimension — dropping one would silently
break "the rows add up"), used = their sum, cumulative running total per
bucket, ratio = used / limit.
- Used vs projected are two different facts and get two different sentences.
Used is a division. Projected is the linear run rate — used * (period length
/ elapsed) — deliberately the whole-period average rather than a trailing
window, because the reader can reproduce it from two figures on screen. A
period 65% elapsed at 78% used projects to 120%: both numbers appear, in
separately labelled cards ("used so far" / "projected by reset") and in one
sentence that states the elapsed share so the gap is explainable. Projection
has its own branches: not started, period closed, nothing used yet (no rate
to project from), and unlimited (a per-day rate instead of a percentage).
- Quota bar: the track spans max(used, limit), so under the allowance the fill
fraction IS the percentage, and over it the fill still ends at the right edge
while the limit becomes a marker inside the track with a hatched overage
segment. Nothing is ever painted outside the track. The projection marker is
drawn only when it actually fits inside the track — clamping it to 100% would
park it on the limit and claim "projected to land exactly on the allowance",
the opposite of what an over-run means. Once used >= limit there is no
"runs out on <date>" either; it already did.
- Trend is CUMULATIVE, not per-bucket, and each point is plotted at its
bucket's end. That makes the last point the headline figure by construction;
a per-bucket chart would end on "today's usage", a different number that
invites exactly the reconciliation error above. A dashed second line
extrapolates to the period end, a reference line marks the allowance.
- Breakdown: dimensions with usage, largest first. Above maxDimensions the tail
merges into ONE "Other" row that states how many dimensions it merged, and a
"Show all N" button expands every row — a cap that hides rows without saying
so is a silent cap. A footer line reconciles on screen: "4 shown + Other =
390K total".
- Every unit owns its formatter (1024-based byte ladder, duration, Intl
currency, compact counts with the exact figure in title). Percentages gain a
decimal near 0, 100 and 105 so they can never read "100%" while there is
headroom left or while the allowance is already blown.
Rendering & styling
- Semantic tokens only: bg-card, border, text-muted-foreground, primary for the
fill and the upgrade CTA, destructive for critical/over, var(--chart-1..5)
for the curve and the breakdown swatches. Chart colours are never text.
- Colour is never the only channel: measured vs projected is solid vs dashed,
"near the limit" adds a hatch to the fill, every tone chip is an icon AND a
word, every breakdown row carries its own label and figure beside its swatch.
- Every figure is tabular-nums. Long dimension names use wrap-anywhere with
min-w-0 (break-words does NOT lower a column's min-content width, so it will
not stop a 50-character identifier from widening the card).
- @container, not viewport breakpoints — this block is a full-width billing
page in one app and a narrow settings card in another.
- Chart: accessibilityLayer={false} (recharts 3 otherwise adds a tabbable svg
with no accessible name inside the role="img" subtree), isAnimationActive
false, YAxis width="auto" so a two-word tick like "466 GiB" is measured
rather than wrapped onto two lines, and an sr-only data table wrapped in a
DIV — sr-only on a table element only sets a width floor and pushes a 375px
viewport into horizontal scroll.
- Period switcher is a roving-tabindex radiogroup and only renders when there
are two or more periods AND an onPeriodChange listener exists.
Customization levers
- Sections: overview / quota / trend / breakdown are four siblings — drop any
of them, or reorder, without touching the derivation.
- Density: maxDimensions controls how many breakdown rows survive before the
Other row; warnAt and criticalAt move the tone thresholds.
- Projection policy: swap the whole-period average for a trailing-7-day rate in
one place — but say so in the copy, because the reader can no longer
reproduce it from what is on screen.
- Chart axis: the y domain is max(used, projected, limit) rounded up a
nice-number ladder. Drop `limit` from that max if you would rather the curve
fill the box on plans whose allowance dwarfs the usage.
- Stale policy: the tint plus the status strip is one choice; setting
status="loading" on the host side gives you the full-skeleton alternative.
What must not change is the heading following the payload.
- Units: add a unit to the enum and give it a formatter in the same switch —
every readout goes through that one function.Concepts
- One fold, four readouts — the headline, the quota percentage, the curve's last point and the breakdown sum are four projections of the same array. They cannot drift apart, because there is nothing to drift: the contract has no place to put a second, disagreeing total.
- Cumulative by choice — the trend is a burn-up, not a per-day bar chart, precisely so that its final point is the number in the big card. A per-bucket chart ends on "today", which looks like it should match the headline and never does.
- Used is not projected — "78% used" is a division of two figures on screen; "projected to reach 120%" is a rate extrapolated over the rest of the period. They are printed as separate cards with separate verbs, and the sentence states the elapsed share so the reader can redo the arithmetic.
- The title follows the payload — the heading names the period the numbers came from, not the chip the user just pressed. That is what makes the classic dashboard lie — a fresh month's title over last month's figures — impossible to render rather than merely rare.
- Stale, not blank — while the next period loads, the old figures stay readable under their own label with a status line naming both. Blanking them would throw away the baseline the reader is comparing against; relabelling them would be a lie.
- Other says how many — the merged row states the number of dimensions inside it and keeps their exact remainder, and a "Show all N" button opens every one. A cap that quietly drops rows breaks the only property the breakdown is for.
- A ceiling that isn't there —
limit: nulldraws no bar and no percentage. A full bar would say "you are at your limit" and an empty one would invent a limit that does not exist. - Zero is not a dashboard — a period with nothing metered renders a named empty state, not a wall of zeros with a 0% bar, which reads as a broken integration rather than a new account.
Two-Factor Setup
The enable-2FA flow built around its one irreversible step — recovery codes are rendered once, the finish button refuses to fire until they are saved, and finishing wipes them from the DOM.
Empty Dashboard
A first-run dashboard page — welcome copy, a setup checklist that counts done and skipped separately, a labelled skeleton of the dashboard-to-be, and a confirmed sample-data escape hatch.