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/coresubpath.package.jsondeclares.,./style.cssand./package.json, and nothing else. The single bundle is built fromsrc/index.ts, which pulls in the React component.react,react-domandreact/jsx-runtimeare marked external at build time, so a Node script that importsscheduleTasksstill needs the peer dependencies (reactandreact-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 criticalD 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.