Tree helpers

Signatures for `buildTaskTree`, `rollUpTasks`, `moveTask` and the other exported tree helpers

buildTaskTree, collectSubtreeIds and rollUpTasks normalize the parentId links, walk a subtree and recompute each parent as a summary row. TaskTree is the normalized tree they share. sortTasksBySequence, validateMove and moveTask sort tasks into row order, check a move and apply it. All six are exported from the package root and run without React or the DOM.

import {
  buildTaskTree,
  collectSubtreeIds,
  moveTask,
  rollUpTasks,
  sortTasksBySequence,
  validateMove,
  type GanttMoveOptions,
  type GanttMoveRejection,
  type GanttTaskMove,
  type GanttTaskMoveChange,
  type TaskTree,
} from '@jaeungkim/gantt-chart';

The hierarchy on screen is described in Task list and hierarchy and the row drag gesture in Reordering rows. Headless core covers running these helpers outside React.

TaskTree

/**
 * A normalized tree built from parentId
 *
 * Orphans (a parent id that is not in the data), self-references and cyclic chains all get
 * their parent link cut and become roots. The parentOf/childIds that come out are therefore
 * always acyclic, so the functions below - and the render - can walk up or down without
 * risking an infinite loop.
 */
export interface TaskTree {
  /** parent id -> child ids (in input order) */
  childIds: Map<string, string[]>;
  /** task id -> normalized parent id (null for a root, an orphan or a cycle) */
  parentOf: Map<string, string | null>;
  /** task id -> depth from the root */
  depthOf: Map<string, number>;
  /** Root ids, in input order - the sibling list childIds has no key for */
  rootIds: string[];
}
FieldTypeContents
childIdsMap<string, string[]>Parent id to its child ids, in input order. A key exists only for a parent with at least one child, so childIds.has(leafId) is false. There is no null key. The root-level sibling list is rootIds instead.
parentOfMap<string, string | null>One entry for every input task. null means a root, an orphan, a self-reference or a member of a cycle.
depthOfMap<string, number>Steps from the root. A root is 0. There is no cap.
rootIdsstring[]Every id whose parentOf is null, in input order.

The tree is not sorted. childIds.get(parent) and rootIds follow the order of the input array.

buildTaskTree

export function buildTaskTree(tasks: TaskNode[]): TaskTree

TaskNode is Pick<Task, "id" | "parentId">. The type is structural, so any object with { id: string; parentId: string | null } fits, including a Task or a TaskTransformed. See Task and task types.

Parent resolution

Each task's parentId resolves to a parent id or null. A task that resolves to null is a root.

InputResolved parent
parentId is null, undefined or ""null
parentId === task.id (self-reference)null
parentId names an id that is not in tasks (orphan)null
The raw parentId chain above that parent revisits an id already seen (cycle)null
Anything elseparentId

A cut link is not reported by a callback, a console warning or a field on TaskTree. The cycle walk records every id it passes, so it ends within n steps for any input.

The cycle check reads each ancestor's raw parentId, not the resolved parentOf. A task whose ancestor chain passes through a cycle is therefore a root as well. With a and b pointing at each other and c pointing at a, all three get parentOf === null and depth 0.

parentId is a required field of Task with type string | null, so it accepts null but may not be omitted.

Duplicate ids

An id that appears twice in tasks is not deduplicated and not reported. The internal lookup keeps the last occurrence, but each pass runs once per array element. The id is written to parentOf and depthOf twice and pushed into childIds or rootIds twice.

collectSubtreeIds

/**
 * The subtree's ids including the root itself (breadth first)
 * An id that is not in the tree yields an empty array
 */
export function collectSubtreeIds(
  tasks: TaskNode[],
  rootId: string,
  tree: TaskTree = buildTaskTree(tasks)
): string[]
ParameterTypeDefaultDescription
tasksTaskNode[]noneRead only when tree is omitted.
rootIdstringnoneThe id whose subtree is collected.
treeTaskTreebuildTaskTree(tasks)A prebuilt tree. Passing one skips the rebuild. tasks is still required.
CaseResult
rootId is in tree.parentOf[rootId, ...descendants], breadth first
rootId is not in tree.parentOf[]
rootId is a member of a cut cycle[rootId], because its children became roots

The order is breadth first, not depth first. The example below has a root root with children a and b, and a has children a1 and a2.

collectSubtreeIds(tasks, 'root'); // ['root', 'a', 'b', 'a1', 'a2']
collectSubtreeIds(tasks, 'a');    // ['a', 'a1', 'a2']
collectSubtreeIds(tasks, 'a1');   // ['a1']

rollUpTasks

