Preview leveling#

Show the user what leveling would change before it touches the project, then keep the leveled plan or throw it away.

Project::level takes &mut self and commits: it writes leveling_delay and pauses into the project and accepts the leveled dates as the new baseline. To get a proposal instead, level a clone. Project::clone is O(1), because the data stays shared until one copy is edited.

Prerequisites#

  • A solved Project: solve(Some(data), ...) has run.
  • The data has resources and assignments, so there's overallocation to level.
  • The project is forward-scheduled. level refuses config.schedule_from_end: true with Error::Input.

Steps#

  1. Clone the solved project:

    let mut draft = project.clone();
    
  2. Level the clone. The original keeps its dates, its baseline and its overallocation:

    let proposal = draft.level(&LevelingOptions::default())?;
    
  3. Show the proposal. proposal.schedule.updates lists the tasks leveling moved, with their new dates, leveling_delay and pauses. The diff is relative to the state the call started from. That state matches the original, so you can apply the entries to the model you already hold.

    for update in &proposal.schedule.updates {
        println!(
            "{}: start {:?}, delay {:?}, pauses {:?}",
            update.id, update.start_date, update.leveling_delay, update.pauses
        );
    }
    
  4. Show what leveling couldn't fix. proposal.schedule.overallocations holds the windows left after leveling, and each window has a reason, such as fixed-task or priority-1000:

    for over in proposal.schedule.overallocations.iter().flatten() {
        for window in &over.windows {
            println!("{}: {} .. {}, {:?}", over.resource, window.from, window.to, window.reason);
        }
    }
    
  5. Apply the user's choice:

    • Accept: replace the original with the clone, project = draft;. The project now holds the leveled baseline.
    • Reject: drop draft. Nothing else needs undoing.

    Replace the original only if it hasn't been edited since you cloned it. Otherwise the proposal was computed from an older state, so level a new clone.

The full example levels two tasks that book the same developer full time on Monday and Tuesday:

use plen_core::{
    AssignmentData, DateTime, LevelingOptions, Project, ProjectData, ResourceData, SolveOptions,
    TaskData,
};

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 booking(task: &str, resource: &str) -> AssignmentData {
    AssignmentData {
        id: None,
        task: task.into(),
        resource: resource.into(),
        value: 1.0,
        offset: None,
        duration: None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Monday, August 10, 2026, on the default calendar.
    let monday = DateTime::from_ymd_hm(2026, 8, 10, 8, 0).ok_or("invalid date")?;
    let data = ProjectData {
        tasks: vec![task("A", monday, 2.0), task("B", monday, 2.0)],
        resources: vec![ResourceData {
            id: "dev1".into(),
            capacity: None,
            calendar_id: None,
        }],
        assignments: vec![booking("A", "dev1"), booking("B", "dev1")],
        ..Default::default()
    };

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

    let mut draft = project.clone();
    let proposal = draft.level(&LevelingOptions::default())?;

    for update in &proposal.schedule.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}, delay {:?}", update.id, update.leveling_delay);
        }
    }
    let left = proposal.schedule.overallocations.iter().flatten().count();
    println!("overallocated resources left: {left}");

    let user_accepts = true;
    if user_accepts {
        project = draft;
    }
    let plan = project.solve(None, &SolveOptions::default())?;
    println!("baseline: {:?}", plan.baseline_id);
    Ok(())
}

Result#

The example prints the one task leveling moved, and nothing is left overallocated:

B: 2026-08-12 08:00 .. 2026-08-14 08:00, delay Some(2.0)
overallocated resources left: 0

B waits two days for A, so its leveling_delay is 2.0. Until you replace project with draft, project still reports the Monday-Tuesday window on dev1, and a solve(None) on it returns no updates. After the swap, baseline_id is the token from the leveling result.

The clone is a full solved project, so you can also call draft.load_profile on it to draw the leveled load. See Resource load. To compare leveling across several alternative change sets with scores, use evaluate with EvaluateOptions.level instead. See Evaluating alternatives. For options, reasons and splits, see Leveling resources.