Working time and calendars#

This guide shows how to tell Planning Engine when work can happen: working hours, weekdays, holidays and short days. It also covers how tasks and resources pick a calendar and how to turn calendars off.

How calendars work#

A task's duration is working time. A 16-hour task doesn't end 16 hours after it starts. It ends once it has used 16 hours of its calendar's working time, so nights, weekends and holidays stretch it. Lags work the same way. leveling_delay and pauses are the exception: they're elapsed wall-clock time.

A calendar is a CalendarData: an id plus an optional Worktime with three parts:

  • hours: the working ranges of a day, as TimeRange values in minutes since midnight. Default: 08:00-12:00 and 13:00-17:00.
  • days: seven flags, index 0 is Sunday. Default: Monday to Friday.
  • dates: exceptions keyed by chrono::NaiveDate. A DayRule makes a date Off, On (working with the calendar's hours) or working with its own Hours(...).

Every project has a calendar named "global". If you don't define one, the engine uses the built-in Monday-Friday, 08:00-12:00 and 13:00-17:00. A calendar in ProjectData.calendars with the id "global" replaces it.

Which calendar a piece of work uses depends on config.calendar_mode (CalendarMode):

Mode A task works on
Resource (default) each assignment's calendar: the resource's calendar, intersected with the task's own calendar when both are set
Task the task's calendar_id, else "global"; resource calendars are ignored
TaskInherited the task's calendar_id, else the nearest ancestor's, else "global"

Resource follows MS Project. Task and TaskInherited follow dhtmlxGantt, which only knows task calendars.

config.use_calendars sits above all of this. It's true by default. Set it to false and every calendar becomes flat 24/7 time.

The engine validates calendars when you pass the data to solve(Some(data)) or schedule. These cases fail with Error::Input at the field path:

  • a duplicate calendar id
  • a calendar with no working time
  • an hour range outside 00:00-24:00
  • a task or resource calendar_id that names no calendar (for resources, only in Resource mode)
  • in Resource mode, a task calendar and a resource calendar that share no weekly working time (reported at assignments[i])

Calendars are part of the snapshot only. SchedulingChanges can't add or edit a calendar. To change one, send the full data again with solve(Some(data)). Edits can still point tasks and resources at existing calendars through calendar_id.

Defining calendars#

Replace "global" to change the working week for the whole project. The calendar below works Monday to Friday, 09:00-17:00. Thursday the 13th is a short day and Friday the 14th is a holiday:

use std::collections::BTreeMap;

use chrono::NaiveDate;
use plen_core::{
    schedule, CalendarData, DateTime, DayRule, ProjectData, SolveOptions, TaskData, TimeRange,
    TimeUnit, Worktime,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let date = |d: u32| NaiveDate::from_ymd_opt(2026, 8, d).ok_or("invalid date");

    let mut dates = BTreeMap::new();
    // Thursday: a short day, 09:00-13:00.
    dates.insert(date(13)?, DayRule::Hours(vec![TimeRange { start: 9 * 60, end: 13 * 60 }]));
    // Friday: a public holiday.
    dates.insert(date(14)?, DayRule::Off);

    let global = CalendarData {
        id: "global".into(),
        worktime: Some(Worktime {
            hours: Some(vec![TimeRange { start: 9 * 60, end: 17 * 60 }]),
            days: Some([false, true, true, true, true, true, false]), // Sun..Sat
            dates,
        }),
    };

    let mut task = TaskData::new("build");
    task.start_date = DateTime::from_ymd_hm(2026, 8, 10, 9, 0); // Monday
    task.duration = Some(36.0);

    let mut data = ProjectData {
        calendars: vec![global],
        tasks: vec![task],
        ..Default::default()
    };
    data.config.duration_unit = TimeUnit::Hour;

    let options = SolveOptions { include_unchanged: true, ..Default::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} -> {end}", update.id);
        }
    }
    Ok(())
}

Output:

build: 2026-08-10 09:00 -> 2026-08-17 17:00

The 36 hours are Monday to Wednesday (24 h), the short Thursday (4 h) and the next Monday (8 h). The holiday and the weekend hold no work. The example needs chrono as a dependency for NaiveDate (cargo add chrono).

A field you leave as None keeps its default. Worktime { days: Some(...), ..Default::default() } changes the weekdays and keeps the 08:00-12:00, 13:00-17:00 hours.

Task calendars#

Set TaskData.calendar_id to run one task on a different calendar. Here the migrate task runs on a night shift, while review stays on the default "global" calendar:

