Gantt ASCII Critical Path

Gantt

An ASCII Gantt chart where a real CPM forward/backward pass decides which bars are critical: zero-float tasks draw heavy `━` in accent, the rest light `─`, and dragging a task re-runs the pass so the critical chain moves.

Install
npx shadcn add https://design.helpmarq.com/r/gantt-ascii-critical-path.json
Source
registry/core/gantt-ascii-critical-path/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// GanttAsciiCriticalPath — a project schedule whose critical path is COMPUTED,
// not annotated. A real CPM pass (Kahn topological order -> ES/EF forward ->
// LS/LF backward -> totalFloat = LS - ES) runs in one useMemo and decides one
// thing: a task with zero float draws the heavy accented set (┝━━┥), every
// other task the light set (├──┤). Dragging a bar re-runs the pass on the same
// frame, so handing slack to a different chain swaps the weights mid-gesture.
//
// No canvas, no rAF: every recompute is a direct response to pointer or key
// input. Empty timeline cells render nothing at all — the ink sits in the bars,
// the week ticks and the one `┊` today column.
//
// NO DEPENDENCY ARROWS. A connector lane anchored to the successor's row can
// place a predecessor's corner glyph at the right COLUMN but never at the right
// ROW, so every edge reads as leaving whatever task sits directly above. Doing
// it truthfully needs vertical routing across intervening rows — more apparatus
// than the edge earns: the bar WEIGHT already says which chain the ship date
// rides on, and that reads at rest with no pointer.
//
// PITCH: the timeline grid is laid out in `ch` units, not pixels. In a
// monospace face 1ch is exactly one advance width, so consecutive `─`/`━`
// glyphs butt up into a continuous rule at ANY font size and in any fallback
// font — a hardcoded px pitch dashes the bars the moment the two disagree.
// The drag handler needs a pixel figure, so it measures the row grid's real
// width / cols at pointerdown rather than assuming one.
// ---------------------------------------------------------------------------

export interface GanttTask {
  /** stable id, referenced by other tasks' deps */
  id: string;
  label: string;
  /** duration in days */
  duration: number;
  /** ids this task cannot start before */
  deps?: string[];
}

export interface GanttAsciiCriticalPathProps {
  tasks?: GanttTask[];
  /** timeline cells drawn per row */
  cols?: number;
  /** day index marked by the `┊` column. A fixed prop, never new Date() */
  today?: number;
  /** week number printed at the first tick */
  startWeek?: number;
  title?: string;
  className?: string;
}

const LABEL_CHARS = 16;

const DEFAULT_TASKS: GanttTask[] = [
  { id: "discovery", label: "Discovery interviews", duration: 4, deps: [] },
  { id: "wireframes", label: "Wireframe review", duration: 3, deps: ["discovery"] },
  { id: "design-system", label: "Design system", duration: 5, deps: ["wireframes"] },
  { id: "api-contract", label: "API contract", duration: 3, deps: ["discovery"] },
  { id: "integrate-payments", label: "Integrate payments", duration: 7, deps: ["api-contract"] },
  { id: "checkout-ui", label: "Checkout UI build", duration: 6, deps: ["design-system"] },
  { id: "launch-comms", label: "Launch comms", duration: 2, deps: ["design-system"] },
  {
    id: "qa-regression",
    label: "QA regression pass",
    duration: 4,
    deps: ["checkout-ui", "integrate-payments"],
  },
  { id: "ship", label: "Production rollout", duration: 3, deps: ["qa-regression", "launch-comms"] },
];

type Computed = {
  es: number;
  ef: number;
  ls: number;
  lf: number;
  float: number;
  critical: boolean;
  cyclic: boolean;
};

function padLabel(label: string): string {
  if (label.length <= LABEL_CHARS) return label.padEnd(LABEL_CHARS, " ");
  return `${label.slice(0, LABEL_CHARS - 1)}…`;
}

