Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildSprintVelocityModel,
  inspectSprintVelocityData,
  type ChartSprintVelocityData,
  type SprintVelocityRow,
} from "./chart-sprint-velocity.contract"

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

Installation

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

Prompt

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.

On This Page