use plen_core::{
    schedule, CalendarData, DateTime, ProjectData, SolveOptions, TaskData, TimeRange, TimeUnit,
    Worktime,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 22:00-06:00: the range ends before it starts, so it crosses midnight
    // and belongs to the day it starts on.
    let night = CalendarData {
        id: "night".into(),
        worktime: Some(Worktime {
            hours: Some(vec![TimeRange { start: 22 * 60, end: 6 * 60 }]),
            ..Default::default()
        }),
    };

    let mut review = TaskData::new("review");
    review.start_date = DateTime::from_ymd_hm(2026, 8, 10, 8, 0);
    review.duration = Some(16.0);

    let mut migrate = TaskData::new("migrate");
    migrate.start_date = DateTime::from_ymd_hm(2026, 8, 10, 22, 0);
    migrate.duration = Some(16.0);
    migrate.calendar_id = Some("night".into());

    let mut data = ProjectData {
        calendars: vec![night],
        tasks: vec![review, migrate],
        ..Default::default()
    };
    data.config.duration_unit = TimeUnit::Hour;

    let options = SolveOptions { include_unchanged: true, ..Default::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} -> {end}", update.id);
        }
    }
    Ok(())
}

Output:

review: 2026-08-10 08:00 -> 2026-08-11 17:00
migrate: 2026-08-10 22:00 -> 2026-08-12 06:00

The night shift's days are the default Monday to Friday. Monday's shift runs until Tuesday 06:00, because a range that crosses midnight belongs to the day it starts on. Ranges that cross midnight are a Planning Engine extension to the dhtmlxGantt calendar format.

A task's own calendar (else "global") also matters in Resource mode. Milestones, lag without a link calendar and slack always use it.

Inheriting a calendar from a summary#

In Task and Resource modes a child doesn't look at its parent's calendar. Switch to CalendarMode::TaskInherited to make a task without a calendar_id use the nearest ancestor's:

use plen_core::{
    schedule, CalendarData, CalendarMode, DateTime, ProjectData, SolveOptions, TaskData, TaskType,
    TimeUnit, Worktime,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let six_day_week = CalendarData {
        id: "six-day".into(),
        worktime: Some(Worktime {
            days: Some([false, true, true, true, true, true, true]),
            ..Default::default()
        }),
    };

    let mut phase = TaskData::new("phase");
    phase.task_type = TaskType::Summary;
    phase.calendar_id = Some("six-day".into());

    let mut pour = TaskData::new("pour");
    pour.parent = Some("phase".into());
    pour.start_date = DateTime::from_ymd_hm(2026, 8, 14, 8, 0); // Friday
    pour.duration = Some(16.0);

    let mut data = ProjectData {
        calendars: vec![six_day_week],
        tasks: vec![phase, pour],
        ..Default::default()
    };
    data.config.duration_unit = TimeUnit::Hour;
    data.config.calendar_mode = CalendarMode::TaskInherited;

    let options = SolveOptions { include_unchanged: true, ..Default::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} -> {end}", update.id);
        }
    }
    Ok(())
}

Output:

pour: 2026-08-14 08:00 -> 2026-08-15 17:00
phase: 2026-08-14 08:00 -> 2026-08-15 17:00

pour works Friday and Saturday on the summary's six-day week. With the default mode it would wait for Monday.

Resource calendars#

In the default Resource mode each assignment works on its resource's calendar, and the task's dates follow its assignments. Give a resource a calendar through ResourceData.calendar_id. The example runs the same project in Resource and Task mode:

use plen_core::{
    schedule, AssignmentData, CalendarData, CalendarMode, DateTime, LinkData, LinkType,
    ProjectData, ResourceData, SolveOptions, TaskData, TimeUnit, Worktime,
};

