Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildMilestoneHealthLayout,
  civilFromDays,
  inspectMilestoneHealthData,
  isoFromDay,
  positionOf,
  type ChartMilestoneHealthData,
  type ChartMilestoneHealthRag,
  type MilestoneHealthOrder,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartMilestoneHealth" card with zod, in
plain DOM (no chart library, no canvas). It is not a Gantt chart with fewer
features: a Gantt row is one span of work, this row is TWO DATES FOR THE SAME
EVENT — the date that was committed and the date now believed — and the whole
product is the distance between them.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    description?: string; asOf?: string;
    items: { id: string; label: string; baseline: string; forecast: string;
             rag: "green" | "amber" | "red"; confidence?: number;
             owner?: string; landed?: boolean;
             revisions?: { at: string; forecast: string }[] }[] }.
- baseline is the committed date, fixed at sign-off and NEVER rebased — a plan
  that rebases at every review cannot report slip at all, which is exactly how
  slip gets hidden. forecast is today's belief, or the actual date once landed.
- rag is carried, not derived. A milestone can be twelve days late and green
  (its blocker just cleared) or sitting on its baseline and red (the date only
  holds because scope was cut). Colouring rows by the arithmetic would
  overwrite the one thing the arithmetic cannot see; say so in the schema doc
  comment.
- confidence is optional, because "nobody has given a number" is a real state.
  Print it as "no confidence", never as 0%.
- revisions are earlier forecasts. `at` is the review date: it ORDERS the
  trail, it never positions it. Every mark on the axis is a target date.
- asOf is the injected "today". The component never calls Date.now(): a chart
  that reads the clock while rendering draws one picture on the server and
  another in the browser, and can never be snapshot-tested.
- Component props = z.infer of the schema plus order: "baseline" | "slip" |
  "input" (default "baseline"), showTrail (default true), showMix (default
  true), locale (default "en-US"), formatDate, onSelectMilestone, onRetry,
  className and the native div props through forwardRef.
- Ship a pure module beside the schema: parseCivilDay / civilFromDays /
  isoFromDay, inspectMilestoneHealthData for the structural pass,
  apportionPercentages, medianOf, positionOf, and buildMilestoneHealthLayout
  returning the rows, the skipped rows, the padded domain, the RAG counts and
  shares, the tallies and the worst open slip.

Behavior — the arithmetic, which is the product
- DATES ARE INTEGER DAY NUMBERS. Parse "YYYY-MM-DD" (optionally followed by a
  time, which is discarded) with Howard Hinnant's days_from_civil, never with
  new Date(string). A milestone lands on a day, not at an instant, so no
  timezone and no DST transition may move a marker or change a slip by one.
  Every printed date is formatted back FROM THAT DAY NUMBER — slicing the
  incoming string, or handing a UTC instant to a local formatter, is how a card
  claims the 26th in Sydney and the 25th in Lisbon.
- slip = forecast − baseline, in whole days. Positive is late, negative is
  early, and early has to render as gracefully as late: the bar simply runs the
  other way from the baseline tick.
- THE TRAIL IS SLIP OVER TIME. Sort revisions by review date and draw each
  earlier forecast as a faded ring on the SAME axis. A row of rings walking
  right is a date that moves a little at every review, which is a different
  diagnosis from one honest correction — and it is the reading no timeline of
  instants can give. lastMove = forecast − the most recent revision's forecast.
- THE HEADLINE IS THE WORST OPEN SLIP. Landed milestones are excluded: the one
  number is about work that can still be saved. When everything has landed,
  fall back to the largest slip in the set and say which rule was used. Ties go
  to the earlier baseline. With no interaction the card explains itself;
  hovering or focusing a row retargets the number, pressing a row pins it
  (aria-pressed plus onSelectMilestone), and a pin outranks a pointer, a
  pointer the focus ring.
- THE MIX BAR USES LARGEST-REMAINDER APPORTIONMENT. Rounding three shares
  independently prints 33/33/33 for one split and 34/33/34 for another, and a
  reader adding up a mix bar is entitled to 100. Floors first, leftover points
  to the biggest fractional parts, ties to the earliest index.
- Median confidence with integer indices only: the middle value for an odd
  count, the mean of the two central ones for an even count. State the method —
  "the 50th percentile" has half a dozen definitions and a fractional array
  index is how every one of them goes wrong.
- DEGENERATE DATA, each handled deliberately: no items → the empty branch; one
  milestone whose baseline, forecast and asOf are the same day → the domain
  would have zero span and every position a division by zero, so pad it by a
  day on each side; a milestone with an unreadable date → skip that row, keep
  the rest, name it in the footnote; a revision missing a date → count it, do
  not drop it in silence, or "the forecast moved twice" quietly becomes "it
  moved once"; every row unreadable → the error branch with the count, not an
  empty axis that still looks like a working chart; two rows sharing an id →
  refuse in the contract, because an id selects a row and a duplicate pins two
  at once.
