Sprint Velocity
A four-state sprint velocity chart — commitment against delivered as paired bars, scope pulled in mid-sprint hatched as its own segment, and a rolling-window band projected over the sprints still to come.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-sprint-velocity.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSprintVelocity" card in CSS grid
(no chart library, no canvas) with zod. A velocity chart is not a bar chart of
totals: its whole value is the pair of comparisons — delivered against what was
PROMISED, and promised against what was PULLED IN LATER — and a build that folds
mid-sprint scope into the commitment has produced a chart that flatters every
team it is pointed at.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string;
sprints: { id: string; label: string; start?: string; end?: string;
committed: number; addedScope?: number;
completed: number; completedAdded?: number }[];
upcoming?: { id: string; label: string; committed?: number }[] }.
- sprints are oldest first and already closed. addedScope is work pulled in
AFTER planning and is NOT part of committed — the scope a sprint finished
with is committed + addedScope. completedAdded is the part of completed that
came out of addedScope, so completed - completedAdded is the only number that
may be divided by committed. Say all of this in the schema doc comments; a
feed that folds added scope into committed silently turns every miss into a
hit.
- upcoming holds the sprints not yet run — the columns the band is drawn over.
Give it a committed when planning has already happened, so the plan can be
compared against what the team has actually been delivering.
- start / end are ISO calendar days parsed AND formatted in UTC. Never slice a
UTC ISO string for display or format it in the viewer's zone: both print the
wrong day west of the data's zone. UTC also makes the length arithmetic exact,
because it has no daylight saving.
- Component props = z.infer of the schema plus rollingWindow (default 6, clamped
1-24), forecastSprints (default 3, clamped 0-8, used only when upcoming is
empty), height (default 200, clamped 120-420), showForecast (default true),
formatValue, locale (default "en-US"), onRetry, className and the native div
props through forwardRef.
- Ship a pure module beside the schema: inspectSprintVelocityData() for the
structural pass, apportion() for largest-remainder percentages, and
buildSprintVelocityModel() returning per-sprint rows, the trailing means, the
forecast, the pooled ratios, the over-commitment list and the axis maximum.
Behavior — the maths, which is the product
- REFUSE contradictory data rather than draw it: completedAdded above
addedScope, completedAdded above completed, completed - completedAdded above
committed, negative or non-finite figures, duplicate ids, unparseable days,
an end before its start. Return codes plus an English sentence naming the
sprint and both numbers, and render that sentence in the error branch.
- The forecast is a WINDOW, stated out loud: the mean of completed over the last
N sprints as the line, and the SLOWEST and FASTEST sprint in that window as the
band. Not a standard deviation — with the three-to-eight samples a real team
has, a normal assumption is unearned, while "every one of the last six landed
between 27 and 34" is a claim the data supports. Whatever method you pick,
print it in the footer.
- Both degenerate windows are first-class and get DIFFERENT sentences: one
closed sprint is a number with no spread ("not yet a range"), while a window
in which every sprint delivered the same is a real zero-width band. Never
collapse them into one message.
- Check every denominator. A hardening sprint commits nothing, so its hit rate
is undefined and must read "nothing was committed", not 0%, Infinity or NaN.
A board where every figure is 0 must still produce an axis (fall back to a
0-1 scale) and two em dashes instead of ratios.
- Percentages that PARTITION a whole go through largest-remainder
apportionment: floor every share, then hand the leftover units to the biggest
fractional parts. Rounding each share on its own prints 84% and 15%, and a
reader who adds them up stops trusting the card.
- Compare the plan for the next sprint against the top of the band. When an
upcoming commitment sits above the fastest sprint in the window, say so in
words — that sentence is the single most useful thing the card produces at
planning.
- Derive the sprint length from the two UTC midnights (inclusive, so +1 day).
When the lengths disagree, points per sprint is an apples-to-oranges series:
keep drawing the bars, and warn that the mean of them is not comparable.
- The four states are first-class branches of one bg-card panel: a pulsing
headline block plus skeleton bar pairs (aria-hidden, with an sr-only
role="status"), an empty state, 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 no sprints falls through to the
empty copy instead of dividing by zero.
Rendering & styling
- Layout: ONE big number, one small label, one muted caption. The headline is
the projected mean at text-5xl with its unit in a small muted span beside it
and the band range underneath; two secondary stats (commitment hit, scope
added) sit at text-xl on the same baseline. Hierarchy comes from size and
weight, not from boxing each stat in its own card.
- The plot is a CSS grid of equal columns — no measurement, no ResizeObserver,
no viewBox arithmetic, and the browser fits the labels with truncate. Segment
heights are percentages of a nice-stepped axis (1/2/5 x 10^k), so an unmeasured
first frame is already correct.
- Every column is a real <button> in a role="group" strip with a roving
tabindex: hover or focus moves the reading, click PINS it (aria-pressed) so it
survives the pointer leaving, which is the only way a touch user can read a
column at all. Arrow keys walk the columns and Home / End jump to the ends,
moving focus through a ref map — no effect, no timer.
- Colour carries ONE distinction and texture carries the other. The plan pair is
neutral (bg-muted-foreground/25) so the single saturated token, var(--chart-1),
can carry the thing the card is about: what shipped. A 45-degree hatch cut in
var(--card) marks everything that was pulled in mid-sprint, on both bars, so it
lightens on a light card, darkens on a dark one and survives a greyscale
printout — which a hue change does not.
- The trailing mean is a polyline in var(--foreground) over the history and a
dashed continuation over the forecast columns, drawn in a 0-100 viewBox with
preserveAspectRatio="none" and vector-effect="non-scaling-stroke" so the
stretch never distorts the line weight or the dashes. The band is a plain
positioned div tinted with var(--chart-1) at low opacity; an upcoming
commitment is a dashed outline, never a filled bar, because it has not happened.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — totals, the completed mix, the pooled hit rate, fastest and slowest
sprint, the projection and any degenerate note. Below it an sr-only WRAPPER DIV
holds a real table, one row per sprint. 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. Each column button's aria-label OPENS with its visible label, so the
accessible name contains the visible name (WCAG 2.5.3).
- The visible readout line is aria-hidden and mirrored into an sr-only
role="status" that speaks ONLY when the keyboard moved the reading — a hover a
screen-reader user never made must not talk. A pointer leaving the strip must
hand the reading back to wherever the keyboard left it, not clear it.
- Motion: the only animation is the skeleton pulse and a colour transition on
the active column, both with motion-reduce variants. Nothing about reading the
chart depends on motion, and there is no entrance animation to sit through.
Customization levers
- rollingWindow is the responsiveness dial: 3 follows a team that just changed
shape, 6 is the default, 10-12 suits a quarterly review where one holiday
sprint should not move the projection.
- showForecast={false} turns the card into a retrospective — band and forecast
columns go, and the headline falls back to the sprint that just closed, which
is then the newest fact on the card. Never leave the headline projecting a
band you have stopped drawing.
- forecastSprints controls how far the band reaches when the contract carries no
upcoming sprints; pass real upcoming entries instead as soon as you have names
and plans, because a named column with a plan on it is worth three "+1" columns.
- height is the density dial; at 120px it is a dashboard tile, at 400px a review
screen. The bars, labels and readout all keep working at both.
- formatValue and unit re-point every printed quantity (hours instead of points,
issues instead of story points); the axis and the headline numeral stay plain
and take their unit from the small label beside them.
- Palette: only var(--chart-1) is spent, deliberately. If you must add a second
hue, give it to the plan pair rather than to added scope — added scope is
already separated by texture, and splitting it by hue as well would leave the
card with no single focal element.
- Interaction: the readout is deliberately one line and there is no tooltip.
Wire onClick on a column to drill into that sprint's issues, or lift the
pinned column into a parent to sync a table beside the chart.Concepts
- Commitment versus delivered — the pair is the point. The left bar is what the team promised at planning, the right bar is what closed, and the gap between them is the finding. A chart that draws only the delivered total answers "were we busy" when the question was "were we right".
- Mid-sprint scope as its own segment — work pulled in after planning is hatched on both bars rather than added to the commitment. Folding it in is how a velocity chart flatters a team: the promise silently grows to fit whatever shipped, and a 44% hit rate reads as a normal sprint.
- Rolling window — the projection is the trailing mean over the last N sprints, not over all history. A team that changed shape six months ago is not the team being planned for, and the window is the one parameter that decides how fast the card forgets.
- Band from the window, not from a distribution — the corridor is the slowest and the fastest sprint in that window. With three to eight samples a standard deviation is a borrowed assumption; "every one of the last six landed between 27 and 34" is something the sprints themselves said, which is also why the method is printed under the chart instead of being left implicit.
- Degenerate window — one closed sprint has a mean and no spread, and a window in which every sprint delivered the same has a real band of zero width. They look identical on the plot and mean opposite things, so the card names which case it is in rather than drawing a confident hairline for both.
- Over-commitment check — the plan for the next sprint is compared against the top of the band. When it sits above the fastest sprint the window contains, the card says so in words; that one sentence is what turns a history chart into a planning instrument.
Project Progress
A four-state rail of projects — one segmented bar per project split into done, in progress, blocked and not started with largest-remainder shares that add to exactly 100, an owner, a due date counted in whole calendar days, a schedule-line tick and a health chip derived from all of it.
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.