Working days at one-hour granularity
A Monday-to-Friday calendar with a named holiday, tasks timed to the hour, and a before-hook that asks before a bar lands on a day off. Three settings make it work:
calendarsays which days are working days;scheduling.granularity: '1h'makes the data step one hour: every date, duration and lag in the chart is a whole number of hours (10:30is reported asmisalignedand aligned);durationMode: 'working'counts a duration in working time only, so a task of two working days that starts on Thursday at 10:00 ends on Monday at 10:00 (the weekend is skipped, the time of day is kept).
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';
// Dates with a time are wall-clock time: `'2026-03-05T10:00'` is 10:00 for every viewer, in every// time zone. 2026-03-04 is a Wednesday.export const tasks: Task[] = [ // 8 hours: Wednesday 09:00 to 17:00. { id: 'freeze', name: 'Code freeze', start: '2026-03-04T09:00', duration: '8h' }, // Thursday 10:00 + 2 working days = Monday 10:00 (the weekend is skipped, the time of day kept). { id: 'qa', name: 'QA pass', start: '2026-03-05T10:00', duration: '2d' }, // 4 hours: Monday 10:00 to 14:00. { id: 'release', name: 'Release', start: '2026-03-09T10:00', duration: '4h' }, // Monday 15:00 + 2 working days, with Tuesday a holiday = Thursday 15:00. { id: 'monitor', name: 'Monitor', start: '2026-03-09T15:00', duration: '2d' },];
export const links: Link[] = [ // No lag: QA keeps the gap the data has, 17 hours from Wednesday 17:00 to Thursday 10:00. // Drag the freeze an hour later and QA follows by an hour. { source: 'freeze', target: 'qa' }, { source: 'qa', target: 'release' }, // A lag is a rule instead: Monitor starts an hour after the release ends, wherever it goes. { source: 'release', target: 'monitor', lag: '1h' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Which days are working days: Monday to Friday, and one named holiday. calendar: { weekends: [0, 6], // the default: Sunday and Saturday holidays: [{ date: '2026-03-10', name: 'Office closed' }], // the name shows when hovering the day }, scheduling: { // The data step: every date, duration and lag is a whole number of hours. // (`10:30` is reported as a `misaligned` issue, and aligned.) granularity: '1h', // A duration counts working time only, for every task (a task can say 'calendar'). durationMode: 'working', // Drags land on working days. `move` is on by default, `resize` is not. skipNonWorkingDays: { move: true, resize: true }, }, // How days off are drawn: 'fill' (default), 'stripes' or false. display: { nonWorkingDays: 'stripes' }, // 6-hour columns; try 'hour' to see the individual hours. viewMode: 'quarter-day', scroll: { initial: 'first-task' },
// Ask before a bar ends up on a day off. Returning false cancels the change; a promise works too. onBeforeTaskChange: ({ conflicts }) => { const dayOff = conflicts.some( (conflict) => conflict.type === 'non-working-start' || conflict.type === 'non-working-end', ); return dayOff ? window.confirm('This lands on a day off. Keep it?') : true; }, // Problems with the data or the options are reported, never thrown. onIssues: ({ source, issues }) => { for (const issue of issues) console.warn(source, issue.code, issue.path, issue.suggested); },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe } from './helpers';import { options } from './options';
const VIEW_MODES = ['day', 'quarter-day', 'hour'] as const;
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '380px'; const status = document.createElement('p'); status.textContent = 'Drag Code freeze: QA keeps its gap, and Monitor its lag.'; root.append(toolbar, chart, status); container.append(root);
// The handler reads `gantt`, which it is passed to: give it a return type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, onTasksChange: (): void => { // The records are computed from the calendar: `Date`s with the wall-clock time the data says. status.textContent = describe(gantt.getTask('qa')); }, });
const controller = new AbortController(); for (const id of VIEW_MODES) { const button = document.createElement('button'); button.type = 'button'; button.textContent = id; button.addEventListener('click', () => gantt.setViewMode(id), { signal: controller.signal }); toolbar.append(button); }
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 } from './helpers';import { options } from './options';
const VIEW_MODES = ['day', 'quarter-day', 'hour'];
export default function Example() { const chart = useRef<GanttHandle>(null); const [viewMode, setViewMode] = useState<string>(options.viewMode); const [status, setStatus] = useState('Drag Code freeze: QA keeps its gap, and Monitor its lag.');
return ( <> <div role="toolbar"> {VIEW_MODES.map((id) => ( <button key={id} type="button" onClick={() => setViewMode(id)}> {id} </button> ))} </div> <Gantt ref={chart} style={{ height: 380 }} defaultTasks={tasks} defaultLinks={links} {...options} viewMode={viewMode} onTasksChange={() => { // The records are computed from the calendar: `Date`s with the wall-clock time the data says. setStatus(describe(chart.current?.getTask('qa'))); }} /> <p>{status}</p> </> );}import type { TaskRecord } from '@ganttlane/dom';
const when = new Intl.DateTimeFormat('en', { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',});
/** * Says where a task is, from its computed record: `Date`s with the wall-clock time the data says, * and the length counted in working days. * * @param record - A task of the chart, or `undefined`. * @returns A sentence. */export function describe(record: TaskRecord | undefined): string { if (!record?.start || !record.endExclusive) return ''; const days = record.duration?.workingDays; return `${record.name}: ${when.format(record.start)} to ${when.format(record.endExclusive)} (${String(days)} working days)`;}Files
data.ts: four tasks timed to the hour, and the links with their lags.options.ts: the calendar, the scheduling rules,display, and two handlers.helpers.ts:describe, which reads a task’s computed record.vanilla.tsandreact.tsx: the chart and the view mode buttons.
Options
| Option | Value | What it does |
|---|---|---|
calendar.weekends |
[0, 6] |
Sunday and Saturday are days off (the default) |
calendar.holidays |
[{ date, name }] |
A named day off; the name shows when hovering the day |
scheduling.granularity |
'1h' |
The data step: one hour |
scheduling.durationMode |
'working' |
Durations count working time only |
scheduling.skipNonWorkingDays |
{ move: true, resize: true } |
Drags land on working days |
display.nonWorkingDays |
'stripes' |
Days off are striped ('fill' is the default) |
viewMode |
'quarter-day' |
6-hour columns; the buttons switch to 'day' and 'hour' |
onBeforeTaskChange |
a function | Asks before a bar ends up on a day off; false cancels, a promise works too |
onIssues |
a function | Reports problems with the data or the options |
What to try
Drag Code freeze an hour later and QA follows by an hour. Drag QA onto the weekend: the before-hook
asks. The line under the chart says where QA is, from its computed record. Hold Alt while dragging
to snap to the data step (one hour here) instead of the view mode’s.
Notes
- The dates are floating wall-clock time:
'2026-03-05T10:00'is 10:00 for every viewer, in every time zone. Strings withZor an offset are rejected. - A task given as a bare date (
'2026-03-06') has day precision and stays on whole days even at an hourly granularity (scheduling.keepDayPrecision, on by default); give it a time to make it a timed task. - A drag snaps to the step of the view mode (
scheduling.snapoverrides it):1hin the quarter-day mode used here. A step finer than the granularity (the hour mode’s 15 minutes) is raised to the granularity by itself. - A working day is the whole 24 hours; working hours (shifts) are not modelled yet. So a gap in working time counts the night between Wednesday 17:00 and Thursday 10:00 (17 hours) and skips the weekend.
- A link without a
lagkeeps the gap it has now. Alag(a number of days, or a duration such as'1h') is a rule, not a minimum: after a drag the successor sits exactly that far from its predecessor, so give dates that already do. If Monitor started at 18:00 here, the first drag of Release would pull it back to one hour after Release ends. - The records are computed from the calendar:
gantt.getTask('qa')hasDates with the wall-clock time the data says, andduration.workingDays.