Previewing Changes#

This guide shows how to see what an edit would do before you commit it. It covers previewing a drag or a resize, keeping previews in sync with the project's baseline, checking the resource load a change would cause, and trying multi-step edits or a leveling run on a copy of the project.

How previews work#

A solved Project holds one accepted baseline: the dates from its last solve or level. Every preview starts from that baseline.

project.preview(changes, options) copies the baseline, applies a SchedulingChanges object to the copy and schedules it. The copy is thrown away when the call returns. The project stays as it was, so you can call preview as often as the UI needs, for example on every mouse move during a drag. Like every Project method, preview is synchronous.

What you get back is a regular ScheduleResult:

  • updates are relative to the baseline. They list the tasks the change would move and the tasks the change itself touched, the same way solve(null) reports them after an apply. An empty change set gives empty updates.
  • conflicts, cycles and notes describe the previewed state. A preview can show the cycle a new link would create, and the project never holds that link.
  • baselineId names the baseline the preview read.

A preview differs from apply in two ways. It isn't partial: if any operation in the change set is invalid, the whole call throws an InputError. And it needs a clean, solved baseline. Before the first solve, and after any apply until the next solve, it throws a ProjectError with code "no-baseline".

For a change that takes more than one step, or for a level run, use project.clone() instead. The clone is an independent Project with the same baseline, cached result and options. Edit, solve or level the clone, and the original stays as it was.

You want to see Use
The effect of one change set (a drag, a resize, a new link) preview
The load a change would put on resources preview with the profile option
Several edits in a row, each solved clone, then apply + solve(null) on the copy
What leveling would do clone, then level on the copy

Previewing an edit#

Build the edit as a SchedulingChanges object, the same one you'd pass to apply, and pass it to preview.

import { Project } from "plen-js";

const project = new Project();
project.solve({
  // All time is working time: plain day arithmetic, no weekends.
  config: { use_calendars: false },
  tasks: [
    { id: "a", start_date: "2026-01-01", duration: 2 },
    { id: "b", start_date: "2026-01-01", duration: 1 },
  ],
  links: [{ id: "ab", source: "a", target: "b", type: "fs" }],
});

// The user drags task "b" to January 5.
const drag = { tasks: { update: [{ id: "b", start_date: "2026-01-05" }] } };

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

// The project still holds the solved dates: "b" starts on January 3.
const current = project.solve(null);
console.log(current.updates.map((u) => `${u.id} ${u.start_date}`));

The solve puts b right after a, on January 3. The preview prints task b: 2026-01-05 00:00 .. 2026-01-06 00:00. The project doesn't change: the next solve(null) returns the cached result of the first solve, with b at 2026-01-03 00:00.

When the user drops the task, send the same change set to apply, then call solve(null). See Editing a project.

The preview options take the same diagnostic switches as the solve options:

  • subtree previews only one branch.
  • reasons adds a reason to each update, so you can tell why a task would move.
  • analytics adds the critical path and slack of the previewed state.

Previews stay sparse: there's no includeUnchanged option. The full option list is on the preview reference.

Guarding with baseline tokens#

A UI that previews in the background can hit a race. Another request applies an edit or reloads the data, and the preview now runs against a baseline the user never saw. Baseline tokens catch this.

Every solve and level result carries baselineId, an opaque token for the accepted baseline. Pass it back as baselineId in the preview options, or in the change set. If the token no longer names the current baseline, preview throws a ProjectError with code "stale-baseline" instead of returning dates for the wrong state.

import { Project, ProjectError } from "plen-js";

const project = new Project();
const loaded = project.solve({
  config: { use_calendars: false },
  tasks: [{ id: "a", start_date: "2026-01-01", duration: 2 }],
});

// The UI keeps the token of the dates it shows.
let shown = loaded.baselineId;

const resize = { tasks: { update: [{ id: "a", duration: 3 }] } };

const preview = project.preview(resize, { baselineId: shown });
// A preview reports the baseline it read, so its token works for the next preview too.
console.log(preview.baselineId === shown); // true

// Meanwhile, another edit is committed.
project.apply(resize);

// Until the next solve there is no baseline to preview against.
try {
  project.preview(resize);
} catch (error) {
  console.log(error instanceof ProjectError && error.code); // "no-baseline"
}

const latest = project.solve(null);

// The old token no longer names the baseline.
try {
  project.preview(resize, { baselineId: shown });
} catch (error) {
  if (!(error instanceof ProjectError) || error.code !== "stale-baseline") throw error;
  // Refresh the view from `latest`, then preview with its token.
  shown = latest.baselineId;
  project.preview(resize, { baselineId: shown });
}

