use super::open_board;
use crate::backlog::BacklogItem;
use crate::error::Result;
use crate::sprint::{SprintId, SprintSpillover, SprintState};
use crate::storage::{BacklogItemRepository, SprintRepository};
use rayon::prelude::*;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VelocitySprint {
pub sprint_id: SprintId,
pub sprint_title: String,
pub points: u32,
pub completed_items: usize,
pub unestimated_completed_items: usize,
pub incomplete_items: usize,
pub spillover: SprintSpillover,
}
#[derive(Debug, Clone, PartialEq)]
pub struct VelocityReport {
pub sprints: Vec<VelocitySprint>,
pub average_points: f64,
pub change_percent: Option<f64>,
}
pub async fn velocity(project_dir: &Path, recent: usize) -> Result<VelocityReport> {
let (_board_dir, repo, _config) = open_board(project_dir).await?;
let (sprints, items) = tokio::try_join!(
SprintRepository::list(&repo),
BacklogItemRepository::list(&repo),
)?;
Ok(compute_velocity(&sprints, &items, recent))
}
pub(crate) fn compute_velocity(
sprints: &[crate::sprint::Sprint],
items: &[BacklogItem],
recent: usize,
) -> VelocityReport {
let start = sprints.len().saturating_sub(recent);
let selected = &sprints[start..];
let rows: Vec<VelocitySprint> = selected
.par_iter()
.map(|sprint| {
let relevant = items
.iter()
.filter(|item| item.sprint.as_deref() == Some(sprint.id.as_str()));
let (points, completed_items, unestimated_completed_items, incomplete_items) = relevant
.fold((0_u32, 0_usize, 0_usize, 0_usize), |acc, item| {
let (points, completed, unestimated, incomplete) = acc;
match item.done_at {
Some(done_at)
if sprint.state != SprintState::Closed
|| sprint
.closed_at
.is_none_or(|closed_at| done_at <= closed_at) =>
{
match item.points {
Some(value) => (
points.saturating_add(value),
completed + 1,
unestimated,
incomplete,
),
None => (points, completed + 1, unestimated + 1, incomplete),
}
}
Some(_) => (points, completed, unestimated, incomplete),
None => (points, completed, unestimated, incomplete + 1),
}
});
VelocitySprint {
sprint_id: sprint.id.clone(),
sprint_title: sprint.title.clone(),
points,
completed_items,
unestimated_completed_items,
incomplete_items,
spillover: sprint.spillover,
}
})
.collect();
let average_points = if rows.is_empty() {
0.0
} else {
rows.iter().map(|row| f64::from(row.points)).sum::<f64>() / rows.len() as f64
};
let change_percent = rows.last().and_then(|latest| {
let prior = &rows[..rows.len().saturating_sub(1)];
if prior.is_empty() {
return None;
}
let baseline =
prior.iter().map(|row| f64::from(row.points)).sum::<f64>() / prior.len() as f64;
(baseline != 0.0).then(|| (f64::from(latest.points) - baseline) / baseline * 100.0)
});
VelocityReport {
sprints: rows,
average_points,
change_percent,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backlog::{BacklogItem, ItemId, Status};
use crate::rank::Rank;
use crate::sprint::{Sprint, SprintId};
use chrono::{DateTime, Utc};
fn now() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("valid timestamp")
}
fn sprint(id: &str) -> Sprint {
Sprint::new(SprintId::new(id).expect("valid sprint id"), id, now()).expect("valid sprint")
}
fn item(number: u32, sprint: &str, points: Option<u32>, completed: bool) -> BacklogItem {
let mut item = BacklogItem::new(
ItemId::new("T", number),
format!("Item {number}"),
Status::new("todo"),
Rank::between(None, None).expect("open bounds produce a rank"),
now(),
)
.expect("valid item");
item.sprint = Some(sprint.to_string());
item.points = points;
item.done_at = completed.then(now);
item
}
#[test]
fn computes_completed_points_and_exposes_unestimated_and_incomplete_counts() {
let sprints = [sprint("S-1")];
let items = [
item(1, "S-1", Some(3), true),
item(2, "S-1", Some(5), true),
item(3, "S-1", None, true),
item(4, "S-1", Some(2), false),
];
let report = compute_velocity(&sprints, &items, 5);
assert_eq!(report.sprints.len(), 1);
let row = &report.sprints[0];
assert_eq!(row.sprint_id.to_string(), "S-1");
assert_eq!(row.points, 8);
assert_eq!(row.completed_items, 3);
assert_eq!(row.unestimated_completed_items, 1);
assert_eq!(row.incomplete_items, 1);
assert_eq!(report.average_points, 8.0);
assert_eq!(
report.change_percent, None,
"one sprint has no comparison baseline"
);
}
#[test]
fn uses_the_most_recent_configured_sprints_and_compares_latest_to_prior_average() {
let sprints = [sprint("S-1"), sprint("S-2"), sprint("S-3")];
let items = [
item(1, "S-1", Some(2), true),
item(2, "S-2", Some(4), true),
item(3, "S-3", Some(9), true),
];
let report = compute_velocity(&sprints, &items, 2);
assert_eq!(
report
.sprints
.iter()
.map(|row| row.sprint_id.to_string())
.collect::<Vec<_>>(),
["S-2", "S-3"]
);
assert_eq!(report.average_points, 6.5);
assert_eq!(report.change_percent, Some(125.0));
}
#[test]
fn avoids_a_misleading_change_rate_when_the_prior_average_is_zero() {
let sprints = [sprint("S-1"), sprint("S-2")];
let items = [item(1, "S-2", Some(3), true)];
let report = compute_velocity(&sprints, &items, 5);
assert_eq!(report.change_percent, None);
}
#[test]
fn reports_spillover_separately_without_adding_it_to_velocity() {
let mut source = sprint("S-1");
source.spillover = crate::sprint::SprintSpillover {
points: 8,
items: 2,
unestimated_items: 1,
};
let sprints = [source, sprint("S-2")];
let items = [item(1, "S-1", Some(3), true)];
let report = compute_velocity(&sprints, &items, 2);
assert_eq!(report.sprints[0].points, 3);
assert_eq!(report.sprints[0].spillover.points, 8);
assert_eq!(report.average_points, 1.5);
assert_eq!(report.change_percent, Some(-100.0));
}
#[test]
fn excludes_retained_spillover_completed_after_the_sprint_closed() {
let mut source = sprint("S-1");
source.state = crate::sprint::SprintState::Active;
source
.close(
now() + chrono::Duration::seconds(10),
crate::sprint::SprintSpillover {
points: 5,
items: 1,
unestimated_items: 0,
},
)
.expect("close sprint");
let mut completed_in_sprint = item(1, "S-1", Some(3), true);
completed_in_sprint.done_at = Some(now() + chrono::Duration::seconds(5));
let mut completed_after_close = item(2, "S-1", Some(5), true);
completed_after_close.done_at = Some(now() + chrono::Duration::seconds(20));
let report = compute_velocity(&[source], &[completed_in_sprint, completed_after_close], 1);
assert_eq!(report.sprints[0].points, 3);
assert_eq!(report.sprints[0].completed_items, 1);
assert_eq!(report.sprints[0].spillover.points, 5);
assert_eq!(report.average_points, 3.0);
}
}