Editing a project#

This guide covers how to change a live Project as the user works: adding, updating and removing tasks, links, resources and assignments, rescheduling after the edit, and recovering when part of an edit is refused.

How editing works#

A Project holds one accepted state: the data from the last solve(data) plus every edit since, with the dates the last solve accepted. You don't send the whole plan again for each change. You describe the change as a SchedulingChanges object and pass it to project.apply().

Editing takes two separate calls:

  1. apply(changes) writes the edit into the accepted state and marks the project unsolved. It doesn't schedule anything.
  2. solve(null) schedules the edited state, accepts the new dates and returns what moved.

Between the two calls the project has no baseline. preview, loadProfile, evaluate, slots and suggestAssignments throw a ProjectError with code "no-baseline" until you solve. You can call apply several times before one solve(null). The edits accumulate.

Both calls are synchronous, like every Project method.

apply accepts what it can. Each operation in the change set applies or is rejected on its own. A rejection doesn't throw. It's listed in the returned report, and it sets needsResync. From then on, solve(null), apply and level throw ProjectError with code "needs-resync". Your model and the project's state may now disagree, and the only way back is to send the full data again with solve(data).

When apply itself throws, nothing was applied. A ProjectError means the call didn't fit the project's state: "needs-resync" after an earlier rejection, or "stale-baseline" when the change set names a baseline that isn't current anymore. An InputError means the change set is malformed, for example an unknown key such as tasks.delete. See Error handling.

Change sets#

A SchedulingChanges object groups the edits by record kind. Each group has three optional arrays: add, update and remove. Leave out any group or array you don't need.

Group add update remove
tasks TaskData TaskUpdate task ids
links LinkAdd LinkUpdate link ids
resources ResourceData ResourceUpdate resource ids
assignments AssignmentData AssignmentUpdate assignment ids

Additions use the same records as ProjectData, except links: a LinkAdd is a LinkData whose id is optional. Leave out a link or assignment id and apply generates one ($generated_link_1, $generated_assignment_1, ...). Generated ids come back in the report's effects, so you can store them in your model.

import { Project } from "plen-js";