- The four states are first-class branches of one bg-card panel: a skeleton
  shaped like the card it replaces (headline block, mix bar, four rows),
  aria-hidden with an sr-only role="status"; an empty state; an error state
  carrying either the transport message or the specific contract refusal, plus
  a "Try again" button only when onRetry exists; and ready.

Rendering & styling
- Layout: no measurement and no ResizeObserver. Positions are percentages of
  the shared domain, so the chart is responsive for free and there is no
  observer, frame or timer to tear down. Rows are `-mx-3 px-3`, which nets to
  zero, so their content box equals the list's — that is what lets one axis row
  underneath line its labels up with every marker above it.
- The focal block is the point of the design: a small uppercase muted eyebrow,
  one oversized tabular numeral (text-5xl, sm:text-6xl) carrying a signed
  value, a small unit beside it, and one muted caption naming the milestone,
  its two dates, its status and its confidence. Everything else is quiet.
  Hierarchy comes from size and weight, not from boxing each block in a card.
- Colour: var(--chart-2) green, var(--chart-3) amber, var(--chart-5) red —
  hues 150 / 65 / 20 on the categorical ramp, so the three separate in both
  themes. Colour is never the only encoding: every row prints its status as a
  word, the mix caption names each band with a count, a landed date is a
  DIAMOND rather than a dot, and the slip is a number either way. Inactive rows
  and the whole trail rest at 45% opacity, so exactly one row at a time is the
  focal element.
- A row is one button: RAG dot plus label, a caption line with the status and
  "baseline → forecast", the signed slip in tabular numerals, a slim confidence
  meter, then the track — rail, dashed today line, slip bar, trail rings,
  baseline tick, forecast marker.
- Accessibility contract: a figure whose sr-only figcaption is the actual
  finding — the tallies, the worst open slip with both its dates, what moved at
  the last review, median confidence, and anything that could not be placed.
  The track is aria-hidden (it is text-free geometry) and every number in it
  lives in the row button's aria-label, which STARTS with the visible milestone
  name so the accessible name contains the visible label. Below it an sr-only
  WRAPPER DIV holds a real table, one row per milestone. Put sr-only on the
  wrapper, never on the table — 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.
- Keyboard: Tab reaches every row, focus retargets the headline, Enter or Space
  pins. A pointer leaving a row must not clear a pin.
- Motion: an opacity transition on the active row plus the skeleton pulse, both
  with motion-reduce variants. Nothing about reading the card depends on
  motion.

Customization levers
- order="slip" turns the card into a triage list, worst first; order="baseline"
  keeps the plan's own narrative; "input" defers to the feed.
- showTrail={false} drops the earlier forecasts from the picture but keeps them
  in the row descriptions and the table — use it for a dense dashboard tile
  where a row is a status line rather than a history.
- showMix={false} removes the aggregate band when the card sits beside a
  portfolio widget that already reports the RAG split.
- asOf is optional: omit it and the today line and its footnote disappear,
  which is what a card about a finished programme wants.
- locale and formatDate re-point every printed date (ISO for engineers, a long
  form for a board pack); formatDate receives the normalised "YYYY-MM-DD" the
  geometry was computed from.
- Palette: the three RAG tokens are the only colours. Re-point them at your own
  status ramp and both themes follow; keep the diamond and the status word so
  the card survives greyscale printing.
- Density: drop the confidence meter first, the caption line second, the axis
  labels last — the bar and the number are the irreducible pair.
- Interaction: onSelectMilestone is the drill-in hook. Lift the pinned id into
  a parent to sync a detail panel, an issue list or a comment thread beside the
  chart.

Concepts

  • Slip — the signed distance in whole days from the date that was committed to the date now believed. It is the reading this card exists for, and it is why the two dates share one row instead of sitting in two columns: a length is comparable at a glance, a pair of dates is arithmetic the reader has to do.
  • Baseline that never rebases — the committed date is frozen at sign-off. Re-baselining at every review is the standard way a slipping plan reports itself as green, so the contract states outright that a feed which moves its baseline cannot report slip at all.
  • Forecast trail — each earlier forecast is drawn as a faded ring on the same axis, oldest review first. Rings walking steadily right mean a date that moves a little at every review, which is a different diagnosis, and a different conversation, from one honest correction — and neither a Gantt bar nor a timeline of instants can show it.
  • Worst open slip — the headline excludes milestones that have already landed, because the one number is about work that can still be saved. When everything has landed the card falls back to the largest slip in the set and says so, rather than printing a confident number under a rule the reader cannot see.
  • RAG as recorded judgement — the traffic light comes from the review, not from the slip. A late-but-green milestone whose blocker just cleared, and an on-time-but-red one whose date only holds because scope was cut, are the whole reason a status column exists; deriving the colour would delete both.
  • Largest-remainder apportionment — the mix is floored first and the leftover points handed to the largest fractional parts, so the printed shares add up to 100 and the painted bar is exactly full. Rounding each share on its own is how a three-way split prints 33 / 33 / 33 and a reader who adds them up loses a percent.

On This Page