Charts

Tiebreak Ledger

A four-state tiebreak ledger — every tiebreak as a point-by-point strip with the serve above or below the line, mini-breaks hatched, plus a selectable scope driving the record, the hold rates and a largest-remainder split of where the points went.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartTiebreakLedgerData,
  ChartTiebreakLedgerSide,
  ChartTiebreakLedgerTiebreak,
} from "./chart-tiebreak-ledger.contract"

export interface ChartTiebreakLedgerProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartTiebreakLedgerData {

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartTiebreakLedger" card — every
tiebreak of a match (or of a rivalry) as one point-by-point run strip per
tiebreak, drawn in hand-rolled CSS grid (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;
    tiebreaks: { id, setLabel, label,
                 points: { server: "a" | "b"; winner: "a" | "b" }[] }[] }
  with a refine that tiebreak ids are unique. Component props = z.infer of
  that schema plus title?, laneHeight? (8-20, default 12), onRetry?,
  emptyState? and className. No parallel hand-written interface.
- The payload carries EVENTS, never aggregates: a point knows only who served
  it and who won it. Scores, mini-breaks (winner !== server), the tiebreak
  record, serve-hold rates and the four-way split are all derived from that
  one array, so the picture and the numbers cannot drift apart.
- Array order is the axis: first point first. No target score is declared —
  a 7-4, a 9-7 and a 10-8 match tiebreak all draw honestly.

Behavior
- Four first-class branches inside one bg-card panel:
  - loading: a skeleton with the ready silhouette (headline, four strips of
    different lengths on one scale, split bar), aria-hidden, plus one
    sr-only role=status.
  - empty: two waiting lanes with the serve divider between them, and copy
    explaining what will appear.
  - error: a destructive message and a "Try again" button rendered only when
    onRetry is supplied.
  - ready: headline, key, the strips, a readout line, the split block, a
    footnote and an sr-only table.
  - A ready payload with no tiebreaks, or with tiebreaks that carry no
    points at all, falls back to the zero state and says which happened —
    including the case where points DID arrive and every one of them was
    dropped for naming somebody who is not in the match, which is the one
    branch the drop notice under the chart never reaches.
- Ledger maths live in one exported pure function returning rows (marks with
  their running score, a four-way tally, the score, the winner), the pooled
  totals, the tiebreak record, the shared scale and a repair count. Points
  naming somebody who is not in the match are dropped and the drop is
  REPORTED under the chart, never swallowed.
- Scope selection: the strips are a role=radiogroup whose first option is
  "All" and whose other options are the rows themselves. The checked option
  scopes the headline, the split bar, the hold rates and the verdict —
  clicking a row and arrowing onto it do exactly the same thing. Roving
  tabindex: one tab stop for the whole card, arrows wrap, Home/End jump.
  The pooled scope is its own value (null, resolved to option index 0),
  never a magic id like "all" — ids come from the payload, so a tiebreak
  legitimately called "all" would otherwise shadow the pooled option and
  leave the tab stop on a radio that is not the checked one.
- Pointer enrichment only where it costs no second tab stop: hovering a mark
  writes "Set 3 · point 11 of 16 · X serving · Y wins it (mini-break) · 6-5"
  into a reserved readout line, which otherwise holds the idle hint;
  pointer-leave on the group clears it.

Rendering & styling
- ONE shared scale for every row: the longest tiebreak in the payload sets a
  CSS grid of N equal columns, and a row fills only its own first columns —
  so one point is exactly the same width everywhere and a strip's length IS
  its point count. The row's serve divider is sized with the same arithmetic,
  calc((100% + gap) * used / N - gap), never a pixel guess.
- Two channels per mark, both read off the same point record: it sits in the
  upper lane when player A served and the lower lane when B did, and takes
  var(--chart-1) or var(--chart-2) by WHO WON it. Lane and fill disagreeing
  is a mini-break, and those marks are additionally hatched with a
  repeating-linear-gradient of the player token against var(--card) — never
  a low-alpha wash, which survives a white card and vanishes on a dark one.
- Split bar: the four ways a point can end (A held, A mini-break, B held,
  B mini-break) partition the scope exactly, so their percentages must total
  100 — apportion them by LARGEST REMAINDER (floor, then hand the leftover
  to the biggest fractional parts) and size each segment by the very
  percentage its legend row prints. Naive per-segment rounding prints 99 or
  101 on real data.
- Numbers: tabular-nums everywhere, scores read player A first in the columns
  and the headline, hold rate = held / served as a whole percent with a "—"
  when nobody served.
- Accessibility: each radio carries a one-sentence label (score, mini-break
  point numbers, serve points won); the strip inside it is aria-hidden
  decoration; the split bar is a role=img with a full-sentence label that
  names the scope but does NOT reuse the visible caption (the caption ends
  in the same point count the sentence opens with, and screen readers then
  hear it twice); an sr-only table repeats every figure; focus-visible ring
  on every option; 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, ring, var(--chart-1), var(--chart-2), var(--card) for the
  hatch. cn() merges className; remaining props spread on the root.

Customization levers
- Row vocabulary: rows are whatever the feed sends, in the order it sends
  them — four tiebreaks of one match, or one row per tiebreak of a season
  rivalry; setLabel is the compact left label and label the line under it.
- Density: laneHeight (8-20) and the column gap set how fat a point reads;
  drop the second label line, or the readout line, for a compact variant.
- Scope model: keep the radiogroup but change what the scope drives (e.g. a
  win-probability sparkline instead of the split), or hard-scope to "all"
  and render the rows as plain non-interactive strips.
- Split vocabulary: the four-way split can collapse to two (points won by
  each player) or grow to six (add aces / returns) — whatever the categories,
  keep them a true partition and keep the largest-remainder apportionment.
- Palette: players take var(--chart-1) and var(--chart-2); remap freely, but
  keep one constant feeding marks, chips, split segments and score cells, and
  keep the mini-break hatch reading the card background.
- Sport: nothing here is tennis-specific — a padel or pickleball tiebreak,
  a badminton 20-all run-out or a squash tiebreak fits the same contract as
  long as serve and winner are known per point.

Concepts

  • Event stream, not a tally — the contract carries only server and winner per point, so the strip, the score, the mini-break count, the hold rate and the record are one walk of one array; a card built on pre-aggregated counts can print a total that its own picture contradicts.
  • Two channels, one record — vertical lane encodes who served, fill encodes who won. A mark whose colour disagrees with its lane is by definition a mini-break, which is why the hatch is redundant reinforcement rather than a third fact to keep in sync.
  • Shared point scale — the longest tiebreak sets the column grid for every row, so a strip's length is its point count and a ten-point match tiebreak visibly outruns a 7-4. Per-row scaling would make every tiebreak the same width and quietly delete the comparison.
  • Scope as selection — the rows are a radio group, so pointing at a tiebreak and arrowing onto it do the same thing: re-scope the summary. That is a different interaction contract from a hover tooltip, which reveals without changing state.
  • Largest-remainder shares — the four ways a point can end partition the scope, so their percentages are apportioned by largest remainder and each segment is sized by the exact number its legend prints; rounding each share on its own totals 99 or 101 and the bar stops being a whole.
  • Mini-break arithmetic — a tiebreak's margin is twice the mini-break difference plus the difference in points served, which is why the card puts mini-breaks next to the record instead of hiding them in a tooltip.

On This Page