Working calendar
`createWorkingCalendar`, `CALENDAR_DAYS`, `WorkingCalendar`
A WorkingCalendar is the day unit the scheduling core counts in. Every shift, lag, duration and
slack figure goes through one, and CALENDAR_DAYS — the default — counts all seven days, so plain
calendar arithmetic is the same code path with a calendar that skips nothing. What turning the
working-day calendar on changes in the chart is in Scheduling.
import {
CALENDAR_DAYS,
createWorkingCalendar,
type WorkingCalendar,
type WorkingCalendarOptions,
} from '@jaeungkim/gantt-chart';The Dayjs values these methods take and return come from dayjs, a runtime dependency of the
package. dayjs is not re-exported from @jaeungkim/gantt-chart — import it from dayjs
itself, in UTC mode.
createWorkingCalendar
// src/core/calendar.ts
/**
* A calendar that skips weekends and holidays.
*
* Takes the same configuration the chart already uses to shade non-working days
* (`holidays` / `isNonWorkingDay`), so what is shaded and what is skipped cannot drift.
*/
export function createWorkingCalendar(
options: WorkingCalendarOptions = {}
): WorkingCalendar| Parameter | Type | Required | Meaning |
|---|---|---|---|
options | WorkingCalendarOptions | no | Defaults to {}, which is a Monday–Friday week with no holidays |
The returned calendar always has skipsNonWorkingDays: true, including for a configuration that
happens to leave every day working.
WorkingCalendarOptions
// src/core/calendar.ts
export interface WorkingCalendarOptions {
/** Working weekdays as UTC day numbers, 0 = Sunday (default Mon-Fri) */
workingWeekdays?: number[];
/** Non-working dates as UTC `YYYY-MM-DD` strings */
holidays?: string[];
/** Replaces the weekday + holiday check entirely */
isNonWorkingDay?: (date: Dayjs) => boolean;
}| Option | Type | Default | Meaning |
|---|---|---|---|
workingWeekdays | number[] | [1, 2, 3, 4, 5] (Mon–Fri) | UTC day numbers, 0 = Sunday. A day not in the array is non-working |
holidays | string[] | [] | UTC YYYY-MM-DD strings, matched by exact string equality against date.format('YYYY-MM-DD') |
isNonWorkingDay | (date: Dayjs) => boolean | undefined | One predicate for the whole check. true means the day is non-working |
isNonWorkingDay wins outright. When it is set, workingWeekdays and holidays are never read —
a calendar built with all three uses only the predicate.
CALENDAR_DAYS
// src/core/calendar.ts
/**
* The default calendar: every day counts.
* Date arithmetic through it is plain calendar arithmetic, so it is what keeps the
* scheduling features behaviour-neutral until a host opts into working days.
*/
export const CALENDAR_DAYS: WorkingCalendarA single shared instance, not a factory. It is the default for the calendar option of
scheduleTasks and computeCriticalPath, so
passing it explicitly is the same as omitting the option. Use it when a function requires a
calendar argument and the caller wants plain calendar days.
WorkingCalendar
// src/core/calendar.ts
/**
* The calendar every piece of date arithmetic in the core routes through.
*
* There is exactly one notion of "a day" in the engine: whatever this object says.
* The default calendar counts every day, so the plain calendar-date behaviour is not a
* separate code path - it is the same code with a calendar that never skips anything.
* Turning the working-day calendar on swaps the object; nothing else changes.
*/
export interface WorkingCalendar {
/** False for the default calendar (every day counts) - lets callers take the cheap path */
readonly skipsNonWorkingDays: boolean;
isWorkingDay(date: Dayjs): boolean;
/** Moves `days` days forward (or backward), skipping non-working days */
addDays(date: Dayjs, days: number): Dayjs;
/** Days from `from` to `to`, counted the same way `addDays` moves. Signed. */
daysBetween(from: Dayjs, to: Dayjs): number;
/** Smallest d where `addDays(from, d) >= target` - how far a task must move to clear a date */
daysUntil(from: Dayjs, target: Dayjs): number;
/** Largest d where `addDays(from, d) <= target` - how far a task may slip before it breaks one */
daysUpTo(from: Dayjs, target: Dayjs): number;
/** The date itself when it is a working day, otherwise the next one (time of day kept) */
snapForward(date: Dayjs): Dayjs;
}Members
| Member | Signature | CALENDAR_DAYS | Working calendar |
|---|---|---|---|
skipsNonWorkingDays | readonly boolean | false | true |
isWorkingDay | (date: Dayjs) => boolean | Always true | true when the day passes the weekday and holiday check, or the isNonWorkingDay predicate returns false |
addDays | (date: Dayjs, days: number) => Dayjs | date.add(days, 'day') | Steps one calendar day at a time and counts down only on working days. The time of day is kept |
daysBetween | (from: Dayjs, to: Dayjs) => number | to.startOf('day').diff(from.startOf('day'), 'day') | Walks day by day and counts only the working days it lands on. The starting day is never counted |
daysUntil | (from: Dayjs, target: Dayjs) => number | Smallest d with addDays(from, d) >= target | Same, in working days |
daysUpTo | (from: Dayjs, target: Dayjs) => number | Largest d with addDays(from, d) <= target | Same, in working days |
snapForward | (date: Dayjs) => Dayjs | Returns date unchanged | The next working day at or after date, time of day preserved |
daysBetween compares at day granularity: it calls startOf('day') on both ends, so two times on
the same date are 0 apart. daysUntil and daysUpTo take the time of day into account, by
correcting the daysBetween answer by at most two whole steps.
addDays and daysBetween are exact inverses from a working day:
daysBetween(from, addDays(from, n)) is n. from must be a working day — from a non-working
day the round trip loses the first step. The identity also needs a span inside the walk limit and
a calendar with at least one working day; both limits are under Constraints below.
Worked values
June 2025: the 2nd is a Monday, the 6th a Friday, the 7th and 8th the weekend, the 9th the next
Monday. workweek is createWorkingCalendar(); withHoliday is
createWorkingCalendar({ holidays: ['2025-06-09'] }).
| Call | Result |
|---|---|
workweek.addDays('2025-06-06', 1) | 2025-06-09 |
workweek.addDays('2025-06-02', 10) | 2025-06-16 |
workweek.addDays('2025-06-09', -1) | 2025-06-06 |
workweek.addDays('2025-06-06T14:30', 1) | 2025-06-09T14:30 |
workweek.daysBetween('2025-06-02', '2025-06-09') | 5 |
workweek.daysBetween('2025-06-06', '2025-06-08') | 0 (Fri to Sun) |
workweek.daysBetween('2025-06-07', '2025-06-09') | 1 (from Sat) |
workweek.daysBetween('2025-06-09', '2025-06-02') | -5 |
workweek.daysUntil('2025-06-06T09:00', '2025-06-09T17:00') | 2 |
workweek.daysUpTo('2025-06-06T09:00', '2025-06-09T08:00') | 0 |
workweek.snapForward('2025-06-07T09:00') | 2025-06-09T09:00 |
withHoliday.addDays('2025-06-06', 1) | 2025-06-10 |
withHoliday.daysBetween('2025-06-06', '2025-06-13') | 4 |
CALENDAR_DAYS.daysBetween('2025-06-02', '2025-06-09') | 7 |
Example
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { createWorkingCalendar } from '@jaeungkim/gantt-chart';
dayjs.extend(utc);
const calendar = createWorkingCalendar({ holidays: ['2025-06-09'] });
// Friday, one working day forward: the weekend and Monday's holiday are both skipped.
calendar.addDays(dayjs.utc('2025-06-06'), 1).toISOString(); // 2025-06-10T00:00:00.000Z
calendar.daysBetween(dayjs.utc('2025-06-06'), dayjs.utc('2025-06-13')); // 4
calendar.isWorkingDay(dayjs.utc('2025-06-09')); // false
calendar.skipsNonWorkingDays; // truePass the calendar on as options.calendar to scheduleTasks or
computeCriticalPath.
Constraints
src/core/calendar.ts imports only the Dayjs type, never dayjs itself. The weekday comes from
date.day() and a holiday is matched against date.format('YYYY-MM-DD'), so both are read in
whatever mode the Dayjs instance you pass is in.
Inside the chart that is always UTC: every date the core hands the calendar comes from
src/core/dates.ts, which is dayjs.utc. Calling the calendar yourself with a local-mode Dayjs
reads a local weekday and a local YYYY-MM-DD, so pass UTC-mode instances, as the example above
does.
A calendar where nothing is a working day does not fail and does not hang. addDays gets a budget
of 367 steps per requested day and falls back to plain calendar days when it runs out, and
snapForward returns its input unchanged after 366 attempts. A predicate that marks every day
non-working produces wrong numbers, not an error.
daysBetween walks one day at a time only up to 18,263 days (about 50 years). Past that it returns
the plain calendar-day difference, so the unit changes silently on a working calendar for spans
that long.
daysBetween returns 0, never -0, for a backwards span that contains no working day.
daysUntil and daysUpTo correct the daysBetween answer by at most two steps in each direction.
They are exact for the day-length spans the scheduler produces, not for arbitrary sub-day
arithmetic.
The calendar carries no notion of hours. A task from 09:00 to 17:00 on one date measures
daysBetween of 0.
The skip limit (366) and the walk limit (18,263) are internal constants of src/core/calendar.ts.
They are not exported from @jaeungkim/gantt-chart and cannot be configured. build — the
internal factory both CALENDAR_DAYS and createWorkingCalendar return from — is not exported
either.
WorkingCalendar is a plain interface. A host may implement it directly instead of calling
createWorkingCalendar, as long as addDays and daysBetween stay inverse — the scheduler and
the critical-path pass both rely on that.
A calendar changes dates, not geometry. Bar geometry, timeline ticks and non-working-day shading
take no calendar argument, so a bar still spans the weekends and holidays it covers — those days
do not count. Shading is the separate showNonWorkingDays prop, described in
The timeline.
The one render-side reader is the bar drag: when skipsNonWorkingDays is true, the dropped edge
is snapForward-ed and the rest of the bar follows by the same number of days. The full list of
what a calendar does and does not affect is in Scheduling.