Scheduling a Project#

This guide shows how to run the scheduler. It covers when to call the one-shot schedule function and when to keep a Project. You'll also see what the solve options change, how forward and backward planning place tasks, and what "converged" means in a result.

How scheduling works#

There are two entry points. Both run the same planner:

  • schedule(data, options) takes a ProjectData object, schedules it once and returns a ScheduleResult. Nothing is kept after the call. It's a shortcut for creating a Project, calling solve(data, options) on it and throwing the project away.
  • project.solve(data, options) does the same work on a long-lived Project. The project keeps the scheduled dates as its accepted baseline, so later calls can edit, preview, level and evaluate against it.

Every solve goes through the same steps. The engine reads your plain objects into its own normalized model and validates them. It builds the scheduling graph from links and summaries, checks for dependency cycles and works out the scope: the whole project, or one branch with subtree. Then it runs the planner until the dates stop changing. The result lists only what moved.

A few rules shape what you get back:

  • Updates are sparse. result.updates holds one Mutation per task whose dates changed, with only the changed fields set. Write them back into your own model. To get every task's dates, set includeUnchanged.
  • Problems are data, not errors. Conflicts, cycles, dropped input and non-convergence come back inside the result. A thrown error means invalid input (InputError) or a call that doesn't fit the project's state (ProjectError). See Error handling.
  • Some tasks never move. The planner leaves these tasks where they are: tasks with auto_scheduling: false, unscheduled tasks, and completed tasks (progress: 1 with config.use_progress). Use them to lock work in place.
  • Calls are synchronous. Every method returns its result directly and runs on the calling thread. There's no I/O and no clock. See Blocking the thread.

One-shot scheduling#

Use schedule in scripts, request handlers and batch jobs that schedule a snapshot and throw it away.

import { schedule } from "plen-js";

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

for (const update of result.updates) {
  console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
}

Task 2 has to wait for task 1, so it's the only update: task 2: 2026-08-12 08:00 .. 2026-08-17 08:00. Task 1 already sits where it belongs, so it isn't reported. The default "global" calendar works Mon-Fri, 08:00-12:00 and 13:00-17:00. That's why the dates skip the weekend.

schedule throws only an InputError, when validation rejects the data. It never mutates the object you pass in.

Note

schedule() always returns dates as "YYYY-MM-DD HH:MM" strings. To get local Date objects, create a Project with dates: "date" and call solve on it.

Solving a project#

Keep a Project when the plan lives on. Typical cases are a Gantt editor with one project per open document, or a server that applies edits over time.

import { Project } from "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: 3 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
};

const project = new Project();

// Install the data, schedule it and accept the dates as the baseline.
const loaded = project.solve(data);

// Nothing changed since: the cached result comes back.
const cached = project.solve(null);
console.log(cached.baselineId === loaded.baselineId); // true

// Edit, then reschedule.
project.apply({ tasks: { update: [{ id: 1, duration: 4 }] } });

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

The two forms of solve do different jobs:

  • solve(data) validates the replacement data, installs it, schedules it and accepts the dates as the new baseline. Use it on the first load and to resync the whole project. If validation fails, it throws an InputError, and the previous state and cached result stay as they were.
  • solve(null) schedules the current state. After an apply it plans the edits. Tasks the edit touched are reported too, even when the planner leaves them where the edit put them. That's why the last loop prints both tasks: task 1: 2026-08-10 08:00 .. 2026-08-14 08:00 and task 2: 2026-08-14 08:00 .. 2026-08-19 08:00. On a clean project with the same options, solve(null) returns the cached result without planning again.

The cache key is the whole options object. Calling solve(null, options) with different options replans from the accepted dates. The data doesn't change, so baselineId stays the same unless a task moves.

Each call returns a fresh copy of the result, even from the cache. Two results never share objects, so you can modify one freely. Compare baselineId values within a session to tell whether the baseline changed.

After an apply with rejected operations, both solve(null) and apply throw ProjectError with code "needs-resync" until you send the full data again with solve(data). Editing is covered in Editing a project.

Note

new Project(options) throws an InputError if an option is unknown or has an invalid value.

Solve options#

SolveOptions changes what a solve schedules and what it reports. The defaults schedule the whole project and return only the moves.

import { Project } from "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: 3 },
    { id: 3, start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: 1, source: 2, target: 3, type: "fs" }],
};
const project = new Project();

// Initial load: every task's dates, why each one sits where it does,
// plus the critical path and slack.
const result = project.solve(data, {
  includeUnchanged: true,
  reasons: true,
  analytics: { criticalPath: true, slack: true },
});
for (const update of result.updates) {
  console.log(update.id, update.reason);
}

// Later: reschedule only task 2 and what depends on it.
project.solve(null, { subtree: { task: 2 } });
  • includeUnchanged also reports tasks that didn't move, each with start_date, end_date and duration set. The result alone is then the full normalized date list, which is what an editor needs on first load. Unscheduled tasks are never reported. See Load all task dates.
  • reasons adds a reason to each update. Its kind says why the task sits there: pushed by a predecessor link ("asap"), pulled by a successor ("alap"), moved with its summary block ("summary-translation"), placed by its constraint ("constraint") or kept at its preferred date ("preferred"). Above, tasks 1 and 2 get { kind: "preferred" } and task 3 gets { kind: "asap", linkId: 1, sourceId: 2 }.
  • analytics asks for the critical path, slack, or both. Both are off by default. See Critical path and slack.
  • subtree limits the run to one task and what depends on it. inclusive defaults to true, so the root task is scheduled too. The subtree applies to this call only. A scoped solve accepts only its own moves, and a later unscoped solve picks up the rest. An unknown or unscheduled subtree task throws an InputError at path subtree.task, and the cached result is kept. See Reschedule a branch.

