Quick start#

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

Requirements#

  • Node.js 18 or later, or any modern browser with a bundler.
  • No WebAssembly and no runtime dependencies. Every call is synchronous and runs on the calling thread.

Install#

The package comes from a private npm 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 Package
Full https://git.dhtmlx.com/api/packages/plen/npm/ @dhx/plen-js
Trial https://git.dhtmlx.com/api/packages/plen-trial/npm/ @dhx/plen-js-trial

The trial is the full engine with one limit: a project with more than 100 tasks is rejected with an InputError. The API is the same.

Each account sees only its own edition. Trial credentials can't install @dhx/plen-js, and full credentials only see the plen registry.

Configure access to the registry#

The examples use the full registry. For the trial, replace plen with plen-trial in the URL, on both lines.

Option A: access token (recommended). Sign in at https://git.dhtmlx.com with your username and password, open Settings → Applications, and create an access token with the scope package: Read. Then create an .npmrc next to your package.json:

@dhx:registry=https://git.dhtmlx.com/api/packages/plen/npm/
//git.dhtmlx.com/api/packages/plen/npm/:_authToken=${DHTMLX_NPM_TOKEN}

The token comes from an environment variable, so the file can go into source control. Set the variable in your shell:

# bash / zsh
export DHTMLX_NPM_TOKEN=your-token
# PowerShell
$env:DHTMLX_NPM_TOKEN = "your-token"

npm fails when the variable is missing, so set it in every environment that installs: as a secret on your CI server, and in Docker builds. npm, pnpm, Bun and Yarn 1 read .npmrc.

Option B: username and password. Pass the base64-encoded username:password pair instead of a token. This doesn't work for accounts with two-factor authentication.

@dhx:registry=https://git.dhtmlx.com/api/packages/plen/npm/
//git.dhtmlx.com/api/packages/plen/npm/:_auth=${DHTMLX_NPM_AUTH}
export DHTMLX_NPM_AUTH=$(printf '%s' 'your-username:your-password' | base64)

Yarn 2 and later read .yarnrc.yml instead of .npmrc:

npmScopes:
  dhx:
    npmRegistryServer: "https://git.dhtmlx.com/api/packages/plen/npm/"
    npmAuthToken: "${DHTMLX_NPM_TOKEN}"

Already using npm.dhtmlx.com. The @dhx scope can point to one registry only. If your project already installs DHTMLX packages such as @dhx/gantt from https://npm.dhtmlx.com/, keep that mapping. The planning engine packages are published there too, and your existing login installs them. If it can't see them, contact DHTMLX support.

Install the package#

Full edition:

npm install @dhx/plen-js

Trial edition, installed under the full name with an npm alias:

npm install @dhx/plen-js@npm:@dhx/plen-js-trial

With pnpm or Yarn, use pnpm add or yarn add with the same argument. Thanks to the alias, your code imports "@dhx/plen-js" with either edition, as the examples on this page do. You can also install @dhx/plen-js-trial under its own name and import from "@dhx/plen-js-trial".

The package has no dependencies. It ships ES module and CommonJS builds with bundled type declarations. You can create a project right after the import. The default export init() exists, but you don't need to call it: it loads nothing and resolves immediately.

Move from the trial to the full edition#

  1. In .npmrc, change plen-trial to plen on both lines, and use a token or the username and password of your full-edition account.
  2. Run npm install @dhx/plen-js@^1. It replaces the alias in package.json with the real package, so your code doesn't change.

If you installed @dhx/plen-js-trial under its own name, also run npm uninstall @dhx/plen-js-trial and change the imports to "@dhx/plen-js".

If the install fails#

  • E401 or Unable to authenticate: the token or password is wrong, or the variable isn't set in the shell that runs npm. Check that the token has the scope package: Read.
  • E404 Not Found: the registry and the package don't match. Trial credentials see only @dhx/plen-js-trial on the plen-trial registry. Also check that the URL ends in /npm/ on both lines.
  • Requests go to npm.dhtmlx.com or registry.npmjs.org: another @dhx:registry line wins. Run npm config get @dhx:registry in the project folder. A project .npmrc overrides your user config.
  • The lockfile still points at the old registry after a change: the lockfile stores full tarball URLs. Delete node_modules and the package's lockfile entry, then install again.

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 must wait for task 1 to finish.

import { schedule, InputError } from "@dhx/plen-js";

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

try {
  const result = schedule(data);
  for (const update of result.updates) {
    console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
  }
} catch (error) {
  if (error instanceof InputError) {
    console.error(`invalid input at ${error.path}: ${error.message}`);
  } else {
    throw error;
  }
}

Save it as main.mjs and run node main.mjs:

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

schedule takes a ProjectData object and returns a ScheduleResult. Its updates array holds only the tasks that moved. 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.

Dates are "YYYY-MM-DD HH:MM" strings in local time, with minute precision and no time zone. Input also accepts Date objects. schedule always returns strings; to get Date objects back, use a Project created with { dates: "date" }. Ids can be numbers or strings, and 1 and "1" name the same task.

When the input is invalid#

Every leaf task needs two of start_date, end_date and duration. Remove task 2's start_date and keep only its duration. schedule now throws 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 InputError has a path that points to the field that failed. Put the start date back and the program runs again.

Bad input is the only thing that makes this call throw. Dependency cycles, constraint conflicts and dropped links come back as data in the ScheduleResult (cycles, conflicts, notes).

Keep a live project#

schedule fits batch jobs and request handlers that see each snapshot once. An editor 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 { Project } from "@dhx/plen-js";

const data = {
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const project = new Project();
const first = project.solve(data);
console.log(`loaded, ${first.updates.length} task(s) moved`);

// The user stretches task 1 to three days.
const changes = { tasks: { update: [{ id: 1, duration: 3 }] } };

const preview = project.preview(changes, { baselineId: first.baselineId });
console.log(`preview: ${preview.updates.length} task(s) would move`);

const report = project.apply(changes);
if (report.needsResync) {
  throw new Error("an edit was rejected; reload the project with solve(data)");
}

const result = project.solve(null);
for (const update of result.updates) {
  console.log(`task ${update.id}: ${update.start_date} -> ${update.end_date}`);
}

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:

  • new Project() creates an empty project. It throws an InputError if you pass an unknown option or an invalid value.
  • project.solve(data) loads and schedules the data. Its dates become the project's baseline, and baselineId names that baseline.
  • project.preview(changes, { baselineId }) 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. If the baseline has moved on since you read baselineId, it throws a ProjectError with the code "stale-baseline".
  • project.apply(changes) commits the change and marks the project as unsolved.
  • project.solve(null) reschedules the current state. Task 1 is reported because it was edited, and task 2 moves to follow it.

Edits are SchedulingChanges objects with add, update and remove lists per tasks, links, resources and assignments. A TaskUpdate sets only the fields you give it. A field set to null is cleared, and a field you leave out stays as it is.

When an edit is rejected#

apply accepts what it can and lists the rest in the returned report. It doesn't throw for a bad operation. Remove a task that doesn't exist, { tasks: { remove: [99] } }, and the report gets this entry in rejected:

{ path: "tasks.remove[0]", message: "unknown task id" }

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

Where to go next#