Records, ids and dates#

This guide covers the types you build input from and read results with: records, lists, ids, dates, patch fields, unions and enums. Read it before you map your own models, database rows or API payloads to org.dhtmlx.plen.

How the data model works#

Everything you pass in and get back is a plain Java object in the org.dhtmlx.plen package. Nothing is AutoCloseable, nothing does I/O, and the engine keeps its own normalized copy of whatever you hand it.

There are five kinds of types:

  • Records are mutable classes that extend ValueRecord. They have public lowerCamelCase fields, a default for every field, and a fluent withX(value) setter per field: ProjectData, TaskData, LinkData, options classes, change sets and results. List fields are Vec<T>, an ArrayList<T>.
  • Ids (Id) are a number or a string. They compare like JavaScript object keys, so 1 and "1" are the same id.
  • Dates (Moment) are naive local date-times at minute precision. There is no timezone, no DST and no "now". A Moment is a wall-clock reading, stored as minutes since 1970-01-01 00:00.
  • Patch fields (Patch<T>) appear only in update records. Each one is in one of three states: leave the field alone, clear it, or set it.
  • Unions (Reason, Conflict, Note, DayRule) are abstract classes with one nested static subclass per case. You read them with instanceof patterns or a pattern switch.

An optional value is a reference type where null means "not given": Double duration, Moment startDate, Id parent. The library doesn't use Optional<T>. Durations, lags, capacities and assignment values are double. Durations and lags count working time in a TimeUnit, while levelingDelay and pauses count elapsed time.

Data flows in one direction. solve(data, …) deep-copies the ProjectData you pass, so later changes to your object don't reach the project. Results flow the other way, but they aren't copies: the project keeps its last result as its baseline and can hand the same object out again. Treat results as read-only, and call deepClone() on one before you change it.

Working with the types#

Building records#

Chain the withX setters. Records that carry an id also have a constructor that takes it, such as new TaskData(id), and every other field keeps its default:

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

public class BuildRecords {
  public static void main(String[] args) throws Exception {
    var start = Moment.fromYmdHm(2026, 8, 10, 8, 0);

    var data = new ProjectData()
        .withTasks(List.of(
            new TaskData(Id.str("design")).withStartDate(start).withDuration(2.0),
            new TaskData(Id.str("build")).withStartDate(start).withDuration(3.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.str("l1")).withSource(Id.str("design")).withTarget(Id.str("build"))))
        .withResources(List.of(new ResourceData().withId(Id.str("dev"))))
        .withAssignments(List.of(
            new AssignmentData().withTask(Id.str("build")).withResource(Id.str("dev")).withValue(1)));

    var result = Engine.schedule(data, new SolveOptions());
    for (var update : result.updates)
      System.out.println(update.id + ": " + update.startDate + " - " + update.endDate);
    // build: 2026-08-12 08:00 - 2026-08-17 08:00
  }
}

Only build is listed: the link moved it, and results report only the tasks that changed.

duration is a Double, so pass 2.0, not 2. A plain int literal doesn't box to Double and the call doesn't compile.

The fields are public, so you can also set them directly: task.duration = 5.0. Every list field starts empty and every nested record starts with its defaults, so data.tasks.add(...) or data.config.durationUnit = TimeUnit.HOUR works without creating anything first. The defaults for each field are on the reference pages, for example TaskData and SchedulingConfig.

The compiler doesn't know which fields a record needs. A link needs id, source and target. An assignment needs task, resource and value, and its id is optional. Set these yourself.

The engine checks whether the data makes sense when you schedule it: a leaf task needs two of start, end and duration, and ids must be unique. Problems throw an InputException with a path such as tasks[0].start_date. Paths use the JSON field names, so startDate shows up as start_date.

Every public method throws NullPointerException when you pass null for an argument that isn't documented as nullable.

toString() on any record prints its fields, such as TaskData{id=design, parent=null, taskType=TASK, …}. It's meant for logs, not for parsing.

Lists#

Every list field is a Vec<T>. Vec extends ArrayList<T>, so it's a java.util.List with all the usual methods. Every setter and constructor that takes a list also accepts any java.util.Collection, and copies it into a new Vec:

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

