@jaeungkim/gantt-chart

Headless core

Scheduling without React or a DOM

A nightly job re-plans the project after the week's timesheets land. A test asserts that pushing one task moves three others. An API endpoint returns the finish date before anyone opens a browser. None of them want a React tree, a stylesheet, or a DOM. The date math the chart runs on every drop is a set of plain functions, and those functions are exported.

The core imports no React and touches no DOM

Everything under src/core/ is data and pure functions. Its only runtime dependency is dayjs plus the utc plugin. A grep over the directory for react, window, document, localStorage, navigator and requestAnimationFrame finds no import and no global — only two prose comments.

The boundary is not a convention. An eslint block scoped to src/core/**/*.{ts,tsx} forbids importing react, react-dom, zustand, @tanstack/*, and the components/, hooks/, stores/, constants/, types/ and utils/ path groups. A second rule bans the globals window, document, navigator, localStorage, sessionStorage and requestAnimationFrame. The rule's own message is "src/core must stay free of React, the DOM and pixel math - keep render-side code in src/utils or src/components".

A host imports these functions from the package root, the same specifier the component comes from:

import { scheduleTasks, computeCriticalPath, type Task } from '@jaeungkim/gantt-chart';

[!IMPORTANT] There is no @jaeungkim/gantt-chart/core subpath. package.json declares ., ./style.css and ./package.json, and nothing else. The single bundle is built from src/index.ts, which pulls in the React component. react, react-dom and react/jsx-runtime are marked external at build time, so a Node script that imports scheduleTasks still needs the peer dependencies (react and react-dom, ^18.0.0 || ^19.0.0) installed and resolvable. The source is headless. The published artifact is one file.

The exports, by job

Thirteen runtime values and ten types come out of the core. They fall into five groups.

Build the graph

Reach for these when you are about to change the shape of the project rather than its dates. canLink answers "would this new dependency close a loop" before you write it, which is the check a form or an import script needs. buildTaskGraph gives you the resolved links, the topological order, and the ids it could not order. findPath answers reachability between two tasks. linkKey produces the string identity of a link, which is the same key criticalLinkIds is populated with.

function buildTaskGraph(tasks: Task[]): TaskGraph;
function canLink(
  tasks: Task[],
  predecessorId: string,
  successorId: string
): { ok: boolean; cycle: string[] | null };
function findPath(tasks: Task[], fromId: string, toId: string): string[] | null;
function linkKey(link: SchedulingLink): string;

canLink rejects a self-link and a cycle, and nothing else. A second identical link between the same pair passes, and so does a second link of a different type — the duplicate check the chart's own link drag applies lives in the render layer and is not exported. Full shapes and the exact contents of TaskGraph.cycle are in Task graph helpers; what the four link types mean is in Dependencies.

Walk the tree

buildTaskTree and collectSubtreeIds read parentId and nothing else; rollUpTasks also reads each child's dates, type and progress. Use buildTaskTree when you need parents, children and depths normalized once, collectSubtreeIds when a bulk operation has to include everything under a row, and rollUpTasks when a report needs the same summary-row dates the chart draws. All three are pure, so a server can produce the exact spans the browser would show.

type TaskNode = Pick<Task, 'id' | 'parentId'>;

function buildTaskTree(tasks: TaskNode[]): TaskTree;
function collectSubtreeIds(tasks: TaskNode[], rootId: string, tree?: TaskTree): string[];
function rollUpTasks(tasks: Task[], tree?: TaskTree): Task[];

TaskNode is structural and is not itself exported, so write the Pick above or pass real Task objects. The optional tree argument on the last two defaults to a fresh buildTaskTree(tasks); pass a prebuilt tree in a loop instead of paying for a rebuild per call. What a summary row overwrites is in Task list and hierarchy, and the signatures in full are in Tree helpers.

Schedule

scheduleTasks is the function the chart runs on drop, and it takes a whole project as readily as one dragged bar. Run it after an import, after a form edit, or in a job that levels the plan overnight. It returns a new array with the successors moved, the ids it moved, and the ids it could not order.

function scheduleTasks(tasks: Task[], options?: ScheduleOptions): ScheduleResult;

Two things matter for a headless caller. The policy option defaults to 'off', which returns before the graph is built: nothing moves, ScheduleResult.cycle comes back null even on cyclic data, and onCycle never fires. And when nothing moved, ScheduleResult.tasks is the same array instance you passed in — compare with === to skip a write. The policies themselves are described in Scheduling, and every option and return field is listed in scheduleTasks.

Compute the critical path

computeCriticalPath runs both passes and hands back slack, early and late dates, durations, and the sets of critical tasks and links. That is the whole answer for a status report or a "which slip matters" query. forwardPass and backwardPass are exported separately for the case where you only need earliest dates, or want to supply your own project finish.

function computeCriticalPath(
  tasks: Task[],
  options?: { calendar?: WorkingCalendar }
): CriticalPathResult;

function forwardPass(
  tasks: Task[],
  calendar?: WorkingCalendar,
  graph?: TaskGraph
): Map<string, EarlyDates>;

function backwardPass(
  tasks: Task[],
  early: Map<string, EarlyDates>,
  calendar?: WorkingCalendar,
  graph?: TaskGraph,
  projectFinish?: Dayjs
): Map<string, LateDates>;

EarlyDates and LateDates are not exported from the package, so the second argument of backwardPass cannot be named directly. Name it structurally instead:

import { forwardPass, backwardPass } from '@jaeungkim/gantt-chart';

type Early = ReturnType<typeof forwardPass>;

const early: Early = forwardPass(tasks);
const late = backwardPass(tasks, early);

The slack definitions and the rule that a task at 100% progress is never critical belong to Scheduling. Field-by-field types are in Critical path.

Build a calendar

scheduleTasks, computeCriticalPath, forwardPass and backwardPass count days through a WorkingCalendar; the graph and tree helpers take none. CALENDAR_DAYS is the default and counts all seven days, so leaving it alone gives plain calendar arithmetic. createWorkingCalendar builds one that skips weekends and holidays, which is what makes a lag of two mean two working days.

const CALENDAR_DAYS: WorkingCalendar;
function createWorkingCalendar(options?: WorkingCalendarOptions): WorkingCalendar;

The object it returns is public API: isWorkingDay, addDays, daysBetween, daysUntil, daysUpTo, snapForward and the skipsNonWorkingDays flag are all callable on their own, which is often all a host needs for its own date arithmetic. What a calendar changes and what it leaves alone is covered in Scheduling; the options and their precedence are in Working calendar.

A complete script

Five tasks go in, two of them sitting earlier than their links allow. The script levels the project, computes the critical path, and prints dates and slack.

// replan.ts
import {
  computeCriticalPath,
  scheduleTasks,
  type Task,
} from '@jaeungkim/gantt-chart';

const tasks: Task[] = [
  {
    id: 'A',
    name: 'Survey',
    startDate: '2025-06-02',
    endDate: '2025-06-05',
    parentId: null,
    sequence: '1',
  },
  {
    id: 'B',
    name: 'Frame',
    startDate: '2025-06-05',
    endDate: '2025-06-10',
    parentId: null,
    sequence: '2',
    dependencies: [{ targetId: 'A', type: 'FS' }],
  },
  {
    id: 'C',
    name: 'Wiring',
    startDate: '2025-06-05',
    endDate: '2025-06-07',
    parentId: null,
    sequence: '3',
    dependencies: [{ targetId: 'A', type: 'FS' }],
  },
  {
    id: 'D',
    name: 'Cladding',
    startDate: '2025-06-08',
    endDate: '2025-06-11',
    parentId: null,
    sequence: '4',
    dependencies: [
      { targetId: 'B', type: 'FS' },
      { targetId: 'C', type: 'FS' },
    ],
  },
  {
    id: 'E',
    name: 'Handover',
    startDate: '2025-06-11',
    endDate: '2025-06-14',
    parentId: null,
    sequence: '5',
    dependencies: [{ targetId: 'D', type: 'FS' }],
  },
];

const { tasks: scheduled, movedIds, cycle } = scheduleTasks(tasks, {
  policy: 'maintain-gap',
});

if (cycle) {
  throw new Error(`could not order: ${cycle.join(', ')}`);
}

const { metrics, criticalTaskIds, projectFinish } =
  computeCriticalPath(scheduled);

console.log('moved:', movedIds.join(', '));
console.log('project finish:', projectFinish);

for (const task of scheduled) {
  const slack = metrics.get(task.id)?.totalSlack;
  console.log(
    [
      task.id,
      task.startDate.slice(0, 10),
      task.endDate.slice(0, 10),
      `slack ${slack ?? '-'}`,
      criticalTaskIds.has(task.id) ? 'critical' : '',
    ].join('  ')
  );
}

It prints:

moved: D, E
project finish: 2025-06-16T00:00:00.000Z
A  2025-06-02  2025-06-05  slack 0  critical
B  2025-06-05  2025-06-10  slack 0  critical
C  2025-06-05  2025-06-07  slack 3  
D  2025-06-10  2025-06-13  slack 0  critical
E  2025-06-13  2025-06-16  slack 0  critical

D was pushed two days by its B link, and E followed it in the same pass. C finishes three days before its successor needs it, so it carries three days of slack and stays off the critical path. The .slice(0, 10) in the loop is doing real work: A, B and C never moved, so they are the very objects passed in and still carry the short strings, while D and E were rewritten and come back as full ISO strings.

What the core does not do

It does not render. No bar geometry, no arrow paths, no ticks, no shading, no colors. Bar positions are computed by the render layer from raw dates, and none of it lives here.

It does not persist or memoize. Every scheduleTasks and computeCriticalPath call rebuilds the graph from scratch, and findPath rebuilds it per call — so canLink, which calls findPath, is a full graph build per candidate link and validating an import row by row is quadratic. Caching is the host app's job.

It does not format. There is no locale and no Intl. Dates go in as strings and come out as strings. Everything a reader sees is produced by the chart or by you — see Locale and date formats.

It does not validate. An endDate before its startDate, an unparseable date string, two identical links between the same pair: none of it is checked or rejected. A link whose delta comes out non-finite is silently skipped, so a task with an unreadable date never moves and never raises. Check the data before you hand it over. A negative lag is not bad input — it is a lead, and the engine applies it.

It does not resolve cycles. It detects them, reports them, and schedules everything else around them. Both ScheduleResult.cycle and CriticalPathResult.cycle list every id that could not be topologically ordered, which includes tasks merely downstream of the loop, and those tasks receive no critical-path metrics at all.

Two links can collide on one key. linkKey is built from the two ids and the type, and ignores lag, so a duplicated dependency produces one entry in criticalLinkIds rather than two.

A broken calendar degrades instead of failing. An isNonWorkingDay predicate that marks every day non-working makes addDays fall back to plain calendar days and snapForward return its input. The numbers come out wrong; nothing throws.

Some helpers are internal. getVisibleTasks, linkDelta, shiftTask, taskStart, taskEnd, isMilestoneTask, normalizeProgress and the UTC dayjs instance exist in src/core but are not re-exported from the package, and neither are the types EarlyDates, LateDates and CriticalPathOptions. Do not plan around importing them.

Every date is UTC. The core parses with dayjs.utc, so a string carrying no zone ('2025-06-02', '2025-06-02T09:00') is a UTC wall clock, not the server's local time — a process in Asia/Seoul writing local timestamps without an offset schedules nine hours from where it meant to, and holidays entries are matched against the UTC YYYY-MM-DD for the same reason. A task the engine moved comes back as a toISOString() string; one it did not move is returned untouched, with whatever string you gave it. The full date contract is in Task data.

Next: scheduleTasks, where every option and every return field of the scheduling entry point is listed.

On this page