Cycle Time Scatter
A four-state cycle time scatter that derives its own p50, p85 and p95 by nearest rank, spends its single accent on the focus percentile and the items that broke it, and measures a stated service level against the number the data actually supports.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-cycle-time-scatter.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCycleTimeScatter" card in plain SVG
(no chart library) with zod. One dot per completed work item: x is the day it
finished, y is how many days it took. The reason this chart exists is that the
percentiles are DERIVED FROM THE DOTS ON SCREEN — a build that accepts p50, p85
and p95 as props, or hardcodes them, has shipped a decorative scatter plot.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string;
items: { id: string; label: string; completedAt: string;
cycleTimeDays: number; blocked?: boolean }[];
serviceLevelDays?: number; timeZone?: string }.
- items are COMPLETED work only. Say so loudly in the schema doc comment:
feeding in open items with "days so far" hides the slow work that has not
landed yet, and every percentile then comes out optimistic. Unfinished work
belongs in an aging-WIP view, not here.
- cycleTimeDays is finite and non-negative. Zero is a real value (same-day
work), not missing data. Fractions are allowed.
- completedAt is either a bare YYYY-MM-DD or a full ISO instant.
- serviceLevelDays is the team's PROMISE, deliberately not a percentile: the
chart's job is to measure that promise against what the data supports.
- timeZone lives in the contract, not in a render prop, because it decides
which calendar day a late-evening completion belongs to — it changes the
reading, not merely the label.
- Component props = z.infer of the schema plus height (default 300, clamped
200-560), percentiles (default [50, 85, 95]), focus (default 85), formatDate,
onRetry, className and the native div props through forwardRef.
- Ship a pure module beside the schema: inspectCycleTimeScatterData() for the
structural pass, calendarDayOf(), percentileOf(), apportionPercents() and
buildCycleTimeScatterModel(), which returns placed points, the percentile
values, the band partition and the derived readings. Have the builder return
null on an empty sample so every caller is forced through the empty branch
instead of dividing by n = 0.
Behavior — the maths, which is the product
- PERCENTILES BY NEAREST RANK, stated in the UI. The value at 1-based rank
ceil(p/100 x n) of the ascending sample. Pick it over interpolation for three
reasons and say which one you picked: it answers the question a forecast
actually asks ("quote a number at least 85% of our work landed inside") as a
guarantee about this sample; it always returns a duration that really
happened; and its index is an integer by construction, where an interpolating
definition asks for element 16.15 of an array and invites exactly the
floor/round/NaN bugs this chart is famous for.
- FOUR BANDS PARTITION THE SAMPLE: at or under p50, p50 to p85, p85 to p95,
above p95. Classify by "first band whose upper edge the value fits under",
which makes ties fall into the lower band and keeps the partition exact.
Apportion the percentages with LARGEST REMAINDER (Hamilton) — floor each
share, hand the leftover points to the biggest fractional parts. Rounding
each share on its own is what prints 55 / 33 / 11 = 99% and leaves a reader
hunting for the missing bucket.
- CALENDAR DAYS ARE INTEGERS. Turn every completedAt into a day number (days
since the epoch) and do all spans, ticks and buckets on those integers. A
bare YYYY-MM-DD names no instant, so no zone may shift it; an ISO instant is
bucketed into the day it falls on in the feed's zone. Then format every
printed date back OUT of that same integer. Two failures die here at once:
DST, which makes a local day 23 or 25 hours long and would otherwise shift a
dot into the neighbouring column, and the classic of slicing a UTC ISO string
to display what the reader takes for a local date.
- SERVICE LEVEL, if given: draw the line, count how many items met it, and
print that share NEXT TO the value the data supports at the focus rank. When
the promise is shorter than the evidence, say so. This comparison is the
whole reason a team looks at this chart before renewing a commitment.
- DEGENERATE DATA, each answered deliberately: zero items goes to the empty
branch; one item makes every percentile that one value, and the card says a
single observation is a sketch; all values identical collapses the three
lines onto each other and puts 100% into the first band; all items finished
on one day gives a zero-width x span, so draw one centred column rather than
a sliver; a maximum of zero days gives a one-day axis rather than a
zero-height one to divide by. Check every denominator.
- COLLISIONS ARE FANNED, NOT JITTERED. Group dots by (day, rounded y) and
spread each group symmetrically by a deterministic offset bounded by the
column width. A reader who counts eleven dots for fourteen items has been
told the wrong thing, and random jitter would move the dots on every render.
- The four states are first-class branches of one bg-card panel: a scattered
skeleton (aria-hidden plus an sr-only role="status"), an empty state, an
error state carrying either the transport message or the specific contract
issue plus a "Try again" button only when onRetry exists, and ready. A ready
payload with no items falls through to the empty copy.
- The structural pass refuses rather than guesses: duplicate ids, non-finite or
negative cycle times (a negative duration means the two timestamps arrived
the wrong way round), an unknown time zone, and a completedAt that is not a
day the calendar has — 2026-02-30 passes a shape check and then rolls
silently into March in a naive parser.
Rendering & styling
- Layout: the viewBox always equals the width the geometry was computed for, so
a not-yet-measured chart is scaled to fit rather than clipped. Measure with a
LOCAL ResizeObserver behind a callback ref, not useRef + useEffect: the plot
lives behind four branches, so the node is replaced rather than merely
resized when the status changes and an effect would never re-run to notice.
Coalesce commits into one animation frame — which is also what avoids
"ResizeObserver loop completed with undelivered notifications" — and tear
down observer and frame both when the node is replaced and on unmount.
- HIERARCHY BY SIZE, NOT BY BOXES. The focus percentile is an oversized numeral
with a small rank caption under it and a muted date-range caption beside it;
everything else is 10-12px. Generous whitespace, one soft-rounded card, no
nested panels.
- ONE ACCENT, var(--chart-1), spent on exactly one idea: the focus percentile
line and the dots above it. Everything else is neutral — dots in
fill-foreground at low opacity, the other percentile lines dashed in
stroke-muted-foreground, the service level dashed in stroke-foreground,
gridlines in stroke-border. Colour is never the only encoding: the tail dots
are also the ones above the accent line, counted in the tail chip, named in
the summary and marked in the table. No gradients, no glow, no glass.
- BLOCKED IS A SHAPE, NOT A SECOND HUE: draw those items as hollow rings. It
survives greyscale and it does not compete with the accent.
- Percentile labels live in a right-hand gutter, outside the data rather than
over it. Place the focus label first and never drop it; then the service
level; then the remaining ranks, each skipped if it would print within ~13px
of one already placed. Every line is still drawn whether or not its label
fits.
- With no cursor the readout points at the SLOWEST item and rings its dot, so
the card explains itself before anyone touches it.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — sample size and window, each percentile, the slowest item, how many
finished above the focus rank and how many of those were blocked, the service
level comparison, any degenerate note and the band split. The <svg> is
aria-hidden and focusable="false". Below it an sr-only WRAPPER DIV holds a
real table, one row per item. 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 375px viewport picks up hundreds of px of horizontal scroll.
- Keyboard: the band chips are the keyboard surface. Tab reaches each chip;
focusing one lands the cursor on the first item in that band; ArrowLeft /
ArrowRight walk the items inside it and Home / End jump to its ends; Enter or
Space pins the band (aria-pressed) and dims the rest. Put the chip's extra
detail in an sr-only span INSIDE the button rather than in an aria-label, so
the visible text stays part of the accessible name (WCAG 2.5.3). The visible
readout is aria-hidden and mirrored into an sr-only role="status" that speaks
only when the cursor was moved by keyboard — a hover a screen-reader user
never made must not talk. A pointer leaving the plot must not clear a
keyboard cursor.
- Motion: the only animation is an opacity transition on dimming plus the
skeleton pulse, both with motion-reduce variants. Nothing about reading the
chart depends on motion.
Customization levers
- percentiles: [50, 85, 95] is the Kanban default. [50, 70, 85, 95] suits a
team negotiating tiers of promise; [85] alone turns the card into a single
forecast tile. The band partition follows the list, so N ranks always give
N+1 chips.
- focus re-points the oversized number, the accent line and the "above the
promise" count without touching the data — set it to 50 for a card about the
typical case, 95 for a card about the tail.
- serviceLevelDays: omit it for a pure descriptive view; supply it whenever the
team has a promise on record, because the gap between the promise and the
supported number is the finding.
- height is the density dial; 200 makes a dashboard tile, 560 an analysis view.
- unit re-points every printed noun (tickets, stories, PRs, orders) and
formatDate the axis labels — the replacement must format in UTC, since the
Date it receives is midnight of a day number.
- timeZone moves the day boundary for a distributed board; leave it at UTC when
the feed already sends bare calendar days.
- Palette: only var(--chart-1) is used. Re-pointing it re-tones the whole
focal read at once; if you must colour by work type, add a second encoding
(shape or a small multiple) rather than spending the accent twice.
- Interaction: the readout is deliberately one line and there is no tooltip.
Wire a click on a dot to open that item, or lift the pinned band into a
parent to filter a table beside the chart.Concepts
- Percentile derived, never configured — the three reference lines are recomputed from whatever dots are currently in view, so filtering the feed to one squad or one month moves the lines with it. A component that takes p85 as a prop is a chart of somebody's opinion; this one is a chart of the work.
- Nearest rank — the quoted duration is the value at rank
ceil(p/100 × n), an integer index into the sorted sample. It buys three things: the answer is a guarantee about this sample rather than an estimate about a distribution, it is always a duration that really happened, and it never asks an array for element 16.15 — which is where interpolating implementations quietly return NaN or the wrong neighbour. - Band partition and largest remainder — every item lands in exactly one of the four bands, so the four percentages have to total 100. Flooring each share and handing the leftover points to the largest fractional parts is what stops the chips reading 55 / 33 / 11 and daring the reader to find the missing percent.
- Service level versus supported number — a service level is a promise somebody made; the focus percentile is what the work has actually been doing. Printing the two side by side turns "we say two weeks" into either evidence or a correction, which is the one conversation this chart is brought to.
- Censoring by exclusion — only completed items have a cycle time, so the slowest work in the system, the items still open, is by definition absent. The percentiles are therefore a floor rather than a full picture, which is why an aging-WIP view is the companion to this one and not a competitor.
- Collision fan-out — items that finished on the same day at the same duration are one dot on top of another. They are spread by a deterministic offset bounded by the column width, so a batch of fourteen release-day items stays countable, and unlike random jitter the picture does not change on every render.
Workload Balance
A four-state diverging load chart — every person measured out from their own capacity line, over-allocation running one way and idle headroom the other on one shared scale, with a tolerance band, an aggregate meter that the per-person picture argues against, and a greedy plan naming who to move work from and to.
Aging WIP
A four-state aging chart for a team board — every item still in progress plotted by column against how many days it has been in flight, read against the percentile bands this board's own finished work actually hit.