public class Lists {
  public static void main(String[] args) throws Exception {
    var task = new TaskData(Id.str("build"))
        .withPauses(List.of(new Pause(1.0, 0.5)));        // List.of(...) is copied into a Vec
    task.pauses.add(new Pause(3.0, 1.0));                 // so the field stays mutable

    var hours = new DayRule.Hours(List.of(new TimeRange(480, 720)));
    var slot = new Slot(Id.str("s1"), Id.str("build"), List.of(Id.str("dev1"), Id.str("dev2")));

    System.out.println(task.pauses.size());               // 2
    System.out.println(hours.ranges.size());              // 1
    System.out.println(slot.allowed);                     // [dev1, dev2]
  }
}

Project.evaluate, evaluateWith, suggestAssignments and SkillLayer.slots take any Collection as well. Vec also has helpers such as convertAll and find, which the engine uses internally. Ordinary List methods are enough in your code.

The package also has helper classes named Set, Queue, PriorityQueue, Array and Runtime. If you write import java.util.*; next to import org.dhtmlx.plen.*;, these names clash, so import the Planning Engine types you need by name.

Ids#

Create an Id with Id.num(...) or Id.str(...):

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

public class Ids {
  public static void main(String[] args) throws Exception {
    Id a = Id.num(7);
    Id b = Id.str("7");

    System.out.println(a.equals(b));            // true
    System.out.println(a.isNum());              // true
    System.out.println(b.strValue());           // 7
    System.out.println(Id.num(1.5));            // 1.5
    System.out.println(Id.num(1e21).key());     // 1e+21

    var names = new HashMap<Id, String>();
    names.put(a, "design");
    System.out.println(names.get(b));           // design
  }
}

equals and hashCode follow key(), the text JavaScript would use for the id as an object key. Ids work as HashMap keys, and a task stored as 7 is found by the lookup "7". As a result, 7 and "7" in one project are a duplicate id, not two tasks: scheduling them throws an InputException at tasks[1].id. 0 and -0.0 both have the key "0".

To get the original value back, check isNum() or isStr() and read numValue() (Double) or strValue() (String). toString() returns the key. Id is immutable.

Numeric ids are double, so integers above 2^53 lose precision. If your database uses 64-bit keys, pass them as strings: Id.str(Long.toString(row.id)). Don't use infinite numbers as ids: key(), equals and hashCode throw IllegalStateException for them. Id.str(null) throws NullPointerException.

SchedulingConfig.rootId (default Id.num(0)) names the virtual root. A task whose parent equals it, by key, is a root task, so a parent of Id.str("0") also counts as no parent. Set rootId if your data uses another root id.

Moments#

A Moment is an immutable class that wraps one long, the public field minutes. You can create one from date and time fields, from a LocalDateTime, from text or from raw minutes:

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

public class Moments {
  public static void main(String[] args) throws Exception {
    Moment maybe = Moment.fromYmdHm(2026, 2, 29, 9, 0);
    System.out.println(maybe == null);                       // true: 2026 isn't a leap year

    var start = Moment.fromYmdHm(2026, 8, 11, 8, 30);
    System.out.println(start);                               // 2026-08-11 08:30
    System.out.println(start.minutes);                       // 29773950
    System.out.println(Moment.parse("2026-08-11T08:30").equals(start)); // true

    var halfHourLater = new Moment(start.minutes + 30);
    System.out.println(halfHourLater.compareTo(start) > 0);  // true

    LocalDateTime forUi = LocalDateTime.ofEpochSecond(start.minutes * 60, 0, ZoneOffset.UTC);
    System.out.println(forUi);                               // 2026-08-11T08:30
    System.out.println(Moment.fromDateTime(forUi).equals(start)); // true
  }
}

fromYmdHm returns null for an invalid date or time, such as hour 24, minute 60 or February 29 in a common year. The examples in these docs pass its result on directly for dates they know are valid. With input you don't control, check for null first, because a null start date just means "not given".

