Resources and assignments#

This guide shows how to tell Planning Engine who does the work: resources with a capacity, assignments that book them on tasks, and slices that book only part of a task. It also shows how to read the overallocation windows that every scheduled plan reports.

How resources work#

Three records describe staffing. They all live in ProjectData next to the tasks:

  • A resource (ResourceData) is a person, a team or a machine. It has an id, an optional capacity and an optional calendarId.
  • An assignment (AssignmentData) books one resource on one task at a utilization rate, value. 1.0 means 100 %, so 0.5 is half of someone's time.
  • A slice is an optional offset / duration pair on an assignment. With a slice, the assignment books only part of the task's work instead of all of it.

capacity uses the same units as value. A resource with withCapacity(2.0) can take two full-time assignments at once, like a team of two. A resource whose capacity is null gets config.defaultResourceCapacity, which is 1.0 by default.

To check load, the engine cuts the timeline into buckets of an hour or a day. The basis comes from config.durationUnit: days for TimeUnit.DAY, hours for HOUR and MINUTE. The load of a resource in a bucket is the sum of the values of its assignments whose task has working time in that bucket. A bucket overallocates when its load is strictly greater than the capacity. Two half-time assignments on a capacity of 1.0 are fine. A third one isn't.

value is a rate, not a number of hours. If a task has any working time inside a bucket, the assignment counts its full value there.

Assignments don't move tasks. Task dates come from links, constraints and calendars, and assignments only decide where load lands. Calendars are the one exception. In the default CalendarMode.RESOURCE, each assignment works on its resource's calendar, so a resource calendar can shift a task. See Resource calendars.

When a project has assignments, every schedule fills ScheduleResult.overallocations. It holds one ResourceOverallocation per contended resource, each with a Vec of OverallocationWindows. Overallocation is data, not an exception. Removing it is the job of leveling.

Project#solve(data, …) and Engine.schedule validate resources and assignments when you pass the data. These cases throw an unchecked InputException with the field path in path:

  • a duplicate resource id (resources[i].id)
  • a capacity that isn't finite or is below 0 (resources[i].capacity)
  • a duplicate assignment id (assignments[i].id)
  • an assignment value that isn't finite or is 0 or less (assignments[i].value)
  • an assignment that names an unknown task or resource (assignments[i].task, assignments[i].resource)
  • an invalid slice: offset below 0 or not finite, duration of 0 or less or not finite (assignments[i].offset, assignments[i].duration)

In Validation.REPAIR mode the engine clears an invalid slice and adds a Note.Repaired instead, so the assignment books the whole task.

Resources and capacity#

Give each resource a capacity that matches what it can take. Here one developer and a QA team of two both work on two tasks that run at the same time:

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

public class Capacity {
    static AssignmentData assign(String task, String resource, double value) {
        return new AssignmentData()
            .withId(Id.str(task + ":" + resource))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(value);
    }

    public static void main(String[] args) {
        // Two 2-day tasks, both starting Monday 2026-08-10.
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.str("api")).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.str("ui")).withStartDate(monday).withDuration(2.0)))
            .withResources(List.of(
                // One developer: capacity falls back to
                // config.defaultResourceCapacity (1.0).
                new ResourceData().withId(Id.str("dev")),
                // A QA team of two people.
                new ResourceData().withId(Id.str("qa")).withCapacity(2.0)))
            .withAssignments(List.of(
                assign("api", "dev", 1.0),
                assign("ui", "dev", 1.0),
                assign("api", "qa", 1.0),
                assign("ui", "qa", 1.0)));

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        for (ResourceOverallocation resource : result.overallocations) {
            for (OverallocationWindow window : resource.windows) {
                System.out.println(resource.resource + ": " + window.from + " -> " + window.to
                    + ", peak " + window.peak + " of " + window.capacity);
            }
        }
    }
}

Output:

dev: 2026-08-10 08:00 -> 2026-08-11 17:00, peak 2.0 of 1.0

Both resources carry a load of 2.0 on Monday and Tuesday. Only dev is overallocated, because its capacity is the default 1.0. qa sits exactly at its capacity, and that isn't overallocation.

To change the default for every resource without a capacity, set config.defaultResourceCapacity. With 8.0, for example, a value of 4.0 is half of a resource. A resource with its own capacity ignores the default.

In a live Project, change capacity with a ResourceUpdate in SchedulingChanges.resources.update. withCapacity(Patch.nullValue()) goes back to the default. Removing a resource also removes all its assignments, and ApplyReport.effects.removedAssignments lists them.

Assignments#

An assignment links a task and a resource by id. One task can have several assignments, and one resource can work on several tasks. The example below books Ana full-time on two parallel tasks and the lead half-time on each, then moves the UI work to Ben:

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

public class Reassign {
    static AssignmentData assign(String task, String resource, double value) {
        return new AssignmentData()
            .withId(Id.str(task + ":" + resource))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(value);
    }

