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 object. It holds six fields: config, calendars, tasks, links, resources and assignments. Only tasks is required. The lists start empty, and config starts as new SchedulingConfig().

ProjectData is an import format, not stored state. Project#solve, Engine.schedule and Project#analyze validate it and build the engine's own normalized model from it. solve deep-copies the object first, so changing it afterwards doesn't touch the project. 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 mutable classes with public fields and a fluent withX(value) setter per field:

  • new TaskData(id) gives a task with every default filled in. Chain the setters you need on top of it.
  • LinkData, CalendarData, ResourceData and AssignmentData have a no-argument constructor. Set their fields with withX.
  • ProjectData and SchedulingConfig start with every default, so you set only what differs.
  • List fields are Vec<T>, an ArrayList<T>. Every list setter also takes a java.util.Collection, so withTasks(List.of(...)) works.

Field names are camelCase (startDate, duration, constraintType, autoScheduling). In JSON they are snake_case (start_date, constraint_type), and taskType / linkType are called type. To load JSON, call Decode.fromJson(json, ProjectData.class). Unknown JSON fields in the data are ignored.

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.
  • Moment 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. levelingDelay and pauses are elapsed wall-clock time.
  • An optional field is null when absent (Double duration, Moment startDate, Id parent).
  • Strict validation (the default) rejects ambiguous input with a path-qualified InputException instead of guessing. Validation.REPAIR fixes what it can and reports each fix.

For every field and its default, see ProjectData, TaskData and SchedulingConfig.

Describing a project#

Tasks#

A leaf task needs two of startDate, endDate and duration. The engine derives the third. If you send all three, they must agree on the task's calendar.

import java.util.List;
import org.dhtmlx.plen.*;

public class TasksExample {
    public static void main(String[] args) throws Exception {
        Moment monday = Moment.fromYmdHm(2026, 3, 2, 8, 0);

        // A summary with no dates: its span comes from its children.
        TaskData phase = new TaskData(Id.str("phase")).withTaskType(TaskType.SUMMARY);

        TaskData design = new TaskData(Id.str("design"))
                .withParent(Id.str("phase"))
                .withStartDate(monday)
                .withDuration(3.0);

        TaskData build = new TaskData(Id.str("build"))
                .withParent(Id.str("phase"))
                .withStartDate(monday)
                .withDuration(5.0);

        // A milestone needs only one date.
        TaskData release = new TaskData(Id.str("release"))
                .withTaskType(TaskType.MILESTONE)
                .withStartDate(monday);

        ProjectData data = new ProjectData()
                .withTasks(List.of(phase, design, build, release))
                .withLinks(List.of(
                        new LinkData()
                                .withId(Id.str("l1"))
                                .withSource(Id.str("design"))
                                .withTarget(Id.str("build")),
                        new LinkData()
                                .withId(Id.str("l2"))
                                .withSource(Id.str("build"))
                                .withTarget(Id.str("release"))));

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        for (Mutation update : result.updates) {
            if (update.startDate != null && update.endDate != null) {
                System.out.println(update.id + ": " + update.startDate + " .. " + update.endDate);
            }
        }
    }
}

The links push build after design and release after build, and only those two tasks appear in updates. Both links use the default LinkType.FS with no lag. 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 startDate + duration is rejected. A summary with no dates needs at least one dated child.
  • TaskType.MILESTONE needs startDate or endDate. Its duration is 0.

