Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  ArrowUpRight,
  ChevronDown,
  CircleAlert,
  Gauge,
  Minus,
  ServerCrash,
  TrendingUp,
  TriangleAlert,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/usage-dashboard.json

Prompt

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 therelimit: null draws 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.

On This Page