Previewing Changes#
This guide shows how to see what an edit would do before you commit it. It covers previewing a drag or a resize, keeping previews in sync with the project's baseline, checking the resource load a change would cause, and trying multi-step edits or a leveling run on a copy of the project.
How previews work#
A solved Project holds one accepted baseline: the dates from its last Solve or Level. Every preview starts from that baseline.
Project.Preview(changes, options) copies the baseline, applies a SchedulingChanges to the copy and schedules it. It drops the copy when the call returns. The project itself doesn't change, so you can call Preview as often as the UI needs, for example on every mouse move during a drag.
You get back a regular ScheduleResult:
Updatesare relative to the baseline. They list the tasks the change would move and the tasks the change itself touched, the same waySolve(null, …)reports them after anApply. An empty change set gives emptyUpdates.Conflicts,CyclesandNotesdescribe the previewed state. A preview can show the cycle a new link would create, and the project never holds that cycle.BaselineIdnames the baseline the preview read.
Preview and Apply differ in two ways. A preview is all or nothing: if any operation in the change set is invalid, the whole call throws InputException for the first rejected operation. A preview also needs a clean, solved baseline. Before the first solve, and after any Apply until the next solve, it throws ProjectException with Code == ProjectError.NoBaseline.
For a change that takes more than one step, or for a Level run, use Project.Clone() instead. A clone is copy-on-write: it costs O(1), and the two copies split only when one of them is edited. Edit, solve or level the clone, and the original stays as it was.
| You want to see | Use |
|---|---|
| The effect of one change set (a drag, a resize, a new link) | Preview |
| The load a change would put on resources | Preview with PreviewOptions.Profile |
| Several edits in a row, each solved | Clone(), then Apply + Solve(null, …) on the copy |
| What leveling would do | Clone(), then Level on the copy |
Previewing an edit#
Build the edit as a SchedulingChanges, the same object you'd pass to Apply, and pass it to Preview.
using Plen;
static Moment Day(int day) =>
Moment.FromYmdHm(2026, 1, day, 0, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
new TaskData("a") { StartDate = Day(1), Duration = 2.0 },
new TaskData("b") { StartDate = Day(1), Duration = 1.0 },
],
Links = [new LinkData { Id = "ab", Source = "a", Target = "b" }],
};
// All time is working time: plain day arithmetic, no weekends.
data.Config.UseCalendars = false;
var project = new Project();
var options = new SolveOptions();
var solved = project.Solve(data, options);
// The user drags task "b" to January 5.
var drag = new SchedulingChanges();
drag.Tasks.Update.Add(new TaskUpdate("b") { StartDate = Day(5) });
var preview = project.Preview(drag, new PreviewOptions());
foreach (var update in preview.Updates)
Console.WriteLine($"task {update.Id}: {update.StartDate} .. {update.EndDate}");
// The project still holds the solved dates: "b" starts on January 3.
Console.WriteLine(ReferenceEquals(solved, project.Solve(null, options))); // True
The solve puts b right after a, on January 3. The preview prints task b: 2026-01-05 00:00 .. 2026-01-06 00:00. The project doesn't change: the next Solve(null, …) returns the cached result of the first solve, the same object.
When the user drops the task, send the same SchedulingChanges to Apply and then call Solve(null, …). See Editing a project.
PreviewOptions has the same diagnostic switches as SolveOptions:
Subtreepreviews only one branch.Reasonsadds aReasonto each update, so you can show why a task would move.Analyticsadds the critical path and slack of the previewed state.
Previews stay sparse: there's no IncludeUnchanged option. The full list is on the PreviewOptions reference.
Guarding with baseline tokens#
A UI that previews in the background can run into a race. Another request applies an edit or reloads the data, and the preview now runs against a baseline the user never saw. Baseline tokens catch this.
Every Solve and Level result carries BaselineId, an opaque token for the accepted baseline. Pass it back in PreviewOptions.BaselineId (or SchedulingChanges.BaselineId). If the token no longer names the current baseline, Preview throws ProjectException with Code == ProjectError.StaleBaseline instead of returning dates for the wrong state.
using Plen;
static Moment Day(int day) =>
Moment.FromYmdHm(2026, 1, day, 0, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks = [new TaskData("a") { StartDate = Day(1), Duration = 2.0 }],
};
data.Config.UseCalendars = false;
var project = new Project();
var options = new SolveOptions();
var loaded = project.Solve(data, options);
// The UI keeps the token of the dates it shows.
var shown = loaded.BaselineId;
var resize = new SchedulingChanges();
resize.Tasks.Update.Add(new TaskUpdate("a") { Duration = 3.0 });
var guarded = new PreviewOptions { BaselineId = shown };
var preview = project.Preview(resize, guarded);
// A preview reports the baseline it read, so its token works for the next preview too.
Console.WriteLine(preview.BaselineId == shown); // True
// Meanwhile, another edit is committed.
project.Apply(resize);
// Until the next solve there's no baseline to preview against.
try
{
project.Preview(resize, new PreviewOptions());
}
catch (ProjectException e) when (e.Code == ProjectError.NoBaseline)
{
Console.WriteLine("no baseline until the next solve");
}
var latest = project.Solve(null, options);
// The old token no longer names the baseline.
try
{
project.Preview(resize, guarded);
}
catch (ProjectException e) when (e.Code == ProjectError.StaleBaseline)
{
// Refresh the view from `latest`, then preview with its token.
project.Preview(resize, new PreviewOptions { BaselineId = latest.BaselineId });
}
A few rules for tokens:
- Take the token from the latest
SolveorLevelresult. A preview result carries the token it read, so a chain of previews during one drag can reuse it. - The token changes whenever the data changes: new data, an
Apply, a solve that moves dates, leveling edits. - Tokens are opaque strings. Don't parse them, store them across runs or compare them between processes. The same data can get a new token on a repeat run.
PreviewchecksPreviewOptions.BaselineIdandSchedulingChanges.BaselineIdseparately. Leave bothnullto skip the check.
For how to handle StaleBaseline and the other project errors, see Error handling.
Preview with load#
Set PreviewOptions.Profile to a ProfileQuery to see the resource load a change would cause. The result then has Profile, the same per-bucket load and capacity that LoadProfile returns, but computed on the previewed dates. Use it to show a resource histogram while the user drags a task.
using Plen;
static Moment Day(int day) =>
Moment.FromYmdHm(2026, 1, day, 0, 0) ?? throw new InvalidOperationException("invalid date");
var data = new ProjectData
{
Tasks =
[
new TaskData("a") { StartDate = Day(1), Duration = 2.0 },
new TaskData("b") { StartDate = Day(3), Duration = 1.0 },
],
Resources = [new ResourceData { Id = "dev", Capacity = 1.0 }],
Assignments =
[
new AssignmentData { Task = "a", Resource = "dev", Value = 1.0 },
new AssignmentData { Task = "b", Resource = "dev", Value = 1.0 },
],
};
data.Config.UseCalendars = false;
var project = new Project();
project.Solve(data, new SolveOptions());
// Drag "b" one day earlier, onto the last day of "a".
var drag = new SchedulingChanges();
drag.Tasks.Update.Add(new TaskUpdate("b") { StartDate = Day(2) });
var options = new PreviewOptions
{
Profile = new ProfileQuery(Day(1), Day(5)) { Tasks = ProfileTasks.Over },
};
var preview = project.Preview(drag, options);
if (preview.Profile is { } profile)
{
foreach (var resource in profile.Resources)
{
foreach (var bucket in resource.Buckets.Where(b => b.Load > b.Capacity))
{
var tasks = string.Join(", ", bucket.Tasks ?? []);
Console.WriteLine(
$"{resource.Resource} overloaded at {bucket.Start}: {bucket.Load} of {bucket.Capacity} ({tasks})");
}
}
}
Both tasks need dev on January 2, so that bucket's load goes over its capacity of 1. With ProfileTasks.Over, each overloaded bucket also lists the tasks that share it.
The query covers buckets that start in [From, To). By default it reports every resource, and the bucket size (Basis) comes from Config.DurationUnit. Narrow it with Resources, or set Basis. The query has no BaselineId of its own: the preview checks PreviewOptions.BaselineId. Every preview also reports Overallocations when the project has assignments, so you can skip the profile if you need only the overloaded windows. See Resource load and the ProfileQuery reference.
Cloning a project#
Preview takes one change set. To see the result of several edits in a row, each one solved before the next, clone the project and work on the copy.
using Plen;
static Moment Day(int day) =>
Moment.FromYmdHm(2026, 1, day, 0, 0) ?? throw new InvalidOperationException("invalid date");
static SchedulingChanges Resize(Id task, double duration)
{
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate(task) { Duration = duration });
return changes;
}
var data = new ProjectData
{
Tasks =
[
new TaskData("a") { StartDate = Day(1), Duration = 2.0 },
new TaskData("b") { StartDate = Day(1), Duration = 1.0 },
],
Links = [new LinkData { Id = "ab", Source = "a", Target = "b" }],
};
data.Config.UseCalendars = false;
var options = new SolveOptions();
var project = new Project();
project.Solve(data, options);
// A cheap copy: it shares state with `project` until one of them is edited.
var draft = project.Clone();
draft.Apply(Resize("a", 4.0));
draft.Solve(null, options);
draft.Apply(Resize("b", 3.0));
var outcome = draft.Solve(null, options);
foreach (var update in outcome.Updates)
Console.WriteLine($"draft task {update.Id}: {update.StartDate} .. {update.EndDate}");
// The original is untouched and still previews against its own baseline.
Console.WriteLine(project.Preview(new SchedulingChanges(), new PreviewOptions()).Updates.Count); // 0
// The user accepts the draft: keep it as the project.
project = draft;
Edits on one copy never affect the other. The copy is a full Project with its own baseline, cache and BaselineId, so you can preview, level, evaluate or keep editing it. To accept the draft, replace the original with it, or replay the same changes on the original with Apply. To discard it, drop the reference. Nothing needs disposing.
Clone before Level too. Level accepts the leveled dates as the project's new baseline, so running it on a copy is how you show the user a leveling proposal before you commit it:
using Plen;
static void ShowLevelingProposal(Project project)
{
var draft = project.Clone();
var leveled = draft.Level(new LevelingOptions());
foreach (var update in leveled.Schedule.Updates)
Console.WriteLine($"task {update.Id} delay {update.LevelingDelay}");
}
The step-by-step version is in Preview leveling. To compare several alternative change sets with scores, use Evaluate instead, which works on disposable copies internally. See Evaluating alternatives.
Clones also help with threads. A Project isn't thread-safe, and even Clone() writes copy-on-write flags on the source. To work from several threads, make one Clone() per thread on a single thread first, or guard a shared project with a lock. See Share a project between threads.