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)), schedule and 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:

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 is a number or a string and compares like a JavaScript object key, so task 1 and task "1" are the same task.
  • DateTime 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 instead of guessing. Validation::Repair 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, TaskData 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.

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

Anything that converts into Id works where an id is expected: &str, String, f64 and i32. Ids compare by their JavaScript key, Id::key(), so a number and its string form are the same id.

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 is a local date-time with minute precision. Build one with DateTime::from_ymd_hm, which returns None for an impossible date, or convert from chrono::NaiveDateTime (seconds are dropped):

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

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") 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 before you set them by hand.

A LinkData connects a source task to a target task. link_type is one of LinkType::Fs (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.

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

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

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 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) 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 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 covers the rest of the mapping.

Validation rules#

The default Validation::Strict mode rejects input that dhtmlxGantt would coerce or guess at. The call returns Error::Input with a path to the field, and a live Project keeps its previous state and cached result.

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.

Some things aren't errors. Links that can't apply become Note::DroppedLink, 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:

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 and Error handling.