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, SprintSpillover, SprintState};
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    /// Close-time unfinished-work snapshot, excluded from velocity calculations.
27    pub spillover: SprintSpillover,
28}
29
30/// Velocity summary for the selected recent sprints.
31#[derive(Debug, Clone, PartialEq)]
32pub struct VelocityReport {
33    /// Selected sprints in creation order, limited to the most recent `recent` entries.
34    pub sprints: Vec<VelocitySprint>,
35    /// Average completed points across the selected sprints.
36    pub average_points: f64,
37    /// Percentage change from the previous-sprint average to the latest sprint, or `None` when no
38    /// comparison is possible.
39    pub change_percent: Option<f64>,
40}
41
42/// Aggregate velocity for the most recent `recent` sprints in `project_dir`.
43///
44/// Count only completed PBIs (`done_at` is set) with points in the total. For a Sprint with an
45/// actual close time, exclude PBIs completed later so retained spillover cannot change historical
46/// velocity. Return unestimated, incomplete, and close-time spillover counts separately. Compare
47/// the latest sprint with the average of the preceding selected sprints; return no percentage when
48/// the baseline is zero or unavailable.
49pub 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
58/// Calculate velocity from already loaded sprints and PBIs.
59pub(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}