export function GanttAsciiCriticalPath({
  tasks = DEFAULT_TASKS,
  cols = 32,
  today = 9,
  startWeek = 12,
  title = "Checkout replatform",
  className = "",
}: GanttAsciiCriticalPathProps) {
  const [offsets, setOffsets] = useState<Record<string, number>>({});
  const [hovered, setHovered] = useState<string | null>(null);
  const [focused, setFocused] = useState<string | null>(null);
  const [dragId, setDragId] = useState<string | null>(null);
  const dragRef = useRef<{
    id: string;
    startX: number;
    startOffset: number;
    prev: number;
    cellPx: number;
  } | null>(null);

  // --- CPM: Kahn topological order, forward pass, backward pass -------------
  const { plan, projectEnd, scale, bases } = useMemo(() => {
    const byId = new Map(tasks.map((t) => [t.id, t]));
    const indeg = new Map<string, number>();
    const succ = new Map<string, string[]>();
    for (const t of tasks) {
      const deps = (t.deps ?? []).filter((d) => byId.has(d) && d !== t.id);
      indeg.set(t.id, deps.length);
      for (const d of deps) succ.set(d, [...(succ.get(d) ?? []), t.id]);
    }
    const queue = tasks.filter((t) => (indeg.get(t.id) ?? 0) === 0).map((t) => t.id);
    const order: string[] = [];
    while (queue.length) {
      const id = queue.shift() as string;
      order.push(id);
      for (const s of succ.get(id) ?? []) {
        const n = (indeg.get(s) ?? 0) - 1;
        indeg.set(s, n);
        if (n === 0) queue.push(s);
      }
    }
    // Anything Kahn never drained sits on a cycle: excluded, never hung on.
    const inOrder = new Set(order);

    // FORWARD: ES = max(0, max(EF of deps) + manualOffset), EF = ES + duration
    const es = new Map<string, number>();
    const ef = new Map<string, number>();
    // earliest day a task could start on dependencies alone, before its manual
    // offset — the floor an ArrowLeft nudge must not drift below.
    const base0 = new Map<string, number>();
    for (const id of order) {
      const t = byId.get(id) as GanttTask;
      let base = 0;
      for (const d of t.deps ?? []) if (inOrder.has(d)) base = Math.max(base, ef.get(d) ?? 0);
      base0.set(id, base);
      const start = Math.max(0, base + (offsets[id] ?? 0));
      es.set(id, start);
      ef.set(id, start + Math.max(1, t.duration));
    }
    const end = order.reduce((m, id) => Math.max(m, ef.get(id) ?? 0), 1);

    // BACKWARD, reverse topological: LF = min(LS of successors) or projectEnd
    const ls = new Map<string, number>();
    const lf = new Map<string, number>();
    for (let i = order.length - 1; i >= 0; i--) {
      const id = order[i];
      const t = byId.get(id) as GanttTask;
      const outs = (succ.get(id) ?? []).filter((s) => inOrder.has(s));
      const late = outs.length ? Math.min(...outs.map((s) => ls.get(s) ?? end)) : end;
      lf.set(id, late);
      ls.set(id, late - Math.max(1, t.duration));
    }

    const map = new Map<string, Computed>();
    for (const t of tasks) {
      if (!inOrder.has(t.id)) {
        map.set(t.id, { es: 0, ef: 0, ls: 0, lf: 0, float: 0, critical: false, cyclic: true });
        continue;
      }
      const f = (ls.get(t.id) as number) - (es.get(t.id) as number);
      map.set(t.id, {
        es: es.get(t.id) as number,
        ef: ef.get(t.id) as number,
        ls: ls.get(t.id) as number,
        lf: lf.get(t.id) as number,
        float: f,
        // <= rather than ===: a drag can push a task to negative float
        // mid-gesture, and that row is still the tight one, not a slack one.
        critical: f <= 0,
        cyclic: false,
      });
    }
    return { plan: map, projectEnd: end, bases: base0, scale: Math.max(1, Math.ceil(end / cols)) };
  }, [tasks, offsets, cols]);

  // --- drag: pixel dx -> whole cells -> manualOffset, CPM re-runs live ------
  useEffect(() => {
    if (!dragId) return;
    const move = (e: PointerEvent) => {
      const d = dragRef.current;
      if (!d) return;
      const cells = Math.round((e.clientX - d.startX) / d.cellPx);
      setOffsets((o) => ({ ...o, [d.id]: d.startOffset + cells * scale }));
    };
    const end = () => {
      dragRef.current = null;
      setDragId(null);
    };
    const key = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      const d = dragRef.current;
      if (d) setOffsets((o) => ({ ...o, [d.id]: d.prev }));
      dragRef.current = null;
      setDragId(null);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", end);
    window.addEventListener("pointercancel", end);
    window.addEventListener("keydown", key);
    return () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", end);
      window.removeEventListener("pointercancel", end);
      window.removeEventListener("keydown", key);
    };
  }, [dragId, scale]);

  // Clamped both ways against the task's dependency-earliest start: at −base so
  // repeated ArrowLeft on a task already sitting at day 0 cannot accumulate
  // invisible negative offset that a later ArrowRight has to spend itself
  // unwinding, and at cols*scale − base so held ArrowRight cannot run ES past
  // aria-valuemax while the bar saturates at the last column. aria-valuenow
  // (the clamped ES) therefore stays in step with the stored offset.
  const nudge = (id: string, dir: number) =>
    setOffsets((o) => {
      const base = bases.get(id) ?? 0;
      const next = (o[id] ?? 0) + dir * scale;
      return { ...o, [id]: Math.min(cols * scale - base, Math.max(-base, next)) };
    });

  const colOf = (day: number) => Math.floor(day / scale);
  const todayCol = colOf(today);

  const geom = (id: string) => {
    const c = plan.get(id) as Computed;
    const start = Math.min(cols - 2, Math.max(0, colOf(c.es)));
    const len = Math.max(2, Math.min(cols - start, Math.round((c.ef - c.es) / scale)));
    return { start, len, c };
  };

  const active = hovered ?? focused;

  const readout = (() => {
    if (!active) {
      const crit = tasks.filter((t) => plan.get(t.id)?.critical && !plan.get(t.id)?.cyclic).length;
      const slack = tasks.length - crit;
      return `${projectEnd} days  critical chain ${crit} tasks  ${slack} tasks carry float`;
    }
    const t = tasks.find((x) => x.id === active) as GanttTask;
    const c = plan.get(active) as Computed;
    if (c.cyclic) return `${t.label}  ×  circular dependency, excluded from the pass`;
    return `${t.label}  ES ${c.es}  EF ${c.ef}  LS ${c.ls}  LF ${c.lf}  float ${c.float} day${
      c.float === 1 ? "" : "s"
    }`;
  })();

  // 1ch === one monospace advance, so `─` runs join with no seam at any size.
  const cell = "inline-block text-center";
  const cellStyle = { width: "1ch" } as const;
  const labelStyle = { width: `${LABEL_CHARS}ch` } as const;
  const trackStyle = { gridTemplateColumns: `repeat(${cols}, 1ch)` } as const;

  return (
    <section
      data-gantt
      aria-label={`${title} — Gantt chart with computed critical path`}
      className={`inline-flex select-none flex-col gap-3 font-mono text-[15px] leading-none ${className}`}
    >
      <div className="flex items-baseline justify-between gap-6">
        <span className="text-[11px] uppercase tracking-[0.18em] text-muted">{title}</span>
        <span className="text-[10px] uppercase tracking-[0.18em] text-muted">
          <span className="text-accent">━</span> critical &nbsp; ─ has float
        </span>
      </div>

      <div className="flex flex-col gap-[3px]">
        {/* week labels */}
        <div className="flex items-center">
          <span className="shrink-0 whitespace-pre" style={labelStyle} />
          <span className={cell} style={cellStyle} />
          <div className="grid" style={trackStyle}>
            {Array.from({ length: cols }, (_, i) => {
              const day = i * scale;
              if (day % 7 !== 0) return null;
              return (
                <span
                  key={i}
                  className="whitespace-pre text-[10px] leading-none text-muted"
                  style={{ gridColumn: `${i + 1} / span 4` }}
                >
                  {`W${startWeek + day / 7}`}
                </span>
              );
            })}
          </div>
        </div>

        {/* ruler: `┬` every 7 days over a `─` rule, `┊` at today */}
        <div className="flex items-center">
          <span className="shrink-0 whitespace-pre" style={labelStyle} />
          <span className={`${cell} text-border`} style={cellStyle}>
            ┌
          </span>
          <div className="flex">
            {Array.from({ length: cols }, (_, i) => {
              const day = i * scale;
              const tick = day % 7 === 0;
              const isToday = i === todayCol;
              return (
                <span
                  key={i}
                  className={`${cell} ${
                    isToday ? "text-foreground" : tick ? "text-muted" : "text-muted/40"
                  }`}
                  style={cellStyle}
                >
                  {isToday ? "┊" : tick ? "┬" : "─"}
                </span>
              );
            })}
          </div>
        </div>

        {tasks.map((t) => {
          const { start, len, c } = geom(t.id);
          const isActive = active === t.id;
          const barGlyphs = c.critical
            ? `┝${"━".repeat(Math.max(0, len - 2))}┥`
            : `├${"─".repeat(Math.max(0, len - 2))}┤`;
          const todayFree = todayCol < start || todayCol >= start + len;

          return (
            <div
              key={t.id}
              data-task-row={t.id}
              className="flex items-center"
              onPointerEnter={() => setHovered(t.id)}
              onPointerLeave={() => setHovered((h) => (h === t.id ? null : h))}
            >
              <span
                className={`shrink-0 whitespace-pre text-[13px] transition-colors duration-[140ms] motion-reduce:transition-none ${
                  isActive || c.critical ? "text-foreground" : "text-muted"
                }`}
                style={labelStyle}
              >
                {padLabel(t.label)}
              </span>
              <span className={`${cell} text-border`} style={cellStyle}>
                │
              </span>

              {c.cyclic ? (
                <span className="whitespace-pre pl-1 text-[13px] text-muted">
                  ×  cycle — excluded
                </span>
              ) : (
                <div className="relative grid" style={trackStyle}>
                  <button
                    type="button"
                    role="slider"
                    aria-orientation="horizontal"
                    aria-valuemin={0}
                    aria-valuemax={cols * scale}
                    aria-valuenow={c.es}
                    aria-valuetext={`starts day ${c.es}, ends day ${c.ef}, ${
                      c.critical ? "on the critical path, no float" : `${c.float} days of float`
                    }`}
                    aria-label={`${t.label} schedule. Drag or use arrow keys to reschedule.`}
                    className="flex cursor-ew-resize items-center rounded-[2px] outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
                    style={{ gridColumn: `${start + 1} / span ${len}`, gridRow: 1 }}
                    onFocus={() => setFocused(t.id)}
                    onBlur={() => setFocused((f) => (f === t.id ? null : f))}
                    onPointerDown={(e) => {
                      // Measure the real pitch off the laid-out track instead
                      // of trusting a px constant to match the font metric.
                      const track = e.currentTarget.parentElement;
                      const w = track ? track.getBoundingClientRect().width : 0;
                      dragRef.current = {
                        id: t.id,
                        startX: e.clientX,
                        startOffset: offsets[t.id] ?? 0,
                        prev: offsets[t.id] ?? 0,
                        cellPx: w > 0 ? w / cols : 9,
                      };
                      setDragId(t.id);
                    }}
                    onKeyDown={(e) => {
                      if (e.key === "ArrowLeft") {
                        e.preventDefault();
                        nudge(t.id, -1);
                      } else if (e.key === "ArrowRight") {
                        e.preventDefault();
                        nudge(t.id, 1);
                      } else if (e.key === "Escape" && !dragId) {
                        // Guarded on !dragId: mid-drag the window keydown
                        // listener owns Escape (it restores the pre-drag
                        // offset) and both firing would fight.
                        e.preventDefault();
                        setOffsets((o) => ({ ...o, [t.id]: 0 }));
                      }
                    }}
                  >
                    {barGlyphs.split("").map((ch, k) => (
                      <span
                        key={k}
                        className={`${cell} ${c.critical ? "text-accent" : "text-muted"}`}
                        style={cellStyle}
                      >
                        {ch}
                      </span>
                    ))}
                  </button>

                  {todayFree && todayCol >= 0 && todayCol < cols && (
                    <span
                      aria-hidden
                      className={`${cell} text-foreground`}
                      style={{
                        gridColumn: `${todayCol + 1} / span 1`,
                        gridRow: 1,
                        width: "1ch",
                      }}
                    >
                      ┊
                    </span>
                  )}
                </div>
              )}
            </div>
          );
        })}
      </div>

      <p
        aria-live="polite"
        className="rounded-sm border border-border bg-background px-3 py-2 text-[11px] tabular-nums text-muted"
      >
        {readout}
      </p>
    </section>
  );
}
Use when

