Skip to main content

pinto/service/
velocity.rs

1//! Sprint velocity aggregation service.
2
3use 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/// Velocity and estimate coverage for one sprint.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VelocitySprint {
14    /// Stable sprint ID.
15    pub sprint_id: SprintId,
16    /// Sprint display title.
17    pub sprint_title: String,
18    /// Total points for completed PBIs that have estimates.
19    pub points: u32,
20    /// Number of completed PBIs, including unestimated items.
21    pub completed_items: usize,
22    /// Number of completed PBIs without estimates.
23    pub unestimated_completed_items: usize,
24    /// Number of incomplete PBIs in the sprint.
25    pub incomplete_items: usize,
26}
27
28/// Velocity summary for the selected recent sprints.
29#[derive(Debug, Clone, PartialEq)]
30pub struct VelocityReport {
31    /// Selected sprints in creation order, limited to the most recent `recent` entries.
32    pub sprints: Vec<VelocitySprint>,
33    /// Average completed points across the selected sprints.
34    pub average_points: f64,
35    /// Percentage change from the previous-sprint average to the latest sprint, or `None` when no
36    /// comparison is possible.
37    pub change_percent: Option<f64>,
38}
39
40/// Aggregate velocity for the most recent `recent` sprints in `project_dir`.
41///
42/// Count only completed PBIs (`done_at` is set) with points in the total. Return unestimated and
43/// incomplete counts separately. Compare the latest sprint with the average of the preceding
44/// selected sprints; return no percentage when the baseline is zero or unavailable.
45pub 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
54/// Calculate velocity from already loaded sprints and PBIs.
55fn 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}