Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildProjectProgressLayout,
  chartProjectProgressHealthLabels,
  describeDaysLeft,
  formatCalendarDay,
  type ChartProjectProgressData,
  type ChartProjectProgressHealth,
  type ChartProjectProgressSegmentKey,
  type ChartProjectProgressSort,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartProjectProgress" card with zod — a
rail of projects, one segmented progress bar per row. Two things make it worth
building rather than reaching for a stacked bar chart: every share is
APPORTIONED so a row's four segments add to exactly 100, and the health chip is
DERIVED from counts and dates rather than read out of a field somebody last
touched three weeks ago.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; today: string;
    unit?: { one: string; many: string };
    items: { id: string; name: string; owner: string;
             startDate?: string; dueDate: string;
             tasks: { done: number; inProgress: number;
                      blocked: number; notStarted: number } }[] }
- `today`, `startDate` and `dueDate` are CALENDAR DAYS, `YYYY-MM-DD`, matched by
  regex. They are days on a wall calendar, not instants: no zone, no time.
- `blocked` is its own count, never folded into `inProgress`. A blocked item is
  not slow progress, it is progress that has stopped, and merging the two is how
  a board reports itself healthy while nothing moves.
- `startDate` is optional and its absence is a first-class answer, not a
  default: with no start day there is no schedule line, so the row reports
  "no schedule" instead of inventing a pace to judge itself against.
- `unit` carries BOTH forms because English plurals do not invert reliably
  ("issues" → "issue" but "stories" → "storie") and every count is printed
  beside the noun. Default { one: "task", many: "tasks" }.
- Component props = z.infer of the schema plus sort ("contract" | "risk" |
  "due" | "progress" | "name", default "contract"), riskThreshold (default 10,
  clamped 1-50), showSummary / showLegend / showFootnote (default true),
  onRetry, emptyState, className and the native div props through forwardRef.
- Ship a pure module beside the schema: parseCalendarDay(),
  formatCalendarDay(), daysBetween(), apportion(), describeDaysLeft() and
  buildProjectProgressLayout() returning rows (counts, four apportioned
  segments, donePct, daysLeft, expectedPct, drift, health, healthNote) plus the
  board-level summary. It must be pure — no clock, no DOM, no state — so the
  same items and the same reading day always give the same card.

Behavior — the arithmetic, which is the product
- APPORTION, NEVER ROUND EACH PART. Floor every exact share, then hand the
  leftover points to the largest fractional parts first (largest remainder /
  Hamilton). Break ties on a TOTAL ORDER — larger fraction, then larger raw
  value, then earlier index — otherwise two identical inputs round differently
  depending on how the engine's sort happened to fall. Rounding each part on
  its own prints 62 / 15 / 4 / 23 = 104 about as often as it prints 100, and a
  bar whose labels add to 104% teaches the reader to distrust every other
  number on the card.
- MINIMUM ONE POINT. One blocked item in 500 floors to 0 and its fraction
  (0.2) loses every leftover point, so the bar would show nothing where a
  blocked item is. Lift each non-empty part to one point and make the largest
  part pay, which leaves the total at exactly 100. Say the rule out loud in the
  footnote — the cost is bounded by one point per part and it is the price of
  never drawing a bar that claims there is no blocked work when there is.
- USE THE APPORTIONED NUMBER FOR BOTH THE WIDTH AND THE LABEL. If the segment
  widths came from the exact fractions and the labels from the rounded ones, a
  segment would be a hair wider or narrower than the number written on it.
- NOTHING SCOPED IS NOT ZERO PER CENT. A project with four zero counts has no
  whole to take a share of. Guard the division at the source: shares are all 0,
  the row's headline is an em dash, and the chip reads "unscoped". Same at
  board level — an entirely unscoped board prints an em dash, never NaN%.
- DATES ARE COUNTED AS WHOLE CALENDAR DAYS. Parse `YYYY-MM-DD` into y/m/d, ROUND
  TRIP it through Date.UTC to reject 2026-02-31 and two-digit years, and derive
  a day number from the same triple you print from. Format with an Intl
  formatter pinned to UTC. Never subtract two local-midnight Dates (47 hours
  across a spring-forward night floors to "1 day") and never slice an ISO
  string out of a Date to display it (that prints yesterday west of Greenwich).
- SCHEDULE LINE. Where a straight run from the start day to the due day says
  the project should be today: clamp(elapsed / window, 0, 1). The window must
  have width — a project whose start and due dates are the same day has no
  line, and that is the divide-by-zero that turns the reading into Infinity%.
  Drift is the apportioned done share minus the schedule line, in whole points,
  so the chip is based on the two numbers the card actually prints.
- HEALTH IS DERIVED, in this precedence: complete when nothing is open (a
  project delivered late is still finished, and a red chip on it sends people
  to look at nothing) → overdue past the due day → blocked while any item is
  blocked → at risk more than riskThreshold points behind the line → on track →
  no schedule when there is no line to draw → unscoped when nothing is counted.
  Whatever the chip loses to (a blocked count on an overdue project, the exact
  drift) goes into a one-clause reason beside it, so nothing is thrown away.
  Name WHY a schedule is missing — no start date, an unreadable due date, a
  zero-width window, an unreadable reading day — because "No schedule" with no
  reason is a chip people learn to ignore.
- Sorting is a lever, not a default. Every comparator ends at the item's
  position in the caller's array, so each one is a total order and the same
  board always renders in the same sequence.
