Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  axisFormatterFor,
  buildReviewTurnaroundLayout,
  formatTurnaroundHours,
  inspectReviewTurnaroundData,
  speakTurnaroundHours,
  type ChartReviewTurnaroundData,
  type ChartReviewTurnaroundSort,
  type ReviewerTurnaround,

Installation

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

Prompt

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.

On This Page