1use crate::EngineError;
2use chrono::{DateTime, Utc};
3use dpm_model::{
4 Applicability, EventTime, Plan, Timeline, WorkItem, WorkItemId, WorkKind, WorkStatus,
5};
6use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, BTreeSet};
8
9#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
12pub struct ProgressSummary {
13 pub percent_complete: f64,
15 pub verified: bool,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub completed_at: Option<EventTime>,
21 #[serde(default, skip_serializing_if = "ProgressScope::is_counted")]
23 pub scope: ProgressScope,
24}
25
26#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ProgressScope {
30 #[default]
32 Counted,
33 NotSelected,
36 Undecided,
39}
40
41impl ProgressScope {
42 fn is_counted(&self) -> bool {
43 *self == Self::Counted
44 }
45
46 fn of(applicability: &Applicability) -> Self {
47 match applicability {
48 Applicability::NotSelected { .. } | Applicability::AllChildrenExcluded { .. } => {
49 Self::NotSelected
50 }
51 Applicability::Undecided { .. } => Self::Undecided,
52 _ => Self::Counted,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ProgressProjection {
60 pub overall: ProgressSummary,
62 pub work: BTreeMap<WorkItemId, ProgressSummary>,
64}
65
66pub fn progress(plan: &Plan, now: DateTime<Utc>) -> Result<ProgressProjection, EngineError> {
74 plan.validate()?;
75 Ok(progress_with(plan, &Timeline::at(plan, now)))
76}
77
78pub(crate) fn progress_with(plan: &Plan, timeline: &Timeline) -> ProgressProjection {
79 let done = timeline.completed();
80 let scope = |id: WorkItemId| ProgressScope::of(timeline.applicability(id));
81 let tasks: Vec<_> = plan
82 .work_items
83 .values()
84 .filter(|w| w.is_executable() && scope(w.id).is_counted())
85 .collect();
86 let mut work = BTreeMap::new();
87 for item in plan.work_items.values() {
88 let verified = done.contains(&item.id);
89 let percent_complete = match item.kind {
90 WorkKind::Task => task_percent(item),
91 WorkKind::Milestone => {
92 if verified {
93 100.0
94 } else {
95 0.0
96 }
97 }
98 WorkKind::WorkPackage => {
99 let descendants: Vec<_> = tasks
100 .iter()
101 .copied()
102 .filter(|t| within(plan, t, item.id))
103 .collect();
104 average(&descendants, verified)
105 }
106 };
107 work.insert(
108 item.id,
109 ProgressSummary {
110 percent_complete,
111 verified,
112 completed_at: timeline.completed_at(item.id),
113 scope: scope(item.id),
114 },
115 );
116 }
117 let verified = !done.is_empty()
118 && plan
119 .work_items
120 .keys()
121 .all(|id| done.contains(id) || scope(*id) == ProgressScope::NotSelected);
122 let completed_at = verified
123 .then(|| {
124 plan.work_items
125 .keys()
126 .filter_map(|id| timeline.completed_at(*id))
127 .reduce(EventTime::latest)
128 })
129 .flatten();
130 ProgressProjection {
131 overall: ProgressSummary {
132 percent_complete: average(&tasks, verified),
133 verified,
134 completed_at,
135 scope: ProgressScope::Counted,
136 },
137 work,
138 }
139}
140
141fn task_percent(work: &WorkItem) -> f64 {
142 if matches!(
143 work.execution.status,
144 WorkStatus::Submitted | WorkStatus::Verified | WorkStatus::Done
145 ) {
146 100.0
147 } else {
148 f64::from(work.execution.reported_progress_percent)
149 }
150}
151
152fn average(tasks: &[&WorkItem], verified: bool) -> f64 {
153 if tasks.is_empty() {
154 return if verified { 100.0 } else { 0.0 };
155 }
156 tasks
157 .iter()
158 .map(|w| task_percent(w) / tasks.len() as f64)
159 .sum::<f64>()
160 .clamp(0.0, 100.0)
161}
162
163fn within(plan: &Plan, task: &WorkItem, container: WorkItemId) -> bool {
164 let mut parent = task.parent;
165 let mut seen = BTreeSet::new();
166 while let Some(id) = parent {
167 if id == container {
168 return true;
169 }
170 if !seen.insert(id) {
171 return false;
172 }
173 parent = plan.work_items.get(&id).and_then(|w| w.parent);
174 }
175 false
176}
177
178#[cfg(test)]
179mod tests;