Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildWorkloadBalanceLayout,
  DEFAULT_TOLERANCE,
  type ChartWorkloadBalanceData,
  type WorkloadRow,
  type WorkloadSort,
} from "./chart-workload-balance.contract"

export interface ChartWorkloadBalanceProps

Installation

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

Prompt

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.

On This Page