Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartH2hHistoryData,
  ChartH2hHistoryMeeting,
  ChartH2hHistorySurface,
} from "./chart-h2h-history.contract"

export interface ChartH2hHistoryProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartH2hHistoryData {

Installation

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

Prompt

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.

On This Page