Propagation and conflicts
When you drag a task, its links decide what else moves. scheduling.propagation chooses between
two policies: 'preserve-lag', where the tasks after the edited one move so that every link keeps
its lag, and 'none', where only the task you drag moves and the links are just drawn. Either way
a link is checked: a successor that sits closer to its predecessor than the link allows is a
conflict, drawn in red and reported with the change.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// A chain of four tasks and a fifth beside it. 2026-03-02 is a Monday; a task with an `end` ends on// that day (inclusive), and the milestone has just a `start`.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06' }, // Starts a day after Design ends (the lag of the first link). { id: 'build', name: 'Build', start: '2026-03-08', end: '2026-03-14' }, // Starts two days after Build starts. Its link has no lag, so it keeps the gap it has now. { id: 'docs', name: 'Docs', start: '2026-03-10', end: '2026-03-16' }, // Starts the day Build ends. { id: 'review', name: 'Review', start: '2026-03-15', end: '2026-03-17' }, // Two days after Review ends. { id: 'release', name: 'Release', type: 'milestone', start: '2026-03-20' },];
export const links: Link[] = [ { source: 'design', target: 'build', lag: 1 }, // finish-to-start, the default type { source: 'build', target: 'docs', type: 'SS' }, // no lag: the gap it has when the data is loaded { source: 'build', target: 'review', lag: 0 }, { source: 'review', target: 'release', lag: 2 },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Every day is a working day, so the only conflicts are about links. calendar: { weekends: [] }, scheduling: { // What an edit does to the tasks linked to it: // 'preserve-lag' the tasks after it move too, so that every link keeps its lag (default); // 'none' only the task you edit moves: the links are drawn, and checked. // The select above the chart changes it with `setOptions`, without recreating the chart. propagation: 'preserve-lag', }, // One column a week: the whole plan fits. viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { brokenLinks, describe, PROPAGATION, startMoved } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The chart fills its container and scrolls inside it, so the container needs a height. const chart = document.createElement('div'); chart.style.height = '320px'; const status = document.createElement('p'); status.textContent = 'Drag Design, then switch Propagation to none and drag it again.'; root.append(toolbar, chart, status); container.append(root);
// The "refuse" box is read by the before-hook each time a change is about to be applied. const refuse = document.createElement('input'); refuse.type = 'checkbox';
// The handlers read `gantt`, which they are passed to: give it a type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // Asked before a change is applied, with the conflicts it would have; `false` cancels it. onBeforeTaskChange: ({ conflicts }) => { const broken = brokenLinks(conflicts); if (!refuse.checked || broken.length === 0) return true; status.textContent = `Refused: ${broken.map((conflict) => conflict.message).join('; ')}.`; return false; }, // One event per change, a drag or `updateTask`: the edited task first, then the tasks the // links moved, and the conflicts of the result. onTasksChange: (changeSet): void => { status.textContent = describe(changeSet, (id) => gantt.getTask(id)?.name ?? String(id)); }, });
const controller = new AbortController(); const { signal } = controller;
const addButton = (label: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal }); toolbar.append(button); };
// `scheduling.propagation` changes without recreating the chart; it matters at the next edit. const policy = document.createElement('select'); for (const value of PROPAGATION) { const option = document.createElement('option'); option.value = option.textContent = value; option.selected = value === options.scheduling.propagation; policy.append(option); } policy.addEventListener( 'change', () => { const propagation = PROPAGATION.find((value) => value === policy.value); if (propagation) gantt.setOptions({ scheduling: { propagation } }); }, { signal }, ); const policyLabel = document.createElement('label'); policyLabel.append('Propagation ', policy); const refuseLabel = document.createElement('label'); refuseLabel.append(refuse, ' Refuse a broken link'); toolbar.append(policyLabel, refuseLabel);
// The same edits as a drag, from code (`reason: 'api'`): they go through the before-hook too. addButton('Design +3 days', () => { void gantt.updateTask('design', { start: startMoved(gantt.getTask('design'), 3) }); }); addButton('Review -3 days', () => { void gantt.updateTask('review', { start: startMoved(gantt.getTask('review'), -3) }); }); addButton('Reset', () => { gantt.setTasks(tasks); status.textContent = 'Back to the start.'; });
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { brokenLinks, describe, PROPAGATION, startMoved } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [propagation, setPropagation] = useState<string>(options.scheduling.propagation); const [refuse, setRefuse] = useState(false); const [status, setStatus] = useState( 'Drag Design, then switch Propagation to none and drag it again.', ); const nameOf = (id: string | number): string => chart.current?.getTask(id)?.name ?? String(id);
return ( <> <div role="toolbar"> <label> Propagation{' '} <select value={propagation} onChange={(event) => setPropagation(event.target.value)}> {PROPAGATION.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> <label> <input type="checkbox" checked={refuse} onChange={(event) => setRefuse(event.target.checked)} />{' '} Refuse a broken link </label> {/* The ref is the chart's methods. The same edits as a drag, from code (`reason: 'api'`). */} <button type="button" onClick={() => { void chart.current?.updateTask('design', { start: startMoved(chart.current.getTask('design'), 3), }); }} > Design +3 days </button> <button type="button" onClick={() => { void chart.current?.updateTask('review', { start: startMoved(chart.current.getTask('review'), -3), }); }} > Review -3 days </button> <button type="button" onClick={() => { chart.current?.setTasks(tasks); setStatus('Back to the start.'); }} > Reset </button> </div> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 320 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...options} // In React the props are the truth: `scheduling` is passed from state after the spread, // not changed with `setOptions` on the ref, which the next render would overwrite. scheduling={{ ...options.scheduling, propagation: PROPAGATION.find((value) => value === propagation), }} // Asked before a change is applied, with the conflicts it would have; `false` cancels it. onBeforeTaskChange={({ conflicts }) => { const broken = brokenLinks(conflicts); if (!refuse || broken.length === 0) return true; setStatus(`Refused: ${broken.map((conflict) => conflict.message).join('; ')}.`); return false; }} // One event per change, a drag or `updateTask`: the edited task first, then the tasks the // links moved, and the conflicts of the result. onTasksChange={(changeSet) => setStatus(describe(changeSet, nameOf))} /> <p>{status}</p> </> );}import type { Change, ChangeSet, Conflict, TaskId, TaskRecord } from '@ganttlane/core';
/** The values of `scheduling.propagation` that are available. */export const PROPAGATION = ['preserve-lag', 'none'] as const;export type Propagation = (typeof PROPAGATION)[number];
const DAY = 24 * 60 * 60 * 1000;
/** A length of time in days with its sign: `+2d`, `-1d`. */function signedDays(ms: number): string { const days = ms / DAY; return `${days > 0 ? '+' : ''}${String(days)}d`;}
/** How far a change moved a task: one number for a move, the edges that moved for a resize. */function shift(change: Change): string { const start = change.after.start - change.before.start; const end = change.after.endExclusive - change.before.endExclusive; if (start === end) return signedDays(start); const edges = []; if (start !== 0) edges.push(`start ${signedDays(start)}`); if (end !== 0) edges.push(`end ${signedDays(end)}`); return edges.join(', ');}
/** * The start of a task moved by some days, for `updateTask`: a `Date` with the same wall-clock time. * * @param record - The task, from `getTask`. * @param days - How many days to move it; negative is earlier. * @returns The new start, or `undefined` for a task without a bar. */export function startMoved(record: TaskRecord | undefined, days: number): Date | undefined { const start = record?.start; if (!start) return undefined; return new Date( start.getFullYear(), start.getMonth(), start.getDate() + days, start.getHours(), start.getMinutes(), );}
/** The conflicts that say a link's gap is below its lag. */export function brokenLinks(conflicts: readonly Conflict[]): readonly Conflict[] { return conflicts.filter((conflict) => conflict.type === 'link-violated');}
/** * Says what a drag changed: the dragged task, the tasks the links moved with it, and the * conflicts of the result, each with the task it is drawn on. * * @param changeSet - What `onTasksChange` reports. Its dates are in core's floating milliseconds. * @param nameOf - The name of a task, from the chart. * @returns A sentence for the status line. */export function describe(changeSet: ChangeSet, nameOf: (id: TaskId) => string): string { const [dragged, ...followers] = changeSet.changes; if (!dragged) return 'Nothing changed.'; let text = `${changeSet.reason}: ${nameOf(dragged.id)} (${shift(dragged)}).`; text += followers.length === 0 ? ' No other task moved.' : ` Followed: ${followers.map((change) => `${nameOf(change.id)} (${shift(change)})`).join(', ')}.`; for (const conflict of changeSet.conflicts) { text += ` Conflict on ${nameOf(conflict.id)}: ${conflict.message}.`; } return text;}Files
data.ts: four tasks in a chain and a fifth beside it, and the links with their lags.options.ts:scheduling.propagation, the calendar and the view mode.helpers.ts:describe, which turns aChangeSetinto a sentence, and the helpers for the buttons.vanilla.tsandreact.tsx: the chart, the select, the box and the buttons.
Options
| Option | Value | What it does |
|---|---|---|
scheduling.propagation |
'preserve-lag' |
What an edit does to linked tasks: 'preserve-lag' or 'none' (the select) |
calendar.weekends |
[] |
Every day is a working day, so the only conflicts are about links |
viewMode |
'week' |
One column a week: the whole plan fits |
scroll.initial |
'first-task' |
Opens on the plan; the default, 'today', is far from these dates |
onBeforeTaskChange |
a function (in the example) | Asked before a change is applied, with its conflicts; false cancels it |
onTasksChange |
a function (in the example) | One ChangeSet per change, with every task it moved and its conflicts |
link.lag |
1, 0, 2, none |
The gap a link keeps; without one it keeps the gap it has when loaded |
What to try
- Drag Design to the right with
preserve-lag. Build, Docs, Review and Release all follow, each keeping its lag. The status line lists the tasks in the order the chart moved them. - Switch to
noneand drag Design again: only Design moves. As soon as Design ends less than a day (the lag of the link) before Build starts, the link turns red, Build gets a red outline, and the status line names the conflict, the gap the link needs and the gap it has. - Drag Review to the left, in front of the end of Build: it moves freely, and the link from
Build turns red in both modes, because a link is checked against its predecessor whichever
policy is on. Release follows Review with
preserve-lagand stays withnone. Design +3 days and Review -3 days make the same edits from code, withupdateTask. - Tick Refuse a broken link and repeat: the before-hook sees the conflict in its argument,
returns
false, and the bar goes back. - After a conflict, drag Build (with
preserve-lag): the links put Review and Release back at their lags and the conflict goes.
Notes
- Where propagation goes. An edit travels from the edited task to its successors, never to its
predecessors. A task that moves moves its own successors in turn, in one pass, so a cycle cannot
loop. A
lockedtask never moves and stops the propagation. - Lags. A link with a
lagkeeps exactly that gap; a link with none keeps the gap it had when the data was loaded (Docs stays two days behind the start of Build). link-violated. A conflict is reported for an active link whose gap is below what it requires: itslagwhen it has one, or no more overlap than at load when it does not. It has thelinkIdand the two gaps in milliseconds (requiredMs,actualMs). Drawn: the link and the bar of the successor are red (data-conflict). Reported:changeSet.conflicts(inonTasksChangeandonBeforeTaskChange) andrecord.conflicts(getTask('review').conflicts). A gap wider than the lag is not a conflict.- A conflict is a warning. The change is accepted, and nothing is corrected, unless your
onBeforeTaskChangereturnsfalse(or a modifiedChangeSet). 'push-forward'is the policy that would push the successors of an edit forward until every link holds. It is not available yet:scheduling.propagation: 'push-forward'is reported as anunsupported-optionissue and'preserve-lag'is used.- React.
schedulingis passed to<Gantt>after the spread ofoptions, from state: in React the props are the truth, so an option changed withsetOptionson the ref would be overwritten by the next render.onBeforeTaskChangeis given inline and always reads the current state.