@jaeungkim/gantt-chart

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
ParameterTypeRequiredMeaning
optionsWorkingCalendarOptionsnoDefaults 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;
}
OptionTypeDefaultMeaning
workingWeekdaysnumber[][1, 2, 3, 4, 5] (Mon–Fri)UTC day numbers, 0 = Sunday. A day not in the array is non-working
holidaysstring[][]UTC YYYY-MM-DD strings, matched by exact string equality against date.format('YYYY-MM-DD')
isNonWorkingDay(date: Dayjs) => booleanundefinedOne 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: WorkingCalendar

A 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

MemberSignatureCALENDAR_DAYSWorking calendar
skipsNonWorkingDaysreadonly booleanfalsetrue
isWorkingDay(date: Dayjs) => booleanAlways truetrue when the day passes the weekday and holiday check, or the isNonWorkingDay predicate returns false
addDays(date: Dayjs, days: number) => Dayjsdate.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) => numberto.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) => numberSmallest d with addDays(from, d) >= targetSame, in working days
daysUpTo(from: Dayjs, target: Dayjs) => numberLargest d with addDays(from, d) <= targetSame, in working days
snapForward(date: Dayjs) => DayjsReturns date unchangedThe 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'] }).

CallResult
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; // true

Pass 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.

On this page