[Canonical page](<https://docs.dhtmlx.com/planning-engine/js/scheduling/project-data/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/project-data.md>) 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)`](<https://docs.dhtmlx.com/planning-engine/js/api/project-solve.md>), [`schedule(data)`](<https://docs.dhtmlx.com/planning-engine/js/api/schedule.md>) and [`project.analyze(data)`](<https://docs.dhtmlx.com/planning-engine/js/api/project-analyze.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/common.md#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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/common.md#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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#inputerror>) whose `path` points at the field. [`new Project({ validation: "repair" })`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md#projectoptions>) fixes what it can and reports each fix.

How inputs are read and how dates come back is covered in [Plain objects and dates](<https://docs.dhtmlx.com/planning-engine/js/plain-objects-and-dates.md>). For every field and its default, see [`TaskData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/task-data.md>), [`LinkData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/link-data.md>) and [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/js/api/types/scheduling-config.md>).

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

```js
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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/task-data.md>) covers constraints (`constraint_type`, `constraint_date`), calendars (`calendar_id`) and leveling inputs (`priority`, `leveling_delay`, `pauses`). See [Links, constraints and summaries](<https://docs.dhtmlx.com/planning-engine/js/scheduling/links-constraints-summaries.md>), [Working time and calendars](<https://docs.dhtmlx.com/planning-engine/js/scheduling/working-time-calendars.md>) and [Leveling resources](<https://docs.dhtmlx.com/planning-engine/js/resources-and-assignment/leveling-resources.md>).

### 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:

```js
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:

```js
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](<https://docs.dhtmlx.com/planning-engine/js/plain-objects-and-dates.md>) 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.

```js
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](<https://docs.dhtmlx.com/planning-engine/js/resources-and-assignment/leveling-resources.md>) before you set them by hand.

### Links

A [`LinkData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/link-data.md>) 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`.

```js
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](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md#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](<https://docs.dhtmlx.com/planning-engine/js/scheduling/links-constraints-summaries.md>).

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

```js
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:

```js
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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-level.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/scheduling-config.md>) 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#inputerror>) with a `path` to the field, and a live [`Project`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md>) keeps its previous state and cached result.

```js
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](<https://docs.dhtmlx.com/planning-engine/js/resources-and-assignment/resources-and-assignments.md>).

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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md#note>) 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:

```js
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](<https://docs.dhtmlx.com/planning-engine/js/how-to/accept-messy-input.md>) and [Error handling](<https://docs.dhtmlx.com/planning-engine/js/error-handling.md>).
