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 fluentwithX(value)setter per field:ProjectData,TaskData,LinkData, options classes, change sets and results. List fields areVec<T>, anArrayList<T>. - Ids (
Id) are a number or a string. They compare like JavaScript object keys, so1and"1"are the same id. - Dates (
Moment) are naive local date-times at minute precision. There is no timezone, no DST and no "now". AMomentis 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 withinstanceofpatterns or a patternswitch.
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:
;
;
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:
;
;
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(...):
;
;
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:
;
;
;
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:
;
;
;
;
Calendar date exceptions in Worktime.dates are a sorted map from Moment to DayRule, keyed by the date's midnight:
;
;
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:
;
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:
;
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:
;
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:
;
;
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:
;
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(), fromDeepCloneable<T>, returns a copy that shares no mutable list or nested record with the original. Most records implement it. TheReason,ConflictandNotecases andTimeRangehold only immutable values and have justcopy().
Id and Moment are immutable, so copies share them safely. Use deepClone() when you want to edit either side:
;
;
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:
;
;
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.