Pick gantt-ascii-critical-path when the point is which chain the delivery date rides on, and the reader should be able to drag a task and watch criticality move to a different chain. schedule-ascii-freebusy is for finding a free slot across several people's calendars rather than a dependency-scheduled plan; timeline-agent-lanes is for concurrent runtime activity in swimlanes; timeline-changelog-wave is for a chronological event feed with no dependency math.

Build spec

Build <GanttAsciiCriticalPath tasks? cols? today? startWeek? title? className?> as pure DOM box-drawing text — no canvas, no rAF, every recompute a direct response to pointer or keyboard input. MECHANISM: a genuine Critical Path Method pass, computed in ONE useMemo keyed on tasks + manualOffset + cols. Topological order comes from Kahn's algorithm over the dependency graph (indegree map, zero-indegree queue, self-edges and dangling dep ids filtered out first); any task Kahn never drains sits on a cycle, so it is excluded from the pass and renders its row as `× cycle — excluded` rather than hanging. FORWARD pass in topological order: ES = max(0, max(EF of deps) + manualOffset[id]), EF = ES + duration; projectEnd = max(EF). BACKWARD pass in REVERSE topological order: LF = min(LS of successors), or projectEnd when a task has none; LS = LF − duration; totalFloat = LS − ES; critical ⇔ totalFloat <= 0 (the <= rather than === is what keeps a row well-formed when a drag pushes a task into negative float mid-gesture — it is still the tight chain, not a slack one). ROW: a 16-char label column truncated with `…`, a `│` rule, then `cols` timeline cells (default 32) at scale = max(1, ceil(projectEnd / cols)) days per cell. BAR: non-critical = `├` + `─`×(len−2) + `┤` in text-muted; critical = `┝` + `━`×(len−2) + `┥` in text-accent — the WEIGHT change is the primary encoding, so the critical chain still reads in a pure-monochrome screenshot and hue is only reinforcement. Nothing else is drawn on the row: no slack run, no float glyphs — bar weight is the only encoding, and float stays a number in the readout. PITCH (the constant that makes or breaks the drawing): the timeline track is `grid-template-columns: repeat(cols, 1ch)` and every glyph cell is `width: 1ch`, NOT a hardcoded pixel figure. In a monospace face 1ch is exactly one advance width, so consecutive `─`/`━` glyphs butt into a continuous rule at any font size and under any fallback font; a px constant that disagrees with the font metric by even a pixel draws every bar as spaced dashes, which is the single most common way this component is got wrong. The drag handler is the one place a pixel number is needed, and it MEASURES it — `track.getBoundingClientRect().width / cols` read off the laid-out row at pointerdown — instead of assuming one. HEADER: `W12`-style labels computed from DAYS (startWeek + day/7, so they stay truthful at any scale) over a rule of `─` with `┬` at every 7-day tick; today is a fixed day-index prop (default 9, never derived from new Date(), so the frame is deterministic for a screenshot gate) drawn as a `┊` column in text-foreground repeated down every row, and drawn only where no bar already occupies that cell so glyphs never collide. Empty timeline cells render literally nothing — the ink concentrates in the bars, the ticks and the one `┊` instead of washing the frame in low-contrast filler. NO DEPENDENCY ARROWS ARE DRAWN, deliberately: a connector lane anchored to the successor's row can only place a predecessor's corner glyph at the right COLUMN, never at the right ROW, so every edge reads as leaving whichever task happens to sit directly above the successor; drawing edges truthfully needs vertical routing across intervening rows, which is more apparatus than the edge earns here. Dependencies stay in the data, where they drive ES/EF/LS/LF — the bar WEIGHT is what tells the reader which chain the ship date rides on, and that reads at rest with no pointer. INTERACTION: pointerdown on a bar records startX, the current offset and the measured cell pitch, then attaches window pointermove/pointerup/keydown listeners (window listeners, not setPointerCapture, which a synthetic pointerId would throw on); pointermove converts pixel dx to whole cells via round(dx / cellPx) and writes manualOffset[id], re-running the CPM memo on the same frame — which is the whole point: a task you drag can hand criticality to a different chain and you watch the `━`/`─` weights swap live. Escape during a drag restores the previous offset; Escape on a focused bar clears its manual offset entirely, returning the task to its computed schedule; ArrowLeft/ArrowRight nudge the focused task ±1 cell through the identical code path, clamped to [−base, cols*scale − base] where base is the task's dependency-earliest start: the lower clamp stops a left nudge on a task already sitting at day 0 from banking invisible negative offset that the next right nudge would spend unwinding, the upper stops a held right nudge from running ES past aria-valuemax while the bar saturates at the last column — every keypress moves the bar, and aria-valuenow stays in step with the stored offset. Hovering or focusing a task brightens its label and prints `Integrate payments ES 7 EF 14 LS 11 LF 18 float 4 days` into an aria-live=polite readout, which at rest prints the project summary (`25 days critical chain 6 tasks 3 tasks carry float`) instead. THEME: every colour is a token class (text-accent, text-muted, text-muted/40, text-foreground, text-border, border-border, bg-background) — no hex anywhere, so light and dark both render correctly. --border is reserved for the structural `│` and `┌` rules, which may safely recede. REDUCED MOTION: there is no animation loop at all; the only transition is the 140ms colour ease on the row label, carrying motion-reduce:transition-none so prefers-reduced-motion snaps instead of easing and nothing is lost. A11Y: each bar is a real <button> carrying role=slider with aria-orientation=horizontal, aria-valuemin=0, aria-valuemax=cols*scale, aria-valuenow=ES and an aria-valuetext naming its day range and float — the arrow keys are then the role's own expected operation rather than an undocumented extra on a button — plus an aria-label naming the task and the available action, a visible focus-visible:ring-2 ring-inset ring-accent on every bar, and full arrow-key operation; the readout is aria-live=polite so a screen reader hears the recomputed float. Props: tasks ({id,label,duration,deps}[], default a 9-task checkout replatform plan whose zero-float chain is discovery → wireframes → design-system → checkout-ui → qa-regression → ship, with api-contract carrying 4 days of float, integrate-payments 4 and launch-comms 8), cols (default 32), today (day index, default 9), startWeek (default 12), title, className.

Tags
ganttscheduletimelineasciibox-drawingcritical-pathdragproject-management