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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-workload-balance.jsonPrompt
Build a React + TypeScript + Tailwind "ChartWorkloadBalance" card in plain CSS
(no chart library, no SVG) with zod. The point of the chart is not "here are
some bars": it is that a team can be booked to exactly 100% in aggregate while
one person sits at 145% and another at 40%, and that the honest question is
"does the surplus actually fit in the gaps". A build that does not answer that
has missed it.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; unit?: string; tolerance?: number;
members: { id: string; name: string; role?: string; assigned: number;
capacity: number; items?: number }[] }.
- assigned and capacity are in the SAME unit and are compared PER PERSON.
capacity is what that person can take in this window, already net of time
off and meetings — say so in the schema doc comment, because a feed that
sends a contractual weekly figure makes every reading wrong. capacity: 0 is
legal and means fully unavailable.
- tolerance (default 0.05) is a dead band as a fraction of each person's OWN
capacity: ±5% of 32h is ±1.6h, ±5% of 8h is ±0.4h. Someone thirty minutes
over a 32-hour week is not over-allocated in any sense a human acts on.
- caption is a caller-formatted string, never a date the component parses.
Nothing here slices an ISO instant, so nothing here can print the wrong day.
- Component props = z.infer of the schema plus sort ("balance" | "name" |
"given", default "balance"), barHeight (default 18, or 14 above 12 rows,
clamped 8-36), axisIntervals (default 6, clamped 2-10), minBarWidth
(default 2, clamped 0-8), showMeter, showPlan, maxTransfers (default 3,
clamped 1-12), formatValue, locale, skeletonRows, onSelectMember, onRetry,
emptyState, className and the native div props through forwardRef.
- Ship a pure module beside the schema: inspectWorkloadBalanceMembers() for
the structural pass, apportionPercents() (largest remainder),
quantileSorted() (R-7) and buildWorkloadBalanceLayout() returning the rows,
the axis, the totals, the counts, the utilisation spread, the transfers and
the unplaceable remainder.
Behavior — the maths, which is the product
- ONE SCALE ACROSS BOTH ARMS. A 10h headroom bar and a 10h over-allocation bar
must be drawn the same length; that is the only way a reader can see whether
the surplus fits in the gaps. The two arms are free to be different LENGTHS
— round each side outward to a whole number of the same nice step and let
the capacity line land where the data puts it, including hard against an
edge when nobody has any room. Never force the axis symmetric: that would
claim a headroom that does not exist.
- THE TOLERANCE BAND IS LOAD-BEARING, in two places. A row inside its band is
reported as balanced, and it is drawn as a mark ON the capacity line rather
than as a bar — and, crucially, it does not stretch the axis. Skip that
second part and a perfectly balanced board pulls the axis down to ±1h and
paints half-track bars under rows that read "on capacity".
- Guard every denominator. utilisation is assigned / capacity and must be null
— not Infinity, not NaN — when capacity is 0; that person still carries
work, so their surplus still counts, and their row says "no capacity"
instead of a percentage. Team-level shares are null when total capacity is
0. The median is null for an empty roster, because the median of nothing is
not zero.
- Percentages that partition a whole use LARGEST REMAINDER. The meter prints
"86% booked / 14% headroom" of capacity; rounding each independently prints
99% or 101% under a label that says these are the two halves of one whole.
- The spread stat is the MEDIAN utilisation plus the lowest and highest, by
the R-7 rule: position h = (n-1)p, linearly interpolated between the two
order statistics that straddle h. Never index an array with a fractional
index — it reads undefined and poisons everything downstream with NaN.
- THE PLAN IS THE POINT. Greedy: deepest surplus into widest gap, repeat.
Greedy is not "good enough" here, it is right — there are no costs to
optimise, every plan that empties the donors moves the same total, and
greedy is the one with the fewest, largest moves. Ties break on feed order
so the same roster always yields the same plan. Return the moves largest
first, so a caller showing the top three shows the biggest levers and can
honestly call the rest smaller.
- CENSORING IS A FIRST-CLASS ANSWER. When the surplus exceeds the total
headroom, say the remainder has nowhere to go: this team is over capacity in
total, not just unevenly loaded, and no amount of sideways movement fixes
it. A chart that quietly draws a plan there is lying by omission.
- The headline number and the plan must AGREE: total-to-move counts only rows
the tolerance calls over, which is exactly what the transfers plus the
unplaceable remainder add up to. Keep the aggregate figures (which ignore
the band) under different names, or the card will claim to move hours it
never plans for.
- The four states are first-class branches of one bg-card panel: a skeleton
shaped like the answer (headline block, meter, diverging wedge of rows;
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 members falls through to the empty copy instead of dividing by zero.
Rendering & styling
- Hierarchy comes from size and weight, not from boxes. One oversized numeral
— the amount that has to move — with a small label under it and a muted
caption under that; everything else is text-xs. Generous gaps, one rounded
card, no nested cards, no gradient, no glow.
- ONE saturated accent, and it means exactly one thing: work above somebody's
capacity. Use var(--chart-3) for it and let headroom be
var(--muted-foreground); do NOT give headroom a second hue, or the card has
two things shouting on a surface whose whole job is to point at one. The
headline numeral takes the accent only when there is something to act on, so
a balanced board prints a calm zero.
- Colour is never the only encoding: over runs right and headroom left,
balanced rows carry a mark of their own shape on the line, and every row
prints its verdict in words ("14h over", "11h spare", "on capacity").
- Semantic tokens only: bg-card, bg-muted, text-muted-foreground, border,
ring, var(--chart-3), and color-mix(in oklab, var(--foreground) 30%,
transparent) for the capacity line — --border is far too faint on a dark
card for the one line every bar is measured from.
- Layout: table-fixed with declared name and value column widths, so the track
is whatever is left over and is identical in every row — which is what lets
one capacity line run straight down the plot WITHOUT measuring anything.
There is no ResizeObserver, no rAF and no state except the selection, so
there is nothing to tear down and the first paint is already correct. Wrap
the table in overflow-x-auto rather than letting the track fall under ~96px.
- Gridlines are one repeating-linear-gradient on the track plus a single line
pinned right; bar widths are max(<pct>%, <floor>px) so a tiny deviation
stays visible without JS. Tick labels are thinned by container queries on
the track itself against a static ladder of @min-[Npx] classes — the ends
always survive, interior labels drop outward-in.
- Accessibility contract: an sr-only paragraph states the actual finding
(counts, team utilisation, what must move, whether it fits, who is deepest,
the utilisation spread) ahead of the table it summarises. The table is real
and visible — no aria-hidden picture with a hidden duplicate — with a
sr-only caption naming the scale. Each row's name is a real toggle button
with aria-pressed whose accessible name STARTS with the visible name, so
"click Priya Raman" still matches. The pinned readout is mirrored into an
sr-only role="status" that is empty until somebody actually selects a row.
- Motion: the only animation is a width transition on the bars and the
skeleton pulse, both with motion-reduce variants. Nothing about reading the
chart depends on motion.
- Degenerate data, each handled deliberately: empty roster -> empty branch;
one member -> works, and the axis simply has one arm; everyone identical ->
no bars, no ticks, capacity line centred, "nobody falls outside the band";
nobody idle -> capacity line hard against the left edge, clamped in CSS so
its 1px stays inside the track; capacity 0 with work on it -> counts as
surplus, prints "no capacity", never Infinity; duplicate id / negative /
non-finite -> refuse in the contract and render the reason.
Customization levers
- tolerance is the judgement dial and lives in the DATA, not in props: raise
it for a team that plans in half-days, drop it to 0 for a billing view where
every minute over is real.
- showMeter={false} drops the aggregate strip for a dense dashboard tile;
showPlan={false} leaves the picture and the numbers but no suggestion, for
a read-only report where nobody can act.
- maxTransfers trades a short nudge (1-2) against a full worklist (8-12); the
count of what is hidden is always printed.
- sort: "balance" for the wedge, "name" for a roster people scan for
themselves, "given" when the feed order already means something.
- barHeight and axisIntervals are the density dial — thin the bars first,
then the ticks. minBarWidth={0} for strict proportionality.
- unit and formatValue re-point every printed number: hours, story points,
tickets, currency. A one- or two-character unit hugs the number, a word
takes a space.
- Palette: re-point the accent to any --chart-N to match a project, but keep
headroom neutral — the single-accent rule is the design, not a default.
- Interaction: onSelectMember lifts the pinned row so a parent can open that
person's queue beside the chart; the card itself never mutates the roster,
so drag-to-rebalance is a host concern, not a hidden behaviour here.Concepts
- Capacity line, per person — the origin of every bar is that individual's own capacity, not a team average and not zero. It is what lets a 16-hour part-timer and a 40-hour engineer sit in the same plot without the part-timer looking permanently idle, and it is why the axis is measured in distance-from-capacity rather than in load.
- One scale across both arms — over-allocation and headroom share a single unit-per-pixel, so a bar on the left and a bar on the right of the same length are the same number of hours. That is the whole reading: if the right-hand bars are visibly longer than the left-hand ones can absorb, no rebalance exists and the team is simply over capacity. The arms are still allowed to be different lengths — forcing symmetry would draw headroom nobody has.
- Tolerance dead band — a fraction of each person's own capacity inside which nobody counts as over or under. It scales with the person, it keeps the rebalance plan free of moves worth twenty minutes, and it deliberately does not stretch the axis: a within-band wobble that set the scale would turn a perfectly balanced board into a dramatic-looking picture.
- Greedy rebalance — deepest surplus poured into the widest gap, repeatedly. There is nothing to optimise between equal-sized moves, so greedy is not a shortcut here: it is the plan with the fewest, largest steps, which is the one a human can actually carry out. Ties break on feed order, so the same roster always produces the same suggestion.
- Unplaceable surplus — the part of the over-allocation that no one has room for. Naming it is the difference between an imbalance (movable) and an overload (not), and it is the one answer a rebalancing chart is most tempted to hide behind a plausible-looking plan.
- Aggregate as the foil — the meter along the top says how booked the team is in total, on purpose. It is the number that reassures a planner while the distribution underneath is wrong, and putting the two on the same card is the argument this component exists to make.
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.
Cycle Time Scatter
A four-state cycle time scatter that derives its own p50, p85 and p95 by nearest rank, spends its single accent on the focus percentile and the items that broke it, and measures a stated service level against the number the data actually supports.