Records, ids and dates#
This guide covers the types you build input from and read results with: records, ids, dates, patch fields, unions and enums. Read it before you map your own models, database rows or API payloads to Plen.Core.
How the data model works#
Everything you pass in and get back is a plain .NET object in the Plen namespace. Nothing is IDisposable, nothing does I/O, and the engine keeps its own normalized copy of whatever you hand it.
There are five kinds of types:
- Records are mutable
sealed classes with{ get; set; }properties:ProjectData,TaskData,LinkData, options classes, change sets and results. Fields the engine can't do without are C#requiredmembers, so the compiler stops you from leaving them out. Every other field has a default, and a record with only its required fields set is a valid record. - Ids (
Id) are a number or a string. They compare like JavaScript object keys, so1and"1"are the same id. - Dates (
Moment) are naive local date-times at minute precision. There is no timezone, no DST and no "now": aMomentis a wall-clock reading, stored as minutes since 1970-01-01 00:00. - Patch fields (
Patch<T>) appear only in update records. Each one is in one of three states: leave the field alone, clear it, or set it. - Unions (
Reason,Conflict,Note,DayRule) are abstract records with one nested sealed record per case. You read them with pattern matching.
Durations, lags, capacities and assignment values are double. Durations and lags count working time in a TimeUnit; LevelingDelay and pauses count elapsed time.
Data flows in one direction. Solve(data, …) deep-copies the ProjectData you pass, so later changes to your object don't reach the project. Results flow the other way, but they aren't copies: the project keeps its last result as its baseline and can hand the same object out again. Treat results as read-only, and call DeepClone() on one before you change it.
Working with the types#
Building records#
Create records with object initializers. Records that have an id also have a constructor that takes it, such as new TaskData(id), which fills every other field with its default:
using Plen;
var start = Moment.FromYmdHm(2026, 8, 10, 8, 0)!.Value;
var data = new ProjectData
{
Tasks =
[
new TaskData("design") { StartDate = start, Duration = 2 },
new TaskData("build") { StartDate = start, Duration = 3 },
],
Links = [new LinkData { Id = "l1", Source = "design", Target = "build" }],
Resources = [new ResourceData { Id = "dev" }],
Assignments = [new AssignmentData { Task = "build", Resource = "dev", Value = 1 }],
};
var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
Console.WriteLine($"{update.Id}: {update.StartDate} - {update.EndDate}");
// build: 2026-08-12 08:00 - 2026-08-17 08:00
Only build is listed: the link moved it, and results report only the tasks that changed.
LinkData needs Id, Source and Target. AssignmentData needs Task, Resource and Value, and its Id is optional. Leave out a required member and the code doesn't compile.
Every list property starts empty and every nested record starts with its defaults. You can call data.Tasks.Add(...) or set data.Config.DurationUnit without creating anything first. Default values for each field are on the reference pages, for example TaskData and SchedulingConfig.
The compiler checks only the shape. The engine checks whether the data makes sense when you schedule it: a leaf task needs two of start, end and duration, and ids must be unique. Problems throw an InputException with a path such as tasks[1].duration. Paths use the JSON field names, so StartDate shows up as start_date.
Every public method throws ArgumentNullException when you pass null for an argument that isn't declared nullable.
Ids#
string, int and double convert to Id implicitly, so you can write "a" or 7 wherever an Id is expected. To be explicit, use Id.Str(...) or Id.Num(...):
using Plen;
Id a = 7;
Id b = "7";
Console.WriteLine(a == b); // True
Console.WriteLine(a.IsNum); // True
Console.WriteLine(b.StrValue); // 7
Console.WriteLine(Id.Num(1.5)); // 1.5
Console.WriteLine(Id.Num(1e21).Key()); // 1e+21
Equality and hashing follow Key(), the text JavaScript would use for the id as an object key. Ids work as Dictionary<Id, T> keys, and a task stored as 7 is found by the lookup "7". As a result, 7 and "7" in one project are a duplicate id, not two tasks. 0 and -0 both have the key "0".
To get the original value back, check IsNum or IsStr and read NumValue (double?) or StrValue (string?). ToString() returns the key.
Numeric ids are double, so integers above 2^53 lose precision. If your database uses 64-bit keys, pass them as strings: Id.Str(row.Id.ToString()). Don't use infinite numbers as ids. Key() throws InvalidOperationException for them.
SchedulingConfig.RootId (default 0) names the virtual root. A task whose Parent equals it, by key, is a root task. So a Parent of "0" counts as no parent too. Set RootId if your data uses another root id.
Moments#
A Moment is a readonly record struct that wraps one long, Minutes. You can create one from a date and time, from a DateTime or from raw minutes:
using Plen;
Moment? maybe = Moment.FromYmdHm(2026, 2, 29, 9, 0);
Console.WriteLine(maybe is null); // True: 2026 isn't a leap year
var start = Moment.FromYmdHm(2026, 8, 11, 8, 30)!.Value;
Console.WriteLine(start); // 2026-08-11 08:30
Console.WriteLine(start.Minutes); // 29773950
var halfHourLater = new Moment(start.Minutes + 30);
Console.WriteLine(halfHourLater > start); // True
DateTime forUi = start.ToDateTime(); // DateTimeKind.Unspecified
Moment back = Moment.FromDateTime(forUi);
Console.WriteLine(back == start); // True
FromYmdHm returns null for an invalid date or time, such as hour 24, minute 60 or February 29 in a common year. The examples on this page write !.Value for dates they know are valid. With input you don't control, check for null instead. FromDateTime drops seconds and fractions. Moments compare with <, >, <=, >= and CompareTo, and ToString() gives the YYYY-MM-DD HH:MM form.
Adding minutes to Minutes moves a moment in wall-clock time. It doesn't respect calendars, so leave date math in working time to the scheduler.
FromDateTime reads the date and time fields as they are and ignores DateTime.Kind. A UTC value gets scheduled as if it were local wall-clock time. Convert it to the zone the plan is in first:
using Plen;
var utc = new DateTime(2026, 8, 10, 6, 30, 0, DateTimeKind.Utc);
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
var local = TimeZoneInfo.ConvertTimeFromUtc(utc, berlin);
Console.WriteLine(Moment.FromDateTime(local)); // 2026-08-10 08:30
ToDateTime() throws ArgumentOutOfRangeException for a moment outside years 1-9999, which System.DateTime can't hold. For such dates, read Minutes directly.
Moment is a value type, so an unset Moment field is 1970-01-01 00:00, not "no date". Optional dates use Moment? instead, as TaskData.StartDate does, and there null means "not given".
Calendar date exceptions in Worktime.Dates are keyed by Moment, but only the date counts. Use midnight keys: a second key on the same day replaces the first without an error.
using Plen;
var calendar = new CalendarData
{
Id = "office",
Worktime = new Worktime
{
Dates =
{
[Moment.FromYmdHm(2026, 12, 24, 0, 0)!.Value] = new DayRule.Off(),
[Moment.FromYmdHm(2026, 12, 31, 0, 0)!.Value] = new DayRule.Hours([new TimeRange(480, 720)]),
},
},
};
TimeRange holds minutes since midnight, so 480-720 is 08:00-12:00. More on calendars in Working-time calendars.
Patch fields#
Update records (TaskUpdate, LinkUpdate, ResourceUpdate, AssignmentUpdate) have a Patch<T> for every field except Id. A patch is in one of three states:
| State | How to write it | Effect |
|---|---|---|
| Missing | leave the field out (default) |
the field stays as it is |
| Null | Patch<T>.Null |
clears an optional field, or restores a scalar field's default |
| Value | Patch<T>.Of(value), or assign a T |
sets the field |
A new update record changes nothing until you set fields:
using Plen;
var update = new TaskUpdate("build")
{
StartDate = Moment.FromYmdHm(2026, 8, 17, 8, 0)!.Value, // set
Duration = 4, // set
ConstraintType = Patch<ConstraintType>.Null, // clear
Parent = Id.Str("phase-2"), // set
};
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(update);
T converts to Patch<T> implicitly, but C# applies only one user-defined conversion at a time. A string doesn't become a Patch<Id>, so write Id.Str("phase-2") or (Id)"phase-2". A Moment? doesn't become a Patch<Moment> either: unwrap it with .Value, or pick the state yourself.
Pauses is a Patch<List<Pause>>. A value replaces the whole list and Null clears it. Clearing a field the record can't exist without, such as a link's Source, is rejected. Apply reports it in ApplyReport.Rejected instead of throwing.
To read a patch, switch on Kind, or use TryGetValue:
using Plen;
static string Describe(Patch<double> p) => p.Kind switch
{
PatchKind.Missing => "unchanged",
PatchKind.Null => "reset",
_ => $"set to {p.Value}",
};
Console.WriteLine(Describe(default)); // unchanged
Console.WriteLine(Describe(Patch<double>.Null)); // reset
Console.WriteLine(Describe(2.5)); // set to 2.5
Value throws InvalidOperationException on a patch that isn't in the Value state. Check HasValue first, or use TryGetValue(out var v).
A Mutation in a result lists only the fields that changed, and they map one-to-one onto a TaskUpdate. A changed field becomes a value, and a field that is null stays missing:
using Plen;
static TaskUpdate ToUpdate(Mutation m) => new(m.Id)
{
StartDate = m.StartDate is { } start ? start : Patch<Moment>.Missing,
EndDate = m.EndDate is { } end ? end : Patch<Moment>.Missing,
Duration = m.Duration is { } duration ? duration : Patch<double>.Missing,
LevelingDelay = m.LevelingDelay is { } delay ? delay : Patch<double>.Missing,
Pauses = m.Pauses is { } pauses ? pauses.ConvertAll(p => p.DeepClone()) : Patch<List<Pause>>.Missing,
};
The pauses are deep-cloned because the result is shared with the project (see Copies and shared results). The full edit workflow is in Editing a project.
Unions and pattern matching#
Reason, Conflict, Note and DayRule each have a private constructor, so the nested records are the only cases. Match them with switch. The variants are positional records, so you can deconstruct them in the pattern:
using Plen;
var result = Engine.Schedule(data, new SolveOptions { Reasons = true });
foreach (var conflict in result.Conflicts)
{
string text = conflict switch
{
Conflict.ConstraintViolation v =>
$"{v.Task}: {v.Constraint} wants {v.Required}, planned {v.Actual}",
Conflict.CalendarNonWorking(var task, var proposed, var snapped) =>
$"{task}: {proposed} is non-working, moved to {snapped}",
_ => conflict.ToString(),
};
Console.WriteLine(text);
}
foreach (var update in result.Updates)
{
string why = update.Reason switch
{
Reason.Asap(var link, var source) => $"pushed by {source} through link {link}",
Reason.Alap(var link, var target) => $"pulled by {target} through link {link}",
Reason.SummaryTranslation(var summary) => $"moved with summary {summary}",
Reason.Constraint => "placed by its constraint",
Reason.Preferred => "kept at its preferred date",
_ => "no reason requested",
};
Console.WriteLine($"{update.Id}: {why}");
}
Here data is a ProjectData like the one in Building records. The compiler can't tell that a union is closed, so keep a discard arm _ to avoid the non-exhaustive switch warning. Reason is null unless you set Reasons = true.
Note works the same way. Note.Repaired(var path, var message) matches what Validation.Repair fixed, and Note.DroppedLink, DroppedTask and DroppedAssignment carry the id and a reason string. To build a DayRule, create a case directly: new DayRule.Off(), new DayRule.On() or new DayRule.Hours(ranges).
Reason, Conflict and Note variants compare by value, and the ids inside them compare by key. Two CalendarNonWorking conflicts for task 1 and task "1" with the same dates are equal.
Enums#
Option values are plain C# enums. The Default column shows what a new record or config uses when you don't set the field:
| Enum | Values | Default |
|---|---|---|
TimeUnit |
Minute, Hour, Day |
Day (SchedulingConfig.DurationUnit) |
TaskType |
Task, Summary, Milestone |
Task |
LinkType |
Fs, Ss, Ff, Sf |
Fs |
ConstraintType |
Asap, Alap, Snet, Snlt, Fnet, Fnlt, Mso, Mfo |
none (TaskData.ConstraintType is null) |
CalendarMode |
Resource, Task, TaskInherited |
Resource |
GapBehavior |
Preserve, Compress |
Preserve |
Validation |
Strict, Repair |
Strict |
TimeUnit has no week, month or year. Express longer durations in days.
The constraint types other than Asap and Alap are dated, so a task that uses one also needs a ConstraintDate. They apply only when SchedulingConfig.ApplyConstraints is true.
The enums have no parse or spelling helpers. ToString() gives the C# name ("Fs", "TaskInherited"), not the JSON spelling ("fs", "task-inherited"). The Plen.Wire package handles JSON spellings. If you map your own strings, write the mapping yourself:
using Plen;
static LinkType ParseGanttLinkType(string type) => type switch
{
"0" => LinkType.Fs,
"1" => LinkType.Ss,
"2" => LinkType.Ff,
"3" => LinkType.Sf,
_ => throw new ArgumentException($"unknown link type {type}"),
};
Copies and shared results#
Every record implements IDeepCloneable<T>. DeepClone() returns a copy that shares no list or nested record with the original, so you can edit either side safely. The examples below use the data from Building records:
using Plen;
var variant = data.DeepClone();
variant.Tasks[0].Duration = 5;
variant.Tasks.Add(new TaskData("test") { StartDate = data.Tasks[0].StartDate, Duration = 1 });
Console.WriteLine(data.Tasks[0].Duration); // 2
Console.WriteLine(data.Tasks.Count); // 2
You don't need to clone before solving: Solve(data, …) already copies the data, and the project never holds your object.
Results are the opposite case. Solve(null, …) with no pending edits and equal options returns the cached result, the same instance. Clones of the project share it too, and Evaluate and SuggestAssignments read it as the baseline. Changing it in place would change what the project sees, so clone it first:
using Plen;
var project = new Project();
var first = project.Solve(data, new SolveOptions());
var again = project.Solve(null, new SolveOptions());
Console.WriteLine(ReferenceEquals(first, again)); // True
var mine = first.DeepClone();
mine.Updates.RemoveAll(u => u.Kind == MutationKind.Summary); // safe: a private copy
LevelingResult.DeepClone() copies the leveling result but still shares its Schedule. Call DeepClone() on Schedule too if you want to edit it.
Equality depends on the type. Options and results (SolveOptions, ScheduleResult, Mutation, Pause, ApplyReport and others) implement IEquatable<T> with value equality. They compare lists element by element and doubles with ==, so NaN never equals itself. Input records and change sets (ProjectData, TaskData, LinkData, TaskUpdate, SchedulingChanges) compare by reference. Compare their fields yourself if you need to detect changes.
ToString() on any record prints a debug dump of its fields. It's meant for logs, not for parsing.