Previewing Changes#

This guide covers how to see what an edit would do before you commit it: 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. A preview always starts from that baseline.

Project::preview(&changes, &options) copies the baseline, applies a SchedulingChanges value to the copy and schedules it. The copy is dropped when the call returns. The project stays as it was, so preview takes &self, and you can call it as often as the UI needs, for example on every mouse move during a drag.

What you get back is a regular ScheduleResult:

  • updates are relative to the baseline. They list the tasks the change would move and the tasks the change itself touched, like the solve(None) after an apply. An empty change set gives empty updates.
  • conflicts, cycles and notes describe the previewed state. A preview can show a cycle that a new link would create, without the project ever holding it.
  • baseline_id names the baseline the preview read.

A preview differs from apply in two places. It isn't partial: if any operation in the change set is invalid, the whole call fails with Error::Input for the first rejected operation. And it needs a clean, solved baseline. Before the first solve, and after any apply until the next solve, it fails with ProjectError::NoBaseline.

For a change that takes more than one step, or for a level run, there's a second tool: Project::clone. Its data lives in shared Arcs, so a clone costs O(1), and the copies split only when one of them is edited. Edit, solve or level the clone. 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(None) on the copy
What leveling would do clone, then level on the copy

Previewing an edit#

Build the edit as a SchedulingChanges, the same value you'd pass to apply, and pass it to preview.

use plen_core::{
    DateTime, LinkData, LinkType, Patch, PreviewOptions, Project, ProjectData, SchedulingChanges,
    SolveOptions, TaskData, TaskUpdate,
};

fn day(d: u32) -> Result<DateTime, Box<dyn std::error::Error>> {
    Ok(DateTime::from_ymd_hm(2026, 1, d, 0, 0).ok_or("invalid date")?)
}

fn task(id: &str, start: DateTime, duration: f64) -> TaskData {
    let mut task = TaskData::new(id);
    task.start_date = Some(start);
    task.duration = Some(duration);
    task
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut data = ProjectData {
        tasks: vec![task("a", day(1)?, 2.0), task("b", day(1)?, 1.0)],
        links: vec![LinkData {
            id: "ab".into(),
            source: "a".into(),
            target: "b".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..Default::default()
    };
    // All time is working time: plain day arithmetic, no weekends.
    data.config.use_calendars = false;

    let mut project = Project::default();
    project.solve(Some(data), &SolveOptions::default())?;

    // The user drags task "b" to January 5.
    let mut drag = SchedulingChanges::default();
    let mut moved = TaskUpdate::new("b");
    moved.start_date = Patch::Value(day(5)?);
    drag.tasks.update.push(moved);

    let preview = project.preview(&drag, &PreviewOptions::default())?;
    for update in &preview.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("task {}: {start} .. {end}", update.id);
        }
    }

    // The project still holds the solved dates: "b" starts on January 3.
    let jan5 = Some(day(5)?);
    let current = project.solve(None, &SolveOptions::default())?;
    assert!(current.updates.iter().all(|u| u.start_date != jan5));
    Ok(())
}

The solve puts b right after a, on January 3. The preview reports b at 2026-01-05 00:00 .. 2026-01-06 00:00. The project doesn't change: the next solve(None) returns the cached result of the first solve.

When the user drops the task, send the same SchedulingChanges to apply and then solve(None). See Data Updates.

PreviewOptions takes the same diagnostic switches as SolveOptions:

  • subtree previews only one branch.
  • reasons adds a Reason to each update, which tells you why a task would move.
  • analytics adds the critical path and slack of the previewed state.

Previews stay sparse: there's no include_unchanged 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 baseline_id, an opaque token for the accepted baseline. Pass it back in PreviewOptions.baseline_id (or SchedulingChanges.baseline_id). If the token no longer names the current baseline, preview fails with ProjectError::StaleBaseline instead of returning dates for the wrong state.

use plen_core::{
    DateTime, Error, Patch, PreviewOptions, Project, ProjectData, ProjectError, SchedulingChanges,
    SolveOptions, TaskData, TaskUpdate,
};

fn day(d: u32) -> Result<DateTime, Box<dyn std::error::Error>> {
    Ok(DateTime::from_ymd_hm(2026, 1, d, 0, 0).ok_or("invalid date")?)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut a = TaskData::new("a");
    a.start_date = Some(day(1)?);
    a.duration = Some(2.0);
    let mut data = ProjectData {
        tasks: vec![a],
        ..Default::default()
    };
    data.config.use_calendars = false;

    let mut project = Project::default();
    let options = SolveOptions::default();
    let loaded = project.solve(Some(data), &options)?;

    // The UI keeps the token of the dates it shows.
    let shown = loaded.baseline_id.clone();

    let mut resize = SchedulingChanges::default();
    let mut longer = TaskUpdate::new("a");
    longer.duration = Patch::Value(3.0);
    resize.tasks.update.push(longer);

    let guarded = PreviewOptions {
        baseline_id: shown.clone(),
        ..Default::default()
    };
    let preview = project.preview(&resize, &guarded)?;
    // A preview reports the baseline it read, so its token works for the next preview too.
    assert_eq!(preview.baseline_id, shown);

    // Meanwhile, another edit is committed.
    project.apply(&resize)?;

    // Until the next solve there is no baseline to preview against.
    assert!(matches!(
        project.preview(&resize, &PreviewOptions::default()),
        Err(Error::Project(ProjectError::NoBaseline))
    ));

    let latest = project.solve(None, &options)?;

    // The old token no longer names the baseline.
    match project.preview(&resize, &guarded) {
        Err(Error::Project(ProjectError::StaleBaseline)) => {
            // Refresh the view from `latest`, then preview with its token.
            let fresh = PreviewOptions {
                baseline_id: latest.baseline_id.clone(),
                ..Default::default()
            };
            project.preview(&resize, &fresh)?;
        }
        other => {
            other?;
        }
    }
    Ok(())
}