    static void report(String label, ScheduleResult result) {
        List<String> busy = new ArrayList<>();
        for (ResourceOverallocation resource : result.overallocations) {
            busy.add(resource.resource.toString());
        }
        System.out.println(label + ": overallocated " + busy);
    }

    public static void main(String[] args) {
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.str("api")).withStartDate(monday).withDuration(2.0),
                new TaskData(Id.str("ui")).withStartDate(monday).withDuration(2.0)))
            .withResources(List.of(
                new ResourceData().withId(Id.str("ana")),
                new ResourceData().withId(Id.str("ben")),
                new ResourceData().withId(Id.str("lead"))))
            .withAssignments(List.of(
                assign("api", "ana", 1.0),
                assign("ui", "ana", 1.0),
                // The lead spends half of their time on each task.
                assign("api", "lead", 0.5),
                assign("ui", "lead", 0.5)));

        Project project = new Project();
        report("initial", project.solve(data, new SolveOptions()));

        // Move the UI work from Ana to Ben.
        SchedulingChanges changes = new SchedulingChanges();
        changes.assignments.update.add(
            new AssignmentUpdate(Id.str("ui:ana")).withResource(Patch.of(Id.str("ben"))));
        ApplyReport applied = project.apply(changes);
        if (!applied.rejected.isEmpty()) {
            RejectedOperation rejected = applied.rejected.get(0);
            throw new IllegalStateException(rejected.path + ": " + rejected.message);
        }

        report("after reassign", project.solve(null, new SolveOptions()));
    }
}

Output:

initial: overallocated [ana]
after reassign: overallocated []

The lead's two half-time assignments add up to 1.0, so only Ana shows up at first. The AssignmentUpdate changes the resource of ui:ana. Here the reassignment doesn't change the task dates. Dates change only when the task ends up on a different calendar.

Other assignment edits go through the same SchedulingChanges:

  • assignments.add takes an AssignmentData. With a null id the engine generates one and reports it in ApplyReport.effects.generatedAssignments.
  • assignments.update can change task, resource, value, offset and duration, each as a Patch.
  • assignments.remove takes assignment ids. Removing a task removes its assignments too.

An assignment on an unscheduled task isn't deleted, but it leaves the active plan. It books no load, and the result carries a Note.DroppedAssignment. When the task is scheduled again, the assignment comes back with its slice. A milestone has no span, so its assignments book nothing.

Assignment slices#

By default an assignment books the whole task. Set offset and duration to book only part of it, for example for a reviewer who joins on the last day. Both are boxed Double values of worked time in the task's durationUnit, measured on the assignment's calendar:

  • offset: where the slice starts, counted from the task start. null means 0.
  • duration: how much work the slice books. null means up to the task end.

The example runs the same project twice. The lead reviews the 4-day build task, first for all of it, then only on its last working day:

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

public class Slices {
    static ResourceData resource(String id) {
        return new ResourceData().withId(Id.str(id));
    }

    static AssignmentData assign(String task, String resource) {
        return new AssignmentData()
            .withId(Id.str(task + ":" + resource))
            .withTask(Id.str(task))
            .withResource(Id.str(resource))
            .withValue(1.0);
    }

    static ProjectData project(Double offset, Double duration) {
        Moment monday = Moment.fromYmdHm(2026, 8, 10, 8, 0);
        return new ProjectData()
            .withTasks(List.of(
                // `build`: 4 working days, Monday to Thursday.
                new TaskData(Id.str("build")).withStartDate(monday).withDuration(4.0),
                // `plan`: Monday only.
                new TaskData(Id.str("plan")).withStartDate(monday).withDuration(1.0)))
            .withResources(List.of(resource("dev"), resource("lead")))
            .withAssignments(List.of(
                assign("build", "dev"),
                // The lead reviews `build`: the whole task, or only a slice of it.
                assign("build", "lead").withOffset(offset).withDuration(duration),
                assign("plan", "lead")));
    }

    public static void main(String[] args) {
        SolveOptions options = new SolveOptions().withIncludeUnchanged(true);
        // Whole task, then the last working day only (offset 3 days, 1 day long).
        Double[][] slices = {{null, null}, {3.0, 1.0}};
        for (Double[] slice : slices) {
            ScheduleResult result = Engine.schedule(project(slice[0], slice[1]), options);
            System.out.println("offset " + slice[0] + ", duration " + slice[1]);
            for (Mutation update : result.updates) {
                System.out.println("  " + update.id + ": " + update.startDate + " -> " + update.endDate);
            }
            for (ResourceOverallocation resource : result.overallocations) {
                for (OverallocationWindow window : resource.windows) {
                    System.out.println("  " + resource.resource + " overallocated: "
                        + window.from + " -> " + window.to);
                }
            }
        }
    }
}

Output:

offset null, duration null
  build: 2026-08-10 08:00 -> 2026-08-14 08:00
  plan: 2026-08-10 08:00 -> 2026-08-11 08:00
  lead overallocated: 2026-08-10 08:00 -> 2026-08-10 17:00
