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.
npx shadcn add https://design.helpmarq.com /r/gantt-ascii-critical-path.jsonregistry/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 ─ 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>
);
}
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 <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.