Plain objects and dates#

This guide covers what plen-js accepts as input and what it hands back: plain objects, field names, dates, numbers and ids. Read it before you connect the scheduler to your UI state, a server API or a database.

Plain objects in, plain objects out#

The library has no model classes to build. Every argument is a plain object or an array, and every result is a plain object you can read, change or throw away.

On the way in, the library reads your data the way JSON.stringify does:

  • toJSON() is called, so objects that define it (including Date) work as values.
  • Fields set to undefined, functions and symbols are dropped. Inside arrays, they become null.
  • Number, String and Boolean wrapper objects unwrap to their values.
  • Each getter is read once.
  • Only own enumerable keys count. A Map or a Set arrives as an empty object.
  • Circular structures and BigInt values throw a TypeError, as they do in JSON.stringify.

Your input is never mutated. You can pass objects straight from your UI store; the library copies what it needs.

On the way out, every call returns fresh objects that share nothing with your input or with the project's internal state. Keys come in sorted order. Changing a result can't affect later calls.

Record fields the library doesn't know are ignored, so a task can carry your own text, color or owner fields. Option objects and change sets are stricter: an unknown key there throws an InputError.

Field spelling#

Two naming styles meet in the API, and each has its own place:

Where Style Examples
Project data: tasks, links, calendars, resources, assignments, config snake_case start_date, duration_unit, constraint_type, leveling_delay, calendar_id
Options objects camelCase includeUnchanged, baselineId, criticalPath, withinSlackOnly
Result envelopes and reports camelCase baselineId, needsResync, capacityMode, linkId
Task fields inside results snake_case start_date, end_date, leveling_delay

Data fields are snake_case, and task fields in results use the same names, so an entry of updates works as a TaskUpdate.

A wrong spelling in an options object fails loudly:

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

const project = new Project();
const data = { tasks: [{ id: 1, start_date: "2026-01-05", duration: 2 }] };

try {
  project.solve(data, { include_unchanged: true });
} catch (error) {
  if (error instanceof InputError) {
    console.log(error.kind);    // "parse"
    console.log(error.message); // unknown field `include_unchanged`, expected one of ...
  }
}

A wrong spelling in a task record doesn't throw, because unknown record fields are ignored. A task with startDate instead of start_date looks like a task without a start date. Watch for validation errors that name a field you think you passed.

Assignments accept task_id and resource_id as aliases for task and resource.

Dates in#

Every date field takes a DateValue: a string or a Date. You can mix them in one call.

Strings use the YYYY-MM-DD form, with an optional time of day as HH:MM or HH:MM:SS, separated by a space or a T. A date without a time means midnight. Seconds are dropped.

Date objects are read from their local components: year, month, day, hour and minute in the timezone of the running code. Seconds and milliseconds are dropped. No timezone or UTC offset ever reaches the engine; all scheduling runs on naive local wall-clock time at minute precision.

import { Project } from "plen-js";

const project = new Project();
const result = project.solve(
  {
    tasks: [
      { id: 1, start_date: new Date(2026, 0, 5, 8, 30, 45), duration: 2 },
      { id: 2, start_date: "2026-01-05", duration: 1 },
      { id: 3, start_date: "2026-01-05T09:15", duration: 1 },
    ],
    links: [{ id: 1, source: 1, target: 2 }],
  },
  { includeUnchanged: true },
);

console.log(result.updates.map((u) => [u.id, u.start_date]));
// [ [ 1, '2026-01-05 08:30' ], [ 2, '2026-01-07 08:30' ], [ 3, '2026-01-05 09:15' ] ]

Task 1's Date becomes 2026-01-05 08:30: the 45 seconds are gone. Task 2 starts at midnight in the input and moves after task 1 through the link.

Bad dates throw an InputError with kind: "validation":

  • an unparseable string, such as "05/01/2026" (message "invalid date")
  • an invalid Date, such as new Date("x") (message "Invalid Date")

If your dates come as UTC ISO strings from a server, such as "2026-01-05T08:30:00Z", turn them into Date objects first. The engine has no timezone, so you decide which wall-clock time a task should show.

