Quick start#

By the end of this page, you'll have a Java program that schedules two linked tasks, and a live project that reschedules them when one changes.

Requirements#

  • JDK 21 or newer. The library is a single JAR with no dependencies of its own.
  • No async setup. Every call is synchronous and CPU-only, with no I/O and no clock.

Install#

The library comes from a private Maven registry at git.dhtmlx.com. With your license or trial you received a username and a password for one of two editions:

Edition Registry URL Coordinates
Full https://git.dhtmlx.com/api/packages/plen/maven com.dhtmlx:plen
Trial https://git.dhtmlx.com/api/packages/plen-trial/maven com.dhtmlx:plen-trial

The trial is the full engine with one limit: a project with more than 50 tasks is rejected. Only the artifact id differs. The package org.dhtmlx.plen and its classes are the same, so the code in these docs runs on both.

The examples below use the full edition. For the trial, replace plen with plen-trial in the registry URL and in the artifact id.

Maven#

Add the credentials to ~/.m2/settings.xml, not to the project. They come from environment variables:

<settings>
  <servers>
    <server>
      <id>dhtmlx</id>
      <username>${env.DHTMLX_MAVEN_USER}</username>
      <password>${env.DHTMLX_MAVEN_PASSWORD}</password>
    </server>
  </servers>
</settings>

In pom.xml, add the repository and the dependency. The repository id must match the server id in settings.xml:

<repositories>
  <repository>
    <id>dhtmlx</id>
    <url>https://git.dhtmlx.com/api/packages/plen/maven</url>
  </repository>
</repositories>

<dependencies>
  <dependency>
    <groupId>com.dhtmlx</groupId>
    <artifactId>plen</artifactId>
    <version>1.0.0</version>
  </dependency>
</dependencies>

Set the variables, then resolve the dependency:

# bash / zsh
export DHTMLX_MAVEN_USER=your-username
export DHTMLX_MAVEN_PASSWORD=your-password
mvn dependency:resolve
# PowerShell
$env:DHTMLX_MAVEN_USER = "your-username"
$env:DHTMLX_MAVEN_PASSWORD = "your-password"

On a CI server, set the same two variables as secrets.

Gradle#

In settings.gradle.kts, add the registry:

dependencyResolutionManagement {
    repositories {
        mavenCentral()
        maven {
            name = "dhtmlx"
            url = uri("https://git.dhtmlx.com/api/packages/plen/maven")
            credentials(PasswordCredentials::class)
            // Optional: take com.dhtmlx only from this registry.
            content { includeGroup("com.dhtmlx") }
        }
    }
}

credentials(PasswordCredentials::class) reads the dhtmlxUsername and dhtmlxPassword properties, named after the repository. Put them in ~/.gradle/gradle.properties:

dhtmlxUsername=your-username
dhtmlxPassword=your-password

Or set them as the environment variables ORG_GRADLE_PROJECT_dhtmlxUsername and ORG_GRADLE_PROJECT_dhtmlxPassword, for example on CI. Then add the dependency in build.gradle.kts:

dependencies {
    implementation("com.dhtmlx:plen:1.0.0")
}

Credentials and IDEs#

IntelliJ IDEA and Eclipse import the Maven or Gradle setup above. IntelliJ IDEA reads ~/.m2/settings.xml. If it uses its bundled Maven with another settings file, set the file under Settings โ†’ Build, Execution, Deployment โ†’ Build Tools โ†’ Maven โ†’ User settings file. The IDE process must see the environment variables. On macOS, start the IDE from a shell, or put the credentials directly into settings.xml or gradle.properties.

Move from the trial to the full edition#

Change the registry URL from .../plen-trial/maven to .../plen/maven, use your full-edition credentials, and change the artifact id from plen-trial to plen. Keep the version. The code doesn't change.

If the build can't resolve the library#

  • 401 Unauthorized: the username or password is wrong, or the variables aren't set in the shell that runs the build. With Maven, check that the server id in settings.xml matches the repository id in pom.xml.
  • Could not find com.dhtmlx:plen: the registry and the artifact don't match. Trial credentials see only plen-trial. Also check that the URL ends in /maven.
  • A failed download is cached: Maven and Gradle remember failed resolutions. Run mvn -U ..., or gradle --refresh-dependencies.

Imports#

Everything lives in one package, org.dhtmlx.plen, so import org.dhtmlx.plen.*; brings in the engine, the skill layer and the JSON API. A few names in that package clash with JDK types: MatchException and Runtime with java.lang, Set and Queue with java.util. The examples on this page never use those names, so the wildcard import is fine. If you also write import java.util.*;, import the clashing names you need one by one.

Schedule a snapshot#

Start with the smallest useful plan: two tasks and a finish-to-start link. Both tasks start on Monday, 2026-08-10, at 08:00, so task 2 starts too early. It has to wait for task 1 to finish.

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

public class QuickStart {
    public static void main(String[] args) {
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        if (monday == null) {
            throw new IllegalArgumentException("invalid date");
        }

        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.num(1)).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.num(2)).withStartDate(monday).withDuration(1.0)))
            .withLinks(List.of(
                new LinkData()
                    .withId(Id.num(1))
                    .withSource(Id.num(1))
                    .withTarget(Id.num(2))
                    .withLinkType(LinkType.FS)));

        try {
            ScheduleResult result = Engine.schedule(data, new SolveOptions());
            for (Mutation update : result.updates) {
                System.out.println("task " + update.id + ": " + update.startDate + " -> " + update.endDate);
            }
        } catch (InputException e) {
            System.err.println("invalid input at " + e.path + ": " + e.getMessage());
            System.exit(1);
        }
    }
}

