Charts

Critical Path

A four-state task dependency board that derives the schedule from durations and links alone — a forward and backward pass, total and free float per task, the zero-float route drawn in the one accent colour, and a cyclic plan named rather than walked forever.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import {
  buildCriticalPathLayout,
  chainThrough,
  inspectCriticalPathData,
  isCriticalLink,
  lastWorkedDay,
  parsePlanStart,
  planDate,
  type ChartCriticalPathData,

Installation

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

Prompt

Build a React + TypeScript + Tailwind "ChartCriticalPath" card with zod. It is
not a Gantt chart with arrows: a Gantt draws a schedule somebody already
decided, this one is HANDED DURATIONS AND LINKS AND DERIVES THE SCHEDULE. The
product is the two derived numbers — how long the plan takes, and how far each
task can slip before the plan does — so a build that draws the graph and skips
the float has missed the point.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    caption?: string; errorMessage?: string;
    tasks: { id: string; label: string; duration: number;
             dependsOn?: string[]; owner?: string }[];
    startDate?: string }.
- duration is in days and non-negative. Zero is meaningful: it is a milestone,
  a gate that takes no time but still waits for its predecessors.
- dependsOn holds finish-to-start predecessors, the only link type modelled.
  Every id must name a task in the same array.
- startDate is a CALENDAR DAY, "YYYY-MM-DD", not an instant. Parse it field by
  field into UTC midnight, round-trip it back through getUTC* so "2026-02-30"
  is rejected rather than silently becoming 2 March, and derive every printed
  date as that anchor plus a whole number of 86 400 000 ms formatted in UTC.
  Never slice an ISO string to display a day, and never build a date from one
  value and print another — that is how a plan prints the wrong day for every
  reader east of you and again on the far side of a daylight-saving boundary.
  With no startDate, report in day offsets ("day 0 to day 38") rather than
  invent a calendar.
- Component props = z.infer of the schema plus tightFloatDays (default 2,
  clamped 0-365), nodeWidth (default 156, clamped 120-260), showFloatBars,
  showFloatMix, formatDuration, onRetry, emptyState, className and the native
  div props through forwardRef.
- Ship a pure module beside the schema: inspectCriticalPathData() for the
  structural pass, buildCriticalPathLayout() for the schedule, isCriticalLink(),
  chainThrough(), percentileOf(), apportionPercents(), parsePlanStart(),
  planDate() and lastWorkedDay().

