Tournament Schedule · Order of Play
A four-state order-of-play sheet — courts as columns against a vertical time axis, every match a block as long as its estimate, floating "not before" slots, live progress, and the double bookings and rest gaps the sheet implies.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-tournament-schedule.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTournamentSchedule" order-of-play
sheet with zod. No chart library: courts are CSS grid columns, time is a
vertical axis in pixels per hour, and every match is an absolutely positioned
block. Ship it as three files — component, zod contract, seeded mock.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
day: { tournament, label, timezone?, now? };
courts: { id, label }[];
matches: { id, courtId, start, estimatedMinutes, notBefore?, round,
event: "singles" | "doubles" | "mixed", players: string[],
status: "scheduled" | "live" | "finished" | "cancelled",
actualStart?, score? }[] }.
- Component props = z.infer of the schema plus title, pixelsPerHour,
defaultColourBy, showColourSwitch, onRetry, emptyState and className. No
hand-written parallel interface.
- Instants are ISO 8601 parsed with a local parser that reads a zone-less
datetime as UTC and prints it back unconverted: an order of play that says
13:00 must say 13:00 for every reader, and a chart that ran the string
through new Date(...) would hydrate to a different pixel per timezone.
day.now is injected — the component never calls Date.now().
- players are in seating order: the first half is one side, the second the
other (2 = singles, 4 = doubles). An odd count is listed flat rather than
invented into a pairing.
Behavior
- A pure layout builder turns the payload into blocks carrying minutes from
the window start, so the same layout is right at any scale and testable
without rendering. The window is the matches' own span snapped out to whole
half hours; hour gridlines are major, half hours minor.
- Anchor rule: a match with notBefore is a FLOATING block, drawn at the later
of start and notBefore — the earliest minute it can begin — dashed, tagged
"NB 14:00". When something already on that court is estimated to run past
that anchor, the block reports "court busy until about 14:15" and is inset
horizontally so both blocks stay readable rather than one hiding the other.
- Clash detection: group counted matches by player, walk each player's day in
anchor order carrying the LATEST estimated finish seen so far — not the
previous block's, or a long match that swallows a short one and is still
running when the one after it starts would be filed as a rest gap nobody
gets. Overlapping that running finish is a double booking (recorded on both
blocks with the other court and the overlap length, against the block that
actually owns the finish); otherwise it is a rest, measured from that finish
to the next anchor. Cancelled matches are excluded from court time, rest and
clashes — a walkover uses nothing, keeps a SOLID border, a muted rail,
struck-through names and a permanent "walkover" label on its first line, so
dashed never means two things at once.
- Live: the fill is a band in axis coordinates — from the minute play actually
started (actualStart, which may be later than the block's anchor) to the
minute it is now, both clamped to the block — so its lower edge lands exactly
on the now line and the shaded stretch is the minutes actually played.
Shading elapsed / estimatedMinutes instead would put the edge at a fraction
of the block, a y that is no instant at all on a match that started late.
The fraction stays the numeric read: (now − actualStart) / estimatedMinutes,
unclamped, so past the estimate the edge turns destructive and the block
prints "+6 min over". A live match with no actualStart shows no band rather
than one measured from a start time it did not keep.
- Colour switch (radiogroup, roving tabindex): round or draw. Each state
re-derives the legend, whose whole-per-cent shares of the counted court time
are apportioned by LARGEST REMAINDER so both states sum to exactly 100 —
per-slice rounding is how a three-round day prints 101.
- Four first-class branches: loading (a skeleton in the ready silhouette),
empty (a tennis court drawn from ITF metres — 23.77 × 10.97 m, service lines
6.40 m from the net, 1.37 m alleys — plus copy), error (message + retry when
onRetry exists), ready. A ready payload with nothing placeable falls back to
the zero state and says how many rows it lost and why.
- Nothing is dropped in silence: unreadable instants, repeated ids, unknown
courts and non-positive estimates are counted and reported under the sheet.
Rendering & styling
- Semantic tokens only. Each colour key takes var(--chart-N) once and spends
it three ways — the block's tint, its full-strength left accent bar and the
legend swatch — so a low-alpha wash can never be the only carrier of colour
on a near-black card. Surfaces are bg-card / border / muted-foreground;
clashes and overruns are destructive; the now line and the live ring are
primary.
- Text inside a block degrades by height, not by clipping: floor((height −
padding) / lineHeight) decides how many of line one, side one, side two and
the tail line are drawn. Line one is always drawn, which is why the state a
reader must not lose — the anchor, and "walkover" in place of the draw —
lives there rather than in the tail.
- One tab stop on the sheet: role="grid", rows are courts, cells are matches,
aria-activedescendant follows the cursor. Up/Down walk one court's matches
(time runs down), Left/Right jump to the nearest match on the court beside
it, Home/End reach the ends of a court. Every cell carries a full sentence
as its accessible name and the same sentences repeat in an sr-only table.
- The sheet scrolls horizontally below its natural width instead of crushing a
column, and because real focus never leaves the grid the cursor scrolls its
own cell into view on every move — otherwise the arrow keys walk onto columns
that are clipped and unreachable. An hour label within 12px of the now marker
is dropped rather than drawn under it.
- The pointer/keyboard readout reserves three single-line rows and truncates
each of them, so nothing under the sheet moves as the pointer crosses it; a
doubles block's rest line names the two tightest gaps and counts the rest,
with the full list left to the accessible name and the table.
Customization levers
- Density: pixelsPerHour (44–160) is the one scale knob — it sets block
heights, how many text lines survive and the whole sheet's height. Change
MIN_COLUMN_PX to trade columns against horizontal scrolling.
- Orientation: courts are columns because printed order-of-play sheets read
that way; swap the grid axis (rows = courts, time across) if you want the
gantt reading, keeping the same layout builder.
- Which sub-blocks: the three stat tiles, the hover readout, the legend and
the footnote are independent — drop any of them for a compact board without
touching the sheet.
- Colour keys: colour by round, by draw, or by anything else on the match by
changing the one key function; the tone index cycles five chart tokens, so
a fixed token per key (brand colours per draw) is a one-line swap.
- Domain: estimatedMinutes carries whatever your sport books — 85–105 minutes
for best-of-three singles, 60–80 for no-ad doubles, 25 for a padel round
robin — and round is free text, so qualifying, juniors and consolation draws
need no schema change.
- Time policy: the parser's "print the feed's clock" rule is one function;
replace it with a zone-aware formatter if your sheet is read across zones.Concepts
- Anchored to the earliest minute — a "not before" match has no start time, only a floor. The block is drawn at the later of
startandnotBefore, dashed, and when the court above it is still estimated to be busy it says so ("court busy until about 14:15") instead of pretending the slot is free. - The sheet's own contradictions — a schedule is a set of promises, and this one checks them. Each player's day is walked carrying the latest estimated finish so far, not just the block before, so a long match that swallows a short one and is still running when the next starts is caught as the double booking it is instead of being reported as rest; a genuine gap becomes that player's rest before the next match. Walkovers are excluded from all three reads, because nobody plays them.
- A live band is minutes, not a fraction — the shaded stretch of a live block runs from the minute play actually started to the minute it is now, clamped to the block and drawn on the same axis as the hour lines, so its edge lands on the now marker and covers exactly the minutes played.
elapsed / estimateis printed as a number instead of being used as a y coordinate: on a match that began 12 minutes late the two disagree, and only one of them is an instant. Past the estimate the edge stops at the block's end and turns destructive, so an overrunning match reads as late rather than as finished. - Largest-remainder shares — the legend partitions the day's counted court minutes into whole per cents that add to exactly 100 in both colour states; rounding each slice on its own is how a three-round day prints 101 and reads as a data error.
- One value positions, colours and labels a mark — a block's vertical place comes from the anchor it prints, its height from the estimate it states, and its colour from the very round or draw written on it, which is also the legend swatch and the accent bar. No mark can be filed under one key and painted under another.
- Court × time as a grid you can walk — one tab stop,
role="grid"with courts as rows and matches as cells: up and down step through a court's day because time runs down the sheet, left and right jump to the nearest match on the next court, and every cell speaks a full sentence including its players' rest and any clash.
GPU Utilization
A four-state cluster-utilisation card: a device-by-time SM occupancy strip, one memory line per device group against the HBM ceiling, and idle stalls drawn as bubbles sized by their duration — with the idle share, the longest stall and a wall-clock budget that totals exactly 100% derived in-component.
Draw Path
A four-state seeded route through a draw — one round per rail station with the projected opponent's rating and head-to-head, faint branches for whoever could arrive instead, Elo win chances compounded into a survival bar, and a tab panel for each round's whole field.