Stamina Decay
A four-state stamina card — several match metrics stacked as small multiples over one shared minute axis, each read against its own opening-window baseline, with the drop-off after a chosen cut quantified in the metric's own unit and as a share of that baseline.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-stamina-decay.jsonPrompt
Build a React + TypeScript + Tailwind "ChartStaminaDecay" component — several
match metrics against elapsed minutes, each judged against its own opening
window — in hand-rolled SVG (no chart library) with zod.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string;
metrics: { id: string; label: string; unit: string;
points: { minute: number; value: number }[];
higherIsBetter?: boolean }[];
baselineWindowMinutes: number > 0;
sets?: { index: number; fromMinute: number; toMinute: number }[] }.
- unit is a suffix appended straight onto a formatted number, so it carries its
own leading space when it needs one: "%", " kph", " s".
- higherIsBetter defaults to true. It exists so a metric that RISES with
fatigue (seconds between points, unforced errors per game) is not printed as
an improvement; everything downstream reads a signed "decline" where negative
always means worse.
- baselineWindowMinutes is a LENGTH measured from the first reading on the
axis, not an absolute minute, so the card also works for a feed whose clock
starts mid-match.
- Refine: metric ids unique (they are React keys and table columns); ready
carries at least one metric with two readings; a set ends after it starts;
sets do not overlap (checked on a sorted copy, so an unordered payload is not
reported as an overlap it does not have). Each refinement guards its own
inputs — zod runs them all.
- Props = z.infer of the schema plus panelHeight?: number (default 58, clamped
40–160), defaultCutMinute?: number, onCutChange?: (minute: number) => void,
onRetry?: () => void, className, and the rest spread on the root. No parallel
hand-written interface.
Behavior
- One shared x axis in elapsed minutes spans every reading AND every declared
set; each metric gets its own stacked panel with its own y scale, because
points, kph and seconds share no scale. The axis is drawn once, under all
panels — that shared clock is the entire point of the form.
- Baseline: per metric, the mean of ITS OWN readings inside the opening window.
Drawn as a dashed rule across the whole panel, so the later stretch is read
against it by position, not by memory.
- Cut: the reader picks where "after" begins from a small set of offered cuts —
each set's end (named "after set 2") plus the opening window's own end,
de-duplicated when they land on the same minute (the scoreboard's name wins).
A cut inside the opening window is never offered: it would compare the
baseline with a stretch that helped compute it. A cut with no reading after it
is dropped too. Default = the earliest cut, or the one nearest
defaultCutMinute.
- Drop-off: mean after the cut, minus the baseline, printed in the metric's own
unit AND as a share of the baseline. The difference between two percentages is
quoted in POINTS, never per cent — "first serve fell 8%" and "fell 8 points"
are different claims.
- The row prints baseline, after and the move together, so the move is derived
from the two values AS PRINTED (read the formatted number back, subtract),
never rounded on its own: 56.5 → 48.6 has to say −7.9. Three independently
rounded numbers do not reconcile, and a reader who catches one subtraction
failing stops trusting every other number on the card. The share of the
baseline is that same shown move, signed as a DECLINE (negative is worse,
flipped for higherIsBetter: false) — the readout, the ranking and the verdict
must all quote the same signed number, and the footnote says which one it is.
- Verdict per metric: down / held / up against a stated noise floor (1.5% of
the baseline), because a first-serve percentage over a set is ~40 serves and
a radar gun is quoted to ±2 kph — under that the two windows are arguing
about one ball. Panels are ranked by that signed decline percentage (not by
absolute difference) and the steepest decliner is badged; the card's headline
sentence is "N of M metrics are below the opening baseline ..., steepest X at
−Y%".
- Only readings after the cut, none inside the opening window (a metric a feed
starts tracking from set 3): the sr-only sentence states the mean after the
cut and says there is nothing to compare it with. "Nothing measured after the
cut" is reserved for the case where that is true.
- A metric that shipped no readings at all is NAMED in the footnote rather than
silently omitted; readings with a non-finite minute or value, or a repeat of a
minute already taken, are dropped and counted (two readings at one minute are
a contradiction, and the mean of a contradiction is a number nobody measured).
- A time ledger under the panels splits the measured span into opening window /
between / after the cut. Those shares are apportioned by LARGEST REMAINDER so
every cut still sums to exactly 100 — and each bar is exactly as long as the
integer printed beside it, never the raw fraction.
- Four first-class branches: loading (a deterministic silhouette echoing panel +
baseline rule, aria-hidden, with an sr-only status line), empty (also used
when a ready payload has nothing drawable), error (+ retry only when onRetry
exists), ready.
Rendering & styling
- Semantic tokens only: metric i is var(--chart-{(i % 5) + 1}) — payload order,
not drawable order, so an empty metric cannot shuffle its neighbours' colours.
Opening window fill-primary/10 dark:fill-primary/20; the gap between baseline
and after-mean fill-destructive/20 dark:fill-destructive/30 when the metric
decayed, fill-primary/15 dark:fill-primary/25 otherwise; grid and set dividers
stroke-border; baseline rule stroke-muted-foreground; cut rule stroke-
foreground. Card is rounded-xl border bg-card.
- The panel's two y tick labels are the smallest and largest reading themselves,
positioned at their own values — not a rounded frame, so no label can name a
number the panel does not contain. Print precision comes from the spread of
that metric's readings and is shared by ticks, readouts and the data table.
- Width is measured with a ResizeObserver (disconnected on unmount and before
any rebuild); under a narrow plot the in-place tags are dropped and the set
bands fall back to "S3" and then to nothing, rather than overlapping.
- Cut chooser = native radios inside a fieldset with an sr-only legend (the
visible prompt is aria-hidden so it is not read twice), sr-only inputs and a
peer-checked / peer-focus-visible chip — real radiogroup semantics and arrow
keys for free. Transitions carry motion-reduce:transition-none, the skeleton
motion-reduce:animate-none.
- The svg is role="img" with the whole reading as its accessible name; every
number is repeated in an sr-only table (rank-sampled minutes, both ends kept).
Customization levers
- Panel density: panelHeight (40–160) and the LABEL_H / ROW_GAP constants —
a 40px panel with three metrics is a sidebar card, 110px with two is a report
figure.
- How many metrics: the form stays honest from one to about six panels; past
that split the card, since the shared axis is what a reader is scanning.
- Cut vocabulary: replace the candidate builder to offer thirds of the match,
a fixed "after 60 min", or a single cut supplied by the server via
defaultCutMinute; wire onCutChange to keep it in the URL.
- Verdict strictness: NOISE_FLOOR is the one number deciding down / held / up —
raise it for noisy amateur tracking, drop it for lab-grade radar.
- Blocks you can drop: the time ledger, the footnote, the in-plot baseline tag,
the set strip (omit sets entirely and the axis keeps working).
- Palette: the colour formula cycles five chart tokens; map a fixed token per
metric id instead when a metric has a house colour.
- Not just tennis: the same card reads a marathon (pace, cadence, heart-rate
drift), a boxing card by round, or an esports session — anything with a clock
and an opening reference.Concepts
- Opening-set baseline — each metric is compared with itself over the first
baselineWindowMinutes, in its own unit. That is what makes a card of percentages, kph and seconds readable at all: nothing is normalised into a made-up index, and the reference is the one every coach already argues from. - Shared clock, private scales — panels share the x axis and nothing else. The question the form answers is "did these sag at the same minute", so the minute must line up across panels; forcing a common y would flatten every metric except the largest.
- Chosen cut, not a fixed half — "after" is a reader's decision, offered as the cuts a scoreboard actually names (end of set 1, of set 2, …). Picking one re-derives every number on the card, so the same panel can answer "he faded after the opener" and "he only faded in the fifth".
- Signed decline, not raw delta —
higherIsBetterflips the sign so negative always means worse, and that signed number is the one printed beside every row; without it a player taking four seconds longer between points reads as an improvement, and the verdict, the badge and the percentage the card ranks by disagree with each other. The move itself is the difference between the baseline and the after-mean as printed —56.5% → 48.6%quotes−7.9 pts— because a row whose own subtraction fails costs the reader every other number on the card. - Noise floor before a verdict — a move under 1.5% of the baseline is called held, not decay. Ranking is by percentage of baseline because a 2 kph loss and a 2-point loss are not the same size, and the card says so in its own footnote.
- Largest-remainder apportionment — the minutes ledger floors every share and hands the leftover points to the largest fractional parts, so the three phases read exactly 100% at every cut. Per-share rounding would print 99% or 101% and quietly cost the reader's trust in every other number.
Surface Splits
A four-state surface-by-surface record card — win-loss bars with paired service and return sub-bars, each read against a tick at the player's own overall level, and the strongest and weakest surfaces called out above a match floor.
Doubles Formation
A four-state doubles serve-formation card — a real tennis, badminton or pickleball court in metres, one tab per formation with its usage share and win rate, and four player markers that glide to the new stance.