1use super::open_board;
4use crate::backlog::BacklogItem;
5use crate::error::Result;
6use crate::sprint::{SprintId, SprintSpillover, SprintState};
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 pub spillover: SprintSpillover,
28}
29
30#[derive(Debug, Clone, PartialEq)]
32pub struct VelocityReport {
33 pub sprints: Vec<VelocitySprint>,
35 pub average_points: f64,
37 pub change_percent: Option<f64>,
40}
41
42pub async fn velocity(project_dir: &Path, recent: usize) -> Result<VelocityReport> {
50 let (_board_dir, repo, _config) = open_board(project_dir).await?;
51 let (sprints, items) = tokio::try_join!(
52 SprintRepository::list(&repo),
53 BacklogItemRepository::list(&repo),
54 )?;
55 Ok(compute_velocity(&sprints, &items, recent))
56}
57
58pub(crate) fn compute_velocity(
60 sprints: &[crate::sprint::Sprint],
61 items: &[BacklogItem],
62 recent: usize,
63) -> VelocityReport {
64 let start = sprints.len().saturating_sub(recent);
65 let selected = &sprints[start..];
66 let rows: Vec<VelocitySprint> = selected
67 .par_iter()
68 .map(|sprint| {
69 let relevant = items
70 .iter()
71 .filter(|item| item.sprint.as_deref() == Some(sprint.id.as_str()));
72 let (points, completed_items, unestimated_completed_items, incomplete_items) = relevant
73 .fold((0_u32, 0_usize, 0_usize, 0_usize), |acc, item| {
74 let (points, completed, unestimated, incomplete) = acc;
75 match item.done_at {
76 Some(done_at)
77 if sprint.state != SprintState::Closed
78 || sprint
79 .closed_at
80 .is_none_or(|closed_at| done_at <= closed_at) =>
81 {
82 match item.points {
83 Some(value) => (
84 points.saturating_add(value),
85 completed + 1,
86 unestimated,
87 incomplete,
88 ),
89 None => (points, completed + 1, unestimated + 1, incomplete),
90 }
91 }
92 Some(_) => (points, completed, unestimated, incomplete),
93 None => (points, completed, unestimated, incomplete + 1),
94 }
95 });
96 VelocitySprint {
97 sprint_id: sprint.id.clone(),
98 sprint_title: sprint.title.clone(),
99 points,
100 completed_items,
101 unestimated_completed_items,
102 incomplete_items,
103 spillover: sprint.spillover,
104 }
105 })
106 .collect();
107 let average_points = if rows.is_empty() {
108 0.0
109 } else {
110 rows.iter().map(|row| f64::from(row.points)).sum::<f64>() / rows.len() as f64
111 };
112 let change_percent = rows.last().and_then(|latest| {
113 let prior = &rows[..rows.len().saturating_sub(1)];
114 if prior.is_empty() {
115 return None;
116 }
117 let baseline =
118 prior.iter().map(|row| f64::from(row.points)).sum::<f64>() / prior.len() as f64;
119 (baseline != 0.0).then(|| (f64::from(latest.points) - baseline) / baseline * 100.0)
120 });
121
122 VelocityReport {
123 sprints: rows,
124 average_points,
125 change_percent,
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::backlog::{BacklogItem, ItemId, Status};
133 use crate::rank::Rank;
134 use crate::sprint::{Sprint, SprintId};
135 use chrono::{DateTime, Utc};
136
137 fn now() -> DateTime<Utc> {
138 DateTime::from_timestamp(0, 0).expect("valid timestamp")
139 }
140
141 fn sprint(id: &str) -> Sprint {
142 Sprint::new(SprintId::new(id).expect("valid sprint id"), id, now()).expect("valid sprint")
143 }
144
145 fn item(number: u32, sprint: &str, points: Option<u32>, completed: bool) -> BacklogItem {
146 let mut item = BacklogItem::new(
147 ItemId::new("T", number),
148 format!("Item {number}"),
149 Status::new("todo"),
150 Rank::between(None, None).expect("open bounds produce a rank"),
151 now(),
152 )
153 .expect("valid item");
154 item.sprint = Some(sprint.to_string());
155 item.points = points;
156 item.done_at = completed.then(now);
157 item
158 }
159
160 #[test]
161 fn computes_completed_points_and_exposes_unestimated_and_incomplete_counts() {
162 let sprints = [sprint("S-1")];
163 let items = [
164 item(1, "S-1", Some(3), true),
165 item(2, "S-1", Some(5), true),
166 item(3, "S-1", None, true),
167 item(4, "S-1", Some(2), false),
168 ];
169
170 let report = compute_velocity(&sprints, &items, 5);
171
172 assert_eq!(report.sprints.len(), 1);
173 let row = &report.sprints[0];
174 assert_eq!(row.sprint_id.to_string(), "S-1");
175 assert_eq!(row.points, 8);
176 assert_eq!(row.completed_items, 3);
177 assert_eq!(row.unestimated_completed_items, 1);
178 assert_eq!(row.incomplete_items, 1);
179 assert_eq!(report.average_points, 8.0);
180 assert_eq!(
181 report.change_percent, None,
182 "one sprint has no comparison baseline"
183 );
184 }
185
186 #[test]
187 fn uses_the_most_recent_configured_sprints_and_compares_latest_to_prior_average() {
188 let sprints = [sprint("S-1"), sprint("S-2"), sprint("S-3")];
189 let items = [
190 item(1, "S-1", Some(2), true),
191 item(2, "S-2", Some(4), true),
192 item(3, "S-3", Some(9), true),
193 ];
194
195 let report = compute_velocity(&sprints, &items, 2);
196
197 assert_eq!(
198 report
199 .sprints
200 .iter()
201 .map(|row| row.sprint_id.to_string())
202 .collect::<Vec<_>>(),
203 ["S-2", "S-3"]
204 );
205 assert_eq!(report.average_points, 6.5);
206 assert_eq!(report.change_percent, Some(125.0));
207 }
208
209 #[test]
210 fn avoids_a_misleading_change_rate_when_the_prior_average_is_zero() {
211 let sprints = [sprint("S-1"), sprint("S-2")];
212 let items = [item(1, "S-2", Some(3), true)];
213
214 let report = compute_velocity(&sprints, &items, 5);
215
216 assert_eq!(report.change_percent, None);
217 }
218
219 #[test]
220 fn reports_spillover_separately_without_adding_it_to_velocity() {
221 let mut source = sprint("S-1");
222 source.spillover = crate::sprint::SprintSpillover {
223 points: 8,
224 items: 2,
225 unestimated_items: 1,
226 };
227 let sprints = [source, sprint("S-2")];
228 let items = [item(1, "S-1", Some(3), true)];
229
230 let report = compute_velocity(&sprints, &items, 2);
231
232 assert_eq!(report.sprints[0].points, 3);
233 assert_eq!(report.sprints[0].spillover.points, 8);
234 assert_eq!(report.average_points, 1.5);
235 assert_eq!(report.change_percent, Some(-100.0));
236 }
237
238 #[test]
239 fn excludes_retained_spillover_completed_after_the_sprint_closed() {
240 let mut source = sprint("S-1");
241 source.state = crate::sprint::SprintState::Active;
242 source
243 .close(
244 now() + chrono::Duration::seconds(10),
245 crate::sprint::SprintSpillover {
246 points: 5,
247 items: 1,
248 unestimated_items: 0,
249 },
250 )
251 .expect("close sprint");
252 let mut completed_in_sprint = item(1, "S-1", Some(3), true);
253 completed_in_sprint.done_at = Some(now() + chrono::Duration::seconds(5));
254 let mut completed_after_close = item(2, "S-1", Some(5), true);
255 completed_after_close.done_at = Some(now() + chrono::Duration::seconds(20));
256
257 let report = compute_velocity(&[source], &[completed_in_sprint, completed_after_close], 1);
258
259 assert_eq!(report.sprints[0].points, 3);
260 assert_eq!(report.sprints[0].completed_items, 1);
261 assert_eq!(report.sprints[0].spillover.points, 5);
262 assert_eq!(report.average_points, 3.0);
263 }
264}