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 Id is a number or a string, compared the way JavaScript object keys are. Task 1 and task "1" are the same task.
  • A DateValue is a "YYYY-MM-DD HH:MM" string or a Date. Dates are naive local time at minute precision. There's no time zone, no DST and no "today".
  • duration and lag are working time, measured on calendars. leveling_delay and pauses are elapsed wall-clock time.
  • Strict validation (the default) rejects ambiguous input with an InputError whose path points 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.

import { schedule } from "plen-js";

const result = schedule({
  tasks: [
    // A summary with no dates: its span comes from its children.
    { id: "phase", type: "summary" },
    { id: "design", parent: "phase", start_date: "2026-03-02 08:00", duration: 3 },
    { id: "build", parent: "phase", start_date: "2026-03-02 08:00", duration: 5 },
    // A milestone needs only one date.
    { id: "release", type: "milestone", start_date: "2026-03-02 08:00" },
  ],
  links: [
    { id: "l1", source: "design", target: "build", type: "fs" },
    { id: "l2", source: "build", target: "release", type: "fs" },
  ],
});

for (const update of result.updates) {
  console.log(update.id, update.start_date, update.end_date);
}

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 with start_date + duration is 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" needs start_date or end_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:

import { InputError, schedule } from "plen-js";

try {
  schedule({
    tasks: [
      { id: 1, start_date: "2026-03-02 08:00", duration: 1 },
      { id: "1", start_date: "2026-03-02 08:00", duration: 2 },
    ],
  });
} catch (error) {
  if (!(error instanceof InputError)) throw error;
  console.log(error.path, error.message); // tasks[1].id duplicate task id "1"
}

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:

import { Project } from "plen-js";

const project = new Project({ dates: "date" });
const result = project.solve(
  {
    tasks: [
      // 09:30:45 local time becomes 09:30.
      { id: 1, start_date: new Date(2026, 2, 2, 9, 30, 45), duration: 1 },
      // The same date as a string.
      { id: 2, start_date: "2026-03-02T09:30", duration: 1 },
    ],
  },
  { includeUnchanged: true },
);

for (const update of result.updates) {
  console.log(update.id, update.start_date); // a local Date at 09:30 for both
}
Note

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.

import { schedule } from "plen-js";

const result = schedule(
  {
    config: { duration_unit: "hour" },
    tasks: [
      // 12 working hours: the default calendar works 08-12 and 13-17.
      { id: "review", start_date: "2026-03-02 08:00", duration: 12 },
      // This task counts in minutes instead of the project's hours.
      { id: "sync", start_date: "2026-03-02 08:00", duration: 90, duration_unit: "minute" },
    ],
  },
  { includeUnchanged: true },
);

for (const update of result.updates) {
  console.log(update.id, update.start_date, update.end_date);
}
// 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.

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.

import { schedule } from "plen-js";

const task = (id) => ({ id, start_date: "2026-03-02 08:00", duration: 2 });

const result = schedule({
  tasks: [task("a"), task("b"), task("c")],
  links: [
    // b starts 1 working day after a starts.
    { id: "ss", source: "a", target: "b", type: "ss", lag: 1 },
    // c finishes 4 working hours after b finishes; 2 is the numeric code for "ff".
    { id: "ff", source: "b", target: "c", type: 2, lag: 4, lag_unit: "hour" },
    // Points at a task that doesn't exist: dropped, not an error.
    { id: "stray", source: "c", target: "missing" },
  ],
});

for (const note of result.notes) {
  if (note.kind === "dropped-link") {
    console.log(`link ${note.link} dropped: ${note.reason}`);
  }
}
// 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_links is false.

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: false makes the task fixed. The planner never moves it, but links from it still push its successors.
  • unscheduled: true takes 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_progress on, a task with progress: 1 is completed and stays put.
import { schedule } from "plen-js";

const result = schedule({
  config: { use_progress: true },
  tasks: [
    // Completed, because use_progress is on.
    { id: "done", start_date: "2026-03-02 08:00", duration: 2, progress: 1 },
    // Fixed: the planner never moves it.
    { id: "pinned", start_date: "2026-03-02 08:00", duration: 1, auto_scheduling: false },
    // Out of the plan: no dates needed.
    { id: "later", unscheduled: true },
  ],
});

for (const note of result.notes) {
  if (note.kind === "dropped-task") {
    console.log(`task ${note.task}: ${note.reason}`); // task later: unscheduled
  }
}

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:

import { schedule } from "plen-js";

const result = schedule(
  {
    config: {
      apply_constraints: true, // constraints act as hard bounds
      gap_behavior: "compress", // pull tasks to their earliest
      duration_unit: "hour",
      project_start: "2026-03-02 08:00", // nothing starts before this
      calendar_mode: "task", // task calendars only
      root_id: -1, // data with a virtual root of -1
    },
    tasks: [
      // parent equals root_id: no parent.
      { id: 1, parent: -1, start_date: "2026-03-02 08:00", duration: 4 },
    ],
  },
  { includeUnchanged: true },
);

console.log(result.updates[0].end_date); // 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.

import { InputError, schedule } from "plen-js";

try {
  schedule({
    tasks: [
      { id: "ok", start_date: "2026-03-02 08:00", duration: 1 },
      { id: "broken", duration: 2 }, // only one of start / end / duration
    ],
  });
  console.log("scheduled");
} catch (error) {
  if (!(error instanceof InputError)) throw error;
  console.log(`${error.path ?? "-"}: ${error.message}`);
  // tasks[1].start_date: task needs two of start_date / end_date / duration
}

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:

import { Project } from "plen-js";

const project = new Project({ validation: "repair" });
const result = project.solve({
  tasks: [
    { id: "a", start_date: "2026-03-02 08:00", duration: 1 },
    // Same id again: in repair mode the last record wins.
    { id: "a", start_date: "2026-03-02 08:00", duration: 3 },
  ],
});

for (const note of result.notes) {
  if (note.kind === "repaired") {
    console.log(`${note.path}: ${note.message}`);
    // tasks[1].id: duplicate id replaced the earlier record, retaining its position
  }
}
Note

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.