1use super::open_board;
4use crate::backlog::BacklogItem;
5use crate::error::Result;
6use crate::sprint::SprintId;
7use crate::storage::{BacklogItemRepository, SprintRepository};
8use rayon::prelude::*;
9use std::path::Path;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VelocitySprint {
14 pub sprint_id: SprintId,
16 pub sprint_title: String,
18 pub points: u32,
20 pub completed_items: usize,
22 pub unestimated_completed_items: usize,
24 pub incomplete_items: usize,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30pub struct VelocityReport {
31 pub sprints: Vec<VelocitySprint>,
33 pub average_points: f64,
35 pub change_percent: Option<f64>,
38}
39
40pub async fn velocity(project_dir: &Path, recent: usize) -> Result<VelocityReport> {
46 let (_board_dir, repo, _config) = open_board(project_dir).await?;
47 let (sprints, items) = tokio::try_join!(
48 SprintRepository::list(&repo),
49 BacklogItemRepository::list(&repo),
50 )?;
51 Ok(compute_velocity(&sprints, &items, recent))
52}
53
54fn compute_velocity(
56 sprints: &[crate::sprint::Sprint],
57 items: &[BacklogItem],
58 recent: usize,
59) -> VelocityReport {
60 let start = sprints.len().saturating_sub(recent);
61 let selected = &sprints[start..];
62 let rows: Vec<VelocitySprint> = selected
63 .par_iter()
64 .map(|sprint| {
65 let relevant = items
66 .iter()
67 .filter(|item| item.sprint.as_deref() == Some(sprint.id.as_str()));
68 let (points, completed_items, unestimated_completed_items, incomplete_items) = relevant
69 .fold((0_u32, 0_usize, 0_usize, 0_usize), |acc, item| {
70 let (points, completed, unestimated, incomplete) = acc;
71 if item.done_at.is_some() {
72 match item.points {
73 Some(value) => (
74 points.saturating_add(value),
75 completed + 1,
76 unestimated,
77 incomplete,
78 ),
79 None => (points, completed + 1, unestimated + 1, incomplete),
80 }
81 } else {
82 (points, completed, unestimated, incomplete + 1)
83 }
84 });
85 VelocitySprint {
86 sprint_id: sprint.id.clone(),
87 sprint_title: sprint.title.clone(),
88 points,
89 completed_items,
90 unestimated_completed_items,
91 incomplete_items,
92 }
93 })
94 .collect();
95 let average_points = if rows.is_empty() {
96 0.0
97 } else {
98 rows.iter().map(|row| f64::from(row.points)).sum::<f64>() / rows.len() as f64
99 };
100 let change_percent = rows.last().and_then(|latest| {
101 let prior = &rows[..rows.len().saturating_sub(1)];
102 if prior.is_empty() {
103 return None;
104 }
105 let baseline =
106 prior.iter().map(|row| f64::from(row.points)).sum::<f64>() / prior.len() as f64;
107 (baseline != 0.0).then(|| (f64::from(latest.points) - baseline) / baseline * 100.0)
108 });
109
110 VelocityReport {
111 sprints: rows,
112 average_points,
113 change_percent,
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::backlog::{BacklogItem, ItemId, Status};
121 use crate::rank::Rank;
122 use crate::sprint::{Sprint, SprintId};
123 use chrono::{DateTime, Utc};
124
125 fn now() -> DateTime<Utc> {
126 DateTime::from_timestamp(0, 0).expect("valid timestamp")
127 }
128
129 fn sprint(id: &str) -> Sprint {
130 Sprint::new(SprintId::new(id).expect("valid sprint id"), id, now()).expect("valid sprint")
131 }
132
133 fn item(number: u32, sprint: &str, points: Option<u32>, completed: bool) -> BacklogItem {
134 let mut item = BacklogItem::new(
135 ItemId::new("T", number),
136 format!("Item {number}"),
137 Status::new("todo"),
138 Rank::between(None, None).expect("open bounds produce a rank"),
139 now(),
140 )
141 .expect("valid item");
142 item.sprint = Some(sprint.to_string());
143 item.points = points;
144 item.done_at = completed.then(now);
145 item
146 }
147
148 #[test]
149 fn computes_completed_points_and_exposes_unestimated_and_incomplete_counts() {
150 let sprints = [sprint("S-1")];
151 let items = [
152 item(1, "S-1", Some(3), true),
153 item(2, "S-1", Some(5), true),
154 item(3, "S-1", None, true),
155 item(4, "S-1", Some(2), false),
156 ];
157
158 let report = compute_velocity(&sprints, &items, 5);
159
160 assert_eq!(report.sprints.len(), 1);
161 let row = &report.sprints[0];
162 assert_eq!(row.sprint_id.to_string(), "S-1");
163 assert_eq!(row.points, 8);
164 assert_eq!(row.completed_items, 3);
165 assert_eq!(row.unestimated_completed_items, 1);
166 assert_eq!(row.incomplete_items, 1);
167 assert_eq!(report.average_points, 8.0);
168 assert_eq!(
169 report.change_percent, None,
170 "one sprint has no comparison baseline"
171 );
172 }
173
174 #[test]
175 fn uses_the_most_recent_configured_sprints_and_compares_latest_to_prior_average() {
176 let sprints = [sprint("S-1"), sprint("S-2"), sprint("S-3")];
177 let items = [
178 item(1, "S-1", Some(2), true),
179 item(2, "S-2", Some(4), true),
180 item(3, "S-3", Some(9), true),
181 ];
182
183 let report = compute_velocity(&sprints, &items, 2);
184
185 assert_eq!(
186 report
187 .sprints
188 .iter()
189 .map(|row| row.sprint_id.to_string())
190 .collect::<Vec<_>>(),
191 ["S-2", "S-3"]
192 );
193 assert_eq!(report.average_points, 6.5);
194 assert_eq!(report.change_percent, Some(125.0));
195 }
196
197 #[test]
198 fn avoids_a_misleading_change_rate_when_the_prior_average_is_zero() {
199 let sprints = [sprint("S-1"), sprint("S-2")];
200 let items = [item(1, "S-2", Some(3), true)];
201
202 let report = compute_velocity(&sprints, &items, 5);
203
204 assert_eq!(report.change_percent, None);
205 }
206}