Head-to-Head History
A four-state rivalry ledger — one row per meeting on a center-spine timeline with the score and a surface chip on the winner's side, a running tally with per-surface splits, and the closing streak named in the header.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-h2h-history.jsonPrompt
Build a React + TypeScript + Tailwind "ChartH2hHistory" card — a two-player
rivalry ledger drawn as a center-spine timeline in plain HTML/CSS (no chart
library) with zod for the contract.
Contract
- A zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
players: { a: { name }, b: { name } };
context?: string;
meetings: { id, year: int, event, round,
surface: "hard" | "clay" | "grass" | "indoor",
winner: "a" | "b", score }[] }
with a refine that meeting ids are unique. Component props = z.infer of that
schema plus title?, onRetry?, emptyState? and className. No parallel
hand-written interface.
- Semantics, stated in the schema's JSDoc: meetings arrive CHRONOLOGICAL,
oldest first — the tally and the closing streak are computed off that order.
Round is free vocabulary ("F", "SF", "RR"); score is a verbatim string
("6-4 3-6 7-6(5)", "6-3 2-1 ret."), never parsed.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: a skeleton with the ready silhouette (tally headline, legend
strip, spined rows with side-alternating dots), aria-hidden, plus one
sr-only role=status.
- empty: the spine with waiting muted dots and copy — these two have never
met.
- error: a destructive message and a "Try again" button rendered only when
onRetry is supplied.
- ready: tally headline, surface legend/splits strip, the timeline, a
reading footnote. A ready payload with zero meetings falls back to the
empty body.
- Tally maths live in one exported pure function returning the a/b totals,
per-surface splits (fixed surface order, only surfaces actually played) and
the closing streak counted backwards from the last meeting.
- The header folds three facts into one line without repeating a name: who
leads (or "all square"), and who holds the closing streak — "X leads and has
won the last 3 meetings", with special wording when one player has won every
meeting or there has been only one.
- Interaction: every row is one focusable element in a roving-tabindex list —
one tab stop for the card, arrows/Home/End move between meetings, and hover
or focus shows a floating tooltip (event, round, winner d. loser, surface,
full score). The hit target is the whole padded row. Pointer-leave hands the
tooltip back to the focused row instead of blanking it.
Rendering & styling
- Center-spine layout: a 3-column grid per row (side, 1.25rem spine cell,
side) over one absolutely-positioned vertical bg-border line. The score (and
the surface chip as the spine node) sit on the WINNER's side — player a
left, player b right — and the muted "year · event · round" line sits on the
loser's side, so a streak reads as a run of dots down one side before any
number confirms it.
- Surface is a small square chip in a FIXED token mapping used by chip nodes
and legend alike: hard var(--chart-1), grass var(--chart-2), clay
var(--chart-3), indoor var(--chart-4) — never re-derived from whichever
surfaces the rivalry contains. The legend strip doubles as the per-surface
mini-tallies ("Hard 4–1", a's wins first).
- Scores print in tabular-nums text tokens; meta lines in muted text tokens;
no text ever wears a series colour.
- Tooltip: a fixed-width bg-popover card floated above the row, position
clamped inside the row with CSS clamp(); aria-hidden because the row's own
aria-label already says the same sentence.
- Accessibility: the list is role=group named by a full-text summary; each row
is role=img with a one-sentence label ("2024 Cincinnati, SF, on hard: X beat
Y 7-5 6-4."); an sr-only table repeats the whole record; focus-visible ring
on rows; hover transitions carry motion-reduce:transition-none and skeleton
pulses motion-reduce:animate-none.
- Semantic tokens only: bg-card, bg-muted, border, text-muted-foreground,
text-destructive, bg-popover, var(--chart-1..4). cn() merges className;
remaining props spread on the root.
Customization levers
- Row vocabulary: rounds and scores are printed verbatim — feed Davis Cup
rubbers, exhibition sets or best-of-five majors without touching the
component.
- Meta density: the loser-side "year · event · round" line can drop the year
(redundant when meetings cluster) or gain the city; the tooltip carries the
full line either way.
- Palette: surfaces read from one fixed four-token map — remap the tokens to
the host theme but keep chip, spine node and legend reading from the same
constant, and never assign colours by array index.
- Header policy: the lede folds leader and streak into one sentence; swap it
for separate lines if the card has vertical room, but keep the tally "a – b"
with a's number first to match the spine's sides.
- Density: row padding and the 1.25rem spine cell set the card's height; the
timeline itself has no fixed height and grows one row per meeting.Concepts
- Winner-side encoding — the spine answers "who won?" positionally: the score and the surface chip hang left when player a won, right when player b won. Ownership is read before it is counted, and a run of dots down one side is the streak the header then names.
- Ledger, not timeline scale — rows are evenly spaced meetings, not proportional time: a rivalry is a sequence of discrete matches, and "the next meeting" matters where "fourteen months later" mostly does not. When true elapsed time is the story, that is an event timeline, not this card.
- Fixed surface palette — hard, clay, grass and indoor each own one chart token everywhere on the card, never assigned by index over whatever subset arrived: a pair who have only met on clay still get clay's colour, so two rivalries side by side stay comparable.
- Tally reconciled with its rows — the 7–5 headline, the per-surface splits and the streak are all computed from the same meetings array the rows draw, in one exported pure function; nothing on the card can disagree with the spine.
- Verbatim scores — "7-6(8)", "2-1 ret." and other tennis notation are printed as handed, never parsed or reformatted: the score string is a quotation from the record, and mangling a retirement into numbers would falsify it.
- Roving focus with a tooltip echo — the whole card is one tab stop; arrows walk the meetings oldest to newest, and the floating tooltip is the pointer's visible echo of the sentence each row already announces to screen readers.
Padel Wall Play
A four-state padel wall-play ledger — one bar per wall-interaction class (direct, back wall, side wall, double wall) sized by shot count on a shared scale, split into points won and lost, with usage shares, win rates and a bounce glyph per class.
Return Position
A four-state return-position map — where the returner stood at contact against first and second serves, on a to-scale ITF baseline view with per-serve median-depth rules, inside/behind shares, per-dot tooltips and a keyboard walk.