Project data#
This guide shows how to describe a plan for Planning Engine: tasks, links, ids, dates, durations, and the config switches that change how the plan is scheduled. It also lists what strict validation rejects, so you can build valid input the first time.
How project data works#
A plan goes in as one plain ProjectData object. It holds six arrays and objects: config, calendars, tasks, links, resources and assignments. Only tasks is required. The rest default to empty, and every config field has its own default.
ProjectData is an import format, not stored state. project.solve(data), schedule(data) and project.analyze(data) validate it and build the engine's own model from it. Your object is never mutated, and the engine never hands the data back. Results report changes as sparse updates, and you write them back into your own model.
Field names are snake_case (start_date, duration, constraint_type, auto_scheduling). Task and link records are read field by field, and unknown fields are ignored, so you can pass objects with extra UI fields as they are.
A few rules hold everywhere:
- An
Idis a number or a string, compared the way JavaScript object keys are. Task1and task"1"are the same task. - A
DateValueis a"YYYY-MM-DD HH:MM"string or aDate. Dates are naive local time at minute precision. There's no time zone, no DST and no "today". durationandlagare working time, measured on calendars.leveling_delayandpausesare elapsed wall-clock time.- Strict validation (the default) rejects ambiguous input with an
InputErrorwhosepathpoints at the field.new Project({ validation: "repair" })fixes what it can and reports each fix.
How inputs are read and how dates come back is covered in Plain objects and dates. For every field and its default, see TaskData, LinkData and SchedulingConfig.
Describing a project#
Tasks#
A leaf task needs two of start_date, end_date and duration. The engine derives the third. If you send all three, they must agree on the task's calendar.
const = ;
The links push build after design and release after build. The summary phase has no dates of its own, so its span comes from design and build. Its update carries only the field that changed, the new end_date.
Each task type has its own date rule:
"task"(the default) is a leaf: two of start, end and duration."summary"takes both dates or neither. Its span is derived, so a summary withstart_date+durationis rejected. A summary with no dates needs at least one dated child."project"is accepted as an input alias of"summary". Results always say"summary"."milestone"needsstart_dateorend_date. Its duration is 0.
parent links a task to its summary. A missing parent, null, "" and a value equal to config.root_id (default 0) all mean "no parent". A parent that doesn't exist, or a parent chain that loops back on itself, is a validation error.
The rest of TaskData covers constraints (constraint_type, constraint_date), calendars (calendar_id) and leveling inputs (priority, leveling_delay, pauses). See Links, constraints and summaries, Working time and calendars and Leveling resources.
Ids and dates#
An id is a number or a string. Ids compare as JavaScript object keys do, so a number and its string form are the same id:
Keep this in mind when you mix sources: task 1 from one system and task "1" from another collide as duplicates. Numeric ids are plain JS numbers, so integers past 2^53 lose precision. Results echo each id in the form you sent it.
A date is a string or a Date. Strings take YYYY-MM-DD, with an optional HH:MM or HH:MM:SS after a space or a T. A Date is read from its local components and cut to the minute:
const = new;
const = ;
Set dates: "date" to get result dates as local Date objects instead of "YYYY-MM-DD HH:MM" strings; schedule() always returns strings.
No time zone reaches the engine. Convert zoned timestamps to the plan's local time before you build the data. An invalid Date throws InputError with the message "Invalid Date". The engine never reads the clock, so an entirely undated project is rejected instead of being placed at "today". Plain objects and dates has the full input and output rules.
Durations and units#
duration is working time in a unit: "minute", "hour" or "day". config.duration_unit sets the unit for the whole project (default "day"). A task's own duration_unit overrides it for that task.
const = ;
// review 2026-03-02 08:00 2026-03-03 12:00
// sync 2026-03-02 08:00 2026-03-02 09:30
Durations are numbers, so 1.5 days or 0.25 hours are valid. A negative duration is rejected at tasks[i].duration. A non-finite one (NaN, Infinity) is rejected before validation, with the message "Numbers must be finite". Week, month and year units aren't supported, and duration_unit: "week" throws an InputError.
Day units count whole working days. From a midnight anchor, Tuesday 00:00 + 4 days ends at Saturday 00:00, which is the close of Friday. From a non-midnight anchor, days are counted to the same time of day. With a sparse calendar, that can skip working hours: on a Mon-Wed 9:00-13:00 calendar, a 3-day task from Monday 9:00 ends the next Monday 9:00. If that matters, use hour units.
Two task fields use elapsed time instead of working time: leveling_delay and pauses. Both are in the task's duration_unit, where a day is 24 hours, and both are rounded to whole minutes on use. The leveler writes them. Read Leveling resources before you set them by hand.
Links#
A LinkData object connects a source task to a target task. type is "fs" (finish-to-start, the default), "ss", "ff" or "sf". Numeric codes work too: 0-3 or "0"-"3", in that order. lag is working time added along the link, in config.duration_unit unless the link sets its own lag_unit.
const ;
const = ;
// link stray dropped: unknown target task "missing"
Some links can't apply. The engine drops them and reports a dropped-link note with the reason instead of failing:
- the source or target task doesn't exist;
- the source or target task is unscheduled;
- the link joins a summary to one of its own descendants, and
config.descendant_linksisfalse.
A duplicate link id is a validation error. The engine keeps a link from a task to itself and reports it as a one-task cycle. How each link type moves its target, and how links attach to summaries, is in Links, constraints and summaries.
Fixed, unscheduled and completed tasks#
Three task states keep a task where it is:
auto_scheduling: falsemakes the task fixed. The planner never moves it, but links from it still push its successors.unscheduled: truetakes the task out of the plan. It isn't scheduled or reported, and it doesn't need dates. Its descendants go with it. Links that touch it are dropped with a note.- With
config.use_progresson, a task withprogress: 1is completed and stays put.
const = ;
progress must be between 0 and 1. A completed task also stays in place inside a summary that moves as a block. In analytics, completed tasks have zero slack and are never critical.
Scheduling config#
config holds the project-wide switches. Every field is optional, so set only what you need:
const = ;
; // 2026-03-02 12:00
The switches you'll change most often:
| Field | Default | Effect |
|---|---|---|
apply_constraints |
false |
Turns on the eight constraint types. Off, constraints are ignored, including mso pins |
schedule_from_end |
false |
Backward (ALAP) planning. level refuses such projects |
gap_behavior |
"preserve" |
"preserve" keeps tasks at their own dates when links allow; "compress" pulls every task to its earliest position |
move_projects |
true |
Summaries move as a block; false schedules their leaves one by one |
use_progress |
false |
Tasks with progress: 1 are completed and fixed |
project_start |
none | Lower bound for the plan, and the date source for undated tasks in repair mode |
project_end |
none | Upper bound, used by backward projects |
duration_unit |
"day" |
Unit of duration and lag |
use_calendars |
true |
false makes every calendar flat 24/7 time |
calendar_mode |
"resource" |
Which calendars place the work; "task" / "task-inherited" use task calendars only |
max_iterations |
8 |
Planner pass limit; must be at least 1 |
root_id |
0 |
The parent value that means "no parent" |
project_constraint, group_mode, descendant_links, strict_calendar and default_resource_capacity cover rarer cases. See SchedulingConfig for all of them.
root_id exists for data whose virtual root isn't 0. The engine strips it on import, so stored tasks never point at the root. With a non-zero root_id, a task with id 0 is an ordinary parent.
When use_calendars is left out, Planning Engine schedules on calendars. Set it explicitly if your data has its own working-time switch.
Validation rules#
The default "strict" validation rejects input that would need coercion or guessing. The call throws an InputError with a path to the field, and a live Project keeps its previous state and cached result.
Strict import rejects:
| Input | Error path |
|---|---|
| Duplicate task, link, resource or calendar id | tasks[i].id, links[i].id, resources[i].id, calendars[i] |
| A leaf with fewer than two of start, end and duration | tasks[i].start_date |
| Start, end and duration that don't agree | tasks[i].duration |
end_date before start_date |
tasks[i].end_date |
| A summary with only one date, or start + duration | tasks[i].start_date |
| A summary with no dates and no dated children | tasks[i] |
| A milestone with no date | tasks[i].start_date |
| A negative duration | tasks[i].duration |
A dated constraint without constraint_date |
tasks[i].constraint_date |
An unknown parent, or a cycle in the hierarchy |
tasks[i].parent |
progress outside 0..1, priority above 1000, negative leveling_delay |
tasks[i].progress, .priority, .leveling_delay |
Unsorted, overlapping or invalid pauses |
tasks[i].pauses[j] |
An unknown task calendar_id |
tasks[i].calendar_id |
| A calendar with no working time | calendars[i] |
max_iterations: 0 |
config.max_iterations |
Strict mode also rejects week, month and year units, and customWeeks calendars. Two checks run on the whole input before validation and don't depend on the mode: a non-finite number anywhere ("Numbers must be finite", with the field name as path) and an invalid Date ("Invalid Date"). Resource and assignment rules (capacity, value, slices, calendars with no shared working time) are in Resources and assignments.
Some things aren't errors. Links that can't apply become dropped-link notes, unscheduled tasks become dropped-task notes, and dependency cycles are reported in cycles. Unknown fields in records are ignored. See ScheduleResult for the note shapes.
When the data comes from a UI that saves messy records, switch to repair mode instead of fixing the input yourself:
const = new;
const = ;
new Project(options) throws an InputError if an option is unknown or has an invalid value.
Repair deduplicates ids (the last record wins and keeps the first position), derives missing dates and durations, lets dates win in an inconsistent triple, and clears dated constraints without a date. Each fix shows up as a repaired note with its path. A task with no dates gets config.project_start, or the earliest date of another task. When neither exists, as in a fully undated project, repair fails at tasks[i].start_date. See Accept messy input and Error handling.