use super::open_board;
use crate::backlog::BacklogItem;
use crate::error::{Error, Result};
use crate::sprint::SprintId;
use crate::storage::{BacklogItemRepository, SprintRepository};
use chrono::{Duration, NaiveDate};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BurndownMetric {
Points,
Count,
}
impl BurndownMetric {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
BurndownMetric::Points => "points",
BurndownMetric::Count => "items",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BurndownDay {
pub date: NaiveDate,
pub remaining: u32,
pub ideal: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Burndown {
pub sprint_id: SprintId,
pub sprint_title: String,
pub metric: BurndownMetric,
pub total: u32,
pub days: Vec<BurndownDay>,
}
pub async fn burndown(project_dir: &Path, sprint_id: &SprintId) -> Result<Burndown> {
let (_board_dir, repo, _config) = open_board(project_dir).await?;
let sprint = SprintRepository::load(&repo, sprint_id).await?;
let (start, end) = match (sprint.start, sprint.end) {
(Some(s), Some(e)) => (s.date_naive(), e.date_naive()),
_ => return Err(Error::SprintPeriodUnset(sprint_id.clone())),
};
if end < start {
return Err(Error::InvalidSprintPeriod { start, end });
}
let mut items = BacklogItemRepository::list(&repo).await?;
items.retain(|it| it.sprint.as_deref() == Some(sprint_id.as_str()));
if items.is_empty() {
return Err(Error::SprintEmpty(sprint_id.clone()));
}
Ok(compute_burndown(
sprint.id,
sprint.title,
start,
end,
&items,
))
}
fn compute_burndown(
sprint_id: SprintId,
sprint_title: String,
start: NaiveDate,
end: NaiveDate,
items: &[BacklogItem],
) -> Burndown {
let metric = if !items.is_empty() && items.iter().all(|it| it.points.is_some()) {
BurndownMetric::Points
} else {
BurndownMetric::Count
};
let weight = |it: &BacklogItem| -> u32 {
match metric {
BurndownMetric::Points => it.points.unwrap_or(0),
BurndownMetric::Count => 1,
}
};
let total: u32 = items.iter().map(weight).sum();
let last = (end - start).num_days().max(0);
let days = (0..=last)
.map(|i| {
let date = start + Duration::days(i);
let completed: u32 = items
.iter()
.filter(|it| it.done_at.is_some_and(|t| t.date_naive() <= date))
.map(weight)
.sum();
let remaining = total.saturating_sub(completed);
let ideal = if last == 0 {
0.0
} else {
f64::from(total) * (last - i) as f64 / last as f64
};
BurndownDay {
date,
remaining,
ideal,
}
})
.collect();
Burndown {
sprint_id,
sprint_title,
metric,
total,
days,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backlog::{ItemId, Status};
use crate::rank::Rank;
use crate::service::test_support::init_temp;
use crate::service::{NewItem, add_item, assign_sprint, create_sprint, move_item};
use chrono::{DateTime, Utc};
fn sid(s: &str) -> SprintId {
SprintId::new(s).expect("valid sprint id")
}
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
}
fn utc(y: i32, m: u32, d: u32) -> DateTime<Utc> {
day(y, m, d)
.and_hms_opt(12, 0, 0)
.expect("valid time")
.and_utc()
}
fn item(n: u32, points: Option<u32>, done_at: Option<DateTime<Utc>>) -> BacklogItem {
let mut it = BacklogItem::new(
ItemId::new("T", n),
format!("Task {n}"),
Status::new("todo"),
Rank::between(None, None).expect("open bounds produce a rank"),
Utc::now(),
)
.expect("valid item");
it.points = points;
it.done_at = done_at;
it
}
#[test]
fn uses_points_metric_when_every_item_is_estimated() {
let items = [item(1, Some(3), None), item(2, Some(5), None)];
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 8),
&items,
);
assert_eq!(b.metric, BurndownMetric::Points);
assert_eq!(b.total, 8);
}
#[test]
fn falls_back_to_count_metric_when_any_item_unestimated() {
let items = [item(1, Some(3), None), item(2, None, None)];
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 8),
&items,
);
assert_eq!(b.metric, BurndownMetric::Count);
assert_eq!(b.total, 2, "count metric weights each item as 1");
}
#[test]
fn spans_every_day_inclusive() {
let items = [item(1, None, None)];
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 20),
&items,
);
assert_eq!(b.days.len(), 15, "6th..20th inclusive is 15 days");
assert_eq!(b.days.first().unwrap().date, day(2026, 7, 6));
assert_eq!(b.days.last().unwrap().date, day(2026, 7, 20));
}
#[test]
fn remaining_drops_as_items_complete() {
let items = [
item(1, None, Some(utc(2026, 7, 7))),
item(2, None, Some(utc(2026, 7, 8))),
item(3, None, Some(utc(2026, 7, 8))),
];
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 8),
&items,
);
let remaining: Vec<u32> = b.days.iter().map(|d| d.remaining).collect();
assert_eq!(remaining, [3, 2, 0]);
}
#[test]
fn points_metric_burns_down_by_points() {
let items = [
item(1, Some(3), Some(utc(2026, 7, 7))),
item(2, Some(5), None),
];
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 8),
&items,
);
let remaining: Vec<u32> = b.days.iter().map(|d| d.remaining).collect();
assert_eq!(remaining, [8, 5, 5]);
}
#[test]
fn ideal_line_is_linear_from_total_to_zero() {
let items: Vec<BacklogItem> = (1..=4).map(|n| item(n, None, None)).collect();
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 10),
&items,
);
let ideals: Vec<f64> = b.days.iter().map(|d| d.ideal).collect();
assert_eq!(ideals, [4.0, 3.0, 2.0, 1.0, 0.0]);
}
#[test]
fn single_day_sprint_has_one_point_with_zero_ideal() {
let items = [item(1, None, None)];
let b = compute_burndown(
sid("S-1"),
"S".into(),
day(2026, 7, 6),
day(2026, 7, 6),
&items,
);
assert_eq!(b.days.len(), 1);
assert_eq!(b.days[0].ideal, 0.0);
assert_eq!(b.days[0].remaining, 1);
}
#[tokio::test]
async fn burndown_reports_period_unset_when_dates_missing() {
let dir = init_temp().await;
create_sprint(dir.path(), &sid("S-1"), "Sprint", None, None)
.await
.unwrap();
let item = add_item(dir.path(), "Task", NewItem::default())
.await
.unwrap();
assign_sprint(dir.path(), &sid("S-1"), &item.id)
.await
.unwrap();
let err = burndown(dir.path(), &sid("S-1")).await.unwrap_err();
assert_eq!(err, Error::SprintPeriodUnset(sid("S-1")));
}
#[tokio::test]
async fn burndown_reports_empty_when_no_items_assigned() {
let dir = init_temp().await;
create_sprint(
dir.path(),
&sid("S-1"),
"Sprint",
None,
Some((utc(2026, 7, 6), utc(2026, 7, 20))),
)
.await
.unwrap();
let err = burndown(dir.path(), &sid("S-1")).await.unwrap_err();
assert_eq!(err, Error::SprintEmpty(sid("S-1")));
}
#[tokio::test]
async fn burndown_missing_sprint_returns_not_found() {
let dir = init_temp().await;
let err = burndown(dir.path(), &sid("S-9")).await.unwrap_err();
assert_eq!(err, Error::SprintNotFound(sid("S-9")));
}
#[tokio::test]
async fn burndown_aggregates_assigned_items_only() {
let dir = init_temp().await;
let now = Utc::now();
let start = now - Duration::days(2);
let end = now;
create_sprint(dir.path(), &sid("S-1"), "Sprint", None, Some((start, end)))
.await
.unwrap();
let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
let _c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
assign_sprint(dir.path(), &sid("S-1"), &a.id).await.unwrap();
assign_sprint(dir.path(), &sid("S-1"), &b.id).await.unwrap();
move_item(dir.path(), &a.id, "done").await.unwrap();
let result = burndown(dir.path(), &sid("S-1")).await.unwrap();
assert_eq!(result.metric, BurndownMetric::Count);
assert_eq!(result.total, 2, "only the two assigned items count");
assert_eq!(result.days.last().unwrap().remaining, 1);
}
}