- The four states are first-class branches of one bg-card panel: a skeleton in
  the ready layout (aria-hidden, plus an sr-only role="status"), a zero state, an
  error state carrying a "Try again" button only when onRetry exists, and ready.
  status="ready" with no items falls through to the zero state, because a board
  that exists with nothing on it is not an error.

Rendering & styling
- Layout: one row is three stacked lines — name and the row's percentage with
  its chip; a full-width bar; one caption line. Full-width bars need no
  breakpoints and cannot overflow, and the number sits top-right where the eye
  looks for it. Hierarchy comes from size and weight: one oversized board
  figure (text-5xl), a smaller per-row figure (text-2xl), a small label and a
  muted caption. Do not box each row in its own card.
- Colour: done is var(--chart-1), in progress var(--chart-2), blocked
  var(--destructive), and NOT STARTED IS BARE TRACK (bg-muted) — the honest
  picture of work nobody has touched is empty bar, not a fourth hue competing
  with the three that mean something. Blocked carries a 45-degree hatch cut in
  var(--card) as well as a hue, so it survives greyscale and colour blindness;
  every segment is also named with its count and share in the legend, in the
  hover breakdown and in the table. The schedule line is a one-pixel tick in
  the foreground colour riding OUTSIDE the bar's clip so it stays visible at
  either end. Chips carry text ("At risk", "Blocked", "Complete"), never colour
  alone.
- THE HOVER BREAKDOWN USES RESERVED SPACE. The caption line under each bar is
  always there: idle it names the owner and the date, active it swaps to the
  counts and shares. Expanding the row instead would move every row below it
  under the pointer, which re-triggers the hover the reader was moving away
  from. Nothing in the rail changes height, ever.
- Accessibility: the rail is a role="grid" with ONE tab stop; ArrowUp/Down and
  Home/End move an aria-activedescendant cursor over rows that are role="row" >
  role="gridcell". Arm the cursor only on :focus-visible — a mouse click also
  focuses the container, and treating that as a cursor pins a row with no ring
  to explain it. Each gridcell's accessible name STARTS WITH the visible
  project name and contains the visible percentage and the visible chip text,
  so it never replaces what is on screen. Bars are aria-hidden text-free
  geometry; everything they say exists as text in the caption, the legend and
  an sr-only table with one row per project. Put sr-only on the WRAPPER DIV,
  never on the table — CSS width is only a lower bound for a table box, so
  width:1px does not hold one back.
- Motion: the only animation is the skeleton pulse and a colour transition on
  the row highlight, both with motion-reduce variants. Nothing about reading
  the rail depends on motion, and the component owns no timer, observer or
  animation frame to leak.

Customization levers
- sort: "contract" for a printed status report that must not reshuffle,
  "risk" for a stand-up screen that should lead with the worst, "due" for a
  what-lands-next view, "progress" or "name" for a stable reference list.
- riskThreshold: 10 is a band that keeps a normal week's noise from turning the
  rail amber; 5 for a tight release train, 20 for early discovery work.
- showSummary / showLegend / showFootnote are the density dial, in that order:
  drop the footnote first, then the legend, then the headline block when the
  card is a tile inside a bigger dashboard.
- unit re-points every printed count at once — issues, stories, deliverables,
  checklist items — without touching a sentence.
- Palette: swap var(--chart-1) and var(--chart-2) to change which end of the
  bar is loudest; keep blocked on var(--destructive) and keep the hatch, and
  keep "not started" as bare track rather than giving it a hue.
- Segments: the four-way split is the contract. If you need a fifth state (in
  review, waiting on customer), add it to the tasks object AND to the key list
  the layout apportions over — the apportionment is written over an array, so
  it needs no other change.
- Interaction: rows are deliberately not buttons, because there is no single
  obvious action for a project row. Wire onClick on the gridcell to open the
  project, or lift the active index into a parent to drive a detail panel
  beside the rail.

Concepts

  • Largest-remainder apportionment — the four segments are floored, then the leftover points go to the largest fractional parts first, so a row's shares add to exactly 100 instead of to 99 or 104. Ties break on a total order (fraction, then raw value, then position), because a tie broken by sort stability makes the same data round differently between renders.
  • Minimum-one-point rule — a segment holding real work is never allowed to print 0%. One blocked item in 500 floors to zero and loses every leftover point, and a bar that shows nothing where a blocked item is has quietly answered the one question the rail exists for. Lifting it costs the largest segment one point and leaves the total at 100.
  • Derived health — the chip is recomputed from counts and dates on every render, so it cannot go stale the way a hand-set RAG field does. Precedence runs complete, overdue, blocked, at risk, on track, and whatever the chip lost to is spelled out in the clause beside it rather than dropped.
  • Schedule line — a straight run from the start day to the due day, read at today. The tick on the bar is where the plan says the project should be, and drift is the gap between that and where it is, which is what turns "62% done" from a number into a judgement.
  • Calendar-day arithmetic — days are counted between wall-calendar days, never between instants, so a two-day gap that straddles a clock change is still two days and a due date reads the same in every zone. The value printed and the value counted with come from the same parsed triple.
  • Reserved-space breakdown — hovering a row swaps its caption from owner-and-date to counts-and-shares in a line that is always the same height. Expanding the row would move every row below it under the pointer and re-trigger the hover the reader was leaving, so the rail never changes height.

On This Page