parse accepts YYYY-M-D, optionally followed by h:m[:s] or Th:m[:s], and throws IllegalArgumentException ("invalid date") for anything else. fromDateTime drops seconds and fractions. Moment implements Comparable<Moment>, so it sorts and works as a TreeMap key, and equals compares minutes. toString() gives the YYYY-MM-DD HH:MM form.

Moment has no method that returns a LocalDateTime. To get one, read minutes and treat it as an offset from 1970-01-01 00:00, as LocalDateTime.ofEpochSecond(minutes * 60, 0, ZoneOffset.UTC) does above. The UTC offset there only turns the count back into wall-clock fields. It doesn't make the moment a UTC time.

Adding minutes to minutes moves a moment in wall-clock time. It doesn't respect calendars, so leave date math in working time to the scheduler.

A LocalDateTime carries no zone, and fromDateTime reads its fields as they are. If your times arrive in UTC or another zone, convert them to the zone the plan is in first:

import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import org.dhtmlx.plen.*;

public class TimeZones {
  public static void main(String[] args) throws Exception {
    var utc = ZonedDateTime.of(2026, 8, 10, 6, 30, 0, 0, ZoneOffset.UTC);
    var local = utc.withZoneSameInstant(ZoneId.of("Europe/Berlin")).toLocalDateTime();

    System.out.println(Moment.fromDateTime(local));          // 2026-08-10 08:30
  }
}

Calendar date exceptions in Worktime.dates are a sorted map from Moment to DayRule, keyed by the date's midnight:

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

public class DateExceptions {
  public static void main(String[] args) throws Exception {
    var worktime = new Worktime();
    worktime.dates.put(Moment.fromYmdHm(2026, 12, 24, 0, 0), new DayRule.Off());
    worktime.dates.put(Moment.fromYmdHm(2026, 12, 31, 0, 0),
        new DayRule.Hours(List.of(new TimeRange(480, 720))));

    var calendar = new CalendarData().withId(Id.str("office")).withWorktime(worktime);
  }
}

TimeRange holds minutes since midnight, so 480-720 is 08:00-12:00. There's more on calendars in Working-time calendars.

Patch fields#

Update records (TaskUpdate, LinkUpdate, ResourceUpdate, AssignmentUpdate) have a Patch<T> for every field except id. A patch is in one of three states:

State How to write it Effect
Missing Patch.missing(), the default of every field the field stays as it is
Null Patch.nullValue() clears an optional field, or restores a scalar field's default
Value Patch.of(value) sets the field

A new update record changes nothing until you set fields:

import org.dhtmlx.plen.*;

public class TaskEdit {
  public static void main(String[] args) throws Exception {
    var update = new TaskUpdate(Id.str("build"))
        .withStartDate(Patch.of(Moment.fromYmdHm(2026, 8, 17, 8, 0)))  // set
        .withDuration(Patch.of(4.0))                                     // set
        .withConstraintType(Patch.nullValue())                           // clear
        .withParent(Patch.of(Id.str("phase-2")));                        // set

    var changes = new SchedulingChanges();
    changes.tasks.update.add(update);
  }
}

Java infers the patch's type from the setter, so Patch.nullValue() and Patch.of(4.0) need no type arguments. As with records, numeric patches take boxed values: Patch.of(4.0) for a Patch<Double>, not Patch.of(4).

pauses is a Patch<Vec<Pause>>. A value replaces the whole list and nullValue() clears it. Clearing a field the record can't exist without, such as a link's source, is rejected. apply reports it in ApplyReport.rejected instead of throwing.

Patch.of(null) isn't the same as Patch.nullValue(). It's a Value patch that holds null. On a task's or resource's calendarId it clears the calendar. On any other field it throws NullPointerException when the change is applied, so use nullValue() to clear.

To read a patch, switch on its kind, or use tryGetValue:

import org.dhtmlx.plen.*;

public class ReadPatch {
  static String describe(Patch<Double> p) {
    return switch (p.kind) {
      case MISSING -> "unchanged";
      case NULL -> "reset";
      case VALUE -> "set to " + p.value();
    };
  }