offset 3.0, duration 1.0
  build: 2026-08-10 08:00 -> 2026-08-14 08:00
  plan: 2026-08-10 08:00 -> 2026-08-11 08:00

Without a slice, the lead is booked on build and plan on Monday. With offset 3.0 and duration 1.0, the review books only Thursday, so the conflict goes away. The task dates are identical in both runs: slices change load, never dates.

How slices behave:

  • The slice is clipped to the task's work. withOffset(3.0).withDuration(5.0) on a 4-day task books only the last day, and an offset at or past the task's duration books nothing.
  • Clipping happens on every schedule, not on import. If the task gets shorter later, the slice follows it.
  • Non-working time and leveling pauses don't count as worked time. A pause or a levelingDelay moves the slice together with the work.
  • Two assignments of the same resource on the same task book separately, each at its own value. Where their slices overlap, the load adds up.
  • In CalendarMode.RESOURCE a slice is measured on the assignment's calendar. A sliced assignment still counts toward the task's calendar envelope, even when its slice is empty.
  • Every load consumer sees the sliced load: overallocations, loadProfile, the overload metric of evaluate and the decisions of level.

In a live project, edit a slice with AssignmentUpdate.offset and AssignmentUpdate.duration. Patch.nullValue() clears a field: offset goes back to the task start and duration to the task end. Slice edits never change task dates, but they still produce a new baseline id. An invalid value doesn't throw. It comes back as a rejected operation in ApplyReport.rejected, with a path such as assignments.update[0].

SchedulingChanges changes = new SchedulingChanges();
changes.assignments.update.add(
    new AssignmentUpdate(Id.str("build:lead"))
        .withOffset(Patch.nullValue())
        .withDuration(Patch.nullValue()));
project.apply(changes);
project.solve(null, new SolveOptions());

For a step-by-step version, see Split assignment work.

Planning Engine has no assignment mode. Every resource is checked for overallocation, and a mode field in JSON input is ignored. Express uneven work with slices.

Overallocation windows#

A window is a run of contiguous overallocated buckets of one resource. Each OverallocationWindow has these fields:

Field Meaning
from, to The window edges as Moments, clipped to the contending tasks' working time (and to their slices)
capacity The resource's capacity
peak The highest bucket load in the window
overload The sum of load − capacity over the window's buckets, in capacity × buckets
tasks The contending task ids, in input order
reason Set only by level: why leveling left this window. See Leveling resources

Non-working time splits windows. Here two 2-day tasks start on a Friday and continue on Monday:

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

public class Windows {
    static AssignmentData assign(String task) {
        // No id: the engine generates one on import.
        return new AssignmentData()
            .withTask(Id.str(task))
            .withResource(Id.str("dev"))
            .withValue(1.0);
    }

    public static void main(String[] args) {
        // Both tasks start on Friday 2026-08-14 and run 2 working days.
        Moment friday = Moment.fromYmdHm(2026, 8, 14, 8, 0);
        ProjectData data = new ProjectData()
            .withTasks(List.of(
                new TaskData(Id.str("api")).withStartDate(friday).withDuration(2.0),
                new TaskData(Id.str("ui")).withStartDate(friday).withDuration(2.0)))
            .withResources(List.of(new ResourceData().withId(Id.str("dev"))))
            .withAssignments(List.of(assign("api"), assign("ui")));

        ScheduleResult result = Engine.schedule(data, new SolveOptions());
        for (ResourceOverallocation resource : result.overallocations) {
            for (OverallocationWindow w : resource.windows) {
                System.out.println(resource.resource + ": " + w.from + " -> " + w.to
                    + " peak=" + w.peak + " capacity=" + w.capacity
                    + " overload=" + w.overload + " tasks=" + w.tasks);
            }
        }
    }
}

Output:

dev: 2026-08-14 08:00 -> 2026-08-14 17:00 peak=2.0 capacity=1.0 overload=1.0 tasks=[api, ui]
dev: 2026-08-17 08:00 -> 2026-08-17 17:00 peak=2.0 capacity=1.0 overload=1.0 tasks=[api, ui]

The weekend holds no work, so Friday and Monday become two windows. Each one has an overload of 1.0: one day at one unit over capacity. On an hour basis the lunch break splits windows the same way.

What else windows tell you, and what they don't:

  • overallocations is null when the project has no assignments, and an empty Vec when there's no contention. Check for null before you loop if a project can come without assignments. A resource appears only when it has at least one window, and resources keep their input order.
  • Windows follow the scheduled dates, not the stored ones. If a link pushes ui after api, the contention is gone and so is the window.
  • Detection works in whole buckets, but from and to are clipped to working time. Two 2-day tasks that start Monday end Wednesday 08:00, yet their window ends Tuesday 17:00, because Wednesday before 08:00 holds no working time.
  • Per resource, the windows' overload values add up to the overload that evaluate reports for that resource at the same basis.

For a per-bucket view of load and capacity (a resource histogram), use loadProfile. See Resource load.