Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildBurndownModel,
  inspectBurndownDays,
  type BurndownDay,
  type ChartBurndownData,
} from "./chart-burndown.contract"

export interface ChartBurndownProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,

Installation

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

Prompt

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.

On This Page