Resource Load#

This guide shows how to measure how busy each resource is over a date range. You get the data a resource histogram needs. It covers the profile query, how capacity follows resource calendars, which tasks fill each bucket, and how to get the load for dates you haven't committed yet.

How a load profile works#

loadProfile reads the solved baseline of a JsonProject and cuts the date range into buckets of one hour or one day. For every resource it reports each bucket's load and capacity. The call is read-only: it never schedules and never changes the project.

The request and the response fit together like this:

  • The request is a LoadProfileOptions object. It says what to measure: the range from..to (both required), which resources, the bucket size (basis) and whether to list tasks. It also takes an optional baselineId. Every field except from and to can be left out: you then get every resource, the default basis and no task lists.
  • The response is a LoadProfile in the envelope's result: the basis it used, and one entry per resource with the resource id, its capacityMode and a buckets array. Each bucket is { start, load, capacity }, plus tasks when you ask for them.

A few rules decide what ends up in the buckets:

  • Range. A bucket is included when its start lies in [from, to). A day bucket starts at midnight, so a from of Monday 12:00 skips Monday and starts on Tuesday. to is exclusive.
  • Dense output. Every bucket in the range appears, including empty ones with load 0. That's why the range is capped at 100 000 buckets per resource, about 11 years at hour basis.
  • Basis. By default the basis follows config.duration_unit: "day" for days, "hour" for hours and minutes. Set basis to override it.
  • Load units. Load is in assignment value units, where 1 is 100 %. Nothing is converted to hours. An assignment that works in a bucket books its value there, so two full-time tasks on the same day give a load of 2.
  • Same load as leveling. The profile counts load the same way as leveling and the overallocations in ScheduleResult do: only real work segments, each assignment on its own calendar, cut to its slice. A bucket overallocates when load > capacity.
  • Order. Resources come back in input order, and so do the task ids in a bucket.

The profile always describes the accepted baseline, so the project must be solved and have no unsolved edits. Failures come back as an error envelope:

Failure Error envelope
Project never solved, or edited with apply since the last solve kind: "project", code: "no-baseline"
baselineId doesn't name the current baseline kind: "project", code: "stale-baseline"
to <= from, or more than 100 000 buckets per resource kind: "validation", path: "to"
Unknown id in resources kind: "validation", path: "resources"
tasks isn't "none", "overallocated" or "all" kind: "validation", path: null
Unknown field in the options kind: "parse"

See Error handling for the envelope and the LoadProfile reference for every field.

Querying a profile#

Solve the project, then pass the range to loadProfile as JSON. Save this as profile.cjs:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

// 2026-08-10 is a Monday.
const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "b", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "c", start_date: "2026-08-11 08:00", duration: 1 },
    { id: "d", start_date: "2026-08-12 08:00", duration: 1 },
  ],
  resources: [{ id: "dev" }],
  assignments: [
    { id: "a:dev", task: "a", resource: "dev", value: 1 },
    { id: "b:dev", task: "b", resource: "dev", value: 1 },
    { id: "c:dev", task: "c", resource: "dev", value: 0.5 },
    { id: "d:dev", task: "d", resource: "dev", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // Sunday 9 to Thursday 13; `to` is exclusive.
  const query = { from: "2026-08-09 00:00", to: "2026-08-14 00:00" };
  const profile = unwrap(project.loadProfile(JSON.stringify(query)));

  console.log(`basis: ${profile.basis}`);
  for (const row of profile.resources) {
    for (const bucket of row.buckets) {
      console.log(`${row.resource} ${bucket.start}: load ${bucket.load} of ${bucket.capacity}`);
    }
  }
} finally {
  project.free();
}

Output of node profile.cjs:

basis: day
dev 2026-08-09 00:00: load 0 of 1
dev 2026-08-10 00:00: load 2 of 1
dev 2026-08-11 00:00: load 1.5 of 1
dev 2026-08-12 00:00: load 1 of 1
dev 2026-08-13 00:00: load 0 of 1

The project uses day units, so the basis is "day" and you get five buckets, Sunday to Thursday. Monday has a and b (2), Tuesday has a plus c at half time (1.5), and Wednesday has d (1). Capacity is 1 in every bucket: dev has no capacity of its own, so it takes config.default_resource_capacity.

For a single day, the result in the envelope looks like this:

{
  "basis": "day",
  "resources": [
    {
      "buckets": [{ "capacity": 1, "load": 2, "start": "2026-08-10 00:00" }],
      "capacityMode": "flat",
      "resource": "dev"
    }
  ]
}

Narrowing the query#

Set resources to measure only some resources, and basis to change the bucket size:

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "b", start_date: "2026-08-10 08:00", duration: 1 },
  ],
  resources: [{ id: "dev" }, { id: "qa" }],
  assignments: [
    { task: "a", resource: "dev", value: 1 },
    { task: "b", resource: "dev", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // Monday 07:00 to 10:00, hour buckets, `dev` only.
  const query = {
    from: "2026-08-10 07:00",
    to: "2026-08-10 10:00",
    resources: ["dev"],
    basis: "hour",
  };
  const profile = unwrap(project.loadProfile(JSON.stringify(query)));

  for (const row of profile.resources) {
    console.log(`${row.resource}: ${row.buckets.map((b) => b.load).join(" ")}`);
  }
} finally {
  project.free();
}

Output:

dev: 0 2 2

Only dev comes back, with three hour buckets. 07:00 is before work starts (0), and 08:00 and 09:00 carry both tasks (2 each). An id in resources that isn't in the project fails the call:

{
  "error": { "code": null, "kind": "validation", "message": "unknown resource \"ops\"", "path": "resources" },
  "ok": false
}

Pinning the profile to a baseline#

A UI often fetches the profile some time after the solve. To make sure the profile matches the dates on screen, pass the baselineId from the solve result in the options. If the project was solved again in the meantime, the call returns a stale-baseline error instead of load for other dates.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [{ id: "a", start_date: "2026-08-10 08:00", duration: 2 }],
  resources: [{ id: "dev" }],
  assignments: [{ task: "a", resource: "dev", value: 1 }],
};

