Working time and calendars#
This guide shows how to tell Planning Engine when work can happen: working hours, weekdays, holidays and short days. It also covers how tasks and resources pick a calendar, and how to turn calendars off.
How calendars work#
A task's duration is working time. A 16-hour task doesn't end 16 hours after it starts. It ends once it has used 16 hours of its calendar's working time, so nights, weekends and holidays stretch it. Lags work the same way. leveling_delay and pauses are the exception: they're elapsed wall-clock time.
Calendars go in ProjectData.calendars. Each one is a CalendarData object: an id plus an optional worktime with three fields:
hours: the working ranges of a day, as"H:MM-H:MM"strings. Default:["8:00-12:00", "13:00-17:00"]."24:00"is the only hour-24 value. A range that ends before it starts crosses midnight.days: exactly seven entries, booleans or numbers, index 0 is Sunday. Default: Monday to Friday.dates: exceptions keyed by"YYYY-MM-DD". A value offalse(or0) makes the date a day off.true(or a non-zero number) makes it a working day with the calendar'shours. An array of ranges such as["9:00-13:00"]makes it a working day with those hours.
Every project has a calendar named "global". If you don't define one, the engine uses the built-in Monday-Friday, 08:00-12:00 and 13:00-17:00. A calendar with the id "global" in calendars replaces it.
Which calendar a piece of work uses depends on config.calendar_mode (see SchedulingConfig):
| Mode | A task works on |
|---|---|
"resource" (default) |
each assignment's calendar: the resource's calendar, intersected with the task's own calendar when both are set |
"task" |
the task's calendar_id, else "global"; resource calendars are ignored |
"task-inherited" |
the task's calendar_id, else the nearest ancestor's, else "global" |
"resource" follows MS Project. "task" and "task-inherited" use task calendars only.
config.use_calendars sits above all of this. It's true by default, and null also reads as true. Set it to false and every calendar becomes flat 24/7 time.
The engine checks calendars when you pass data to schedule or solve. These cases return a validation error envelope with the field path:
| Problem | path |
|---|---|
| A duplicate calendar id | calendars[i] |
| A calendar with no working time | calendars[i] |
A task calendar_id that names no calendar |
tasks[i].calendar_id |
A resource calendar_id that names no calendar (only in "resource" mode) |
resources[i].calendar_id |
In "resource" mode, a task calendar and a resource calendar that share no weekly working time |
assignments[i] |
The JSON decoder rejects a few shapes before the engine sees them. These come back as validation with path: null: an hour range it can't read ("8:00-25:00"), a days list without exactly 7 entries, and customWeeks, which isn't supported. For example:
A malformed hour range fails the whole call with a validation error that has no path. In apply, this means no operation from that change set is applied.
Calendars belong to the snapshot only. SchedulingChanges has no calendars field, so apply can't add or edit a calendar. Sending one is a parse error. To change a calendar, send the full data again with solve(dataJson, ...). Edits can still point tasks and resources at existing calendars through calendar_id. An unknown id there is a rejected operation in the ApplyReport, not an error envelope. See Error handling for both levels.
Defining calendars#
Replace "global" to change the working week for the whole project. The calendar below works Monday to Friday, 09:00-17:00. Thursday the 13th is a short day and Friday the 14th is a holiday:
const = ;
const = ;
const = ;
Output:
build: 2026-08-10 09:00 -> 2026-08-17 17:00
The 36 hours are Monday to Wednesday (24 h), the short Thursday (4 h) and the next Monday (8 h). The holiday and the weekend hold no work. includeUnchanged makes updates list the task even though its start didn't move.
Each worktime field you leave out keeps its default. { "days": [0, 1, 1, 1, 1, 1, 1] } changes the weekdays and keeps the 08:00-12:00, 13:00-17:00 hours.
Task calendars#
Set a task's calendar_id to run that task on a different calendar. Here migrate runs on a night shift, while review stays on the default "global" calendar:
const = ;
const = ;
const = ;
Output:
review: 2026-08-10 08:00 -> 2026-08-11 17:00
migrate: 2026-08-10 22:00 -> 2026-08-12 06:00
The night shift keeps the default Monday-to-Friday days. Monday's shift runs until Tuesday 06:00, because a range that crosses midnight belongs to the day it starts on.
A task's own calendar (else "global") also matters in "resource" mode. Milestones, lag and slack always use it.
Inheriting a calendar from a summary#
In "task" and "resource" modes a child doesn't look at its parent's calendar. Set calendar_mode to "task-inherited" to make a task without a calendar_id use the nearest ancestor's:
const = ;
const = ;
const = ;
Output:
pour: 2026-08-14 08:00 -> 2026-08-15 17:00
phase: 2026-08-14 08:00 -> 2026-08-15 17:00
pour works Friday and Saturday on the summary's six-day week. Drop calendar_mode and it runs on "global" instead: Friday, then Monday, ending 2026-08-17 17:00.
Resource calendars#
In the default "resource" mode each assignment works on its resource's calendar, and the task's dates follow its assignments. Give a resource a calendar through its calendar_id. The example runs the same project in "resource" and "task" mode:
const = ;
Output:
resource prepare: 2026-08-14 08:00 -> 2026-08-14 17:00
resource inspect: 2026-08-15 08:00 -> 2026-08-18 17:00
task prepare: 2026-08-14 08:00 -> 2026-08-14 17:00
task inspect: 2026-08-17 08:00 -> 2026-08-18 17:00
In "resource" mode Dana works Saturday, so inspect starts right after prepare and runs Saturday and Tuesday. In "task" mode her calendar is ignored and inspect waits for Monday on "global".
The rules for picking an assignment's calendar:
- The resource has a calendar and the task doesn't: the resource's calendar.
- Both have one: their intersection. Give a task a mornings-only calendar and assign it to Dana, and the work runs Tuesday to Friday mornings only.
- The resource has none: the task's calendar.
- Summaries and milestones always use the task's calendar.
A task with several assignments on different calendars spans the envelope of its assignments. Each assignment starts at the first working time of its own calendar and runs the full duration there. The task ends when the last one ends. The reported duration stays the working time of each assignment, not the length of the envelope. Results don't include per-assignment dates.
Resource calendars also shape resource load: each assignment books work only on its own calendar. See Resource load.
Flat time#
Set config.use_calendars to false to schedule without working hours. Every calendar becomes 24/7, so a day of duration is 24 hours:
const = ;
const = ;
const = ;
Output:
burn-in: 2026-08-14 08:00 -> 2026-08-16 08:00
With calendars on, the same task skips the weekend and ends on Tuesday at 08:00. The engine still validates calendar ids, but they all resolve to the same flat time. That lets you toggle use_calendars without touching the rest of the data. In flat mode, resource and assignment edits never move dates.
When use_calendars is left out, the engine schedules on calendars, so send false explicitly for flat time.
Explicit starts on non-working time#
When an edit sets a start that falls on non-working time, the engine doesn't always snap it. A task with no predecessor keeps the exact start you gave it. Its end is computed forward on the working calendar, so the duration is still right. A task with a predecessor gets its start from the link instead.
const = ;
const = ;
const = new;
Output:
deploy: 2026-08-15 10:00 -> 2026-08-17 12:00
The start stays on Saturday 10:00. The four hours of work run Monday 08:00-12:00. This applies to apply and preview, the calls behind drags in an editor.
This is by design: the UI layer owns snapping. If your editor should only allow working-time starts, snap the date before you send the TaskUpdate.