parent links a task to its summary. null, Id.str("") and a value equal to config.rootId (default Id.num(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 (constraintType, constraintDate), calendars (calendarId), and leveling inputs (priority, levelingDelay, pauses). See Links, constraints and summaries, Working time and calendars and Leveling resources.

Ids and dates#

Build an Id with Id.num(double) or Id.str(String). Ids compare by their JavaScript key, Id#key(), so a number and its string form are the same id. equals, hashCode and toString all follow the key.

import org.dhtmlx.plen.*;

public class IdsExample {
    public static void main(String[] args) {
        Id numeric = Id.num(1);
        Id text = Id.str("1");
        System.out.println(numeric.equals(text)); // true

        // -0 and 0 share the key "0".
        System.out.println(Id.num(-0.0).equals(Id.str("0"))); // true

        System.out.println(numeric); // prints the key: 1
    }
}

This matters when you mix sources. Task 1 from one system and task "1" from another collide as duplicates. Numeric ids are double, so integers past 2^53 lose precision just as they do in JavaScript.

Moment is a local date-time with minute precision. There are three ways to build one:

import java.time.LocalDateTime;
import org.dhtmlx.plen.*;

public class MomentsExample {
    public static void main(String[] args) {
        Moment a = Moment.fromYmdHm(2026, 3, 2, 9, 30);
        Moment b = Moment.fromDateTime(LocalDateTime.of(2026, 3, 2, 9, 30, 45)); // 09:30:45 becomes 09:30
        Moment c = Moment.parse("2026-03-02 09:30");

        System.out.println(a.equals(b) && b.equals(c)); // true
        System.out.println(a); // 2026-03-02 09:30
    }
}

Moment carries no time zone. Convert zoned timestamps to the plan's local wall-clock 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". Records, ids and dates covers both types in more depth.

Durations and units#

duration is working time in a unit: TimeUnit.MINUTE, TimeUnit.HOUR or TimeUnit.DAY. config.durationUnit sets the unit for the whole project (default DAY). A task's own durationUnit overrides it for that task.

import java.util.List;
import org.dhtmlx.plen.*;

public class DurationsExample {
    public static void main(String[] args) throws Exception {
        Moment monday = Moment.fromYmdHm(2026, 3, 2, 8, 0);

        // 12 working hours: the default calendar works 08-12 and 13-17.
        TaskData review = new TaskData(Id.str("review"))
                .withStartDate(monday)
                .withDuration(12.0);

        // This task counts in minutes instead of the project's hours.
        TaskData sync = new TaskData(Id.str("sync"))
                .withStartDate(monday)
                .withDuration(90.0)
                .withDurationUnit(TimeUnit.MINUTE);

        ProjectData data = new ProjectData()
                .withConfig(new SchedulingConfig().withDurationUnit(TimeUnit.HOUR))
                .withTasks(List.of(review, sync));

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        System.out.println(result.updates.size() + " updates");
    }
}

Durations are Double, so 1.5 days or 0.25 hours are valid. A negative or non-finite duration is rejected. TimeUnit has no week, month or year, and Decode rejects those spellings in JSON with unknown duration unit.

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: levelingDelay and pauses. Both are in the task's durationUnit, 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. linkType is one of LinkType.FS (finish-to-start, the default), SS, FF or SF. lag is working time added along the link, in config.durationUnit unless the link sets its own lagUnit.

import java.util.List;
import org.dhtmlx.plen.*;

public class LinksExample {
    public static void main(String[] args) throws Exception {
        Moment monday = Moment.fromYmdHm(2026, 3, 2, 8, 0);
        List<TaskData> tasks = List.of(
                new TaskData(Id.str("a")).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.str("b")).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.str("c")).withStartDate(monday).withDuration(2.0));

        List<LinkData> links = List.of(
                // b starts 1 working day after a starts.
                new LinkData()
                        .withId(Id.str("ss"))
                        .withSource(Id.str("a"))
                        .withTarget(Id.str("b"))
                        .withLinkType(LinkType.SS)
                        .withLag(1.0),
                // c finishes 4 working hours after b finishes.
                new LinkData()
                        .withId(Id.str("ff"))
                        .withSource(Id.str("b"))
                        .withTarget(Id.str("c"))
                        .withLinkType(LinkType.FF)
                        .withLag(4.0)
                        .withLagUnit(TimeUnit.HOUR),
                // Points at a task that doesn't exist: dropped, not an error.
                new LinkData()
                        .withId(Id.str("stray"))
                        .withSource(Id.str("c"))
                        .withTarget(Id.str("missing")));

        ProjectData data = new ProjectData().withTasks(tasks).withLinks(links);

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        for (Note note : result.notes) {
            if (note instanceof Note.DroppedLink dropped) {
                System.out.println("link " + dropped.link + " dropped: " + dropped.reason);
            }
        }
    }
}

Leave lagUnit as null to measure lag in the project's durationUnit.

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.descendantLinks is false.

A duplicate link id or a non-finite lag is a validation error. A link from a task to itself stays in the plan and shows up 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:

  • autoScheduling = 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.useProgress on, a task with progress == 1.0 is completed and stays put.
import java.util.List;
import org.dhtmlx.plen.*;