/**
 * The tasks with every parent recomputed as a summary row
 *
 * Start and end always come from the children, never from what the data says
 * (min(child start)..max(child end)).
 * Deepest first, so a grandchild's move travels up through the parent and the grandparent.
 * An explicit progress is left alone; only a missing one is rolled up from the children.
 */
export function rollUpTasks(
  tasks: Task[],
  tree: TaskTree = buildTaskTree(tasks)
): Task[]

The input is the full Task, not TaskNode. startDate, endDate and progress are read.

Parent fields

FieldValue on the returned parent
startDateAlways min(child startDate). The parent's own value is discarded.
endDateAlways max(child endDate). The parent's own value is discarded.
progressparent.progress ?? rollUpProgress(children). An explicit value is kept, including 0, and is not clamped.
everything elseCopied through unchanged.

Both dates are re-serialized with dayjs(ms).toISOString(). A child's '2025-03-04' comes back as '2025-03-04T00:00:00.000Z' on the parent.

Parents are processed deepest first, and each parent reads the already rewritten version of its children. A grandchild's dates reach the grandparent in one call.

A parent whose children have no parseable startDate or no parseable endDate is left unchanged.

Progress roll-up

This runs only when the parent has no progress. Each child is weighted by its duration in milliseconds, endDate minus startDate, floored at 0.

RuleValue
Child progressClamped to 0 to 100 before weighting.
Missing or non-numeric child progressCounts as 0 and does not count as a reported value.
No child reports a progressundefined. The parent keeps progress: undefined.
Every child has zero durationA plain mean over the child count.
ResultRounded with Math.round to an integer percent.

Ten days at 100% plus thirty days at 0% gives 25. Two children with zero duration at 100% and 0% give 50.

Return identity

CaseReturned array
tree.childIds is empty (no parents)The input array instance
No parent was rewrittenThe input array instance
OtherwiseA new array. Rewritten parents are new objects. Every other element keeps its identity and the order is unchanged.

sortTasksBySequence

/** Sort tasks by their sequence hierarchy - row order comes from this and nothing else */
export function sortTasksBySequence<T extends Pick<Task, "sequence">>(
  tasks: T[]
): T[]

The argument is structural. Anything with a sequence string sorts, and the element type comes back unchanged. The result is a new array. The input is not mutated, and the elements keep their identity. The sort is stable, so two tasks with the same sequence keep their input order. Task data describes how the strings compare.

GanttTaskMove

The destination of a move. validateMove and moveTask both take it.

export interface GanttTaskMove {
  taskId: string;
  /** New parent, null for the root level */
  toParentId: string | null;
  /** Slot among the new parent's children, counted in the list as it stands after the move */
  toIndex: number;
}
FieldTypeDescription
taskIdstringThe task to move. Its whole subtree moves with it.
toParentIdstring | nullThe new parent, or null for the root level. Passing the current parent reorders among siblings.
toIndexnumberSlot among the new parent's children, counted after the moved subtree has been lifted out. An out-of-range value is clamped into the list, not refused.

GanttTaskMoveChange

The move that was applied. moveTask returns it and the chart passes it to onTaskMove.

export interface GanttTaskMoveChange extends GanttTaskMove {
  fromParentId: string | null;
  fromIndex: number;
  /** Sibling the task now follows - null when it is the first child */
  afterId: string | null;
  /** Sibling the task now precedes - null when it is the last child */
  beforeId: string | null;
}
FieldDescription
fromParentIdThe previous parent, null for the root level.
fromIndexThe index among that parent's children before the move.
afterIdThe sibling the task follows after the move, null when it is first.
beforeIdThe sibling the task precedes after the move, null when it is last.

toIndex on the change is the clamped index that was used, not the requested one. toParentId with toIndex suits an API that takes an index. afterId and beforeId suit an API that takes a neighbouring id. Both are read from the same result, so they cannot disagree. Reordering rows covers onTaskMove.

GanttMoveOptions

export interface GanttMoveOptions {
  hierarchy: boolean;
  canReorder?: (task: Task) => boolean;
}
FieldRequiredDescription
hierarchyyesOn, sibling lists come from parentId, normalized the way buildTaskTree normalizes it. Off, depth comes from sequence alone and sibling lists come from the path. A move that changes the parent is refused.
canReordernoWhether a task may move. Omitted, every task may. The chart passes resolveTaskInteraction(task, config).canReorder. See GanttInteractionConfig.

GanttMoveRejection

The reason a move was refused. validateMove returns one of these, or null when the move is allowed.

export type GanttMoveRejection =
  | "unknown-task"
  | "unknown-parent"
  | "read-only"
  | "cycle"
  | "reparent-disabled"
  | "no-op";