Run it from your build, or straight from the source file with the JAR on the class path:

java -cp plen.jar QuickStart.java

Output:

task 2: 2026-08-12 08:00 -> 2026-08-13 08:00

Engine.schedule takes a ProjectData snapshot and returns a ScheduleResult. Its updates list holds only the tasks that moved, as Mutation records. Task 1 didn't move, so it's not in the list.

Durations are working time in days. The default calendar works Monday to Friday, 08:00-12:00 and 13:00-17:00. That's why the two-day task 1 ends on Wednesday at 08:00, not on Tuesday evening.

Records such as TaskData and LinkData are mutable classes with public fields and a fluent withX(...) setter per field. List setters take any Collection, so List.of(...) works. Dates are Moment values: local time, minute precision, no time zone. Moment.fromYmdHm returns null for an invalid date, so the example checks for it before using the value. Ids are Id values, built with Id.num(1) or Id.str("a"). Note that Id.num(1) and Id.str("1") name the same task. Records, ids and dates covers these types in detail.

When the input is invalid#

Every leaf task needs two of startDate, endDate and duration. Remove task 2's start date and keep only its duration:

new TaskData(Id.num(2)).withDuration(1.0)

Engine.schedule now rejects the snapshot before it schedules anything, and the catch block prints:

invalid input at tasks[1].start_date: task needs two of start_date / end_date / duration

The InputException has a path field that points to the field that failed. The path uses the JSON field names, so startDate shows up as start_date. Add the start date back and the program runs again.

InputException is unchecked, like every exception the library throws, so the compiler won't remind you to catch it. Bad input is the only thing that makes this call throw. Dependency cycles, constraint conflicts and dropped links come back as data in ScheduleResult (cycles, conflicts, notes), and the call still returns normally.

Keep a live project#

Engine.schedule fits batch jobs and request handlers that see each snapshot once. An editor backend works differently. It loads the plan once, then sends small changes as the user works. That's what Project does.

This program loads the same two tasks, previews a change, then commits it:

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

public class LiveProject {
    public static void main(String[] args) {
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        if (monday == null) {
            throw new IllegalArgumentException("invalid date");
        }

        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.num(1)).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.num(2)).withStartDate(monday).withDuration(1.0)))
            .withLinks(List.of(
                new LinkData()
                    .withId(Id.num(1))
                    .withSource(Id.num(1))
                    .withTarget(Id.num(2))
                    .withLinkType(LinkType.FS)));

        Project project = new Project();
        ScheduleResult first = project.solve(data, new SolveOptions());
        System.out.println("loaded, " + first.updates.size() + " task(s) moved");

        // The user stretches task 1 to three days.
        SchedulingChanges changes = new SchedulingChanges();
        changes.tasks.update.add(new TaskUpdate(Id.num(1)).withDuration(Patch.of(3.0)));

        ScheduleResult preview = project.preview(changes, new PreviewOptions());
        System.out.println("preview: " + preview.updates.size() + " task(s) would move");

        ApplyReport report = project.apply(changes);
        if (report.needsResync) {
            System.err.println("an edit was rejected; reload the project with solve(data, ...)");
            System.exit(1);
        }

        ScheduleResult result = project.solve(null, new SolveOptions());
        for (Mutation update : result.updates) {
            System.out.println("task " + update.id + ": " + update.startDate + " -> " + update.endDate);
        }
    }
}

Output:

loaded, 1 task(s) moved
preview: 2 task(s) would move
task 1: 2026-08-10 08:00 -> 2026-08-13 08:00
task 2: 2026-08-13 08:00 -> 2026-08-14 08:00

Each call does one job:

  • Project#solve with data loads and schedules the snapshot. It works on its own deep copy of data, so later changes to your object don't reach the project. The scheduled dates become the project's baseline.
  • Project#preview runs the change on a copy of the baseline. The project doesn't change, so it's safe to call on every mouse move during a drag.
  • Project#apply commits the change and marks the project as unsolved.
  • solve(null, ...) reschedules the current state. Task 1 is reported because it was edited, and task 2 moves to follow it.

Edits are SchedulingChanges. A TaskUpdate sets only the fields you give it. Each field is a Patch<T>: Patch.of(value) sets it, Patch.nullValue() clears it, and Patch.missing() (the default) leaves it alone.

A Project isn't AutoCloseable, so there's nothing to close when you're done with it. It's an ordinary object that the garbage collector cleans up. It also isn't synchronized: use one instance from one thread at a time.

When an edit is rejected#

apply accepts what it can and reports the rest in the returned ApplyReport. It doesn't throw for a bad operation. To see this, remove a task that doesn't exist. Add this line before the apply call:

changes.tasks.remove.add(Id.num(99));

The report's rejected list now has an entry with this path and message:

tasks.remove[0] unknown task id

needsResync is now true. Your copy of the plan and the project's copy no longer match. The next solve(null, ...) throws a ProjectException with code == ProjectError.NEEDS_RESYNC (message needs-resync). To fix it, send the full data again with solve(data, ...). That's why the example checks needsResync before it solves. Recover from rejected changes covers this flow in detail.

Next steps#