public class FixedTasksExample {
    public static void main(String[] args) throws Exception {
        Moment monday = Moment.fromYmdHm(2026, 3, 2, 8, 0);

        TaskData done = new TaskData(Id.str("done"))
                .withStartDate(monday)
                .withDuration(2.0)
                .withProgress(1.0); // completed, because useProgress is on

        TaskData pinned = new TaskData(Id.str("pinned"))
                .withStartDate(monday)
                .withDuration(1.0)
                .withAutoScheduling(false); // fixed

        TaskData later = new TaskData(Id.str("later"))
                .withUnscheduled(true); // no dates needed

        ProjectData data = new ProjectData()
                .withConfig(new SchedulingConfig().withUseProgress(true))
                .withTasks(List.of(done, pinned, later));

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        for (Note note : result.notes) {
            if (note instanceof Note.DroppedTask dropped) {
                System.out.println("task " + dropped.task + ": " + dropped.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#

SchedulingConfig holds the project-wide switches. Start from new SchedulingConfig() and override only what you need:

import java.util.List;
import org.dhtmlx.plen.*;

public class ConfigExample {
    public static void main(String[] args) throws Exception {
        Moment kickoff = Moment.fromYmdHm(2026, 3, 2, 8, 0);

        SchedulingConfig config = new SchedulingConfig()
                .withApplyConstraints(true)            // constraints act as hard bounds
                .withGapBehavior(GapBehavior.COMPRESS) // pull tasks to their earliest
                .withDurationUnit(TimeUnit.HOUR)
                .withProjectStart(kickoff)             // nothing starts before this
                .withCalendarMode(CalendarMode.TASK)   // task calendars only
                .withRootId(Id.num(-1));               // data with a virtual root of -1

        TaskData task = new TaskData(Id.num(1))
                .withParent(Id.num(-1)) // equals rootId: no parent
                .withStartDate(kickoff)
                .withDuration(4.0);

        ProjectData data = new ProjectData()
                .withConfig(config)
                .withTasks(List.of(task));
        Engine.schedule(data, new SolveOptions());
    }
}

The switches you'll change most often:

Field Default Effect
applyConstraints false Turns on the eight constraint types. Off, constraints are ignored, including MSO pins
scheduleFromEnd false Backward (ALAP) planning. level refuses such projects
gapBehavior PRESERVE PRESERVE keeps tasks at their own dates when links allow; COMPRESS pulls every task to its earliest position
moveProjects true Summaries move as a block; false schedules their leaves one by one
useProgress false Tasks with progress == 1 are completed and fixed
projectStart null Lower bound for the plan, and the date source for undated tasks in repair mode
projectEnd null Upper bound, used by backward projects
durationUnit DAY Unit of duration and lag
useCalendars true false makes every calendar flat 24/7 time
calendarMode RESOURCE Which calendars place the work; TASK / TASK_INHERITED use task calendars only
maxIterations 8 Planner pass limit; must be at least 1
rootId Id.num(0) The parent value that means "no parent"

projectConstraint, groupMode, descendantLinks, strictCalendar and defaultResourceCapacity cover rarer cases. See SchedulingConfig for all of them.

rootId exists for data whose virtual root isn't 0. The engine clears it on import, so stored tasks never point at the root. ProjectData#canonicalizeRoots() does the same on your own object. With a non-zero rootId, a task with id 0 is an ordinary parent.

Left out, useCalendars is on and Planning Engine schedules on calendars. Set it explicitly when your data shouldn't use them.

Validation rules#

The default Validation.STRICT mode rejects input it would have to coerce or guess at. The call throws an InputException with a path to the field, and a live Project keeps its previous state and cached result. InputException is unchecked, so catch it where you want to report the error.

import java.util.List;
import org.dhtmlx.plen.*;

public class StrictExample {
    public static void main(String[] args) throws Exception {
        Moment monday = Moment.fromYmdHm(2026, 3, 2, 8, 0);

        TaskData ok = new TaskData(Id.str("ok"))
                .withStartDate(monday)
                .withDuration(1.0);

        TaskData broken = new TaskData(Id.str("broken"))
                .withDuration(2.0); // only one of start / end / duration

        ProjectData data = new ProjectData().withTasks(List.of(ok, broken));

        try {
            Engine.schedule(data, new SolveOptions());
            System.out.println("scheduled");
        } catch (InputException e) {
            System.out.println((e.path != null ? e.path : "-") + ": " + e.getMessage());
        }
    }
}

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
endDate before startDate 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 constraintDate tasks[i].constraint_date
An unknown parent, or a cycle in the hierarchy tasks[i].parent
progress outside 0..1, priority above 1000, negative levelingDelay tasks[i].progress, .priority, .leveling_delay
Unsorted, overlapping or invalid pauses tasks[i].pauses[j]
An unknown task calendarId tasks[i].calendar_id
A calendar with no working time calendars[i]
worktime.days with other than 7 entries calendars[i].worktime.days
A non-finite link lag links[i].lag
maxIterations = 0 config.max_iterations

Paths use the JSON field names, so the same path points at the right field whether the data came from withX setters or from JSON. 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.

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

import java.util.List;
import org.dhtmlx.plen.*;

public class RepairExample {
    public static void main(String[] args) throws Exception {
        Moment monday = Moment.fromYmdHm(2026, 3, 2, 8, 0);

        TaskData first = new TaskData(Id.str("a"))
                .withStartDate(monday)
                .withDuration(1.0);

        // Same id again: in repair mode the last record wins.
        TaskData second = new TaskData(Id.str("a"))
                .withStartDate(monday)
                .withDuration(3.0);

        Project project = new Project(new ProjectOptions().withValidation(Validation.REPAIR));
        ProjectData data = new ProjectData().withTasks(List.of(first, second));

        ScheduleResult result = project.solve(data, new SolveOptions());
        for (Note note : result.notes) {
            if (note instanceof Note.Repaired repaired) {
                System.out.println(repaired.path + ": " + repaired.message);
            }
        }
    }
}

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