A few rules for tokens:

  • Take the token from the latest solve or level result. A preview result carries the same 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. 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.
  • preview checks PreviewOptions.baseline_id and SchedulingChanges.baseline_id separately. Leave both as None to 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 load_profile returns, but computed on the previewed dates. Use it to show a resource histogram while the user drags a task.

use plen_core::{
    AssignmentData, DateTime, Patch, PreviewOptions, ProfileQuery, ProfileTasks, Project,
    ProjectData, ResourceData, SchedulingChanges, SolveOptions, TaskData, TaskUpdate,
};

fn day(d: u32) -> Result<DateTime, Box<dyn std::error::Error>> {
    Ok(DateTime::from_ymd_hm(2026, 1, d, 0, 0).ok_or("invalid date")?)
}

fn task(id: &str, start: DateTime, duration: f64) -> TaskData {
    let mut task = TaskData::new(id);
    task.start_date = Some(start);
    task.duration = Some(duration);
    task
}

fn assign(task: &str) -> AssignmentData {
    AssignmentData {
        id: None,
        task: task.into(),
        resource: "dev".into(),
        value: 1.0,
        offset: None,
        duration: None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut data = ProjectData {
        tasks: vec![task("a", day(1)?, 2.0), task("b", day(3)?, 1.0)],
        resources: vec![ResourceData {
            id: "dev".into(),
            capacity: Some(1.0),
            calendar_id: None,
        }],
        assignments: vec![assign("a"), assign("b")],
        ..Default::default()
    };
    data.config.use_calendars = false;

    let mut project = Project::default();
    project.solve(Some(data), &SolveOptions::default())?;

    // Drag "b" one day earlier, onto the last day of "a".
    let mut drag = SchedulingChanges::default();
    let mut moved = TaskUpdate::new("b");
    moved.start_date = Patch::Value(day(2)?);
    drag.tasks.update.push(moved);

    let options = PreviewOptions {
        profile: Some(ProfileQuery {
            tasks: ProfileTasks::Over,
            ..ProfileQuery::new(day(1)?, day(5)?)
        }),
        ..Default::default()
    };
    let preview = project.preview(&drag, &options)?;

    if let Some(profile) = &preview.profile {
        for resource in &profile.resources {
            for bucket in resource.buckets.iter().filter(|b| b.load > b.capacity) {
                println!(
                    "{} overloaded at {}: {} of {} ({:?})",
                    resource.resource, bucket.start, bucket.load, bucket.capacity, bucket.tasks
                );
            }
        }
    }
    Ok(())
}

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, with the bucket size (basis) derived from config.duration_unit. Narrow it with resources, or switch basis. The query field has no baseline_id of its own: the preview checks PreviewOptions.baseline_id. 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.

use plen_core::{
    DateTime, LinkData, LinkType, Patch, Project, ProjectData, SchedulingChanges, SolveOptions,
    TaskData, TaskUpdate,
};

fn day(d: u32) -> Result<DateTime, Box<dyn std::error::Error>> {
    Ok(DateTime::from_ymd_hm(2026, 1, d, 0, 0).ok_or("invalid date")?)
}

fn task(id: &str, start: DateTime, duration: f64) -> TaskData {
    let mut task = TaskData::new(id);
    task.start_date = Some(start);
    task.duration = Some(duration);
    task
}

fn update(id: &str, duration: f64) -> SchedulingChanges {
    let mut changes = SchedulingChanges::default();
    let mut update = TaskUpdate::new(id);
    update.duration = Patch::Value(duration);
    changes.tasks.update.push(update);
    changes
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut data = ProjectData {
        tasks: vec![task("a", day(1)?, 2.0), task("b", day(1)?, 1.0)],
        links: vec![LinkData {
            id: "ab".into(),
            source: "a".into(),
            target: "b".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..Default::default()
    };
    data.config.use_calendars = false;

    let options = SolveOptions::default();
    let mut project = Project::default();
    project.solve(Some(data), &options)?;

    // A cheap copy: it shares state with `project` until it's edited.
    let mut draft = project.clone();
    draft.apply(&update("a", 4.0))?;
    draft.solve(None, &options)?;
    draft.apply(&update("b", 3.0))?;
    let outcome = draft.solve(None, &options)?;

    for update in &outcome.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("draft task {}: {start} .. {end}", update.id);
        }
    }

    // The original is untouched and still previews against its own baseline.
    assert!(project
        .preview(&SchedulingChanges::default(), &Default::default())?
        .updates
        .is_empty());

    // The user accepts the draft: keep it as the project.
    project = draft;
    project.solve(None, &options)?;
    Ok(())
}

Edits on one copy never affect the other. The copy is a full Project with its own baseline, cache and baseline_id, 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 it.

Clone before level too. level takes &mut self and accepts the leveled dates as the new baseline, so running it on a copy is how you show the user a leveling proposal before committing it:

use plen_core::{LevelingOptions, Project};

fn leveling_proposal(project: &Project) -> Result<(), Box<dyn std::error::Error>> {
    let mut draft = project.clone();
    let leveled = draft.level(&LevelingOptions::default())?;
    for update in &leveled.schedule.updates {
        println!("task {} delay {:?}", update.id, update.leveling_delay);
    }
    Ok(())
}

The step-by-step version is in Preview leveling. For comparing several alternative change sets with scores, use evaluate instead, which works on disposable copies internally. See Evaluating alternatives.

Cloning also helps with threads. To use one project from several threads, give each thread its own clone, or wrap the project in a Mutex or RwLock. preview takes &self, so with an RwLock several previews can run at once.