Season Results
A four-state season-results strip — one surface-coloured column per event on a fixed R64-to-W round ladder, title rings, month ticks that keep the off-season visible, a win–loss headline and per-surface event shares.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-season-results.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSeasonResults" card — one tennis
season as a strip of event columns on a fixed round ladder, 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";
season: string; player?: string;
summary: { wins: int >= 0, losses: int >= 0, titles: int >= 0 };
events: { id, name, month: 1-12,
surface: "hard" | "clay" | "grass" | "indoor",
reached: "R64" | "R32" | "R16" | "QF" | "SF" | "F" | "W" }[] }
with a refine that event ids are unique. Component props = z.infer of that
schema plus title?, rungHeight? (14-32 px, default 20), onRetry?,
emptyState? and className. No parallel hand-written interface.
- Semantics, stated in the schema's JSDoc: `summary` is carried as its own
numbers, NOT derived from the events — draw sizes and byes differ, so
"reached the QF" does not say how many matches were won. The only derivable
cross-check is the title count.
Behavior
- Four first-class branches inside one bg-card panel:
- loading: a skeleton with the ready silhouette (headline, legend strip,
the ladder-and-columns plot at its true height for the rung size, tick
line), aria-hidden, plus one sr-only role=status.
- empty: a waiting mini-ladder and copy — season not started.
- error: a destructive message and a "Try again" button rendered only when
onRetry is supplied.
- ready: win–loss headline from the summary, surface legend with event
counts and shares, the twelve-lane strip, month ticks, footnote.
- Layout maths live in one exported pure function: events land in twelve
calendar lanes by month; a fractional or out-of-range month is repaired
(rounded, clamped into 1-12) and counted; an unreadable month or round
drops the event and is counted too — both counts are REPORTED under the
chart, never swallowed. Per-surface event counts get whole-percent shares
apportioned by largest remainder so they sum to exactly 100.
- Cross-check: the strip's "W" count is compared against summary.titles, and
a mismatch prints a plain sentence saying the record and the event list
were counted separately — never silently trusting either.
- A month with no events still shows its tick: the off-season is visible as
silence, not collapsed away.
- Interaction: every event column is one focusable full-height lane in a
roving-tabindex walk — one tab stop for the card, arrows/Home/End move
month-major through the events, hover or focus shows a floating tooltip
(event, month, surface, round reached) positioned over the column's month
and clamped inside the plot. Pointer-leave hands the tooltip back to the
focused column.
Rendering & styling
- The y axis is a FIXED ordinal ladder, R64 at the bottom to W at the top,
one gridline and one left-gutter label per rung; column height =
rank × rungHeight, so a column's top edge reads directly against the round
it names. The ladder never rescales to the season's best result.
- Columns wear their surface's token from one fixed map shared with the
head-to-head ledger: hard var(--chart-1), grass var(--chart-2), clay
var(--chart-3), indoor var(--chart-4) — never assigned by index. Titles
("W") get a small ring (border-foreground on bg-card) straddling the
column's top; the legend carries a ring sample labelled "title".
- Twelve equal month lanes in a CSS grid; events sharing a month split their
lane side by side, capped at a comfortable column width. Month ticks print
single letters (J F M A M J J A S O N D), aria-hidden — full month names
live in tooltips, aria sentences and the sr table.
- Headline "51–11 · 3 titles · 14 events" in tabular-nums text tokens; no
text ever wears a surface colour.
- Accessibility: the strip is role=group named by a full-text summary; each
column is role=img with a one-sentence label ("Wimbledon, July, on grass:
reached the semifinals."); an sr-only table repeats every event;
focus-visible ring on columns; hover transitions carry
motion-reduce:transition-none and skeleton pulses
motion-reduce:animate-none.
- Semantic tokens only: bg-card, bg-muted, border, border-foreground,
text-muted-foreground, text-destructive, bg-popover, var(--chart-1..4).
cn() merges className; remaining props spread on the root.
Customization levers
- Ladder vocabulary: ROUNDS and ROUND_RANK define the ordinal axis — swap in
"R128" below R64 for slam-only seasons, or a shorter ladder for an
exhibition series; keep it fixed rather than derived from the data.
- Density: rungHeight (14-32) sets the plot's height; the twelve lanes and
the 26px column cap set how many same-month events stay comfortable.
- Calendar policy: the twelve fixed lanes are the calendar year — repurpose
to a 10-lane academic season by changing MONTH_TICK/MONTH_NAME and the
lane count together.
- Palette: surfaces read from one fixed four-token map — remap tokens to the
host theme but keep columns and legend reading from the same constant.
- Header policy: the headline trusts the summary; swap in a derived
"titles-only" headline if your feed carries no win-loss record, and keep
the title cross-check either way.Concepts
- Ordinal height, fixed ladder — the y axis is rounds, not numbers: R64 to W are rungs a run climbs, so column height is "how deep", never "how much". The ladder never rescales to the season's best result — a quarterfinal is the same height in a title-laden season and a lean one, which is what makes two seasons comparable side by side.
- Calendar lanes keep the silence — events land in twelve fixed month lanes, and a month with no tournament still shows its tick. Collapsing empty months would make an injury gap look like a busy fortnight; the off-season is information.
- Summary carried, not derived — draw sizes and byes differ, so rounds reached cannot be converted into match wins: the win–loss headline is the feed's own record, printed verbatim, and the surface strip honestly counts events instead of wins. The one thing rounds do prove is titles — so the ring count is cross-checked against the summary, and a mismatch is printed rather than reconciled in secret.
- Ring for the title — "won the title" and "lost the final" are one rung apart but a career apart; the ring on top of a full-height column marks the difference redundantly with height, so a title still reads at a glance in a season full of finals.
- Fixed surface palette — hard, clay, grass and indoor each own one chart token shared with the head-to-head ledger, never assigned by index over whatever surfaces the season visited — so the clay swing is the same colour on every card in the report.
- Roving focus with a tooltip echo — the whole strip is one tab stop; arrows walk the events January to December, and the floating tooltip is the pointer's visible echo of the sentence each column already announces to screen readers.
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.
Front Wall Targets
A four-state squash wall map in hand-rolled SVG — the front wall drawn face-on from WSF markings (hatched tin, service line, out line), every strike a dot whose colour AND shape carry the shot type, tin and out strikes hollow, with largest-remainder type shares and a repair guard that keeps heights honest to their calls.