Chart Burndown
A four-state sprint burndown: remaining work against an ideal that only burns on working days, scope changes drawn as steps, and a projected finish from a burn rate a descope cannot fake.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-burndown.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBurndown" card in plain SVG (no
chart library) with zod. recharts has no primitive for any of the three things
that make a burndown a burndown — a guideline that only descends on working
days, a step ceiling, and a forecast that stops between two columns — so the
geometry is four small pure functions instead.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string;
days: { date: string; // ISO YYYY-MM-DD, read as UTC
remaining: number | null; // null = not reported
scope: number; // total committed at this snapshot
working?: boolean }[]; // does work happen after this snapshot
nonWorkingWeekdays?: number[] } // 0 = Sunday, defaults to [0, 6]
Component props = z.infer of that, plus velocityWindow (default 3),
formatValue, locale (default "en-US"), showScope, showProjection, onRetry,
className and the native div props (title omitted, the contract owns it).
- A snapshot is a reading taken at the START of its day, so `working` governs
the interval that FOLLOWS it. The last snapshot closes the window: it has no
interval after it and therefore never carries capacity, whatever the calendar
or the caller says.
- `remaining: null` means "nobody reported", never "zero". Sending 0 for a
missed standup draws a sprint that finished and then unfinished.
- Ship a pure module beside the schema: inspectBurndownDays() for the
structural pass and buildBurndownModel() returning per-day working / scope /
scopeDelta / ideal / gap / projected, plus capacity, scope aggregates, the
reported bounds, interior gaps, velocity and the projection.
- Parse dates in UTC and format them in UTC. A burndown is keyed by calendar
day; letting the viewer's zone decide shifts the whole series by a column for
anyone west of the data and lands the weekend shading on Friday.
Behavior
- ORDER is the one thing the chart cannot recover from. The x axis is ordinal —
one evenly spaced column per snapshot — so a feed sorted by "last updated"
is drawn as a perfectly plausible zig-zag with no tell at all. Refuse it and
name the day that runs backwards; also refuse duplicate days and dates that
are not real calendar days. Calendar gaps are legal and stay uncollapsed: a
day nobody snapshotted simply has no column.
- THE IDEAL burns only on working days:
capacity = number of working intervals in the window
ahead(i) = working intervals still after snapshot i (suffix scan)
ideal(i) = scope(i) * ahead(i) / capacity
It starts at the committed scope, reaches exactly 0 on the closing snapshot,
runs flat across a weekend, and steps whenever scope changes — by the share
of that change still owed at the moment it lands (+9 with 70% of the capacity
left lifts the guideline by 6.3, not by 9). A straight line from start to end
instead promises burn on days nobody is working and leaves a mid-sprint scope
change invisible.
- GAP = remaining − ideal at the last reported snapshot. Positive is behind,
negative is ahead. Call it out on the plot as a bracket between the two
points plus a chip, and again as a badge in the header.
- BURN RATE counts work closed, not the line's drop:
completed(a..b) = (remaining(a) − remaining(b)) + Σ scopeDelta in (a, b]
velocity = completed / working intervals in [a, b)
where b is the last reported snapshot and a is the furthest *reported*
snapshot within `velocityWindow` working intervals of it. Unreported days in
between still contribute their intervals — the work happened whether or not
anyone wrote it down. Without the Σ term a 900-hour descope reads as three
spectacular days — 626 h/day against a real 326 h/day — and the forecast
built on it under-reports what is left at the boundary by 3,000 hours.
- PROJECTION exists only when there is an unreported tail, something still
open, and velocity > 0. Walk forward from the last reported snapshot
subtracting velocity on working intervals and nothing on the rest. If it
crosses zero, the last point lands at a FRACTIONAL index
(i − 1 + value / velocity) — the work runs out inside a day, not at a column
— and the chart marks that spot with a ring on the baseline. If it does not,
report what is still open at the closing snapshot. A flat or negative rate
has no completion date at all: draw nothing and say why in the footer.
- UNREPORTED DAYS break the actual line rather than being drawn through; a
straight segment across a missing report invents a burn rate nobody measured.
Bridge the break with a muted dotted connector so the eye still follows the
shape while the slope is visibly not a measurement.
- The four states are first-class branches of one bg-card panel: a pulsing
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 with a "Try again" button only when onRetry exists, and ready.
status="ready" with zero days renders the empty branch instead of an axis
with nothing on it.
- DEGENERATE CASES, each handled on purpose: zero days → empty; one snapshot →
no interval, therefore no capacity, no ideal and no rate, drawn as a single
centred dot with the reason in the footer; every value zero → domain falls
back to [0, 1] so the axis still exists; already cleared → projection
suppressed and the readout switches to the completion date; nothing reported
→ scope and ideal only; a descope → the ceiling steps down and the rate stays
honest.
- CLEANUP: the only subscription is one ResizeObserver, disconnected on unmount
and whenever the observed node changes. No timers, no rAF, no window
listeners.
Rendering & styling
- Layout: measure the plot box with a ResizeObserver and set the viewBox to the
measured width, so one user unit is one CSS pixel and every label keeps its
stated size at any card width. Until the first measurement, draw at a 620px
fallback: because the viewBox matches whatever width the geometry used, that
frame (and the server's) is scaled to fit rather than clipped, and once the
real width arrives the scale is exactly 1. Fixed 216px plot band, 46px y-label
lane, 26px date lane; explicit svg height so the card never collapses to zero
in a flex parent.
- Series tokens: remaining var(--chart-1) solid with dots, ideal var(--chart-2)
dashed 6/4, scope ceiling var(--chart-3) dotted 1/4 as a step path
(H then V per column), projection var(--chart-1) dashed 3/3. The projection
deliberately shares the actual line's colour — it IS that line continued —
and separates itself by dash. Colour is never the only channel: every series
owns a dash signature repeated in the legend, scope changes carry an open
diamond, the gap badge carries a triangle (up = behind, down = ahead, bar =
level), and non-working days are a 45° hatch, not just a 7% wash. All five
hold up in greyscale and under any colour vision deficiency.
- Non-working days are shaded from their own column to the next one, which is
exactly the interval the ideal runs flat across, and adjacent ones merge into
one band so a Saturday and a Sunday do not show a seam. The legend swatch has
to restate the hatch as a repeating-linear-gradient: an SVG <pattern> is a
paint server, so url(#id) in a CSS background-image resolves to nothing.
- Y domain: nice-number ceiling aimed at five intervals (step from 1 / 2 / 2.5 /
5 / 10 x 10^k), tick labels switching to compact notation above 10,000 because
38px of lane holds "1,250" but not "1,250,000". Date labels thin out by a
stride computed from the available width, always name the closing snapshot,
and drop the neighbour it would collide with; the first is anchored start,
the last end, so neither can leave the card.
- Accessibility: the svg is a single tab stop with role="listbox", an
aria-label carrying the whole finding (scope, today's remaining against the
ideal, the projection) and one <g role="option"> per snapshot whose aria-label
is the same sentence the visible readout shows. Arrow keys (either axis) step
the cursor, Home / End jump to the ends, Escape clears it; the first arrow
press lands the cursor on the last reported day rather than moving an
invisible one. aria-activedescendant follows the cursor, so the visible
readout line is aria-hidden — a live region there would announce every move
twice. Below it, an sr-only WRAPPER DIV (never the table itself: CSS width is
only a lower bound for a table box, so width:1px does not hold one back)
holds a real table of every day with capacity, scope, remaining, ideal, gap
and projection.
- Motion: none beyond the skeleton pulse, which carries motion-reduce:
animate-none. Nothing about the chart depends on animation.
Customization levers
- velocityWindow: 1 tracks a team that just changed pace and re-forecasts every
day; 3 (default) is the standup horizon; 5-10 stops one blocked day from
erasing the projection. On the sample sprint the same data forecasts a finish
on the last working day at 3 and 12 pts still open at 5 — say which window
you used wherever you publish the number.
- showScope / showProjection: drop the ceiling when scope is frozen, drop the
forecast in a retrospective where the future is not a question.
- nonWorkingWeekdays re-points the calendar (e.g. [5, 6] for a Fri-Sat weekend)
and per-day `working` overrides it for holidays and crunch weekends. Both
feed capacity, so the ideal and the burn rate follow automatically.
- formatValue owns every printed quantity (currency, hours, story points);
`unit` alone covers the common case and hugs the number for punctuation
units. locale drives both Intl formatters.
- Palette: three chart tokens and four dash patterns are the whole visual
vocabulary — re-point the tokens and the legend, markers and chips follow.
- Density: the 216px band, the 46px y lane and the 48px minimum label gap are
the three numbers that trade plot room against text. Raise the label gap for
long date formats; the stride recomputes itself.
- Ideal policy: this one re-bases on every scope change. To keep the original
commitment as the baseline instead, freeze scope(i) at scope(0) in the ideal
formula and let the gap absorb the change — a different, equally defensible
team convention.Concepts
- Working-day capacity — the denominator of everything. A snapshot's flag governs the interval after it, so capacity is the count of working intervals in the window and the closing snapshot never carries one. Weekends and holidays therefore drop out of the ideal instead of quietly promising burn on days nobody works.
- Re-based ideal — the guideline is
scope × working days ahead ÷ capacity, recomputed at every snapshot. That is what makes it start at the commitment, land on zero at the boundary, run flat across a weekend, and step when scope moves — by the share of the change that is still owed, not by the whole of it. - Scope step — work added or removed lands as a step in the ceiling and pushes the remaining line the same way on the same day. Seeing the two move together is the difference between "the team stalled" and "someone added nine points on Thursday".
- Descope-corrected rate — velocity adds the scope deltas back before dividing, so removing work never reads as finishing it. The uncorrected reading of the sample program is 626 h/day against a real 326 h/day, which is the difference between "2,042 hours left at the boundary" and the true 5,042.
- Fractional clearing point — the projection reaches zero inside a day, so its last vertex sits between two columns rather than being rounded to one. Rounding it would move the forecast by up to a full day in either direction.
- Unreported ≠ zero — a missing report breaks the actual line and is bridged with a muted dotted connector: you keep the shape, but the slope across the gap is visibly not a measurement. Sending 0 instead would draw a sprint that finished and then unfinished.
Tile Grid Map
A grid cartogram where every region is an equal-size tile at a hand-supplied grid position, classed by quantile, equal interval or manual breaks — choropleth readability with no projection and no geographic data.
Cumulative Flow Diagram
A four-state cumulative flow diagram that reads band thickness as work in progress and the horizontal gap between the arrival and delivery curves as approximate lead time, annotated on the plot and reachable by keyboard.