"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { Crosshair, GanttChartSquare, Layers, Plus, Route, Undo2, Users, ZoomIn, ZoomOut } from "lucide-react";
import { cn } from "@/lib/utils";
import { DEFAULT_MEMBERS, DEFAULT_TASKS, SEED_START, SEED_TODAY_OFFSET, fmtDay, isoToMs, localTodayIso, msToIso, offsetOf } from "./data";
import { GanttChart } from "./gantt-chart";
import { autoShift, buildRows, criticalSet, endOf } from "./schedule";
import { TaskDetails } from "./task-details";
import type { GanttTask, Member, Scale } from "./types";
export type { GanttTask, Member, Scale } from "./types";
export interface GanttPlannerAppProps {
projectName?: string;
/** ISO date (YYYY-MM-DD) that task offsets are relative to. Defaults to a date that puts "today" mid-project. */
projectStart?: string;
tasks?: GanttTask[];
members?: Member[];
defaultScale?: Scale;
defaultShowBaseline?: boolean;
defaultShowWorkload?: boolean;
/** Fires after every committed change (drag, resize, edit, add, delete, undo). */
onTasksChange?: (tasks: GanttTask[]) => void;
onTaskSelect?: (task: GanttTask | null) => void;
className?: string;
}
const SCALES: Scale[] = ["day", "week", "month"];
let uid = 0;
export function GanttPlannerApp({
projectName = "Orbit 2.0 launch",
projectStart: startProp,
tasks: tasksProp = DEFAULT_TASKS,
members = DEFAULT_MEMBERS,
defaultScale = "day",
defaultShowBaseline = false,
defaultShowWorkload = true,
onTasksChange,
onTaskSelect,
className,
}: GanttPlannerAppProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const [tasks, setTasks] = React.useState<GanttTask[]>(() => autoShift(tasksProp));
const [preview, setPreview] = React.useState<GanttTask[] | null>(null);
const [projectStart, setProjectStart] = React.useState(startProp ?? SEED_START);
const [todayOffset, setTodayOffset] = React.useState<number | null>(null);
const [scale, setScale] = React.useState<Scale>(defaultScale);
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [panel, setPanel] = React.useState(false);
const [focusName, setFocusName] = React.useState(false);
const [showCritical, setShowCritical] = React.useState(false);
const [showBaseline, setShowBaseline] = React.useState(defaultShowBaseline);
const [showWorkload, setShowWorkload] = React.useState(defaultShowWorkload);
const [flash, setFlash] = React.useState<{ ids: Set<string>; n: number }>({ ids: new Set(), n: 0 });
const [toast, setToast] = React.useState<{ id: number; text: string; undo: boolean } | null>(null);
const [jump, setJump] = React.useState(0);
const history = React.useRef<GanttTask[][]>([]);
const seq = React.useRef(0);
// Real "today" is only known on the client. The seeded project is re-anchored so today sits mid-plan.
React.useEffect(() => {
const id = setTimeout(() => {
const today = localTodayIso();
const start = startProp ?? msToIso(isoToMs(today) - SEED_TODAY_OFFSET * 86_400_000);
setProjectStart(start);
setTodayOffset(offsetOf(start, today));
}, 0);
return () => clearTimeout(id);
}, [startProp]);
const view = preview ?? tasks;
const { rows, all } = React.useMemo(() => buildRows(view, collapsed), [view, collapsed]);
const isLeaf = React.useCallback((t: GanttTask) => !view.some((c) => c.parentId === t.id), [view]);
const critical = React.useMemo(() => criticalSet(view, isLeaf), [view, isLeaf]);
const selectedRow = all.find((r) => r.task.id === selectedId) ?? null;
const leaves = view.filter(isLeaf);
const projectEnd = Math.max(0, ...leaves.map(endOf));
const baselineEnd = Math.max(0, ...leaves.map((t) => (t.baseline ? t.baseline.start + t.baseline.duration : endOf(t))));
const slip = projectEnd - baselineEnd;
const doneCount = leaves.filter((t) => !t.milestone && t.progress >= 100).length;
const workCount = leaves.filter((t) => !t.milestone).length;
const say = (text: string, undo = false) => {
const id = ++seq.current;
setToast({ id, text, undo });
setTimeout(() => setToast((t) => (t?.id === id ? null : t)), 4200);
};
const commit = (next: GanttTask[], message?: string, targetIds: string[] = []) => {
const before = new Map(tasks.map((t) => [t.id, t]));
const shifted = new Set(next.filter((t) => before.has(t.id) && before.get(t.id)!.start !== t.start && !targetIds.includes(t.id) && !next.some((c) => c.parentId === t.id)).map((t) => t.id));
history.current = [...history.current.slice(-30), tasks];
setTasks(next);
onTasksChange?.(next);
if (shifted.size) setFlash({ ids: shifted, n: ++seq.current });
if (message) say(shifted.size ? `${message} · ${shifted.size} dependent${shifted.size > 1 ? "s" : ""} shifted` : message, true);
};
const undo = () => {
const prev = history.current.pop();
if (!prev) return;
setTasks(prev);
onTasksChange?.(prev);
say("Change undone");
};
const select = (id: string | null, open = true) => {
setSelectedId(id);
setPanel(!!id && open);
setFocusName(false);
onTaskSelect?.(id ? (tasks.find((t) => t.id === id) ?? null) : null);
};
const update = (id: string, patch: Partial<GanttTask>) => {
const next = autoShift(tasks.map((t) => (t.id === id ? { ...t, ...patch } : t)));
const quiet = Object.keys(patch).every((k) => k === "name" || k === "progress" || k === "assigneeId");
if (quiet) {
setTasks(next);
onTasksChange?.(next);
} else commit(next, "Schedule updated", [id]);
};
const remove = (id: string) => {
const doomed = new Set([id]);
let grew = true;
while (grew) {
grew = false;
for (const t of tasks) {
if (t.parentId && doomed.has(t.parentId) && !doomed.has(t.id)) {
doomed.add(t.id);
grew = true;
}
}
}
const name = tasks.find((t) => t.id === id)?.name ?? "Task";
const next = tasks.filter((t) => !doomed.has(t.id)).map((t) => ({ ...t, deps: t.deps.filter((d) => !doomed.has(d)) }));
commit(next, `Deleted “${name}”`);
select(null);
};
const addTask = (groupId?: string) => {
const sel = selectedRow;
const gid = groupId ?? (sel ? (sel.isGroup ? sel.task.id : sel.task.parentId) : null) ?? [...tasks].reverse().find((t) => t.parentId === null)?.id ?? null;
const start = todayOffset ?? SEED_TODAY_OFFSET;
const task: GanttTask = { id: `task-${++uid}-${seq.current}`, name: "New task", parentId: gid, start, duration: 3, progress: 0, assigneeId: null, deps: [] };
const idx = (() => {
let last = -1;
tasks.forEach((t, i) => {
if (t.parentId === gid || t.id === gid) last = i;
});
return last + 1 || tasks.length;
})();
const next = [...tasks.slice(0, idx), task, ...tasks.slice(idx)];
commit(next, "Task added");
if (gid) setCollapsed((c) => new Set([...c].filter((x) => x !== gid)));
setSelectedId(task.id);
setPanel(true);
setFocusName(true);
};
const toggle = (id: string) =>
setCollapsed((c) => {
const n = new Set(c);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const zoom = (dir: 1 | -1) => setScale((s) => SCALES[Math.max(0, Math.min(2, SCALES.indexOf(s) - dir))]);
// Keyboard shortcuts (scoped to the app; ignored while typing).
const keys = React.useRef({ zoom, undo, addTask: () => addTask(), panel, close: () => setPanel(false) });
React.useEffect(() => {
keys.current = { zoom, undo, addTask: () => addTask(), panel, close: () => setPanel(false) };
});
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const a = document.activeElement;
if (a && a !== document.body && !rootRef.current?.contains(a)) return;
const t = e.target as HTMLElement | null;
const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
const k = keys.current;
if (e.key === "Escape" && k.panel) {
k.close();
return;
}
if (typing) return;
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
k.undo();
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
const map: Record<string, () => void> = {
d: () => setScale("day"),
w: () => setScale("week"),
m: () => setScale("month"),
"+": () => k.zoom(1),
"=": () => k.zoom(1),
"-": () => k.zoom(-1),
c: () => setShowCritical((v) => !v),
b: () => setShowBaseline((v) => !v),
l: () => setShowWorkload((v) => !v),
t: () => setJump((j) => j + 1),
n: () => k.addTask(),
};
const fn = map[e.key.toLowerCase()];
if (fn) {
e.preventDefault();
fn();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const toggleBtn = (on: boolean, onClick: () => void, Icon: typeof Route, label: string, key: string, tone: string) => (
<button
type="button"
onClick={onClick}
aria-pressed={on}
title={`${label} (${key})`}
className={cn(
"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
on ? tone : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-3.5" aria-hidden />
<span className="hidden sm:inline">{label}</span>
</button>
);
return (
<div ref={rootRef} className={cn("relative flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground", className)}>
{/* Toolbar */}
<header className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-3 py-2.5 sm:px-4">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-sky-500 to-violet-600 text-white shadow-sm" aria-hidden>
<GanttChartSquare className="size-4" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">{projectName}</h2>
<p className="flex items-center gap-1.5 truncate text-xs text-muted-foreground">
<span className="tabular-nums">
{doneCount}/{workCount} done
</span>
<span aria-hidden>·</span>
<span>ends {fmtDay(projectStart, Math.max(0, projectEnd - 1))}</span>
{slip !== 0 && (
<span className={cn("rounded-full px-1.5 py-px text-[10px] font-semibold", slip > 0 ? "bg-amber-500/15 text-amber-700 dark:text-amber-300" : "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300")}>
{slip > 0 ? "+" : ""}
{slip}d vs plan
</span>
)}
</p>
</div>
</div>
<div className="flex w-full items-center gap-1.5 sm:w-auto">
<div role="radiogroup" aria-label="Timeline scale" className="flex items-center rounded-lg bg-muted p-0.5">
{SCALES.map((s) => (
<button
key={s}
type="button"
role="radio"
aria-checked={scale === s}
onClick={() => setScale(s)}
title={`${s[0].toUpperCase()}${s.slice(1)} (${s[0].toUpperCase()})`}
className={cn("relative h-7 rounded-md px-2.5 text-xs font-medium capitalize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", scale === s ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
>
{scale === s && <motion.span layoutId="gp-scale" className="absolute inset-0 rounded-md bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
<span className="relative">{s}</span>
</button>
))}
</div>
<div className="hidden items-center md:flex">
<button type="button" aria-label="Zoom out (−)" title="Zoom out (−)" disabled={scale === "month"} onClick={() => zoom(-1)} className="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ZoomOut className="size-4" />
</button>
<button type="button" aria-label="Zoom in (+)" title="Zoom in (+)" disabled={scale === "day"} onClick={() => zoom(1)} className="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ZoomIn className="size-4" />
</button>
</div>
<span className="mx-0.5 hidden h-5 w-px bg-border sm:block" aria-hidden />
{toggleBtn(showCritical, () => setShowCritical((v) => !v), Route, "Critical path", "C", "border-rose-500/40 bg-rose-500/10 text-rose-600 dark:text-rose-400")}
{toggleBtn(showBaseline, () => setShowBaseline((v) => !v), Layers, "Baseline", "B", "border-foreground/20 bg-muted text-foreground")}
{toggleBtn(showWorkload, () => setShowWorkload((v) => !v), Users, "Workload", "L", "border-sky-500/40 bg-sky-500/10 text-sky-700 dark:text-sky-300")}
<span className="flex-1 sm:hidden" />
<button type="button" onClick={() => setJump((j) => j + 1)} title="Scroll to today (T)" aria-label="Scroll to today" className="grid size-8 shrink-0 place-items-center rounded-lg border text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Crosshair className="size-3.5" />
</button>
<button type="button" onClick={() => addTask()} title="New task (N)" className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg bg-primary px-2.5 text-xs font-semibold text-primary-foreground shadow-sm hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<Plus className="size-3.5" aria-hidden />
<span className="hidden sm:inline">Task</span>
</button>
</div>
</header>
<div className="relative flex min-h-0 flex-1">
<GanttChart
tasks={view}
rows={rows}
members={members}
projectStart={projectStart}
scale={scale}
onScale={setScale}
todayOffset={todayOffset}
selectedId={selectedId}
onSelect={(id) => select(id)}
collapsed={collapsed}
onToggle={toggle}
showCritical={showCritical}
critical={critical}
showBaseline={showBaseline}
showWorkload={showWorkload}
flash={flash}
onPreview={setPreview}
onCommit={(next, info) => {
const name = tasks.find((t) => t.id === info.id)?.name ?? "Task";
const verb = info.mode === "move" ? "Moved" : "Resized";
const kids = tasks.filter((t) => t.parentId === info.id).length;
const targetIds = kids ? next.filter((t) => t.parentId === info.id || t.id === info.id).map((t) => t.id) : [info.id];
// include nested leaves of a moved group as targets
const all = new Set(targetIds);
let grew = true;
while (grew) {
grew = false;
for (const t of next) {
if (t.parentId && all.has(t.parentId) && !all.has(t.id)) {
all.add(t.id);
grew = true;
}
}
}
commit(next, `${verb} “${name}” ${info.delta > 0 ? "+" : ""}${info.delta}d`, [...all]);
}}
scrollToToday={jump}
/>
{/* Details panel */}
<AnimatePresence>
{panel && selectedRow && (
<>
<motion.button type="button" aria-label="Close details" className="absolute inset-0 z-40 bg-black/40 md:hidden" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setPanel(false)} />
<motion.aside
key="details"
aria-label="Task details"
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 40 }}
transition={{ type: "spring", stiffness: 420, damping: 40 }}
className="absolute inset-x-0 bottom-0 z-50 h-[75%] overflow-hidden rounded-t-2xl border-t shadow-2xl md:static md:z-auto md:h-auto md:w-80 md:shrink-0 md:rounded-none md:border-l md:border-t-0 md:shadow-none"
>
<TaskDetails
row={selectedRow}
tasks={tasks}
rows={all}
members={members}
projectStart={projectStart}
critical={critical.has(selectedRow.task.id)}
onChange={update}
onDelete={remove}
onAddChild={(g) => addTask(g)}
onClose={() => setPanel(false)}
autoFocusName={focusName}
/>
</motion.aside>
</>
)}
</AnimatePresence>
{/* Toast */}
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center px-4" role="status" aria-live="polite">
<AnimatePresence>
{toast && (
<motion.div key={toast.id} initial={{ opacity: 0, y: 12, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 8 }} className="pointer-events-auto flex max-w-full items-center gap-3 rounded-full bg-foreground py-1.5 pl-4 pr-1.5 text-sm text-background shadow-xl">
<span className="truncate">{toast.text}</span>
{toast.undo && (
<button type="button" onClick={undo} className="inline-flex h-7 shrink-0 items-center gap-1 rounded-full bg-background/15 px-2.5 text-xs font-semibold hover:bg-background/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-background">
<Undo2 className="size-3.5" aria-hidden /> Undo
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
export default GanttPlannerApp;