Behavior — the maths, which is the product
- REFUSE BEFORE YOU SCHEDULE. In this order: duplicate ids (dependencies
  resolve by id, so one task would inherit the other's predecessors), a task
  depending on itself, a link pointing at an id that is not in the plan
  (dropping it shortens the critical path and the chart would report a finish
  date the team cannot hit), a non-finite or negative duration, and finally a
  cycle. Each refusal names the tasks involved and renders in the error branch.
- A CYCLE IS A FIRST-CLASS ANSWER, NOT A HANG. Find it with an ITERATIVE
  depth-first colouring (white / on-the-current-path / finished) over an
  explicit stack — recursion blows the JS stack on a deep generated plan — and
  when a link lands back on the path you are standing on, slice the stack from
  that task to report the loop IN EDGE ORDER. Say plainly why there is no
  answer: a critical path is the longest route through a plan, and a loop has
  no longest route because every lap round it is longer than the last.
- Topological order by Kahn's algorithm. Each task enters the queue at most
  once, so it terminates on any graph including a cyclic one, where it simply
  stops early. If a cycle reaches the scheduler anyway, append the leftovers in
  input order — a meaningless board beats a hung tab.
- FORWARD PASS: earliest start is the largest earliest-finish among the
  predecessors; earliest finish is that plus the duration; the plan's duration
  is the largest earliest finish anywhere. Compute the longest-path rank in the
  same sweep — that rank is the board column, and it is what makes every link
  point rightwards.
- BACKWARD PASS: latest finish is the smallest latest-start among the
  successors, or the plan's finish for a task nothing waits on; latest start is
  that minus the duration.
- TOTAL FLOAT = latest start − earliest start: how far a task can slip before
  the PLAN slips. FREE FLOAT = the earliest start of the soonest successor
  minus this task's earliest finish: how far it can slip before the NEXT TASK
  slips. Report both; they differ exactly where a delay is absorbed by a queue
  rather than by the schedule, and that difference is the finding.
- Zero float is the critical path. Compare against an epsilon, not against an
  exact zero: fractional durations make latestStart − earliestStart land on
  4.44e-16 and an exact test would leave the chart with no critical path at all.
- A LINK IS CRITICAL ONLY IF BOTH ENDS ARE CRITICAL *AND* THE SUCCESSOR STARTS
  THE INSTANT THE PREDECESSOR FINISHES. Two critical tasks can sit either side
  of a link with room in it — the successor is held up by a different
  predecessor — and painting that link accent draws a route the delay does not
  travel along. Ties are real: two routes of equal length are both critical, so
  colour every zero-give link and let prose name just one of them.
- Bucket the tasks by float into critical / tight (≤ tightFloatDays) /
  comfortable and print the split as whole percentages that sum to EXACTLY 100,
  by largest remainder with ties going to the earlier bucket. Rounding each
  share on its own prints 99 or 101 and a strip that does not add up is the
  first thing a reader notices.
- Every denominator is guarded: an empty plan apportions to zeros instead of
  dividing by a total of 0; a plan of all milestones has a duration of 0, so the
  bar scale falls back to 1 and each bar collapses to its minimum tick; a
  single task is its own critical path; five tasks with no links at all are one
  column of five rows, where the critical path is just the longest task.
- Percentiles state their method: median float by linear interpolation between
  order statistics (R-7 / PERCENTILE.INC). The position is fractional, the index
  never is — sorted[7.5] is undefined and undefined formats as "NaN days".
- EARLIEST FINISH IS A BOUNDARY, NOT A DAY. A task starting on day 17 and
  lasting nine days finishes at the instant day 26 begins, so the last day
  anybody touches it is day 25. Print the boundary as the finish date and every
  task in the plan looks a day longer than it is.
- The four states are first-class branches of one bg-card panel: a fixed
  skeleton silhouette (aria-hidden, plus 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. status="ready" with no tasks falls through to the empty copy.

Rendering & styling
- Layout is ARITHMETIC, not measurement: column pitch = nodeWidth + gap, row
  pitch = card height + gap, so the board's size is known before paint. No
  ResizeObserver, no animation frame, no global listener and therefore nothing
  to tear down — which is also why it renders identically on the server.
- ONE ACCENT, ONE FOCAL THING. var(--chart-1) marks the critical path and
  nothing else: the rail down a critical card, the filled part of its slack bar,
  the zero-give links, the first segment of the mix strip. Everything else is
  bg-card / bg-muted / border / text-muted-foreground. Hierarchy comes from size
  and weight — an oversized numeral for the plan length, a small label, a muted
  caption — not from boxing every reading in its own card.
- Colour is never the only encoding: a critical card is also bolder, its float
  line reads "On the critical path" in words, and the sr-only table has a column
  for it.
- Cards sit on a soft bg-muted ground, the links are drawn UNDER them in one
  SVG so a link spanning several columns passes behind an intervening card and
  re-emerges on the same line instead of drawing over it. Cubic curves with
  HORIZONTAL control points, plus a small filled arrowhead at the target so the
  direction of the dependency is explicit.
- Each card carries a mini-timeline of the whole plan on one shared scale:
  offset = earliest start, a solid segment as long as the duration, then a
  HATCHED segment as long as the total float. Position says when the task sits,
  length says how long it takes, hatching says "room to move, not work being
  done". A milestone is a tick; every width is clamped so nothing paints past
  the track.
- Rows are ordered critical-first inside each column, so the path reads as a
  line across the top and everything with room hangs below it.
- The board is horizontally scrollable and the card never overflows its
  container; min-w-0 the whole flex chain or the panel stretches instead.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
  finding — plan length, the route, how many tasks sit on it, who has the most
  room, the median float and the stated method. Each task is a real <button>
  with aria-pressed, reachable by Tab, with a focus-visible ring. Its
  aria-label OPENS WITH THE EXACT STRINGS THE CARD PRINTS (label, meta line,
  float line) before adding what only the geometry shows, so the visible label
  is contained in the accessible name (WCAG 2.5.3). The SVG is aria-hidden and
  focusable="false". Below the board an sr-only WRAPPER DIV holds a real table,
  one row per task. 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.
- Interaction: hovering a card previews the chain a delay would travel — every
  ancestor and descendant, found by a BREADTH-FIRST walk with a seen set so it
  terminates on any graph — and clicking pins it. Everything outside the chain
  dims. Escape clears the pin. The visible readout line is aria-hidden and
  mirrored into an sr-only role="status" that speaks ONLY for a pinned task: a
  hover a screen-reader user never made must not talk.
- Motion: the only animation is the skeleton pulse and an opacity transition on
  dimming, both with motion-reduce variants. Nothing about reading the chart
  depends on motion.

Customization levers
- tightFloatDays moves the line between "nearly critical" and "comfortable"
  without touching the maths — 1 for a tight release train, 5 for a quarter.
- nodeWidth is the density dial (120 for a dense board, 260 when task names are
  long); the card height is fixed so the label always gets its two clamped
  lines.
- showFloatBars={false} keeps every float in the readout, the footer and the
  table but stops drawing the per-task window — use it for a dashboard tile
  where the card is a picture with a caption. showFloatMix={false} drops the
  strip the same way.
- formatDuration re-points every printed duration ("1.5 wk", "12 h") without
  touching the schedule; the hero numeral stays a bare number with a fixed
  "days" label by design.
- startDate is the calendar switch: supply it for real dates, omit it for day
  offsets. Durations are calendar days — feed working-day durations through your
  own calendar first if the plan has to skip weekends.
- Palette: re-point var(--chart-1) to change what "critical" looks like. Adding
  a second hue is the one change that costs you the design — the whole card is
  built so exactly one thing is loud.
- Interaction: the readout is deliberately one line and there is no tooltip.
  Lift the pinned id into a parent to drive a detail panel beside the board, or
  wire the card's onClick to open the ticket.

Concepts

  • Forward and backward pass — one sweep in topological order gives every task the soonest it can start; one sweep back gives the latest it can start without moving the finish date. The chart exists in the gap between those two answers, which is why it needs durations and links rather than the dates a Gantt is handed.
  • Total float versus free float — total float is how far a task can slip before the plan slips; free float is how far it can slip before the next task slips. They diverge exactly where a delay would be absorbed by a queue instead of by the schedule, so a task with nine days of total float and none free is a task whose slip is somebody else's problem immediately.
  • Zero-give link — a link is only critical when both ends have no float and the successor starts the instant the predecessor finishes. Two critical tasks either side of a link with room in it are common — the successor is held up by a different predecessor — and colouring that link would draw a route the delay never travels along.
  • A cycle has no longest route — the critical path is the longest way through a plan, so a loop has no answer at all: every lap round it is longer than the last. The honest response is to name the tasks holding each other up, in order, rather than to pick an arbitrary entry point and draw something plausible.
  • Ties are real — two routes of exactly equal length are both critical. Prose has to name one of them, but the picture must not arbitrate: every zero-give link is drawn in the accent, so the reader sees that there are two ways to be late rather than one.
  • Chain preview — hovering a task lights everything upstream and downstream of it, found by a breadth-first walk with a seen set, and dims the rest. That is the set a day lost here would travel through, which is a different question from "is this task on the critical path" and the one people actually ask before they move a date.

On This Page