[Canonical page](<https://docs.dhtmlx.com/planning-engine/rust/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 `ProjectData` value. It holds six lists: `config`, `calendars`, `tasks`, `links`, `resources` and `assignments`. Only `tasks` is required. The rest default to empty, and `config` defaults to `SchedulingConfig::default()`.

`ProjectData` is an import format, not stored state. [`solve(Some(data))`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#solve>), [`schedule`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/schedule.md>) and [`analyze`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md#analyze>) validate it and build the engine's own normalized model from it. The engine never hands the snapshot back. Results report changes as sparse `updates`, and you write them back into your own model.

The records are plain structs with public fields:

- [`TaskData::new(id)`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/task-data.md#new>) gives a task with every default filled in. Set the fields you need on top of it.
- [`LinkData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-data.md>), [`CalendarData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/calendar-data.md>), [`ResourceData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/resource-data.md>) and [`AssignmentData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/assignment-data.md>) have no constructor. You write them as struct literals.
- `ProjectData` and `SchedulingConfig` implement `Default`, so `..Default::default()` fills the rest.

Field names follow dhtmlxGantt (`start_date`, `duration`, `constraint_type`, `auto_scheduling`), so gantt data maps one-to-one. The two renames are `task_type` and `link_type`, which are called `type` on the wire.

A few rules hold everywhere:

- [`Id`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/id.md>) is a number or a string and compares like a JavaScript object key, so task `1` and task `"1"` are the same task.
- [`DateTime`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/date-time.md>) is a naive local date-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 a path-qualified [`InputError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error.md>) instead of guessing. [`Validation::Repair`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/validation.md>) fixes what it can and reports each fix.

Input records have no `Deserialize`. To read JSON input, use the `plen-wire` crate, which decodes the same field names.

For every field and its default, see [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/project-data.md>), [`TaskData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/task-data.md>) and [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/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.

```rust
use plen_core::{
    schedule, DateTime, LinkData, LinkType, ProjectData, SolveOptions, TaskData, TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;

    // A summary with no dates: its span comes from its children.
    let mut phase = TaskData::new("phase");
    phase.task_type = TaskType::Summary;

    let mut design = TaskData::new("design");
    design.parent = Some("phase".into());
    design.start_date = Some(monday);
    design.duration = Some(3.0);

    let mut build = TaskData::new("build");
    build.parent = Some("phase".into());
    build.start_date = Some(monday);
    build.duration = Some(5.0);

    // A milestone needs only one date.
    let mut release = TaskData::new("release");
    release.task_type = TaskType::Milestone;
    release.start_date = Some(monday);

    let data = ProjectData {
        tasks: vec![phase, design, build, release],
        links: vec![
            LinkData {
                id: "l1".into(),
                source: "design".into(),
                target: "build".into(),
                link_type: LinkType::Fs,
                lag: 0.0,
                lag_unit: None,
            },
            LinkData {
                id: "l2".into(),
                source: "build".into(),
                target: "release".into(),
                link_type: LinkType::Fs,
                lag: 0.0,
                lag_unit: None,
            },
        ],
        ..Default::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}
```

The links push `build` after `design` and `release` after `build`, and only those two tasks appear in `updates`. The summary `phase` has no dates of its own, so its span comes from `design` and `build`.

Each task type has its own date rule:

- [`TaskType::Task`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/task-type.md>) (the default) is a leaf: two of start, end and duration.
- `TaskType::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.
- `TaskType::Milestone` needs `start_date` or `end_date`. Its duration is 0.

`parent` links a task to its summary. `None`, `""` 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/rust/api/core/data/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/rust/scheduling/links-constraints-summaries.md>), [Working time and calendars](<https://docs.dhtmlx.com/planning-engine/rust/scheduling/working-time-calendars.md>) and [Leveling resources](<https://docs.dhtmlx.com/planning-engine/rust/resources-and-assignment/leveling-resources.md>).

### Ids and dates

Anything that converts into [`Id`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/id.md>) works where an id is expected: `&str`, `String`, `f64` and `i32`. Ids compare by their JavaScript key, [`Id::key()`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/id.md#key>), so a number and its string form are the same id.

```rust
use plen_core::Id;

fn main() {
    let numeric: Id = 1.into();
    let text = Id::from("1");
    assert_eq!(numeric, text);

    // -0 and 0 share the key "0".
    assert_eq!(Id::from(-0.0), Id::from("0"));

    println!("{numeric}"); // prints the key: 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 `f64`, so integers past 2^53 lose precision just as they do in JavaScript.

[`DateTime`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/date-time.md>) is a local date-time with minute precision. Build one with [`DateTime::from_ymd_hm`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/date-time.md#from_ymd_hm>), which returns `None` for an impossible date, or convert from `chrono::NaiveDateTime` (seconds are dropped):

```rust
use chrono::{NaiveDate, NaiveDateTime};
use plen_core::DateTime;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let a = DateTime::from_ymd_hm(2026, 3, 2, 9, 30).ok_or("invalid date")?;

    let naive = NaiveDate::from_ymd_opt(2026, 3, 2)
        .and_then(|day| day.and_hms_opt(9, 30, 45))
        .ok_or("invalid date")?;
    let b = DateTime::from(naive); // 09:30:45 becomes 09:30
    assert_eq!(a, b);

    println!("{a}"); // 2026-03-02 09:30
    let back: NaiveDateTime = a.into();
    println!("{back}");
    Ok(())
}
```

The `chrono` conversions need `chrono` in your own `Cargo.toml` (`cargo add chrono`).

`DateTime` carries no time zone. Convert zoned timestamps to the plan's local time before you build the data. The engine never reads the clock, so an entirely undated project is rejected instead of being placed at "today".

### Durations and units

`duration` is working time in a unit: [`TimeUnit::Minute`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/time-unit.md>), `TimeUnit::Hour` or `TimeUnit::Day`. `config.duration_unit` sets the unit for the whole project (default `Day`). A task's own `duration_unit` overrides it for that task.

```rust
use plen_core::{
    schedule, DateTime, ProjectData, SchedulingConfig, SolveOptions, TaskData, TimeUnit,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;

    // 12 working hours: the default calendar works 08-12 and 13-17.
    let mut review = TaskData::new("review");
    review.start_date = Some(monday);
    review.duration = Some(12.0);

    // This task counts in minutes instead of the project's hours.
    let mut sync = TaskData::new("sync");
    sync.start_date = Some(monday);
    sync.duration = Some(90.0);
    sync.duration_unit = Some(TimeUnit::Minute);

    let data = ProjectData {
        config: SchedulingConfig {
            duration_unit: TimeUnit::Hour,
            ..Default::default()
        },
        tasks: vec![review, sync],
        ..Default::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    println!("{} updates", result.updates.len());
    Ok(())
}
```

Durations are `f64`, so `1.5` days or `0.25` hours are valid. A negative or non-finite duration is rejected. Week, month and year units aren't supported. [`TimeUnit::parse("week")`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/time-unit.md#parse>) returns an error, and the enum has no such variant.

Day units count whole working days, the way dhtmlxGantt does. 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/rust/resources-and-assignment/leveling-resources.md>) before you set them by hand.

### Links

A [`LinkData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-data.md>) connects a `source` task to a `target` task. `link_type` is one of [`LinkType::Fs`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-type.md>) (finish-to-start, the default), `Ss`, `Ff` or `Sf`. `lag` is working time added along the link, in `config.duration_unit` unless the link sets its own `lag_unit`.

```rust
use plen_core::{
    schedule, DateTime, LinkData, LinkType, Note, ProjectData, SolveOptions, TaskData, TimeUnit,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;
    let task = |id: &str| {
        let mut t = TaskData::new(id);
        t.start_date = Some(monday);
        t.duration = Some(2.0);
        t
    };

    let links = vec![
        // b starts 1 working day after a starts.
        LinkData {
            id: "ss".into(),
            source: "a".into(),
            target: "b".into(),
            link_type: LinkType::Ss,
            lag: 1.0,
            lag_unit: None,
        },
        // c finishes 4 working hours after b finishes.
        LinkData {
            id: "ff".into(),
            source: "b".into(),
            target: "c".into(),
            link_type: LinkType::Ff,
            lag: 4.0,
            lag_unit: Some(TimeUnit::Hour),
        },
        // Points at a task that doesn't exist: dropped, not an error.
        LinkData {
            id: "stray".into(),
            source: "c".into(),
            target: "missing".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        },
    ];

    let data = ProjectData {
        tasks: vec![task("a"), task("b"), task("c")],
        links,
        ..Default::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for note in &result.notes {
        if let Note::DroppedLink { link, reason } = note {
            println!("link {link} dropped: {reason}");
        }
    }
    Ok(())
}
```

A per-link `lag_unit` is a Planning Engine extension. dhtmlxGantt always uses the project unit for lag, so leave `lag_unit` as `None` when you need gantt-identical dates.

Some links can't apply. The engine drops them and reports a [`Note::DroppedLink`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>) 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 or a non-finite `lag` 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/rust/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. This flag replaces gantt's per-task cancellation events.
- `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.0` is completed and stays put.

```rust
use plen_core::{
    schedule, DateTime, Note, ProjectData, SchedulingConfig, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;

    let mut done = TaskData::new("done");
    done.start_date = Some(monday);
    done.duration = Some(2.0);
    done.progress = 1.0; // completed, because use_progress is on

    let mut pinned = TaskData::new("pinned");
    pinned.start_date = Some(monday);
    pinned.duration = Some(1.0);
    pinned.auto_scheduling = false; // fixed

    let mut later = TaskData::new("later");
    later.unscheduled = true; // no dates needed

    let data = ProjectData {
        config: SchedulingConfig {
            use_progress: true,
            ..Default::default()
        },
        tasks: vec![done, pinned, later],
        ..Default::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for note in &result.notes {
        if let Note::DroppedTask { task, reason } = note {
            println!("task {task}: {reason}"); // task later: unscheduled
        }
    }
    Ok(())
}
```

`progress` must be between 0 and 1. A completed task also stays in place inside a summary that moves as a block. Here Planning Engine differs on purpose from dhtmlxGantt, which drags completed children along with the summary. In analytics, completed tasks have zero slack and are never critical.

### Scheduling config

`SchedulingConfig` holds the project-wide switches. Start from `Default` and override only what you need:

```rust
use plen_core::{
    schedule, CalendarMode, DateTime, GapBehavior, Id, ProjectData, SchedulingConfig,
    SolveOptions, TaskData, TimeUnit,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let kickoff = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;

    let config = SchedulingConfig {
        apply_constraints: true,           // constraints act as hard bounds
        gap_behavior: GapBehavior::Compress, // pull tasks to their earliest
        duration_unit: TimeUnit::Hour,
        project_start: Some(kickoff),      // nothing starts before this
        calendar_mode: CalendarMode::Task, // task calendars only, as gantt does
        root_id: Id::from(-1),             // gantt data with a virtual root of -1
        ..Default::default()
    };

    let mut task = TaskData::new(1);
    task.parent = Some(Id::from(-1)); // equals root_id: no parent
    task.start_date = Some(kickoff);
    task.duration = Some(4.0);

    let data = ProjectData {
        config,
        tasks: vec![task],
        ..Default::default()
    };
    schedule(data, &SolveOptions::default())?;
    Ok(())
}
```

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/rust/api/core/project/project.md#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` / `TaskInherited` match gantt |
| `max_iterations` | `8` | Planner pass limit; must be at least 1 |
| `root_id` | [`Id::Num(0.0)`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/id.md>) | 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/rust/api/core/data/scheduling-config.md>) for all of them.

`root_id` exists for gantt 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.

If you load data from dhtmlxGantt, set `use_calendars` explicitly from gantt's `work_time`. Left out, Planning Engine schedules on calendars. [Import from dhtmlxGantt](<https://docs.dhtmlx.com/planning-engine/rust/how-to/import-from-dhtmlxgantt.md>) covers the rest of the mapping.

### Validation rules

The default [`Validation::Strict`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/validation.md>) mode rejects input that dhtmlxGantt would coerce or guess at. The call returns [`Error::Input`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/error.md>) with a `path` to the field, and a live [`Project`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/project.md>) keeps its previous state and cached result.

```rust
use plen_core::{schedule, DateTime, Error, ProjectData, SolveOptions, TaskData};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;

    let mut ok = TaskData::new("ok");
    ok.start_date = Some(monday);
    ok.duration = Some(1.0);

    let mut broken = TaskData::new("broken");
    broken.duration = Some(2.0); // only one of start / end / duration

    let data = ProjectData {
        tasks: vec![ok, broken],
        ..Default::default()
    };

    match schedule(data, &SolveOptions::default()) {
        Err(Error::Input(e)) => {
            println!("{}: {}", e.path.as_deref().unwrap_or("-"), e.message);
        }
        Err(e) => return Err(e.into()),
        Ok(_) => println!("scheduled"),
    }
    Ok(())
}
```

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 or non-finite 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]` |
| A non-finite link `lag` | `links[i].lag` |
| `max_iterations: 0` | `config.max_iterations` |

Strict mode also rejects week, month and year units, and gantt's `customWeeks` calendars. Resource and assignment rules (capacity, `value`, slices, calendars with no shared working time) are in [Resources and assignments](<https://docs.dhtmlx.com/planning-engine/rust/resources-and-assignment/resources-and-assignments.md>).

Some things aren't errors. Links that can't apply become [`Note::DroppedLink`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>), unscheduled tasks become `Note::DroppedTask`, and dependency cycles are reported in `cycles`. The wire decoder ignores unknown JSON fields.

When the data comes from a UI that saves messy records, switch to repair mode instead of fixing the input yourself:

```rust
use plen_core::{
    DateTime, Note, Project, ProjectData, ProjectOptions, SolveOptions, TaskData, Validation,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 8, 0).ok_or("invalid date")?;

    let mut first = TaskData::new("a");
    first.start_date = Some(monday);
    first.duration = Some(1.0);

    // Same id again: in repair mode the last record wins.
    let mut second = TaskData::new("a");
    second.start_date = Some(monday);
    second.duration = Some(3.0);

    let mut project = Project::new(ProjectOptions {
        validation: Validation::Repair,
    });
    let data = ProjectData {
        tasks: vec![first, second],
        ..Default::default()
    };
    let result = project.solve(Some(data), &SolveOptions::default())?;
    for note in &result.notes {
        if let Note::Repaired { path, message } = note {
            println!("{path}: {message}");
        }
    }
    Ok(())
}
```

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