Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  absenceKey,
  buildTeamAvailabilityLayout,
  coveragePercent,
  inspectTeamAvailabilityData,
  largestRemainderShares,
  teamAvailabilityReasonLabels,
  teamAvailabilityReasonShortLabels,
  teamAvailabilityReasons,

Installation

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

Prompt

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 orthogonalwhy 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"; partial is 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.

On This Page