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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-aging-wip.jsonPrompt
Build a React + TypeScript + Tailwind "ChartAgingWip" card in plain SVG (no
chart library) with zod. An aging chart is not a cycle-time scatter with a
different x axis: everything on it is UNFINISHED, so every number is still
growing and every reading is an intervention, not a post-mortem. 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"; title: string;
caption?: string; asOf: string;
columns: { id: string; label: string }[];
items: { id, title, columnId, startedAt, blocked?, assignee? }[];
levels: { p: number; days: number; label? }[]; levelsBasis?: string }.
- columns are declared SEPARATELY from the items and given in workflow order,
because an aging chart has to be able to draw an empty column: "nothing has
reached review in nine days" is a finding, and a column that vanishes when it
empties hides it.
- asOf is INJECTED, never Date.now(). An age is a subtraction; a component that
picks its own minuend renders one chart on the server and a different one in
the browser and can never be tested.
- levels are the board's OWN history — "p per cent of finished work took no
longer than days" — so they ascend in p and never descend in days. They may
be empty: the chart then plots ages and says outright it has nothing to judge
them against, rather than inventing a target.
- Component props = z.infer of the schema plus height (default 320, clamped
200-560), watchlistSize (default 3, clamped 0-8), showDistribution (default
true), formatAge, onRetry, className and the native div props through
forwardRef. No hand-written parallel interface.
- Ship a pure module beside the schema: parseInstant(), ageInDays(),
percentileOf(), apportionPercent(), inspectAgingWipData() for the structural
pass and buildAgingWipLayout() returning points, per-column summaries, zone
counts and shares, the median, the oldest and the hottest column.
Behavior — the arithmetic, which is the product
- ONE TIMELINE. Parse ISO 8601 with your own parser that reads a zone-less
value as UTC and honours an explicit Z or ±HH:MM, then print the day back
from THE SAME MILLISECONDS with a UTC-pinned Intl formatter. new Date(
"2026-03-02T09:30") is local time, so the same payload yields a different age
either side of a timezone boundary and a different one again either side of a
DST change; slicing the ISO string to display it is the same bug wearing a
hat. Because both instants are UTC, a day is exactly 86 400 000 ms and DST
never enters the subtraction.
- Age is WHOLE ELAPSED DAYS, floored, and that single integer is what the plot
positions and what every label prints — never one value computed and another
displayed. Elapsed, not inclusive, because the percentile levels come from
elapsed cycle times and the two have to be measured the same way.
- Tiering is inclusive: an item has REACHED a level when its age is at or above
that level's days, since at exactly the p85 age it has used the whole p85
allowance. tier = how many levels it has reached, so tier === levels.length
means past every expectation.
- Percentiles use linear interpolation between closest ranks (NumPy / R type 7
/ PERCENTILE.INC). Look up two INTEGER neighbours and mix them — never index
an array at a fractional rank. Return null for an empty sample rather than
NaN, and the single value for a sample of one rather than dividing by n-1.
- Tier shares are apportioned by LARGEST REMAINDER, so the printed percentages
total exactly 100 instead of 99 or 101 under a bar that fills its track. A
bucket with no items has no fractional part and so can never be handed a
leftover unit; the mirror case — a bucket holding 1 item out of 250, which
apportions to 0 — prints "<1%" and keeps a minimum-width segment, because
"0%" beside a visible bar and a count of 1 contradicts its own picture.
- Refuse rather than guess: a duplicate column id, an item pointing at a column
that is not on the board, an unparseable date, a start after the snapshot
(negative age), or a fan whose days descend as p ascends. Dropping an orphan
card silently would hide exactly the stranded work the chart exists for, so
the error branch names the card and the column it claimed.
- The four states are first-class branches of one bg-card panel: a pulsing
headline-plus-bands skeleton (aria-hidden, with 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. status="ready" with no columns or no items falls through to the
empty copy instead of dividing by zero.
- Degenerate data, each handled deliberately: every item the same age (minimum
= median = maximum, one line of dots kept apart only by the dodge); one
column; ages all zero (the axis is still one whole step tall, so nothing
divides by zero); no levels (no fan, no tier key, no verdict); more items in
a column than it has lanes.
Rendering & styling — Outcrowd calm, one accent
- LEAD WITH ONE NUMBER. The oldest item's age, set at text-5xl semibold with
tabular-nums, above its title in text-sm and a muted text-xs line naming the
column, the start day and which expectation it has passed. A small <dl> of
in-flight / past-the-top-level / blocked sits on the opposite baseline. The
hierarchy is size and weight, not more boxes: nothing inside the card is
boxed again.
- ONE ACCENT, ONE IDEA. var(--chart-1) is spent only on "past every
expectation": the top rule, the wash above it, the dots that have crossed it,
the dot on a chip whose column holds one, and the big numeral when the oldest
item is one of them. Everything else is --foreground and --muted-foreground
at graded opacity, so the card is a calm neutral field with a few hot points.
Colour is never the only encoding — height IS the number, every tier is named
in the key with its count, and the caption says the verdict in words.
- The fan is ground, not lines on top: a rect per zone from the baseline up,
--muted-foreground at 6-18% rising with the tier, then the accent wash above
the last level. A dashed rule at each level labelled "p85 · 11d" in the right
margin; when two levels sit a day apart, resolve the labels from the TOP
DOWNWARDS so the outermost promise — the one the card leads with — keeps its
label and the inner one is dropped rather than overprinted.
- x is CATEGORICAL. Even bands, a hairline divider between them, and a
truncating foreignObject label under each. Within a band, dodge dots
sideways: sort oldest first, place each in the first lane whose last dot is
at least a diameter away, lanes ordered centre, left, right, so every cloud
is symmetric about its column. An ODD lane count, or every column leans the
same way. When the lanes run out, spread the rest round-robin and SAY SO in
the footer — an aging chart that hides how deep a pile is has inverted its
own purpose.
- Blocked items are hollow rings rather than a second hue, so the distinction
survives greyscale and colour blindness; the ring is drawn a step stronger
than the disc it replaces because an outline carries less ink.
- Name the oldest few on the plot (watchlistSize), in a foreignObject with CSS
truncate and a text-shadow halo in var(--card) — the HTML twin of
paint-order:stroke. Flip a label to the left of its dot when the right edge
is closer, and DROP it, never overflow it, when it will not fit or would land
on one already placed.
- 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 when the node is replaced and again on unmount.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — how many are in flight, the oldest and where it sits, the median,
how many are past the top level, which column holds most of them. The <svg>
is aria-hidden and focusable="false"; everything it says exists as text
elsewhere. 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 column chips are the keyboard surface. Tab reaches each; with
one focused, ArrowUp / ArrowDown walk that column's items oldest-first and
Home / End jump to its ends; Enter or Space pins the column (aria-pressed)
and dims the others. Focusing a chip for an EMPTY column must say "nothing in
flight" rather than leaving the readout on the last item. Each chip's
aria-label opens with its visible label and carries its visible count, so the
accessible name contains the visible one (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 — and a pointer leaving the plot must not clear a keyboard
cursor.
- Motion: the only animation is the opacity transition on dimming plus the
skeleton pulse, both with motion-reduce variants. Nothing about reading the
chart depends on motion.
Customization levers
- watchlistSize is the labelling dial: 0 for a dense dashboard tile where the
chart is a shape, 5-8 for a standup screen where names are the point.
- showDistribution={false} drops the tier bar and its key when the same board
already has a tier breakdown elsewhere; the counts stay in the caption and
the table.
- height is the density dial and the only thing that changes how well two dots
a day apart separate.
- levels re-points the whole verdict: two levels for a simple promise, three
for a fan, or the team's agreed SLA instead of the board's own history —
state which in levelsBasis, because "95% of our work" and "what we promised"
are very different claims.
- formatAge re-points every compact label ("11d", "1.5w", "11 j"); spoken prose
stays in words whatever it returns.
- Palette: the accent is one token. Point it at var(--chart-4) for a warmer
alarm, or at var(--chart-2) to make the same chart read as informational
rather than urgent — nothing else in the card competes for attention.
- Interaction: the readout is deliberately one line and there is no tooltip.
Wire onClick on a dot to open that item, or lift the pinned column into a
parent to filter a table beside the chart.Concepts
- Age, not cycle time — every item here is unfinished, so its number is still growing. A cycle-time chart is a post-mortem you can only learn from; an age is a number you can still change today, which is why the whole card is written in the present tense and why finished work is deliberately absent.
- Service level expectation as ground — the bands are not a target anyone agreed to; they are what this board's own finished work actually did. Drawing them as the field the dots sit in, rather than as lines on top, makes "past p95" a place rather than a comparison, and makes an empty
levelsarray an honest state instead of a broken one. - Inclusive tiering — an item has reached a level the moment its age equals that level's days, because at exactly the p85 age it has already spent the entire p85 allowance. Treating the boundary as "not yet" is how a board reports zero breaches on the day it acquires several.
- Deterministic dodge — dots in the same column are nudged sideways into lanes ordered centre, left, right, so a cluster spreads symmetrically and lands in the same place on every render. No jitter, no randomness: a chart whose dots move when you refresh cannot be pointed at in a standup.
- Honest overflow — when a column holds more items than it has lanes, the remainder is spread round-robin and the footer says the dots overlap. An aging chart that quietly thins a pile has inverted its own purpose, and the batch of twenty-two dumped into one queue on the same afternoon is exactly the finding worth surfacing.
- Injected snapshot —
asOfis passed in rather than read from the clock, so the same payload always produces the same picture: the server and the browser agree, a screenshot in a report still means what it meant, and a test can assert an age without freezing time.
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.
WIP Limits
A four-state work-in-progress card: every board column read against its own limit, with a breach as an explicit state and a column that was never capped kept out of the verdict entirely.