Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildWipLimitsLayout,
  inspectWipLimitsData,
  type ChartWipLimitsData,
  type WipColumnReading,
  type WipLimitsLayout,
  type WipLimitsOrder,
} from "./chart-wip-limits.contract"

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartWipLimits" card (plain HTML and
CSS, no chart library) with zod. It reads one kanban board column by column
against the limit that column agreed to, and the whole product is in three
distinctions most implementations flatten: a breach is not "high", a column
sitting exactly on its line is not a breach, and a column that was never
capped is neither.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; unit?: string;
    columns: { id: string; label: string; count: number;
               limit: number | null }[] }.
- columns are in left-to-right BOARD order. count is what the column holds
  right now; limit is what it is allowed to hold.
- limit carries three meanings and they are never interchangeable:
  a positive number is the cap; 0 means the column is CLOSED, so a single
  item in it is already a breach and an empty closed column is the only
  compliant one; null means NO LIMIT WAS CONFIGURED — the column can neither
  comply nor breach. Make limit required (not optional) so a feed that forgot
  to join the policy table fails loudly instead of silently un-policing a
  column.
- unit is a plural noun (default "cards") used by every printed sentence; a
  count of exactly 1 drops a trailing "s" so nothing reads "1 cards".
- Component props = z.infer of the schema plus order: "board" | "pressure"
  (default "board"), showRails (default true), formatCount, onRetry,
  className and the native div props through forwardRef.
- Ship a pure module beside the schema: inspectWipLimitsData() for the
  structural pass, apportionPercents() for the shares, and
  buildWipLimitsLayout() returning one reading per column (state, headroom,
  over, rail geometry, share) plus the board totals (totalCount, totalLimit,
  policedCount, unpolicedCount, overCount, atLimitCount, closedCount,
  headroom, overflow, worst, tightest).

Behavior — the verdicts, which are the product
- FIVE column states, resolved once in the layout so no renderer has to
  remember the rules: "unpoliced" (limit null), "over" (count > limit),
  "closed" (limit 0 and count 0), "at-limit" (count === limit, limit > 0),
  "under". Note the order: a closed column holding anything is "over", not
  "closed".
- AT THE LINE IS NOT A BREACH. count === limit is full and legal — it means
  "pull nothing new", not "you broke the policy". Counting it as a breach is
  the fastest way to make a team stop trusting the card.
- UNPOLICED IS NOT COMPLIANT. A column with limit null contributes to no
  headroom, no overflow, no breach count, and to no board verdict of any
  kind; it only contributes to the board's own total. When NO column has a
  limit, the sentence under the big number must say so outright instead of
  letting "0 over limit" read as a clean bill of health — a board with no
  policy is not a board that is passing.
- Headroom = sum of max(0, limit − count) over policed columns; overflow =
  sum of max(0, count − limit). Both skip unpoliced columns.
- SHARE OF THE BOARD by LARGEST REMAINDER, never per-row rounding: floor
  every exact share, then hand the leftover points to the largest fractional
  parts first, ties to the earlier column. Per-row rounding is what prints a
  board totalling 99% or 101%. When the board holds nothing at all, drop the
  share instead of printing a 0% that came from dividing by zero.
- Guard every denominator: an empty board, a board where every count is 0,
  and a column with limit 0 and count 0 all have to render without producing
  NaN, Infinity or a 0/0. Nothing non-finite may be formatted: print an em
  dash instead.
- 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 plus a "Try again" button only when onRetry exists, and ready.
  status="ready" with no columns falls through to the empty copy.
- The structural pass refuses duplicate ids (readings are keyed by id, so a
  duplicate lets one column's verdict overwrite another's), negative counts
  or limits, and non-finite values — including Infinity as a limit, which is
  a cap that reports the column permanently compliant. Render the reason in
  the error branch instead of drawing a plausible board.

Rendering & styling
- One focal number: the count of columns over their limit, set in the
  largest type on the card, in text-destructive only when it is above zero,
  under a small label and above ONE muted sentence naming the worst column
  ("In review holds 9 against a limit of 4") or, when nothing is over, the
  headroom and the tightest column. Everything else — board total, capped
  columns, headroom, overflow — is a small stat pair in a <dl> beside it.
  Hierarchy comes from size and weight, not from boxing each stat in a card.
- One row per column, unboxed, generous vertical rhythm: label on the left,
  the count in large tabular-nums on the right with a small "/ limit" or
  "no limit" suffix, a capacity rail under them, and one muted caption line
  carrying the verdict and the share.
