Skip to main content

dpm_model/
calendar.rs

1//! Working calendars: when each kind of actor works, in the workspace's time zone.
2//!
3//! Durations and positive lags are authored as hours. Without a [`Calendars`] block every hour is
4//! elapsed time. With one, an estimate counts working hours on the calendar its task resolves to,
5//! reviews wait for the verifier kind's calendar, and a dependency may count its lag in working
6//! time. Projections compile calendars into [`WorkingTime`]; no derived date is ever stored.
7
8use crate::{ActorId, ActorKind, Dependency, LagBasis, Plan, WorkItem};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12mod clock;
13mod working;
14pub use clock::{ClockTime, WorkingPeriod};
15pub use working::{BeyondCalendar, MAX_WINDOW_HOURS, WorkingTime};
16pub(crate) mod validate;
17pub use validate::MAX_CALENDAR_HOURS;
18
19/// Built-in calendar counting every hour, the elapsed-time behaviour of a plan without calendars.
20pub const ALWAYS: &str = "always";
21/// Built-in calendar matching Microsoft Project's Standard: Monday to Friday, 08:00-12:00 and
22/// 13:00-17:00. A plan may redefine it.
23pub const STANDARD: &str = "standard";
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26/// Workspace calendars; present only when some work follows working time.
27#[serde(deny_unknown_fields)]
28pub struct Calendars {
29    /// IANA time zone in which weekly hours and exception dates are read.
30    pub time_zone: String,
31    /// Named calendars; `standard` may be redefined, `always` may not.
32    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33    pub definitions: BTreeMap<String, CalendarDefinition>,
34    /// Calendar of each actor kind unless a task or actor entry says otherwise.
35    #[serde(default, skip_serializing_if = "KindCalendars::is_default")]
36    pub kinds: KindCalendars,
37    /// Availability of named actors, keyed `kind:name`; availability only, never capacity.
38    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39    pub actors: BTreeMap<String, String>,
40    /// Actor kind expected to do work that neither names an executor nor has an owner.
41    #[serde(default = "human", skip_serializing_if = "is_human")]
42    pub default_executor: ActorKind,
43    /// Actor kind that reviews submitted work; verification waits for its calendar.
44    #[serde(default = "human", skip_serializing_if = "is_human")]
45    pub verifier: ActorKind,
46}
47
48fn human() -> ActorKind {
49    ActorKind::Human
50}
51
52fn is_human(kind: &ActorKind) -> bool {
53    *kind == ActorKind::Human
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57/// Default calendar name per actor kind.
58#[serde(deny_unknown_fields)]
59pub struct KindCalendars {
60    /// People; Microsoft Project's Standard calendar unless set.
61    #[serde(default = "standard_name")]
62    pub human: String,
63    /// Software agents; every hour unless set.
64    #[serde(default = "always_name")]
65    pub agent: String,
66    /// External services and CI; every hour unless set.
67    #[serde(default = "always_name")]
68    pub service: String,
69}
70
71fn standard_name() -> String {
72    STANDARD.into()
73}
74
75fn always_name() -> String {
76    ALWAYS.into()
77}
78
79impl Default for KindCalendars {
80    fn default() -> Self {
81        Self {
82            human: standard_name(),
83            agent: always_name(),
84            service: always_name(),
85        }
86    }
87}
88
89impl KindCalendars {
90    /// Whether every kind uses its default calendar, which serialization omits.
91    #[must_use]
92    pub fn is_default(&self) -> bool {
93        *self == Self::default()
94    }
95
96    /// Calendar name of one actor kind.
97    #[must_use]
98    pub fn of(&self, kind: ActorKind) -> &str {
99        match kind {
100            ActorKind::Human => &self.human,
101            ActorKind::Agent => &self.agent,
102            ActorKind::Service => &self.service,
103        }
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
108/// Weekly working periods and dated exceptions of one named calendar.
109#[serde(deny_unknown_fields)]
110pub struct CalendarDefinition {
111    /// Working periods of each weekday; a day without periods is not worked.
112    pub week: WeekPattern,
113    /// Dates whose periods replace the weekly pattern, such as holidays or extra shifts.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub exceptions: Vec<CalendarException>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
119/// Local working periods per weekday, ordered and non-overlapping.
120#[serde(deny_unknown_fields)]
121pub struct WeekPattern {
122    /// Monday.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub mon: Vec<WorkingPeriod>,
125    /// Tuesday.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub tue: Vec<WorkingPeriod>,
128    /// Wednesday.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub wed: Vec<WorkingPeriod>,
131    /// Thursday.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub thu: Vec<WorkingPeriod>,
134    /// Friday.
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub fri: Vec<WorkingPeriod>,
137    /// Saturday.
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub sat: Vec<WorkingPeriod>,
140    /// Sunday.
141    #[serde(default, skip_serializing_if = "Vec::is_empty")]
142    pub sun: Vec<WorkingPeriod>,
143}
144
145impl WeekPattern {
146    /// Working periods of one weekday.
147    #[must_use]
148    pub fn day(&self, day: chrono::Weekday) -> &[WorkingPeriod] {
149        use chrono::Weekday::{Fri, Mon, Sat, Sun, Thu, Tue, Wed};
150        match day {
151            Mon => &self.mon,
152            Tue => &self.tue,
153            Wed => &self.wed,
154            Thu => &self.thu,
155            Fri => &self.fri,
156            Sat => &self.sat,
157            Sun => &self.sun,
158        }
159    }
160
161    /// Every weekday with its periods, Monday first.
162    pub fn days(&self) -> impl Iterator<Item = (chrono::Weekday, &[WorkingPeriod])> {
163        use chrono::Weekday::{Fri, Mon, Sat, Sun, Thu, Tue, Wed};
164        [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
165            .into_iter()
166            .map(|day| (day, self.day(day)))
167    }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171/// Local dates, inclusive, whose working periods replace the weekly pattern.
172#[serde(deny_unknown_fields)]
173pub struct CalendarException {
174    /// First date.
175    pub from: chrono::NaiveDate,
176    /// Last date; the exception covers only `from` when absent.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub to: Option<chrono::NaiveDate>,
179    /// Working periods on each covered date; empty means not worked.
180    #[serde(default, skip_serializing_if = "Vec::is_empty")]
181    pub hours: Vec<WorkingPeriod>,
182    /// Optional label such as a holiday name.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub name: Option<String>,
185}
186
187impl CalendarException {
188    /// Last covered date.
189    #[must_use]
190    pub fn last(&self) -> chrono::NaiveDate {
191        self.to.unwrap_or(self.from)
192    }
193
194    /// Whether the exception covers a date.
195    #[must_use]
196    pub fn covers(&self, date: chrono::NaiveDate) -> bool {
197        self.from <= date && date <= self.last()
198    }
199}
200
201/// How a calendar was chosen for work, from the most to the least specific rule.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum CalendarSource {
205    /// The task names its own calendar.
206    Task,
207    /// The task's owner has an availability entry.
208    Actor,
209    /// The calendar of the owner's kind, else of the planned executor, else of the default.
210    Kind,
211}
212
213/// Calendar and actor kind a projection uses for one work item.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct ResolvedCalendar {
216    /// Calendar name.
217    pub calendar: String,
218    /// Kind of the actor who owns or is expected to do the work.
219    pub executor: ActorKind,
220    /// Rule that chose the calendar.
221    pub source: CalendarSource,
222}
223
224/// A calendar's weekly and exception rules, or a built-in.
225#[derive(Debug, Clone, Copy)]
226pub enum CalendarRules<'a> {
227    /// Every hour is working time.
228    Always,
229    /// The built-in Standard calendar.
230    Standard,
231    /// Weekly periods and exceptions of a defined calendar.
232    Weekly(&'a CalendarDefinition),
233}
234
235/// Microsoft Project's Standard calendar: Monday to Friday, 08:00-12:00 and 13:00-17:00.
236#[must_use]
237pub fn standard_definition() -> CalendarDefinition {
238    let day = vec![
239        WorkingPeriod::new(ClockTime::hours(8), ClockTime::hours(12)),
240        WorkingPeriod::new(ClockTime::hours(13), ClockTime::hours(17)),
241    ];
242    CalendarDefinition {
243        week: WeekPattern {
244            mon: day.clone(),
245            tue: day.clone(),
246            wed: day.clone(),
247            thu: day.clone(),
248            fri: day,
249            sat: Vec::new(),
250            sun: Vec::new(),
251        },
252        exceptions: Vec::new(),
253    }
254}
255
256impl Calendars {
257    /// Calendars with Microsoft Project defaults in one time zone.
258    #[must_use]
259    pub fn in_zone(time_zone: impl Into<String>) -> Self {
260        Self {
261            time_zone: time_zone.into(),
262            definitions: BTreeMap::new(),
263            kinds: KindCalendars::default(),
264            actors: BTreeMap::new(),
265            default_executor: ActorKind::Human,
266            verifier: ActorKind::Human,
267        }
268    }
269
270    /// The rules of a named calendar, or `None` when the name is unknown.
271    #[must_use]
272    pub fn rules(&self, name: &str) -> Option<CalendarRules<'_>> {
273        if name == ALWAYS {
274            return Some(CalendarRules::Always);
275        }
276        match self.definitions.get(name) {
277            Some(definition) => Some(CalendarRules::Weekly(definition)),
278            None if name == STANDARD => Some(CalendarRules::Standard),
279            None => None,
280        }
281    }
282
283    /// Parsed time zone; validation guarantees it parses for a validated plan.
284    pub fn zone(&self) -> Result<chrono_tz::Tz, BeyondCalendar> {
285        self.time_zone.parse().map_err(|_| BeyondCalendar)
286    }
287
288    /// Calendar and executor kind of a work item: its own calendar, else its owner's availability,
289    /// else the calendar of its owner's kind, planned executor or the default executor.
290    #[must_use]
291    pub fn resolve(&self, work: &WorkItem) -> ResolvedCalendar {
292        let owner = work.execution.owner.as_ref();
293        let executor = owner
294            .map(|o| o.kind)
295            .or(work.schedule.executor)
296            .unwrap_or(self.default_executor);
297        let actor = owner.and_then(|o| self.actors.get(&o.to_string()));
298        let (calendar, source) = match (&work.schedule.calendar, actor) {
299            (Some(own), _) => (own.clone(), CalendarSource::Task),
300            (None, Some(available)) => (available.clone(), CalendarSource::Actor),
301            (None, None) => (self.kinds.of(executor).to_owned(), CalendarSource::Kind),
302        };
303        ResolvedCalendar {
304            calendar,
305            executor,
306            source,
307        }
308    }
309
310    /// Name of the calendar reviews wait for.
311    #[must_use]
312    pub fn verifier_calendar(&self) -> &str {
313        self.kinds.of(self.verifier)
314    }
315
316    /// Time at which `hours` of working time on a calendar have passed after `from`.
317    ///
318    /// `None` when the calendar is unknown or the result lies outside chrono's range.
319    #[must_use]
320    pub fn after_working(
321        &self,
322        calendar: &str,
323        from: chrono::DateTime<chrono::Utc>,
324        hours: f64,
325    ) -> Option<chrono::DateTime<chrono::Utc>> {
326        let rules = self.rules(calendar)?;
327        let zone = self.zone().ok()?;
328        let offset = WorkingTime::offset_after(rules, zone, from, hours)?;
329        crate::events::hours_after(from, offset)
330    }
331}
332
333impl Plan {
334    /// Calendar and executor a projection uses for work, or `None` without a calendars block.
335    #[must_use]
336    pub fn work_calendar(&self, work: &WorkItem) -> Option<ResolvedCalendar> {
337        self.calendars.as_ref().map(|c| c.resolve(work))
338    }
339
340    /// When a dependency's positive lag after `event_at` has passed: elapsed hours, or working
341    /// hours of the successor's calendar for a working-time lag. `None` when out of range.
342    #[must_use]
343    pub fn lag_end(
344        &self,
345        edge: &Dependency,
346        event_at: chrono::DateTime<chrono::Utc>,
347        hours: f64,
348    ) -> Option<chrono::DateTime<chrono::Utc>> {
349        let calendars = self.calendars.as_ref();
350        let successor = self.work_items.get(&edge.successor);
351        match (edge.lag_basis, calendars, successor) {
352            (LagBasis::Working, Some(calendars), Some(work)) => {
353                calendars.after_working(&calendars.resolve(work).calendar, event_at, hours)
354            }
355            _ => crate::events::hours_after(event_at, hours),
356        }
357    }
358}
359
360/// Parse a `kind:name` availability key.
361pub(crate) fn actor_key(key: &str) -> Option<ActorId> {
362    key.parse().ok()
363}
364
365#[cfg(test)]
366mod tests;