A few rules for tokens:

  • Take the token from the latest solve or level result. A preview result carries the same token it read, so a chain of previews during one drag can reuse it.
  • The token changes whenever the data changes: new data, an apply, a solve that moves dates, leveling edits.
  • Tokens are opaque. Don't parse them, store them across sessions or compare them in tests. The same data can get a new token on a repeat run.
  • preview checks options.baselineId and changes.baselineId separately. Leave both out to skip the check.

For how to handle "stale-baseline" and the other project errors, see Error handling.

Preview with load#

Set the profile option to see the resource load a change would cause. The result then has profile, the same per-bucket load and capacity that loadProfile returns, but computed on the previewed dates. Use it to show a resource histogram while the user drags a task.

import { Project } from "plen-js";

const project = new Project();
project.solve({
  config: { use_calendars: false },
  tasks: [
    { id: "a", start_date: "2026-01-01", duration: 2 },
    { id: "b", start_date: "2026-01-03", duration: 1 },
  ],
  resources: [{ id: "dev", capacity: 1 }],
  assignments: [
    { task: "a", resource: "dev", value: 1 },
    { task: "b", resource: "dev", value: 1 },
  ],
});

// Drag "b" one day earlier, onto the last day of "a".
const drag = { tasks: { update: [{ id: "b", start_date: "2026-01-02" }] } };

const preview = project.preview(drag, {
  profile: { from: "2026-01-01", to: "2026-01-05", tasks: "overallocated" },
});

for (const resource of preview.profile.resources) {
  for (const bucket of resource.buckets) {
    if (bucket.load > bucket.capacity) {
      console.log(
        `${resource.resource} overloaded at ${bucket.start}: ` +
          `${bucket.load} of ${bucket.capacity} (${bucket.tasks.join(", ")})`,
      );
    }
  }
}

Both tasks need dev on January 2, so that bucket's load goes over its capacity of 1. The loop prints dev overloaded at 2026-01-02 00:00: 2 of 1 (a, b). With tasks: "overallocated", each overloaded bucket also lists the tasks that share it.

The query covers buckets that start in [from, to). By default it reports every resource, with the bucket size (basis) derived from config.duration_unit. Narrow it with resources, or switch basis. The profile object has no baselineId of its own, and passing one there throws an InputError. Put the token in the preview options instead.

Every preview also reports overallocations when the project has assignments, so you can skip the profile if you only need the overloaded windows. See Resource load and the preview reference for the full query fields.

Cloning a project#

preview takes one change set. To see the result of several edits in a row, each solved before the next, clone the project and work on the copy.

import { Project } from "plen-js";

const resize = (id, duration) => ({ tasks: { update: [{ id, duration }] } });

let project = new Project();
project.solve({
  config: { use_calendars: false },
  tasks: [
    { id: "a", start_date: "2026-01-01", duration: 2 },
    { id: "b", start_date: "2026-01-01", duration: 1 },
  ],
  links: [{ id: "ab", source: "a", target: "b", type: "fs" }],
});

// An independent copy with the same baseline and options.
const draft = project.clone();
draft.apply(resize("a", 4));
draft.solve(null);
draft.apply(resize("b", 3));
const outcome = draft.solve(null);

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

// The original is untouched and still previews against its own baseline.
console.log(project.preview({}).updates.length); // 0

// The user accepts the draft: keep it as the project.
project = draft;

The last solve on the draft prints draft task b: 2026-01-05 00:00 .. 2026-01-08 00:00. Edits on one copy never affect the other. The copy is a full Project with its own baseline, cached result and baselineId, and it keeps the original's dates option. You can preview, level, evaluate or keep editing it. To accept the draft, replace the original with it, or replay the same changes on the original with apply. To discard it, drop the reference.

Note

clone() copies the whole project at once, so its cost grows with the project's size.

Clone before level too. level accepts the leveled dates as the new baseline, so running it on a copy is how you show the user a leveling proposal before committing it:

// `project` is a solved Project.
function levelingProposal(project) {
  const draft = project.clone();
  const leveled = draft.level();
  for (const update of leveled.updates) {
    console.log(`task ${update.id} delay ${update.leveling_delay}`);
  }
  return draft;
}
Note

The leveling result is a ScheduleResult with an added rounds field; read updates on it.

The step-by-step version is in Preview leveling. To compare several alternative change sets with scores, use evaluate instead. It scores each candidate on a throwaway copy of its own. See Evaluating alternatives.