Working calendar
The calendar says which days are working days; the rest of the chart reads it. Days off are shaded, a duration can count working days only, and a drag can land on a working day instead of a day off. Here the week is Monday to Thursday in June (Saturday, Sunday and, by a rule of its own, Friday are off), Wednesday 10 June is a holiday and Saturday 13 June is worked anyway.
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';
// The calendar (see `options.ts`): Saturday and Sunday are off, so are Fridays in June (a custom// rule), Wednesday 10 June is a holiday, and Saturday 13 June is worked. 2026-06-08 is a Monday.//// A task with a `duration` and no `end` lasts that many days, counted as `durationMode` says:// 'working' working days only: days off inside the task do not count, so it is longer;// 'calendar' every day counts, a day off or not.// A task with no `durationMode` of its own takes `scheduling.durationMode`, which is 'working'.export const tasks: Task[] = [ // 3 working days: Mon 8, Tue 9 and Thu 11; the holiday on Wed 10 is skipped. Ends Thu 11. { id: 'design', name: 'Design', start: '2026-06-08', duration: 3, progress: 50 }, // 5 working days: Mon 8, Tue 9, Thu 11, Sat 13 (a worked Saturday) and Mon 15. The holiday, the // Friday and the Sunday are skipped. Ends Mon 15, 8 calendar days after it starts. { id: 'build', name: 'Build', start: '2026-06-08', duration: 5, progress: 40 }, // 3 working days: Tue 16, Wed 17, Thu 18. { id: 'review', name: 'Review', start: '2026-06-16', duration: 3 }, // 5 working days from Thu 18: Thu 18, Mon 22, Tue 23, Wed 24 and Thu 25. It spans a weekend. { id: 'handover', name: 'Handover', start: '2026-06-18', duration: 5 }, // The same 5 days, counted in calendar days: Thu 18 to Mon 22. This task says so itself, so it // ignores `scheduling.durationMode`. { id: 'on-call', name: 'On-call', start: '2026-06-18', duration: 5, durationMode: 'calendar', progress: 20, }, // One working day, and it is the worked Saturday: a task may sit on it, and is not in conflict. { id: 'hotfix', name: 'Hotfix', start: '2026-06-13', duration: 1 },];
export const links: Link[] = [];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Which days are working days. From the strongest rule to the weakest, a day is decided by: // `workingDays`, then `holidays`, then `isWorkingDay`, then `weekends`. calendar: { // Days of the week that are off, 0 is Sunday: Saturday and Sunday (the default). weekends: [0, 6], // Named days off: the name shows when you hover the day. holidays: [{ date: '2026-06-10', name: 'Company holiday' }], // Exceptions: days that are worked whatever the other rules say, here a Saturday. workingDays: ['2026-06-13'], // Your own rule: `true` makes the day a working day, `false` a day off, and `undefined` leaves // it to the next rule (`weekends`). It must be pure: the answer is cached. Here, Fridays are // off from June to August. isWorkingDay: ({ weekday, month }) => month >= 6 && month <= 8 && weekday === 5 ? false : undefined, }, scheduling: { // How a `duration` is counted, for the tasks that do not say: 'working' or 'calendar'. durationMode: 'working', // Whether an edited edge moves off a day off, onto the next working day (a start) or the // last one (an end). These are the defaults; the boxes above the chart change them. skipNonWorkingDays: { move: true, resize: false, progress: true }, }, // A narrower day column than the default 40 px, so that the whole plan fits. viewModes: { day: { pxPerDay: 32 } }, 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 { describe, DURATION_MODES, type Skip } 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 = '380px'; const status = document.createElement('p'); status.textContent = 'Drag a bar onto a day off, drag an edge, or drag the little handle at the foot of a bar.'; root.append(toolbar, chart, status); container.append(root);
// The handler reads `gantt`, which it is passed to: give it a type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // The records are computed from the calendar: dates, working days, conflicts. onTasksChange: (changeSet): void => { const [edited] = changeSet.changes; if (edited) status.textContent = describe(gantt.getTask(edited.id), changeSet.reason); }, });
const controller = new AbortController(); const { signal } = controller;
const durations = document.createElement('select'); for (const value of DURATION_MODES) { const option = document.createElement('option'); option.value = option.textContent = value; option.selected = value === options.scheduling.durationMode; durations.append(option); } // The mode changes without recreating the chart: the core counts every duration again. durations.addEventListener( 'change', () => { const durationMode = DURATION_MODES.find((value) => value === durations.value); if (durationMode) gantt.setOptions({ scheduling: { durationMode } }); }, { signal }, ); const durationsLabel = document.createElement('label'); durationsLabel.append('Durations count ', durations); toolbar.append(durationsLabel);
// `skipNonWorkingDays` is one object: give all three flags each time. const skip: Skip = { ...options.scheduling.skipNonWorkingDays }; const addCheckbox = (label: string, flag: keyof Skip): void => { const box = document.createElement('input'); box.type = 'checkbox'; box.checked = skip[flag]; box.addEventListener( 'change', () => { skip[flag] = box.checked; gantt.setOptions({ scheduling: { skipNonWorkingDays: { ...skip } } }); }, { signal }, ); const field = document.createElement('label'); field.append(box, ` ${label}`); toolbar.append(field); }; addCheckbox('Skip days off: move', 'move'); addCheckbox('resize', 'resize'); addCheckbox('progress', 'progress');
const reset = document.createElement('button'); reset.type = 'button'; reset.textContent = 'Reset'; reset.addEventListener( 'click', () => { gantt.setTasks(tasks); status.textContent = 'Back to the start.'; }, { signal }, ); toolbar.append(reset);
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 { describe, DURATION_MODES, type Skip } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [durationMode, setDurationMode] = useState<string>(options.scheduling.durationMode); const [skip, setSkip] = useState<Skip>({ ...options.scheduling.skipNonWorkingDays }); const [status, setStatus] = useState( 'Drag a bar onto a day off, drag an edge, or drag the little handle at the foot of a bar.', );
const checkbox = (label: string, flag: keyof Skip) => ( <label> <input type="checkbox" checked={skip[flag]} onChange={(event) => setSkip({ ...skip, [flag]: event.target.checked })} />{' '} {label} </label> );
return ( <> <div role="toolbar"> <label> Durations count{' '} <select value={durationMode} onChange={(event) => setDurationMode(event.target.value)}> {DURATION_MODES.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> {checkbox('Skip days off: move', 'move')} {checkbox('resize', 'resize')} {checkbox('progress', 'progress')} {/* The ref is the chart's methods. */} <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: 380 }} // 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={{ durationMode: DURATION_MODES.find((value) => value === durationMode), skipNonWorkingDays: skip, }} // The records are computed from the calendar: dates, working days, conflicts. onTasksChange={(changeSet) => { const [edited] = changeSet.changes; if (edited) setStatus(describe(chart.current?.getTask(edited.id), changeSet.reason)); }} /> <p>{status}</p> </> );}import type { ChangeReason, TaskRecord } from '@ganttlane/core';
/** The values of `scheduling.durationMode`. */export const DURATION_MODES = ['working', 'calendar'] as const;export type DurationMode = (typeof DURATION_MODES)[number];
/** The three switches of `scheduling.skipNonWorkingDays`. */export interface Skip { move: boolean; resize: boolean; progress: boolean;}
const day = new Intl.DateTimeFormat('en', { weekday: 'short', day: 'numeric', month: 'short' });
/** * Says where a task is after an edit, from its computed record: its first and last day, how long * it is in working and in calendar days, how much is done, and the conflicts it has (an edge on a * day off). * * @param record - The edited task, from `getTask`. * @param reason - What the edit was: `'move'`, `'resize-start'`, `'resize-end'`, `'progress'`, … * @returns A sentence for the status line. */export function describe(record: TaskRecord | undefined, reason: ChangeReason): string { if (!record?.start || !record.end || !record.duration) return ''; const { workingDays, calendarDays } = record.duration; let text = `${reason}: ${record.name}, ${day.format(record.start)} to ${day.format(record.end)}`; text += ` (${String(workingDays)} working days, ${String(calendarDays)} calendar days).`; if (reason === 'progress' && record.progressDate) { text += ` ${String(Math.round(record.progress))}% done, up to ${day.format(record.progressDate)}.`; } for (const conflict of record.conflicts) text += ` Conflict: ${conflict.message}.`; return text;}Files
data.ts: six tasks, with the dates worked out in comments.options.ts:calendarandscheduling.helpers.ts:describe, which reads a task’s computed record.vanilla.tsandreact.tsx: the chart, a select and three boxes.
For a calendar at one-hour granularity, with hours of the day, see Working days at one-hour granularity.
Options
| Option | Value | What it does |
|---|---|---|
calendar.weekends |
[0, 6] |
Days of the week that are off, 0 is Sunday (the default) |
calendar.holidays |
[{ date, name }] |
A named day off; the name shows when you hover the day |
calendar.workingDays |
['2026-06-13'] |
A day that is worked whatever the other rules say (here a Saturday) |
calendar.isWorkingDay |
a function | Your own rule: true, false, or undefined for “ask the next rule” |
scheduling.durationMode |
'working' |
How a duration is counted, for the tasks that say nothing (the select) |
scheduling.skipNonWorkingDays |
{ move, resize, progress } |
Whether an edited edge moves off a day off (the three boxes) |
task.durationMode |
'calendar' on On-call |
A task counts its own duration its own way |
viewModes |
{ day: { pxPerDay: 32 } } |
A narrower day column than the default 40 px, so that the plan fits |
onTasksChange |
a function (in the example) | One ChangeSet per edit; the example reads the edited task’s record |
What to try
- Look at the lengths. Build, Handover and On-call all last five days, but Build and Handover count working days (the holiday, the Fridays and the weekends do not count, the worked Saturday does) and On-call counts calendar days.
- Switch Durations count to
calendar. Design, Build and Handover become shorter, and Design and Build now end on a day off: they are drawn with a red outline, andrecord.conflictshas anon-working-endfor each. On-call does not change. - Drag Review three days to the right, so that it would start on a Friday. With Skip days
off: move ticked, the start moves on to Monday, and the bar keeps its three working days. Untick
the box and drag again: the bar stays on the Friday, and the status line reports a
non-working-startconflict. - Drag the right edge of Build one day to the left, onto Sunday. With resize ticked the end
goes back to Saturday, the last working day; with it unticked the bar ends on Sunday, with a
non-working-endconflict. - Drag the progress handle of On-call (the little handle at the foot of the done part) into the weekend. With progress ticked the boundary jumps to the edge of the days off; without it, it rests on one. On-call counts calendar days: in a task that counts working days the boundary never rests on a day off, whatever the box says.
- Hover a shaded day for the name of the holiday.
Notes
- Which rule decides a day.
workingDaysfirst, thenholidays, thenisWorkingDay, thenweekends. A rule that returnsundefinedpasses the day on. It must be pure (the answer is cached for the life of the calendar), and it is given aCalendarDay:weekday,month,day, …. durationMode. A working duration skips days off and keeps counting until it has its days;duration.workingDaysandduration.calendarDaysof the record say both. A calendar duration counts every day, so a task can start or end on a day off: that is anon-working-startornon-working-endconflict, a warning, not an error.skipNonWorkingDays.moveis on by default,resizeis not, andprogressis on. It works on a drag:updateTaskand propagation through links put the dates exactly where you say.- A conflict is a warning. It is drawn (
data-conflict), listed inrecord.conflictsandchangeSet.conflicts, and passed toonBeforeTaskChange, as in Propagation and conflicts. - 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.