  public static void main(String[] args) throws Exception {
    System.out.println(describe(Patch.missing()));    // unchanged
    System.out.println(describe(Patch.nullValue()));  // reset
    System.out.println(describe(Patch.of(2.5)));      // set to 2.5

    var out = new Ref<Double>();
    if (Patch.of(2.5).tryGetValue(out))
      System.out.println(out.value);                  // 2.5
  }
}

value() throws IllegalStateException on a patch that isn't in the Value state. Check hasValue() first, or use tryGetValue with a Ref<T> out-holder. isMissing() and isNull() test the other two states.

A Mutation in a result lists only the fields that changed, and they map one-to-one onto a TaskUpdate. A changed field becomes a value, and a field that is null stays missing:

import org.dhtmlx.plen.*;

public class MutationToUpdate {
  static TaskUpdate toUpdate(Mutation m) {
    var u = new TaskUpdate(m.id);
    if (m.startDate != null) u.startDate = Patch.of(m.startDate);
    if (m.endDate != null) u.endDate = Patch.of(m.endDate);
    if (m.duration != null) u.duration = Patch.of(m.duration);
    if (m.levelingDelay != null) u.levelingDelay = Patch.of(m.levelingDelay);
    if (m.pauses != null) u.pauses = Patch.of(m.pauses.convertAll(Pause::deepClone));
    return u;
  }
}

The pauses are deep-cloned because the result is shared with the project (see Copies and shared results). The full edit workflow is in Editing a project.

Unions and pattern matching#

Reason, Conflict, Note and DayRule are abstract classes, and their nested classes are the cases. Match them with a pattern switch or instanceof, and read the case's fields after the match:

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

public class Unions {
  public static void main(String[] args) throws Exception {
    var start = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    var data = new ProjectData()
        .withTasks(List.of(
            new TaskData(Id.str("design")).withStartDate(start).withDuration(2.0),
            new TaskData(Id.str("build")).withStartDate(start).withDuration(3.0)))
        .withLinks(List.of(
            new LinkData().withId(Id.str("l1")).withSource(Id.str("design")).withTarget(Id.str("build"))));

    var result = Engine.schedule(data, new SolveOptions().withReasons(true));

    for (var conflict : result.conflicts) {
      String text = switch (conflict) {
        case Conflict.ConstraintViolation v ->
            v.task + ": " + v.constraint + " wants " + v.required + ", planned " + v.actual;
        case Conflict.CalendarNonWorking c ->
            c.task + ": " + c.proposed + " is non-working, moved to " + c.snapped;
        default -> conflict.toString();
      };
      System.out.println(text);
    }

    for (var update : result.updates) {
      String why = switch (update.reason) {
        case Reason.Asap r -> "pushed by " + r.sourceId + " through link " + r.linkId;
        case Reason.Alap r -> "pulled by " + r.targetId + " through link " + r.linkId;
        case Reason.SummaryTranslation r -> "moved with summary " + r.summaryId;
        case Reason.Constraint r -> "placed by its constraint";
        case Reason.Preferred r -> "kept at its preferred date";
        case null -> "no reason requested";
        default -> update.reason.toString();
      };
      System.out.println(update.id + ": " + why);
    }
    // build: pushed by design through link l1
  }
}

The unions aren't sealed and the cases aren't Java records. So the compiler can't check that a switch covers every case, and you can't use record deconstruction patterns. Keep a default branch and read the fields from the matched variable. Reason is null unless you set reasons to true, and a pattern switch on null throws NullPointerException unless it has a case null branch.

Note works the same way. Note.Repaired carries the path and message of what Validation.REPAIR fixed. Note.DroppedLink, DroppedTask and DroppedAssignment carry the id and a reason string. To build a DayRule, create a case directly: new DayRule.Off(), new DayRule.On() or new DayRule.Hours(ranges).

Union cases compare by value, and the ids inside them compare by key. Two CalendarNonWorking conflicts for task Id.num(1) and task Id.str("1") with the same dates are equal.

Enums#

