Review Turnaround
A four-state review-turnaround card — one lane per reviewer on one shared time axis, the wait for a first look nested inside the wait to merge, a p90 tail whisker, the pooled team median as a dashed rule, and an oversized headline that retargets to whichever reviewer you pick.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-review-turnaround.jsonPrompt
Build a React + TypeScript + Tailwind "ChartReviewTurnaround" card in plain HTML
boxes (no chart library, no SVG plot, no measurement) with zod. It answers one
question about a code review board — how long does a pull request wait before
anyone looks at it, and who is the queue waiting on — and it has to answer it
before anyone touches it.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string;
reviewers: { id: string; name: string; waiting?: number;
reviews: { firstReviewHours: number;
mergeHours?: number | null }[] }[] }.
- BOTH CLOCKS START AT THE SAME INSTANT: the moment the pull request became
ready for review. Say this loudly in the schema doc comment. It is the only
reason the two numbers can share an axis and be subtracted, and a feed that
measures mergeHours from the first review instead shortens every bar by
exactly the wait the chart exists to expose.
- mergeHours is nullable: a pull request that has not merged yet is not a merge
at zero. waiting counts pull requests with NO first review yet.
- Component props = z.infer of the schema plus sort: "turnaround" | "volume" |
"name" (default "turnaround"), minSamples (default 3), tailPercentile
(default 90, clamped 50-99), showMerge (default true), formatHours,
onSelectReviewer, onRetry, className and the native div props through
forwardRef.
- Ship a pure module beside the schema: inspectReviewTurnaroundData() for the
structural pass, percentileOf(), apportionPercent(), formatTurnaroundHours(),
speakTurnaroundHours(), axisFormatterFor() and buildReviewTurnaroundLayout()
returning the ranked rows, the pooled team figures, the flagged bottleneck,
the axis and the degenerate flags.
Behavior — the statistics, which are the product
- PERCENTILES BY LINEAR INTERPOLATION BETWEEN CLOSEST RANKS (Hyndman & Fan type
7, the R and NumPy default): h = (n - 1) * p, then interpolate between
sorted[floor(h)] and its successor with the successor index CLAMPED. Never
index an array with a fractional rank. State the method in the footer — two
dashboards using different percentile definitions disagree by hours on the
same data and neither says so.
- An empty sample has NO median. percentileOf returns null on zero
observations, and every consumer branches on it: a reviewer with nothing
reviewed prints an em dash, sorts last and takes no share, rather than
sorting as though their median were zero.
- THE TEAM REFERENCE IS POOLED, not a mean of medians. Pool every first review
in the window; someone who handled three pull requests must not weigh the
same as someone who handled thirty. Draw it as one dashed vertical rule
crossing every lane, labelled once above the first row.
- RIGHT CENSORING IS FIRST CLASS. Pull requests still waiting for a first
review are counted next to the name and excluded from every percentile,
and the footer says so. Folding them in at hours-so-far invents an ending;
dropping them silently flatters the queue, because the worst cases are
exactly the ones that have not finished.
- A MEDIAN NEEDS A SAMPLE. Only reviewers with at least minSamples reviews can
be named the slowest; the rest are still drawn, still counted, and say out
loud that they are under the floor. One unlucky pull request must not get
somebody blamed for the whole queue.
- The bottleneck is the slowest quotable reviewer STRICTLY ABOVE the team
median. On a board where everyone is identical that is nobody, and the card
says the wait is spread evenly instead of crowning an arbitrary row.
- SHARE OF REVIEWS USES LARGEST-REMAINDER APPORTIONMENT. Rounding each share
independently prints 99% or 101% on the next dataset; floor them all and give
the leftover points to the biggest fractional remainders, ties broken by
position. Say in the footer that the column adds to 100 — it is a claim the
reader can check.
- Every denominator is guarded. All durations zero is a real board (a bot that
approves on green): the team median is 0, so no ratio can be divided by it,
the axis falls back to a one-hour span, and nobody can sit above the median.
A single observation is also real: median equals p90, so no tail is drawn.
- The four states are first-class branches of one bg-card panel: a headline
placeholder over four stepped skeleton lanes (aria-hidden plus an sr-only
role="status"), an empty state that names the waiting count when there is
one, an error state carrying either the transport message or the specific
contract refusal plus a Try again button only when onRetry exists, and ready.
status="ready" with nothing reviewed falls through to the empty copy rather
than taking the median of an empty array.
- Interaction: each row is a toggle button with aria-pressed. Picking one
retargets the oversized headline to that reviewer's median and moves the
accent to their lane; picking it again returns to the team. A selected id
that no longer exists in the data simply reads as nothing selected — no
effect, no cleanup, no stale row.
Rendering & styling
- LAYOUT IS THE TRICK: every lane spans the full content width, with the name
above it and the meta line below it, so the shared axis needs no measuring
and the team-median rule sits at the same percentage in every row. No
ResizeObserver, no requestAnimationFrame, no timers — nothing to tear down.
Rows carry identical padding in every state so a selected row cannot shift
its own lane out of alignment with its neighbours.
- One lane, painted in this order: a hairline track in bg-border, the merge
span from 0 in a very light foreground alpha, the first-review span from 0 on
top of it, a centred whisker with an end cap from p50 to the tail, a two-pixel
notch at the median merge time, and the dashed team rule. The notch matters:
when the slow reviews are the ones still open the median merge time can land
BEHIND the median first review, and the fill alone would vanish under it.
Give any non-null span a 2px minimum width so an instant review reads as a
hairline at the origin rather than as missing data.
- COLOUR IS ONE ACCENT ON A NEUTRAL GROUND. Every lane is drawn in foreground
alphas; exactly one row per view carries var(--chart-3), and that is the row
the card is currently reading — the flagged bottleneck by default, the picked
reviewer after that. Never let colour carry it alone: the slowest row is also
ordered first, marked in words and named in the headline caption beside a dot
in the same accent. Everything else is bg-card / bg-muted / border /
muted-foreground. No hex, no gradients.
- HIERARCHY COMES FROM SIZE, NOT FROM BOXES. One oversized tabular numeral, one
small label under it, one muted caption under that, then a generous gap
before the lanes. Do not wrap the sub-blocks in their own cards.
- The axis gets a SINGLE UNIT chosen from its right edge (minutes, hours or
days), while each individual reading keeps the friendliest unit for its own
size. A scale that prints 24h next to 2d stops being spaceable; a headline
that prints 0.4d instead of 9.1h stops being human.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — reviewer and review counts, the waiting tally, the team median and
tail, who is slowest and by what multiple, the slowest-to-fastest spread and
the method. Each row button's accessible name is its own visible text plus a
short sr-only clause naming what the bare number means, so the visible label
is always contained in the accessible name. Lanes are aria-hidden geometry.
Below the rows an sr-only WRAPPER DIV holds a real table: one row per
reviewer with count, share, median and tail to first review, the multiple of
the team median, median to merge and the waiting count. Put sr-only on the
wrapper, never on the table — CSS width is only a lower bound for a table
box, so width:1px does not hold one back and a narrow viewport picks up
hundreds of px of horizontal scroll. An sr-only role="status" announces the
new reading after a pick, and stays empty until the first user action so
nothing is announced on mount.
- Motion: the only animation is the skeleton pulse and a colour transition on
hover and selection, both with motion-reduce variants. Nothing about reading
the chart depends on motion.
Customization levers
- minSamples is the credibility dial: 3 for a fortnight, 5-8 for a quarterly
review, 1 only when you are willing to name someone off a single pull
request.
- tailPercentile: 90 is the default slow tail; 75 keeps the whisker close to
the box for a calm tile, 95-99 exposes the worst week of a long window.
- showMerge={false} drops the merge span, its notch and its legend entry,
leaving a pure wait-for-a-first-look card — the densest form of this tile. It
has to reach the layout too, so the axis is scaled from the first-review
medians and tails alone: a week-long merge must not set the right edge, or
pick the axis unit, for a mark nobody can see.
- sort: "turnaround" leads with the bottleneck, "volume" turns the card into a
load-distribution read, "name" makes it a stable roster you can scan the same
way every week.
- formatHours re-points every printed duration at once (business hours, working
days, sprints) and takes over the axis too.
- Accent: var(--chart-3) is chosen so the loud row reads as a warning in both
themes. Re-point it at var(--chart-1) for a neutral in-focus highlight when
the card is used for load rather than for blame.
- Interaction: onSelectReviewer lifts the focused id into a parent, so the same
pick can filter a pull request table beside the card. There is no tooltip and
no hover readout on purpose — the headline is the readout.
- Copy: the method footer is the honesty budget. Keep the percentile method and
the censoring note whatever else you cut.Concepts
- Wait split — every review is cut at the first look: the near segment is time nobody had picked it up, the far segment is time it spent actually being reviewed. Drawing them nested on one axis rather than as two separate charts is what makes the queue and the conversation comparable at a glance, and it is why both clocks must start at the same instant.
- Pooled reference — the dashed rule is the median of every first review in the window, not the average of the per-reviewer medians. When load is uneven the two differ by hours, and only the pooled one answers what a pull request on this board actually waits; a light reviewer would otherwise pull the reference around as hard as a heavy one.
- Right-censored queue — pull requests with no first review yet are counted next to the name and excluded from every percentile. Their wait has not ended, so it has no value to rank; including them at hours-so-far invents an ending, and dropping them silently makes a blocked queue look fast because the worst cases are precisely the unfinished ones.
- Quotable median — a row is only allowed to be named the slowest once it clears the sample floor. Below it the numbers are still drawn and still counted, and the row says so; naming a bottleneck off two pull requests is how a dashboard starts an argument that its own data cannot support.
- Single focal accent — exactly one row per view is saturated, and it always means the same thing: this is the row the card is reading. It starts on the flagged bottleneck and follows the selection afterwards, so the loud colour never has to compete with a second loud thing for the reader's attention.
- Focus retargeting — picking a row moves the oversized number from the team median to that reviewer's, and picking it again moves it back. The one number that matters is a viewfinder rather than a fixed statistic, which is what lets the card carry a per-person distribution without adding a tooltip.
OKR Tree
A four-state objective tree whose parents are the weighted rollup of their children, with sibling weights that do not sum to 1 either normalised out loud or drawn at the scale they actually describe.
Focus Time
A four-state per-person day timeline that derives focus blocks from the gaps a calendar leaves, splits them into usable time and scraps at a stated minimum, and leads with the fragmentation score that falls out of the split.