Headless core

Run the chart's tree and calendar math in Node, a worker or a test.

The chart's tree, row-order and calendar math is a set of plain functions that need no React, no stylesheet and no DOM. Run them on your server so a report and the chart cannot disagree about a phase's dates.

Import the helpers

They come from the package root, the same specifier as the component.

import { buildTaskTree, rollUpTasks, createWorkingCalendar } from '@jaeungkim/gantt-chart';

The headless set is eight functions and values: buildTaskTree, collectSubtreeIds, rollUpTasks, sortTasksBySequence, validateMove, moveTask, CALENDAR_DAYS and createWorkingCalendar. Their only runtime dependency is dayjs with the utc plugin.

There is no /core subpath. The package declares ., ./style.css and ./package.json, and ships as one bundle that includes the React component. react and react-dom are external, so a Node script that imports rollUpTasks still needs both (^18 or ^19) installed and resolvable.

Roll up a tree

buildTaskTree normalizes parents, children and depths once. collectSubtreeIds gathers every id under a row. rollUpTasks returns the summary dates and progress the chart draws.

report.ts
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import {
  buildTaskTree,
  createWorkingCalendar,
  rollUpTasks,
  type Task,
} from '@jaeungkim/gantt-chart';

dayjs.extend(utc);

const tasks: Task[] = [
  { id: 'P', name: 'Phase 1', parentId: null, sequence: '1',
    startDate: '2025-06-02', endDate: '2025-06-03' },
  { id: 'A', name: 'Survey', parentId: 'P', sequence: '1.1', progress: 100,
    startDate: '2025-06-02', endDate: '2025-06-05' },
  { id: 'B', name: 'Frame', parentId: 'P', sequence: '1.2', progress: 40,
    startDate: '2025-06-05', endDate: '2025-06-10' },
];

const calendar = createWorkingCalendar({ holidays: ['2025-06-06'] });

for (const task of rollUpTasks(tasks, buildTaskTree(tasks))) {
  const days = calendar.daysBetween(dayjs.utc(task.startDate), dayjs.utc(task.endDate));
  console.log(task.id, task.startDate.slice(0, 10), task.endDate.slice(0, 10), `${days}wd`, task.progress);
}

It prints:

report.ts output
P 2025-06-02 2025-06-10 5wd 63
A 2025-06-02 2025-06-05 3wd 100
B 2025-06-05 2025-06-10 2wd 40

P was declared as ending on 2025-06-03 and comes back ending on 2025-06-10, with a progress of 63 the input never set. A rewritten parent comes back as a full ISO string, while rows the roll-up left alone keep the strings you passed in.

The tree argument is optional and defaults to a fresh buildTaskTree(tasks), so a call inside a loop rebuilds the tree every time. Task list and hierarchy covers what a summary row overwrites, and Tree helpers has the roll-up rule.

Count working days

A calendar is an object your app calls directly. CALENDAR_DAYS counts all seven days. createWorkingCalendar skips weekends and holidays, so five days across a weekend count as three.

isWorkingDay, addDays, daysBetween, snapForward and the skipsNonWorkingDays flag are public API. No other core function takes a calendar, and the tree and move helpers count no days.

The chart builds the same calendar from its workingWeekdays and holidays props, so the days a report counts and the days the chart shades cannot drift. Here holidays is a flat list of UTC YYYY-MM-DD strings; names, colors and multi-day ranges belong to the chart prop. See the timeline for shading, Working calendar for what the chart snaps, and Working calendar helpers for the options.

Reorder rows outside the chart

Row order lives in sequence, a dotted path where "2.1" is the first child of "2". sortTasksBySequence compares the segments as numbers, so "1.10" sorts after "1.9".

validateMove returns a rejection reason, or null when the move is allowed. moveTask applies the move and returns the new array plus a change naming the old and new position. It returns null for any move validateMove rejects, so call validateMove first to learn why.

const result = moveTask(tasks, { taskId: 'B', toParentId: null, toIndex: 0 }, { hierarchy: true });
if (result) await save(result.tasks);

moveTask renumbers every task whose path changed and rewrites parentId on the moved task only. Tasks that stayed put keep their object identity. Reordering rows covers the row drag inside the chart.

Before you integrate

  • rollUpTasks skips a child date it cannot parse. When no child yields a finite date the parent is left alone, so bad input produces a stale summary row rather than an error.
  • buildTaskTree cuts a broken parent link rather than reporting it. An unknown parentId, a self-parent and a cyclic chain all become roots, and nothing is logged.
  • createWorkingCalendar degrades when workingWeekdays is empty. Every day counts as non-working, addDays falls back to plain days and snapForward returns its input.
  • validateMove and moveTask re-sort the tasks and rebuild the sibling lists on every call, and neither one changes a date. Caching is your app's job.
  • Dates are UTC. A string with no zone is read as UTC rather than as server-local time, so a process in Asia/Seoul that writes local timestamps is 9 hours out. Task data has the full date contract.

Rendering and formatting stay outside the core. Bar geometry, arrows, shading and every date string a reader sees come from the chart. No core function reads Task.dependencies or touches a locale. Helpers that are not on the list above are internal, so do not plan around importing them. Props and defaults: GanttProps.