const project = new JsonProject("{}");
try {
  const solved = unwrap(project.solve(JSON.stringify(data), "{}"));

  const options = {
    baselineId: solved.baselineId,
    from: "2026-08-10 00:00",
    to: "2026-08-15 00:00",
  };
  const profile = unwrap(project.loadProfile(JSON.stringify(options)));
  console.log(`${profile.resources.length} resource(s) measured`);
} finally {
  project.free();
}

baselineId sits next to from and to in the same object. After another solve, even with the same data, the same options give:

{
  "error": { "code": "stale-baseline", "kind": "project", "message": "stale-baseline", "path": null },
  "ok": false
}

Take the token from the latest solve result. Tokens are opaque, so don't store them or compare them across runs.

Capacity modes#

Capacity is the resource's capacity (default config.default_resource_capacity, 1). The capacityMode of each resource entry tells you how that capacity is spread over the buckets:

  • "calendar": the resource has its own calendar_id, config.calendar_mode is "resource" (the default) and config.use_calendars is on. Buckets with working time on that calendar get the full capacity. All others get 0.
  • "flat": every bucket gets the full capacity, weekends and nights included. This applies when the resource has no calendar, when calendar_mode is "task" or "task-inherited", or when use_calendars is false.

In calendar mode an assignment always works on the resource's calendar, or on the overlap of the task's and the resource's calendars. So a bucket with capacity 0 never gets load, and a bucket overallocates in the profile exactly when leveling sees it as overallocated.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  calendars: [
    { id: "global", worktime: { hours: ["8:00-16:00"] } },
    { id: "tue-sat", worktime: { hours: ["8:00-16:00"], days: [0, 0, 1, 1, 1, 1, 1] } },
    { id: "mornings", worktime: { hours: ["8:00-12:00"] } },
  ],
  tasks: [{ id: "a", start_date: "2026-08-11 08:00", duration: 2 }], // Tuesday
  resources: [
    { id: "office" },
    { id: "shop", calendar_id: "tue-sat" },
    { id: "part-time", calendar_id: "mornings" },
  ],
  assignments: [{ task: "a", resource: "shop", value: 1 }],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // One week in day buckets, Sunday 9 to Saturday 15.
  const week = unwrap(
    project.loadProfile(JSON.stringify({ from: "2026-08-09 00:00", to: "2026-08-16 00:00" })),
  );
  for (const row of week.resources) {
    const capacity = row.buckets.map((b) => b.capacity).join(" ");
    const load = row.buckets.map((b) => b.load).join(" ");
    console.log(`${row.resource} (${row.capacityMode}): capacity ${capacity} | load ${load}`);
  }

  // Tuesday 06:00 to 18:00 in hour buckets, for the part-time resource.
  const day = unwrap(
    project.loadProfile(
      JSON.stringify({
        from: "2026-08-11 06:00",
        to: "2026-08-11 18:00",
        resources: ["part-time"],
        basis: "hour",
      }),
    ),
  );
  console.log(`part-time hourly capacity: ${day.resources[0].buckets.map((b) => b.capacity).join(" ")}`);
} finally {
  project.free();
}

Output:

office (flat): capacity 1 1 1 1 1 1 1 | load 0 0 0 0 0 0 0
shop (calendar): capacity 0 0 1 1 1 1 1 | load 0 0 1 1 0 0 0
part-time (calendar): capacity 0 1 1 1 1 1 0 | load 0 0 0 0 0 0 0
part-time hourly capacity: 0 0 1 1 1 1 0 0 0 0 0 0

What the week shows:

  • office has no calendar, so it's "flat" with capacity 1 on all seven days, Sunday and Saturday included.
  • shop is "calendar". Its capacity is 0 on Sunday and Monday and 1 from Tuesday to Saturday. Task a runs Tuesday and Wednesday, so those two buckets have load 1.
  • At hour basis, part-time has capacity only in the four hour buckets between 08:00 and 12:00. The other eight hours are 0.

