Team Availability
A four-state week-by-week coverage card — one oversized coverage number for the week you picked, a rail of week bars against a target line, and a per-person day grid where partial days stay fractions instead of being rounded to whole days off.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-team-availability.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTeamAvailability" card in plain HTML
and CSS (no chart library) with zod. It answers one question week by week — how
much of the team is actually available — and it is only worth building if
partial days survive as fractions all the way to the printed number. A half day
rounded up to a whole absence overstates the hole; a half day dropped because it
is "not a real day off" hides it. Both are the normal failure and both are
wrong.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; workdays: string[];
members: { id: string; name: string; role?: string;
absences: { date: string; available: number;
reason: "pto" | "holiday" | "partial" | "other";
note?: string }[] }[];
weekStartsOn?: 0 | 1; target?: number; errorMessage?: string }.
- workdays IS THE DENOMINATOR: plain YYYY-MM-DD calendar dates, one per working
day in the window. Weekends and company shutdowns are excluded by simply not
appearing. A repeated date is refused — it would inflate the denominator once
per member and quietly raise coverage.
- The feed sends EXCEPTIONS, NOT ATTENDANCE. A member with an empty absences
array is fully available on every working day. That is the shape every HR and
calendar API actually exposes, and it means the denominator cannot drift with
the exception list.
- available is the fraction of ONE WORKING DAY the person IS available, in
0…1 — not hours, so a team on six-hour days needs no conversion table. 0 is
out all day, 0.5 a half day.
- reason is the CAUSE, available is the DEGREE, and they are orthogonal: a half
day of holiday is reason "pto" with available 0.5, never reason "partial".
"partial" means structurally reduced capacity — a four-day contract, a person
split with another team, a day mortgaged to on-call.
- Component props = z.infer of the schema plus defaultWeek ("thinnest" default,
or "first" | "last" | a clamped index), onWeekChange, order ("given" default
or "least-available"), showRoster (default true), showBreakdown (default
true), formatDays, onRetry, className and the native div props through
forwardRef.
- Ship a pure module beside the schema: parseCalendarDate(),
startOfWeekUtc(), inspectTeamAvailabilityData() for the structural pass,
largestRemainderShares(), coveragePercent() and
buildTeamAvailabilityLayout() returning weeks, per-member summaries, the
resolved cells keyed by member and day, the thinnest and fullest weeks, the
count below target and the number of out-of-window entries.
Behavior — the arithmetic, which is the product
- COVERAGE = available person-days ÷ (people × working days), per week and over
the whole window. Away is accumulated as 1 − available, so four half days
across four people in a five-day week read 90%, which no whole-day rounding
can produce. Tidy every accumulator to about six decimals at the end: sums of
thirds and quarters otherwise surface as 17.499999999999996.
- DATES ARE CALENDAR DATES, NOT INSTANTS. Parse YYYY-MM-DD by hand into UTC
midnight and read it back only with UTC getters and UTC-pinned Intl
formatters. `new Date("2026-03-02")` parses to UTC midnight but every
convenient getter reads it back locally, so it prints as 1 March anywhere
behind Greenwich and a Monday drops into the previous week's bucket. Bucketing
by whole UTC days is also the only version that survives daylight saving: in
local time one day a year is 23 hours long. Reject dates that match the shape
but do not exist, such as 2026-02-30, and use setUTCFullYear rather than
Date.UTC so years 0–99 are not silently mapped onto the 1900s.
- Every label printed for a date must be formatted from the same millis the
bucketing used. Never slice an ISO string for display next to a number derived
from a parsed one.
- AWAY SPLIT BY REASON uses LARGEST-REMAINDER apportionment, not per-share
rounding: floor every share, then hand the leftover points to whichever shares
were cut hardest. Rounding each one independently is how "60% · 27% · 14%"
ships under a heading that promises 100. A share that floors to 0 while its
value is positive prints "<1%" rather than a zero the reader has to distrust.
- Guard every denominator: no people, no working days, or a week with neither,
must route to the empty branch instead of dividing. Numbers are checked finite
before they are formatted.
- Coverage is printed as a whole percent with the two lies a bare round tells
removed: 0.998 must not print as a full house while somebody is away, and
0.004 must not print as nobody being there at all.
- Absence entries dated outside workdays cannot be counted — the denominator
does not contain them — but they are COUNTED AND NAMED rather than dropped in
silence, because a weekend row from a calendar export otherwise survives for a
quarter.
- The structural pass refuses, with the reason rendered in the error branch:
a date that is not a calendar date, a repeated working day, two members
sharing an id, two rows for the same person on the same date (a day holds one
fraction; two rows leave no way to say whether they replace or add up), and an
available outside 0…1.
- The four states are first-class branches of one panel: a pulsing headline and
rail skeleton (aria-hidden, plus an sr-only role="status"), an empty state
that explains the shape it needs, 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 or no working days
falls through to the empty copy.
Rendering & styling
- HIERARCHY COMES FROM SIZE AND WEIGHT, NOT FROM BOXES. Three typographic
levels and nothing else: an oversized tabular-nums coverage numeral with a
small muted percent sign, one small label naming the week, one muted caption
giving the person-days and how many people are away. Generous vertical
rhythm; a single soft-cornered bg-card panel, no nested cards.
- ONE ACCENT ELEMENT PER VIEW. var(--chart-1) is spent entirely on the selected
week: its bar fill and a rule beneath it. Nothing else is coloured. Absence is
painted in ink (bg-foreground at ~85%) so the accent stays unambiguous, and
because absence then has no hue left, REASON IS ENCODED AS TEXTURE — flat,
a 45° hatch, a dot screen, vertical stripes — cut in var(--card) so it
lightens on a light card, darkens on a dark one and survives greyscale. The
same swatch appears in the legend beside the reason name and its share.
- WEEK RAIL: one flex-1 bar per week over a bg-muted track, fill height =
coverage. A dashed border-border line crosses the whole rail at the target, so
a thin week is visibly short without a second colour. The selected week also
gets a rule underneath, because a week at 0% coverage has no bar height left
to carry the selection. Week labels sit in their own absolutely positioned
row so a label may be wider than its bar; they are thinned by a stride
computed from the MEASURED rail width, with the first and last always drawn
and the last dropped rather than allowed to crowd its neighbour.
- Measure with a LOCAL ResizeObserver behind a callback ref, not useRef +
useEffect: the rail lives behind four branches, so the node is replaced rather
than merely resized when the status changes and an effect would never re-run
to notice. Coalesce commits into one animation frame — which is also what
avoids "ResizeObserver loop completed with undelivered notifications" — and
tear down observer and frame both when the node is replaced and on unmount.
- ROSTER GRID for the selected week: a CSS grid of name, one square per working
day, and the days-available total. Each square is a bg-muted track; the AWAY
fraction is painted from the top, so a fully available day is a faint empty
rect and a half day is a half-height ink block. Columns are minmax-bounded so
the grid shrinks instead of overflowing.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — window coverage, thinnest and fullest week, weeks below target, the
away split, the fraction rule and any out-of-window count. The rail is a
role="radiogroup" of role="radio" buttons with a ROVING TABINDEX: Tab reaches
the selected week, ArrowLeft/Right (and Up/Down) walk the weeks, Home/End jump
to the ends, Enter or Space picks. Each radio's aria-label carries the week,
its date range, its coverage and its person-days, so the headline needs no
separate live region and nothing is announced twice. The picture — the rail
fills, the roster squares, the legend swatches — is aria-hidden geometry; the
same data lives in two sr-only tables (one row per week, one row per person)
inside an sr-only WRAPPER DIV, never with sr-only on the table itself: CSS
width is only a lower bound for a table box, so width:1px does not hold one
back and a 375px viewport picks up hundreds of px of horizontal scroll.
- Motion: the only animation is a colour transition when the selection moves,
plus the skeleton pulse — both with motion-reduce variants. Nothing about
reading the card depends on motion.
Customization levers
- defaultWeek is the editorial decision: "thinnest" makes the card open on the
week that needs attention, "first"/"last" read the window in order, a number
pins it. Pair it with onWeekChange to drive a roster or a task list beside the
card.
- showRoster={false} and showBreakdown={false} turn the card into a dashboard
tile — headline, rail and target line only — without losing the accessible
week table.
- order="least-available" re-sorts the roster by how much of the SELECTED week
each person loses, which is the order you want when the card is being used to
reassign work; "given" keeps the contract's order for a stable roster.
- target moves the dashed line and the "weeks below target" count; set it from
the team's own service level rather than leaving it at 0.8.
- weekStartsOn switches the bucket boundary between Monday and Sunday; the
labels, the ranges and the accessible tables all follow it.
- formatDays re-points every person-day figure (hours instead of days, a
locale-aware number, "3½").
- Reason vocabulary: the four codes and their long and short labels are exported
as plain records — translate them, or narrow the enum to the two your HR feed
actually emits and drop the matching textures.
- Palette: absence is ink and the accent is spent on the selection. Swapping
which of var(--chart-1..5) marks the selection is safe; giving each reason its
own hue is not, unless you keep the textures as well — five stacked hues at
28px square is exactly the chart that stops working in greyscale.
- Density: the rail height, the square height and whether roles print under
names are the three dials, in that order.Concepts
- Exceptions, not attendance — the feed sends only the days someone is not fully in, and everybody unlisted is assumed present. That is the shape calendar and HR systems actually expose, and it has a structural payoff: the denominator is fixed by the working-day list alone, so a missing or duplicated absence row can move the numerator but can never quietly change what "100%" means.
- The partial day stays a fraction — availability is a fraction of one working day, carried through the sum untouched. Rounding a half day up to a whole absence overstates the hole and rounding it away hides it, so a week of four half days reads 90% rather than 80% or 100%. This is the one behaviour the whole card exists to protect, which is why the fraction is stated in the footnote and repeated in the accessible table.
- Cause and degree are orthogonal — why someone is out (
pto,holiday,partial,other) and how much of the day is gone are separate fields. A half day of leave is PTO at 0.5, not "partial";partialis reserved for structurally reduced capacity like a four-day contract. Collapsing the two is what makes a reason breakdown double-count. - The selected week is the only focal element — picking a week is the card's single interaction, so the accent colour is spent entirely on that one bar and everything else stays neutral ink. Selection travels by pointer or by a roving tabindex, and because a week at zero coverage has no bar left to colour, the selection is also drawn as a rule under the column.
- Largest-remainder shares — the away split is a partition of one quantity, so its percentages have to total exactly 100. Flooring each share and handing the leftover points to whichever were cut hardest guarantees that; rounding each independently is how a breakdown ships reading 99% or 101% under a heading that promises otherwise.
- Out-of-window entries are named, not dropped — a row dated on a weekend or past the end of the window cannot be counted, because the denominator does not contain that day. Saying how many were ignored turns a silent feed bug into a visible one; silently discarding them is how a broken export survives a whole quarter.
Release Readiness
A four-state go/no-go board that rolls per-workstream checks, sign-offs and open blockers into one derived verdict, leads with the single number that reaches zero exactly when the release is clear, and refuses to call an unread board.
Milestone Health
A four-state milestone slip chart — every row runs from the committed baseline to today's forecast on one shared date axis, with earlier forecasts left behind as a faded trail, a recorded RAG status, per-milestone confidence, and the worst open slip printed as the one number the card leads with.