fn project(mode: CalendarMode) -> ProjectData {
    let tue_sat = CalendarData {
        id: "tue-sat".into(),
        worktime: Some(Worktime {
            days: Some([false, false, true, true, true, true, true]),
            ..Default::default()
        }),
    };

    // `prepare` has no resources, so it runs on the global calendar
    // and ends Friday 17:00.
    let mut prepare = TaskData::new("prepare");
    prepare.start_date = DateTime::from_ymd_hm(2026, 8, 14, 8, 0);
    prepare.duration = Some(8.0);

    let mut inspect = TaskData::new("inspect");
    inspect.start_date = DateTime::from_ymd_hm(2026, 8, 14, 8, 0);
    inspect.duration = Some(16.0);

    let mut data = ProjectData {
        calendars: vec![tue_sat],
        tasks: vec![prepare, inspect],
        links: vec![LinkData {
            id: "l1".into(),
            source: "prepare".into(),
            target: "inspect".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        resources: vec![ResourceData {
            id: "dana".into(),
            capacity: None,
            calendar_id: Some("tue-sat".into()),
        }],
        assignments: vec![AssignmentData {
            id: Some("inspect:dana".into()),
            task: "inspect".into(),
            resource: "dana".into(),
            value: 1.0,
            offset: None,
            duration: None,
        }],
        ..Default::default()
    };
    data.config.duration_unit = TimeUnit::Hour;
    data.config.calendar_mode = mode;
    data
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let options = SolveOptions { include_unchanged: true, ..Default::default() };
    for mode in [CalendarMode::Resource, CalendarMode::Task] {
        let result = schedule(project(mode), &options)?;
        for update in &result.updates {
            if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
                println!("{mode:?} {}: {start} -> {end}", update.id);
            }
        }
    }
    Ok(())
}

Output:

Resource prepare: 2026-08-14 08:00 -> 2026-08-14 17:00
Resource inspect: 2026-08-15 08:00 -> 2026-08-18 17:00
Task prepare: 2026-08-14 08:00 -> 2026-08-14 17:00
Task inspect: 2026-08-17 08:00 -> 2026-08-18 17:00

In Resource mode Dana works Saturday, so inspect starts right after prepare and runs Saturday and Tuesday. In Task mode her calendar is ignored and inspect waits for Monday on "global".

The rules for picking an assignment's calendar:

  • The resource has a calendar and the task doesn't: the resource's calendar.
  • Both have one: their intersection. Give a task the mornings calendar and assign it to Dana, and the work runs Tuesday to Friday mornings only.
  • The resource has none: the task's calendar.
  • Summaries and milestones always use the task's calendar.

A task with several assignments on different calendars spans the envelope of its assignments. Each assignment starts at the first working time of its own calendar and runs the full duration there. The task ends when the last one ends. The reported duration stays the working time of each assignment, not the length of the envelope. Results don't include per-assignment dates.

Resource calendars also shape resource load: each assignment books work only on its own calendar. See Resource load.

Flat time#

Set config.use_calendars to false to schedule without working hours. Every calendar becomes 24/7, so a day of duration is 24 hours:

use plen_core::{schedule, DateTime, ProjectData, SolveOptions, TaskData};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut task = TaskData::new("burn-in");
    task.start_date = DateTime::from_ymd_hm(2026, 8, 14, 8, 0); // Friday
    task.duration = Some(2.0); // days

    let mut data = ProjectData {
        tasks: vec![task],
        ..Default::default()
    };
    data.config.use_calendars = false;

    let options = SolveOptions { include_unchanged: true, ..Default::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} -> {end}", update.id);
        }
    }
    Ok(())
}

Output:

burn-in: 2026-08-14 08:00 -> 2026-08-16 08:00

With calendars on, the same task skips the weekend and ends on Tuesday at 08:00. The engine still validates calendar ids, but they all resolve to the same flat time, so you can toggle use_calendars without touching the rest of the data. In flat mode resource and assignment edits never move dates.

If you load data from dhtmlxGantt, send use_calendars explicitly. See Import from dhtmlxGantt.

Explicit starts on non-working time#

When an edit sets a start that falls on non-working time, the engine doesn't always snap it. A task with no predecessor keeps the exact start you gave it. Its end is computed forward on the working calendar, so the duration is still right. A task with a predecessor gets its start from the link instead.

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

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut task = TaskData::new("deploy");
    task.start_date = DateTime::from_ymd_hm(2026, 8, 13, 8, 0); // Thursday
    task.duration = Some(4.0);

    let mut data = ProjectData {
        tasks: vec![task],
        ..Default::default()
    };
    data.config.duration_unit = TimeUnit::Hour;

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

    // The user drags the task onto Saturday 10:00.
    let saturday = DateTime::from_ymd_hm(2026, 8, 15, 10, 0).ok_or("invalid date")?;
    let mut update = TaskUpdate::new("deploy");
    update.start_date = Patch::Value(saturday);
    let mut changes = SchedulingChanges::default();
    changes.tasks.update.push(update);
    project.apply(&changes)?;

    let result = project.solve(None, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} -> {end}", update.id);
        }
    }
    Ok(())
}

Output:

deploy: 2026-08-15 10:00 -> 2026-08-17 12:00

The start stays on Saturday 10:00. The four hours of work run Monday 08:00-12:00. This applies to Project::apply and preview, the calls behind drags in an editor.

This is by design. It's ported from the dhtmlxGantt planner, where the UI layer owns snapping. If your editor should only allow working-time starts, snap the date before you send the TaskUpdate.