Add config: { calendar_mode: "task" } to the data (or use_calendars: false) and all three resources become "flat", with capacity 1 in every bucket. See Working time and calendars for calendar setup and SchedulingConfig for the config fields.

Task lists per bucket#

A histogram usually needs to say who is overloading a resource. Set tasks in the options to fill each bucket's tasks array:

tasks Bucket tasks
"none" (default; null reads the same) absent from every bucket
"overallocated" ids in buckets where load > capacity, [] elsewhere
"all" ids of every task booked in the bucket
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "b", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "c", start_date: "2026-08-11 08:00", duration: 1 },
    { id: "d", start_date: "2026-08-12 08:00", duration: 1 },
  ],
  resources: [{ id: "dev" }],
  assignments: [
    { task: "a", resource: "dev", value: 1 },
    { task: "b", resource: "dev", value: 1 },
    { task: "c", resource: "dev", value: 0.5 },
    { task: "d", resource: "dev", value: 1 },
  ],
};

const project = new JsonProject("{}");
try {
  unwrap(project.solve(JSON.stringify(data), "{}"));

  // Monday 10 to Thursday 13.
  const query = { from: "2026-08-10 00:00", to: "2026-08-14 00:00", tasks: "overallocated" };
  const profile = unwrap(project.loadProfile(JSON.stringify(query)));

  for (const bucket of profile.resources[0].buckets) {
    console.log(`${bucket.start}: load ${bucket.load}, tasks ${JSON.stringify(bucket.tasks)}`);
  }
} finally {
  project.free();
}

Output:

2026-08-10 00:00: load 2, tasks ["a","b"]
2026-08-11 00:00: load 1.5, tasks ["a","c"]
2026-08-12 00:00: load 1, tasks []
2026-08-13 00:00: load 0, tasks []

With "overallocated", Monday lists a and b, and Tuesday lists a and c. Wednesday and Thursday aren't overloaded, so they get empty arrays. Switch to "all" and Wednesday lists d too, because "all" reports every booked task regardless of capacity.

If you need only the overloaded periods, not the full histogram, skip the profile. Every ScheduleResult of a project with assignments carries overallocations: per-resource windows with peak, overload and the contending tasks (see Overallocation). Leveling resources shows how to remove the overload.

Load of hypothetical dates#

loadProfile only reads the baseline. To see the load an edit would cause, add a profile query to the options of preview. The result's profile is computed on the previewed dates, and the project stays as it was.

const { JsonProject } = require("./pkg-node/plen_wasm.js");

function unwrap(text) {
  const response = JSON.parse(text);
  if (!response.ok) {
    const { kind, code, message, path } = response.error;
    throw new Error(`${kind} ${code ?? ""}: ${message} (at ${path})`);
  }
  return response.result;
}

const data = {
  tasks: [
    { id: "a", start_date: "2026-08-10 08:00", duration: 2 },
    { id: "b", start_date: "2026-08-10 08:00", duration: 1 },
    { id: "c", start_date: "2026-08-11 08:00", duration: 1 },
  ],
  resources: [{ id: "dev" }, { id: "qa" }],
  assignments: [
    { id: "a:dev", task: "a", resource: "dev", value: 1 },
    { id: "b:dev", task: "b", resource: "dev", value: 1 },
    { id: "c:dev", task: "c", resource: "dev", value: 0.5 },
  ],
};

const project = new JsonProject("{}");
try {
  const solved = unwrap(project.solve(JSON.stringify(data), "{}"));
  const range = { from: "2026-08-10 00:00", to: "2026-08-12 00:00" };

  // What if `b` moves from `dev` to `qa`?
  const changes = { assignments: { update: [{ id: "b:dev", resource: "qa" }] } };
  const options = { baselineId: solved.baselineId, profile: range };
  const preview = unwrap(project.preview(JSON.stringify(changes), JSON.stringify(options)));

  for (const row of preview.profile.resources) {
    console.log(`previewed ${row.resource}: ${row.buckets.map((b) => b.load).join(" ")}`);
  }

  // The baseline still has both tasks on `dev`.
  const base = unwrap(project.loadProfile(JSON.stringify({ ...range, resources: ["dev"] })));
  console.log(`baseline dev: ${base.resources[0].buckets.map((b) => b.load).join(" ")}`);
} finally {
  project.free();
}

Output:

previewed dev: 1 1.5
previewed qa: 1 0
baseline dev: 2 1.5

In the preview, dev drops to 1 on Monday and keeps 1.5 on Tuesday, and qa gets 1 on Monday. The baseline profile still shows 2 and 1.5 for dev. Without a profile query, the preview result has no profile field.

The profile query takes the same fields as the loadProfile options except baselineId. A baselineId inside profile is an unknown field and fails the preview with a parse error. Put it at the top level of the preview options instead, as the example does. An invalid query, such as an unknown resource, fails the whole preview with a validation error.

To profile a multi-step edit or a leveling run, clone() the project, edit or level the clone, and call loadProfile on it. Cloning is cheap and the original stays as it was. Free the clone when you're done. See Previewing changes for both approaches.