Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartSeasonResultsData,
  ChartSeasonResultsEvent,
  ChartSeasonResultsRound,
  ChartSeasonResultsSurface,
} from "./chart-season-results.contract"

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

Installation

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

Prompt

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.

On This Page