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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-project-progress.jsonPrompt
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.
Point Leverage
Every point of a match plotted by how much of the win probability it could swing, with a draggable high-leverage line and per-player conversion above it.
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.