use crate::EngineError;
use chrono::{DateTime, Utc};
use dpm_model::{
Applicability, EventTime, Plan, Timeline, WorkItem, WorkItemId, WorkKind, WorkStatus,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ProgressSummary {
pub percent_complete: f64,
pub verified: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<EventTime>,
#[serde(default, skip_serializing_if = "ProgressScope::is_counted")]
pub scope: ProgressScope,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProgressScope {
#[default]
Counted,
NotSelected,
Undecided,
}
impl ProgressScope {
fn is_counted(&self) -> bool {
*self == Self::Counted
}
fn of(applicability: &Applicability) -> Self {
match applicability {
Applicability::NotSelected { .. } | Applicability::AllChildrenExcluded { .. } => {
Self::NotSelected
}
Applicability::Undecided { .. } => Self::Undecided,
_ => Self::Counted,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressProjection {
pub overall: ProgressSummary,
pub work: BTreeMap<WorkItemId, ProgressSummary>,
}
pub fn progress(plan: &Plan, now: DateTime<Utc>) -> Result<ProgressProjection, EngineError> {
plan.validate()?;
Ok(progress_with(plan, &Timeline::at(plan, now)))
}
pub(crate) fn progress_with(plan: &Plan, timeline: &Timeline) -> ProgressProjection {
let done = timeline.completed();
let scope = |id: WorkItemId| ProgressScope::of(timeline.applicability(id));
let tasks: Vec<_> = plan
.work_items
.values()
.filter(|w| w.is_executable() && scope(w.id).is_counted())
.collect();
let mut work = BTreeMap::new();
for item in plan.work_items.values() {
let verified = done.contains(&item.id);
let percent_complete = match item.kind {
WorkKind::Task => task_percent(item),
WorkKind::Milestone => {
if verified {
100.0
} else {
0.0
}
}
WorkKind::WorkPackage => {
let descendants: Vec<_> = tasks
.iter()
.copied()
.filter(|t| within(plan, t, item.id))
.collect();
average(&descendants, verified)
}
};
work.insert(
item.id,
ProgressSummary {
percent_complete,
verified,
completed_at: timeline.completed_at(item.id),
scope: scope(item.id),
},
);
}
let verified = !done.is_empty()
&& plan
.work_items
.keys()
.all(|id| done.contains(id) || scope(*id) == ProgressScope::NotSelected);
let completed_at = verified
.then(|| {
plan.work_items
.keys()
.filter_map(|id| timeline.completed_at(*id))
.reduce(EventTime::latest)
})
.flatten();
ProgressProjection {
overall: ProgressSummary {
percent_complete: average(&tasks, verified),
verified,
completed_at,
scope: ProgressScope::Counted,
},
work,
}
}
fn task_percent(work: &WorkItem) -> f64 {
if matches!(
work.execution.status,
WorkStatus::Submitted | WorkStatus::Verified | WorkStatus::Done
) {
100.0
} else {
f64::from(work.execution.reported_progress_percent)
}
}
fn average(tasks: &[&WorkItem], verified: bool) -> f64 {
if tasks.is_empty() {
return if verified { 100.0 } else { 0.0 };
}
tasks
.iter()
.map(|w| task_percent(w) / tasks.len() as f64)
.sum::<f64>()
.clamp(0.0, 100.0)
}
fn within(plan: &Plan, task: &WorkItem, container: WorkItemId) -> bool {
let mut parent = task.parent;
let mut seen = BTreeSet::new();
while let Some(id) = parent {
if id == container {
return true;
}
if !seen.insert(id) {
return false;
}
parent = plan.work_items.get(&id).and_then(|w| w.parent);
}
false
}
#[cfg(test)]
mod tests;