With subtree and includeUnchanged together, the result holds every task in the subtree (with their ancestors). It also holds the tasks outside the subtree that changed or were touched by an edit.

level and preview don't inherit these options. level solves with defaults first, and preview takes its own PreviewOptions.

Forward and backward planning#

By default the planner works forward: each task starts as early as its links allow. Set config.schedule_from_end to plan backward instead. Each task then ends as late as its successors allow, and the project is anchored at its end.

import { schedule } from "plen-js";

const result = schedule({
  config: {
    schedule_from_end: true,
    gap_behavior: "compress",
    project_end: "2026-08-28 17:00",
  },
  tasks: [
    { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
    { id: 2, start_date: "2026-08-12 08:00", duration: 3 },
  ],
  links: [{ id: 1, source: 1, target: 2, type: "fs" }],
});

for (const update of result.updates) {
  console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
}

Task 2 has no successor, so project_end caps it and it ends at 2026-08-28 17:00. Task 1 ends where task 2 starts, on 2026-08-25 17:00.

The project bounds act only on tasks at the open ends of the graph:

  • project_start (forward) applies to tasks without predecessors. With gap_behavior: "compress" it pulls them onto the project start. With "preserve" (the default) it only raises tasks that start earlier and leaves later ones at their dates. For a task with predecessors it's a hard floor only when apply_constraints is on.
  • project_end (backward) caps tasks without successors, in either gap mode. Without it, such a task keeps its current end.

A task whose constraint points the other way gets a secondary pass in the opposite direction. In a forward project with apply_constraints, an "alap" task moves as late as its successors allow:

import { schedule } from "plen-js";

// "order" can happen any time before "install"; "build" sets the pace.
const result = schedule({
  config: { apply_constraints: true },
  tasks: [
    { id: "build", start_date: "2026-08-10 08:00", duration: 4 },
    { id: "order", start_date: "2026-08-10 08:00", duration: 1, constraint_type: "alap" },
    { id: "install", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [
    { id: "l1", source: "build", target: "install", type: "fs" },
    { id: "l2", source: "order", target: "install", type: "fs" },
  ],
});

for (const update of result.updates) {
  console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
}

install waits for build and starts on 2026-08-14 08:00. order moves from Monday to 2026-08-13 08:00, so it ends right when install starts.

Leveling is forward-only. project.level throws an InputError at path config.schedule_from_end for a project with schedule_from_end: true. Constraint types and summary behavior are covered in Links, constraints and summaries.

Determinism and convergence#

The same input and options always give the same result. The engine reads no clock and uses no randomness. It iterates in input order and topological order, never in hash order. Result keys come in sorted order. That makes them safe to cache, diff and compare in tests.

The one exception is baselineId. Tokens are opaque and may differ between runs for the same data, so never compare them across runs or store them. Take the current one from the latest solve result.

The planner runs passes until the dates stop changing. result.converged tells you whether it got there within config.max_iterations passes:

  • An unscoped run converges in two passes by design. The second pass reproduces the first.
  • A scoped run (subtree) can need more passes, because tasks outside the scope feed into it.
  • max_iterations defaults to 8 and must be at least 1. 0 throws an InputError at path config.max_iterations.
import { schedule } from "plen-js";

const result = schedule({
  config: { max_iterations: 4 },
  tasks: [{ id: 1, start_date: "2026-08-10 08:00", duration: 1 }],
});

if (result.cycles.length > 0) {
  console.log(`dependency cycles: ${result.cycles.length}`);
} else if (!result.converged) {
  console.log("planner stopped after max_iterations");
}

converged: false isn't an error, and the result is still valid.

A dependency cycle stops the run before planning. Dates stay unchanged, updates is empty and cycles lists each unique cycle. For everything else a result carries, see Reading results.

Blocking the thread#

Every Project method and schedule run synchronously on the thread that calls them. A solve of a small plan finishes quickly. A long level or evaluate run on a large project blocks the event loop for its whole duration, so the page or server can't respond in the meantime.

When that matters, move the project into a Worker and post data and results as messages. Inputs and results are plain objects, so they cross the worker boundary without conversion.

// scheduler-worker.js
import { Project } from "plen-js";

const project = new Project();

self.onmessage = ({ data: message }) => {
  if (message.type === "load") {
    self.postMessage(project.solve(message.data, { includeUnchanged: true }));
  } else if (message.type === "level") {
    self.postMessage(project.level());
  }
};
// main thread
const worker = new Worker(new URL("./scheduler-worker.js", import.meta.url), { type: "module" });

worker.onmessage = ({ data: result }) => {
  for (const update of result.updates) {
    console.log(update.id, update.start_date, update.end_date);
  }
};

worker.postMessage({
  type: "load",
  data: {
    tasks: [
      { id: 1, start_date: "2026-08-10 08:00", duration: 2 },
      { id: 2, start_date: "2026-08-10 08:00", duration: 3 },
    ],
    links: [{ id: 1, source: 1, target: 2, type: "fs" }],
  },
});

The project lives inside the worker, so keep every call for that plan there. For the full setup, see Run in a worker.