Dates out#

Result dates are strings in YYYY-MM-DD HH:MM form by default. Pass dates: "date" to the Project constructor to get local Date objects instead:

import { Project } from "plen-js";

const project = new Project({ dates: "date" });
const result = project.solve(
  { tasks: [{ id: 1, start_date: "2026-01-05 08:30", duration: 2 }] },
  { includeUnchanged: true },
);

const start = result.updates[0].start_date;
console.log(start instanceof Date);              // true
console.log(start.getHours(), start.getMinutes()); // 8 30

The Date is built from the local components of the result, so getHours() returns the wall-clock hour the engine computed. The setting covers every date in results:

  • start_date and end_date in updates
  • conflict dates (required, actual, proposed, snapped)
  • overallocation windows (from, to)
  • load profile buckets (start), from loadProfile() and from preview() with profile
  • finish dates in evaluate and suggestAssignments metrics

The option is stored on the project. A clone() keeps it.

Note

Set dates: "date" to get result dates as local Date objects instead of "YYYY-MM-DD HH:MM" strings; schedule() always returns strings.

An invalid value, such as dates: "iso", makes the constructor throw an InputError with path: "dates".

Note

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

Pick the representation by what consumes the dates. Use strings when they go to a Gantt chart that parses "YYYY-MM-DD HH:MM", to a server or to storage. Use Date objects when your UI code does date math or formats dates with Intl.

Numbers and ids#

Every numeric field is a plain JS number: durations, lags, capacities, assignment values, progress, leveling_delay and pause offsets. Nothing uses bigint.

Numbers must be finite. NaN, Infinity or -Infinity anywhere in the input throws an InputError with kind: "validation", message "Numbers must be finite", and the field name in path:

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

try {
  new Project().solve({
    tasks: [{ id: 1, start_date: "2026-01-05", duration: 1, progress: Number.NaN }],
  });
} catch (error) {
  if (error instanceof InputError) {
    console.log(error.message, error.path); // "Numbers must be finite" "progress"
  }
}

The check runs before the data is decoded. That's why path holds just the field name, not the full tasks[0].progress path that other validation errors carry. -0 is read as 0.

Ids (Id) are strings or numbers, and results return each id in the type you passed in. Mixing types for the same entity is a trap: ids compare the way JS object keys do, so 7 and "7" are the same id. Two tasks with these ids fail with duplicate task id "7".

Numeric ids are JS numbers, so integers above Number.MAX_SAFE_INTEGER lose precision before they reach the engine. If your database uses 64-bit ids, pass them as strings.

Storing and sending results#

Results are plain data, so you can keep them, mutate them, structuredClone them or post them to a worker without extra steps.

With the default string dates, JSON.stringify(result) gives text you can store or send as is. Parsing it back gives the same object.

With dates: "date", be careful at the JSON boundary. JSON.stringify turns each Date into a UTC ISO string through Date#toJSON, so a task that starts at 08:30 local time can show up as "2026-01-05T07:30:00.000Z". Code that later reads only the date and time parts sees the wrong hour. For JSON, keep string dates: use a second project with the default dates, or format the Date objects yourself.

import { Project } from "plen-js";

const data = {
  tasks: [
    { id: 1, start_date: "2026-01-05 08:30", duration: 2 },
    { id: 2, start_date: "2026-01-05 08:30", duration: 1 },
  ],
  links: [{ id: 1, source: 1, target: 2 }],
};

async function main() {
  const project = new Project(); // string dates
  const { updates } = project.solve(data);

  await fetch("/api/tasks/batch", {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(updates),
    // [{"end_date":"2026-01-08 08:30","id":2,"kind":"task","start_date":"2026-01-07 08:30"}]
  });
}

main();

Each entry in updates holds only the fields that changed, with the same names and value formats as the task data. You can merge it into your stored task records, or pass it back to the library as a TaskUpdate in a later change set.

Results with Date objects can also go straight back in as input, since every date field accepts a Date. The round trip keeps the wall-clock time.

For the full shape of a result, see ScheduleResult. For the input records, see ProjectData.