ValueTrigger
'unknown-task'move.taskId is not in tasks.
'unknown-parent'move.toParentId is set and not in tasks. A null parent is always accepted.
'read-only'options.canReorder returned false for the task.
'reparent-disabled'options.hierarchy is false and the move changes the parent.
'cycle'move.toParentId is inside the moved task's own subtree, the task itself included. Checked only when hierarchy is on, because a move never rewrites parentId otherwise.
'no-op'The task is already that parent's child at that index.

validateMove

/**
 * Whether a move can be applied, and why not when it cannot
 */
export function validateMove(
  tasks: Task[],
  move: GanttTaskMove,
  options: GanttMoveOptions
): GanttMoveRejection | null

null means the move is allowed. Any other value is the GanttMoveRejection naming the reason.

The checks run in the order of the GanttMoveRejection table above, and the first failure is returned. The function is cheap enough to call on every frame of a drag. The chart calls it that way to show a blocked drop while the pointer is down.

moveTask

/**
 * Applies a move, renumbering every sequence from the row order that results
 */
export function moveTask(
  tasks: Task[],
  move: GanttTaskMove,
  options: GanttMoveOptions
): { tasks: Task[]; change: GanttTaskMoveChange } | null

moveTask returns null for any move that validateMove refuses. Call validateMove first to get the reason.

sequence is a path. Its length is the depth and its last segment the slot among siblings, so no key fits between "1.1" and "1.2". moveTask renumbers instead. It rearranges the forest, walks it depth first in row order and gives every task the path it renders at. parentId is rewritten on the moved task only. Its children keep pointing at it, so the subtree moves untouched.

FieldChanged on
sequenceEvery task whose path in the tree changed.
parentIdThe moved task only, and only when options.hierarchy is on.
everything elseNo task. Dates, progress and dependencies are copied through.

A task whose sequence and parentId are both unchanged is returned as the same object, so a host diffing by identity sees only the tasks that moved. The array itself is always new. Its element order is the input order, not the new row order. Sort it with sortTasksBySequence to render it.

Example

The example below builds a tree, rolls up the parent and moves a child to the root level.

import {
  buildTaskTree,
  collectSubtreeIds,
  moveTask,
  rollUpTasks,
} from '@jaeungkim/gantt-chart';
import type { Task } from '@jaeungkim/gantt-chart';

const tasks: Task[] = [
  {
    id: 'p', name: 'Phase 1', parentId: null, sequence: '1',
    startDate: '2025-03-01', endDate: '2025-03-02',
  },
  {
    id: 'c1', name: 'Design', parentId: 'p', sequence: '1.1',
    startDate: '2025-01-01', endDate: '2025-01-11', progress: 100,
  },
  {
    id: 'c2', name: 'Build', parentId: 'p', sequence: '1.2',
    startDate: '2025-01-11', endDate: '2025-02-10', progress: 0,
  },
];

const tree = buildTaskTree(tasks);
tree.depthOf.get('c1');              // 1
tree.childIds.get('p');              // ['c1', 'c2']
tree.rootIds;                        // ['p']
collectSubtreeIds(tasks, 'p', tree); // ['p', 'c1', 'c2']

const rolled = rollUpTasks(tasks, tree);
rolled[0].startDate;                 // '2025-01-01T00:00:00.000Z'
rolled[0].endDate;                   // '2025-02-10T00:00:00.000Z'
rolled[0].progress;                  // 25

const moved = moveTask(tasks, { taskId: 'c2', toParentId: null, toIndex: 0 }, {
  hierarchy: true,
});
moved!.tasks.map((t) => `${t.id} ${t.sequence}`); // ['p 2', 'c1 2.1', 'c2 1']
moved!.change.beforeId;                           // 'p' - c2 landed above it

Notes

  • getVisibleTasks is the collapse filter that drops any task with a collapsed ancestor. It is in the same core module, but not exported from the package root, so it cannot be imported from @jaeungkim/gantt-chart. The chart calls it internally. A host drives the same behaviour through the collapsedIds and defaultCollapsedIds props, described in Task list and hierarchy.
  • TaskNode and rollUpProgress are internal and not exported from the package root. This page names them only to describe the signatures above.
  • All six functions are pure and never mutate their input. buildTaskTree returns a new TaskTree on every call, and collectSubtreeIds, sortTasksBySequence and moveTask a new array. Only rollUpTasks can return the array instance it was given.
  • The three tree helpers order nothing. Row order comes from sequence, and parentId sets depth only. sortTasksBySequence applies that order and moveTask is the only function that rewrites it. Nothing validates that the two fields agree. See Task data.
  • validateMove and moveTask check structure only. Whether a task may be moved is the host's decision, passed in as options.canReorder.