Fazekit

Code

"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;

More in Productivity

View all →