Option values are plain Java enums with UPPER_SNAKE constants. The Default column shows what a new record or config uses when you don't set the field:

Enum Values Default
TimeUnit MINUTE, HOUR, DAY DAY (SchedulingConfig.durationUnit)
TaskType TASK, SUMMARY, MILESTONE TASK
LinkType FS, SS, FF, SF FS
ConstraintType ASAP, ALAP, SNET, SNLT, FNET, FNLT, MSO, MFO none (TaskData.constraintType is null)
CalendarMode RESOURCE, TASK, TASK_INHERITED RESOURCE
GapBehavior PRESERVE, COMPRESS PRESERVE
Validation STRICT, REPAIR STRICT

TimeUnit has no week, month or year. Express longer durations in days.

The constraint types other than ASAP and ALAP are dated, so a task that uses one also needs a constraintDate. They apply only when SchedulingConfig.applyConstraints is true.

toString() and name() give the Java name ("FS", "TASK_INHERITED"), not the JSON spelling ("fs", "task-inherited"). Decode and Encode handle the JSON spellings. If you map your own strings, write the mapping yourself:

import org.dhtmlx.plen.*;

public class NumericLinkTypes {
  static LinkType parseNumericLinkType(String type) {
    return switch (type) {
      case "0" -> LinkType.FS;
      case "1" -> LinkType.SS;
      case "2" -> LinkType.FF;
      case "3" -> LinkType.SF;
      default -> throw new IllegalArgumentException("unknown link type " + type);
    };
  }
}

Copies and shared results#

Every record has two copy methods:

  • copy() is shallow. The copy is a new object, but its lists and nested records are the same instances as the original's.
  • deepClone(), from DeepCloneable<T>, returns a copy that shares no mutable list or nested record with the original. Most records implement it. The Reason, Conflict and Note cases and TimeRange hold only immutable values and have just copy().

Id and Moment are immutable, so copies share them safely. Use deepClone() when you want to edit either side:

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

public class Copies {
  public static void main(String[] args) throws Exception {
    var start = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    var data = new ProjectData()
        .withTasks(List.of(
            new TaskData(Id.str("design")).withStartDate(start).withDuration(2.0),
            new TaskData(Id.str("build")).withStartDate(start).withDuration(3.0)));

    var variant = data.deepClone();
    variant.tasks.get(0).duration = 5.0;
    variant.tasks.add(new TaskData(Id.str("test")).withStartDate(start).withDuration(1.0));

    System.out.println(data.tasks.get(0).duration);  // 2.0
    System.out.println(data.tasks.size());           // 2

    var shallow = data.copy();
    shallow.tasks.add(new TaskData(Id.str("review")));
    System.out.println(data.tasks.size());           // 3: the list is shared
  }
}

You don't need to clone before solving: solve(data, …) already copies the data, and the project never holds your object.

Results are the opposite case. solve(null, …) with no pending edits and equal options returns the cached result, the same instance. Copies of the project share it too, and evaluate and suggestAssignments read it as the baseline. Changing it in place would change what the project sees, so clone it first:

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

public class SharedResults {
  public static void main(String[] args) throws Exception {
    var start = Moment.fromYmdHm(2026, 8, 10, 8, 0);
    var data = new ProjectData()
        .withTasks(List.of(new TaskData(Id.str("design")).withStartDate(start).withDuration(2.0)));

    var project = new Project();
    var first = project.solve(data, new SolveOptions());
    var again = project.solve(null, new SolveOptions());

    System.out.println(first == again);              // true

    var mine = first.deepClone();
    mine.updates.removeIf(u -> u.kind == MutationKind.SUMMARY); // safe: a private copy
  }
}

LevelingResult.deepClone() copies the leveling result but still shares its schedule. Call deepClone() on schedule too if you want to edit it.

Records compare by value. ValueRecord gives every record an equals and hashCode over its public fields, lists element by element. That covers input records, change sets, options and results alike, so data.equals(data.deepClone()) is true and you can use equals to detect changes. Doubles compare with Double.equals, so NaN equals NaN, but -0.0 doesn't equal 0.0. Project is the exception: it compares by identity.