Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartStaminaDecayData,
  ChartStaminaDecayMetric,
  ChartStaminaDecayPoint,
  ChartStaminaDecaySet,
} from "./chart-stamina-decay.contract"

export interface ChartStaminaDecayProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-stamina-decay.json

Prompt

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 deltahigherIsBetter flips 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 printed56.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.

On This Page