const project = new Project();
project.solve({
  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 report = project.apply({
  tasks: {
    // A new task, linked after task 2 below.
    add: [{ id: 3, start_date: "2026-08-10 08:00", duration: 1 }],
    // Task 1 now takes three days.
    update: [{ id: 1, duration: 3 }],
  },
  // The link has no id: apply generates one.
  links: { add: [{ source: 2, target: 3, type: "fs" }] },
});
console.log(report.applied.map((op) => op.path));
console.log("generated links:", report.effects.generatedLinks);

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

The report lists tasks.add[0], tasks.update[0] and links.add[0], and the new link is $generated_link_1. After the solve, task 1 runs until 2026-08-13 08:00, task 2 moves to 2026-08-13 08:00 .. 2026-08-18 08:00, and task 3 starts when task 2 ends.

Operations don't run in the order you wrote them. apply runs them in a fixed order, so every reference comes after the adds it may need and every removal comes before the edits it could break:

  1. tasks: remove, add, update
  2. links: remove, update, add
  3. resources: remove, add, update
  4. assignments: remove, update, add

An operation is named by its group, array and index, such as tasks.update[0]. The same path shows up in the report's applied and rejected lists.

The change set itself is checked strictly: an unknown key on it or on a group throws InputError. Unknown fields inside a record are ignored, the same as in ProjectData.

Update records#

Update records (TaskUpdate, LinkUpdate, ResourceUpdate, AssignmentUpdate) name the record by id. Every other field is optional, and each field has three states:

  • Left out (or undefined): the field keeps its value.
  • A value: the field is set.
  • null: an optional field is cleared, or a field that always has a value goes back to its default. auto_scheduling goes back to true, unscheduled to false, progress to 0, priority to 500, leveling_delay to 0, pauses to empty, a link's type to "fs" and its lag to 0.

constraint_type: "" also clears a constraint.

import { Project } from "plen-js";

const project = new Project();
project.solve({
  config: { apply_constraints: true },
  tasks: [{ id: "review", start_date: "2026-08-10 08:00", duration: 1 }],
});

// A value sets a field: "start no earlier than Thursday".
project.apply({
  tasks: {
    update: [{ id: "review", constraint_type: "snet", constraint_date: "2026-08-13 08:00" }],
  },
});
for (const update of project.solve(null).updates) {
  console.log(`pinned: ${update.id} starts ${update.start_date}`);
}

// null clears the constraint; start_date moves the task back.
// Every field you leave out keeps its value.
project.apply({
  tasks: {
    update: [
      { id: "review", constraint_type: null, constraint_date: null, start_date: "2026-08-10 08:00" },
    ],
  },
});
for (const update of project.solve(null).updates) {
  console.log(`unpinned: ${update.id} starts ${update.start_date}`);
}

The first solve moves the task to 2026-08-13 08:00. The second puts it back on Monday. Clearing the constraint alone wouldn't do that: the accepted state already holds the Thursday dates, so the update also sets start_date.

Which dates a task update keeps#

A task has three linked date fields: start_date, end_date and duration. When an update sets some of them, apply keeps what you sent and derives the rest:

  • start_date alone keeps the duration and recomputes the end (a drag).
  • end_date alone keeps the start and recomputes the duration (a resize from the right edge).
  • duration alone keeps the start and recomputes the end.
  • start_date and end_date together recompute the duration.
  • A new calendar_id, or a new parent while calendars are inherited (calendar_mode: "task-inherited"), keeps the start and duration and recomputes the end on the new calendar. Under an inherited calendar, the descendants that inherit it re-derive their ends too.

An explicit start_date on non-working time is kept as sent when the task has no predecessor. The end is computed forward from it on the working calendar, so only the start sits outside working time. Snap the date in your UI if you don't want that.

Feeding results back#

An entry in a result's updates uses the same field names as a TaskUpdate. Its extra kind and reason fields are ignored like any unknown field, so you can pass the entries to apply as they are. That's useful when you want to commit dates you got from preview or from a leveled clone:

import { Project } from "plen-js";

const project = new Project();
const loaded = project.solve({
  tasks: [{ id: 1, start_date: "2026-08-10 08:00", duration: 2 }],
});

const preview = project.preview({
  tasks: { update: [{ id: 1, start_date: "2026-08-12 08:00" }] },
});

// The user confirms: commit exactly the previewed dates.
project.apply({ baselineId: loaded.baselineId, tasks: { update: preview.updates } });
console.log(project.solve(null).updates);

The solve reports task 1 at 2026-08-12 08:00 .. 2026-08-14 08:00, the dates the preview showed.

pauses replaces the whole list. Set it to [] or null to remove every pause. See Clear leveling.

Applying#

apply returns a report with these fields:

  • applied lists the path of every accepted operation.
  • rejected lists the path and message of every refused one.
  • effects lists what the edit changed beyond the records you named (below).
  • needsResync is true when anything was rejected.
  • revision is the project's revision number after this edit.

Removals cascade, and effects tells you what went with them. Removing a task removes its descendants, every link that touches any of them and every assignment on them. Removing a resource removes its assignments.

import { Project } from "plen-js";

const project = new Project();
project.solve({
  tasks: [
    { id: "phase", type: "summary" },
    { id: "spec", parent: "phase", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "release", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  links: [{ id: "spec-release", source: "spec", target: "release" }],
  resources: [{ id: "ann" }],
  assignments: [{ id: "ann-spec", task: "spec", resource: "ann", value: 1 }],
});

const { effects } = project.apply({ tasks: { remove: ["phase"] } });
console.log("tasks:", effects.removedTasks);
console.log("links:", effects.removedLinks);
console.log("assignments:", effects.removedAssignments);

Removing the summary phase also removes spec, the link spec-release and the assignment ann-spec. Delete the same records from your own model.

The other effects lists:

  • generatedLinks and generatedAssignments hold the ids apply made up for additions without one.
  • droppedLinks and droppedAssignments hold records that were accepted but are now inactive, for example a link to an unscheduled task. They stay in the project and come back into play when the task does.

apply edits the project in place. Clones made earlier don't see the edit, so you can clone() a project, apply a multi-step edit to the clone and drop it if the user cancels. For a single edit, preview does the same job without a clone. See Previewing changes.

Note

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

Field-only task edits and assignment updates take a fast path that patches the affected records without rebuilding the whole model. Frequent small edits, like drags, stay cheap.

Rescheduling#

After apply, call solve(null). It schedules the edited state, accepts the dates as the new baseline and returns the updates relative to the previous one.

Tasks that a successful operation added or updated are always reported, even when the planner leaves them exactly where the edit put them. The same goes for tasks whose working calendars changed because of a resource or assignment edit. Your model gets the derived end or duration for every task you edited, not only for the ones that moved. These reported tasks accumulate across several apply calls until the next acyclic solve(null) or level. A cyclic solve keeps them pending.

Guarding against stale edits#

A UI usually edits the dates it showed the user. If the project changed in between (an edit from another tab, a leveling run), applying the edit can do the wrong thing. Set baselineId on the change set to the token of the result the user saw. apply then refuses the whole change set with ProjectError code "stale-baseline" when that token isn't current.

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

const project = new Project();
const loaded = project.solve({
  tasks: [{ id: 1, start_date: "2026-08-10 08:00", duration: 2 }],
});
const seen = loaded.baselineId;

// The edit was made against the dates the user saw.
const changes = { baselineId: seen, tasks: { update: [{ id: 1, duration: 3 }] } };
project.apply(changes);

// A second edit still carrying the old token is refused as a whole.
try {
  project.apply(changes);
} catch (error) {
  if (error instanceof ProjectError && error.code === "stale-baseline") {
    console.log("stale: reload first");
  } else {
    throw error;
  }
}

const solved = project.solve(null);
console.log("new baseline differs:", solved.baselineId !== seen);

A token names a baseline only while the project is solved and clean. After an apply, any baselineId is stale until the next solve, so take a fresh token from each result. Tokens are opaque: don't store them or compare them across runs. Leave baselineId out to skip the check.

Rejections and resync#

An operation is rejected when it can't apply: an unknown id in update or remove, a duplicate id in add, null on a required field (a link's source or target, an assignment's task, resource or value), or a record that fails validation after the edit, such as an invalid pause or assignment slice. Each operation is tried on its own copy, so a rejected operation leaves no partial edit behind, and the operations around it still apply.

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

function load() {
  return { tasks: [{ id: "design", start_date: "2026-08-10 08:00", duration: 2 }] };
}

const project = new Project();
project.solve(load());

const report = project.apply({
  tasks: {
    remove: ["deleted-elsewhere"],
    update: [{ id: "design", duration: 4 }],
  },
});
for (const op of report.applied) console.log(`applied ${op.path}`);
for (const op of report.rejected) console.log(`rejected ${op.path}: ${op.message}`);

if (report.needsResync) {
  // solve(null) and apply now throw until the full data is sent again.
  try {
    project.solve(null);
  } catch (error) {
    if (!(error instanceof ProjectError && error.code === "needs-resync")) throw error;
    project.solve(load());
  }
}

The update applies and the removal is rejected with rejected tasks.remove[0]: unknown task id. The project now holds the update but not the removal, which is probably not what your model holds. That's why any rejection blocks further edits: solve(null), apply and level throw ProjectError with code "needs-resync" until a successful solve(data) replaces the whole state with data you know is right.

The resync data must pass validation. If it doesn't, solve(data) throws InputError, and the project still needs a resync. See Recover from rejected changes.

evaluate and suggestAssignments use the same apply on disposable copies. A rejected candidate shows up as its error ({ path, message }) instead of blocking the project. See Evaluating alternatives.

What change sets can't do#

Some edits have no operation. For these, rebuild the data in your model and send it with solve(data):

  • Calendars can't be added, changed or removed. Tasks and resources can switch to another existing calendar through calendar_id.
  • config can't change: duration_unit, use_calendars, calendar_mode, apply_constraints and the other settings are fixed until the next full solve.
  • A task's type and duration_unit aren't in TaskUpdate. An update that carries them is accepted, but the fields are ignored. To turn a task into a summary or a milestone, remove it and add it again (its links and assignments go with the removal), or resync.
  • An id can't be renamed. update finds the record by its id and never changes it.
  • ResourceUpdate changes only capacity and calendar_id.

Calls that don't need a change set also don't use one. level writes its own leveling_delay and pauses edits and accepts the result. See Leveling resources.