- RAIL SCALE IS PER COLUMN: max(count, limit). The track therefore ENDS at
  that column's limit while there is headroom left, and when the column is
  over, the line falls at limit/count with the excess painted past it. A full
  rail always means "at this column's line" whatever the numbers are — say
  out loud in the footer that widths compare fullness, never counts across
  rows. A 2px tick marks the line; nudge it fully inside the track at 0% and
  100%, or the two most important positions are the two that half disappear.
- THE THREE LIMIT MEANINGS MUST SURVIVE WITHOUT COLOUR. A policed column gets
  a real rail; an unpoliced column gets a dashed rule and the words "no limit
  set"; a closed column gets a rail whose line sits at the very start. Each
  distinction is carried by the suffix, the caption AND the rail, so turning
  showRails off loses none of them.
- Colour: bg-muted track, var(--chart-1) for the part within the limit,
  bg-destructive for the part past it, bg-foreground for the limit tick.
  Destructive appears only when something is actually over, which is what
  makes it the single loud thing on an otherwise neutral card. No gradients,
  no glow, no shadowed tiles.
- Accessibility: the whole card is real text read exactly once — there is no
  aria-hidden picture with a transcript beside it, because the rails are
  decoration over numbers that are already written out. Rails and the empty
  state's glyph are aria-hidden; the retry button is the only interactive
  element and carries a focus-visible ring.
- Column labels WRAP, they are not truncated: the name is the row's identity
  and an ellipsis with nowhere to reveal the rest hides the thing being
  judged. Use overflow-wrap:anywhere plus min-width:0 so an unbroken name
  cannot set the row's minimum width and push the count off the card.
- Motion: the only animation is the skeleton pulse, with motion-reduce
  disabling it. Nothing about reading the card depends on motion, and there
  are no timers, observers or listeners to clean up.

Customization levers
- order="pressure" sorts breaches first (by how far past the line, then by
  size), then the columns on their line, then the ones with least room, with
  unpoliced last — use it on a wall display where nobody will scroll; keep
  "board" when the card sits next to the board itself.
- showRails={false} is the density dial for a dashboard tile: every verdict
  survives as text.
- formatCount and unit re-point every printed number — story points instead
  of cards, "GB" instead of a noun.
- The focal number is a swap point: exchange the breach count for headroom or
  for the board total if your team pulls rather than polices, keeping the
  label/number/sentence shape.
- Palette: --chart-1 for the within-limit fill is the only accent; re-point it
  to any --chart-n, and keep --destructive for the excess so the loud colour
  stays reserved for a real breach.
- Density: the row gap and the numeral sizes are the two dials. Drop the
  share from the caption before shrinking the count.
- Interaction: the card is deliberately static. Wire a row onClick to filter
  the board by that column, or lift `worst` into a parent to drive a banner.

Concepts

  • Breach as an explicit state — a column is over only when count > limit, and that verdict is computed once and carried as a named state rather than re-derived at every render site. Sitting exactly on the line is a separate state: it is full, it means "pull nothing new", and calling it a breach is the fastest way to teach a team to ignore the card.
  • Unpoliced is not compliant — a column with limit: null has no verdict to give. It is excluded from headroom, from overflow and from the breach count, and when no column on the board has a limit the card says that out loud instead of reporting a comfortable "0 over limit". A board with no policy is not a board that is passing, and that is the single most common lie this kind of chart tells.
  • A closed columnlimit: 0 is a real policy, not a missing one: the column is closed, so the first item to land in it is already a breach and an empty one is the only compliant state. It is drawn with a real rail whose line sits at the very start, which is what keeps it visually distinct from the dashed rule of a column nobody capped.
  • Per-column rail scale — each rail is scaled to max(count, limit), so the track ends at that column's own line and a full rail always means "at the limit" whatever the numbers behind it are. The price is that widths compare fullness and never counts across rows, which is why the footer says so rather than leaving a reader to assume the bars share a scale.
  • Headroom as the pull signal — the sum of max(0, limit − count) over policed columns answers the only question a standup actually asks of a limit: how much more may we pull before something breaks. When it reaches zero with nothing over, the board is full rather than broken, and the sentence under the big number changes to say exactly that.
  • Largest-remainder shares — the per-column share of the board's work in progress is apportioned by flooring every exact share and handing the leftover points to the largest fractional parts. Rounding each share on its own is what makes a board print 99% or 101%; when the board holds nothing at all the share is dropped entirely rather than printed as a 0% that came from dividing by zero.

On This Page