1use super::open_board;
8use crate::backlog::BacklogItem;
9use crate::error::{Error, Result};
10use crate::sprint::SprintId;
11use crate::storage::{BacklogItemRepository, SprintRepository};
12use chrono::{Duration, NaiveDate};
13use std::path::Path;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum BurndownMetric {
22 Points,
24 Count,
26}
27
28impl BurndownMetric {
29 #[must_use]
31 pub fn as_str(self) -> &'static str {
32 match self {
33 BurndownMetric::Points => "points",
34 BurndownMetric::Count => "items",
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub struct BurndownDay {
42 pub date: NaiveDate,
44 pub remaining: u32,
46 pub ideal: f64,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct Burndown {
53 pub sprint_id: SprintId,
55 pub sprint_title: String,
57 pub metric: BurndownMetric,
59 pub total: u32,
61 pub days: Vec<BurndownDay>,
63}
64
65pub async fn burndown(project_dir: &Path, sprint_id: &SprintId) -> Result<Burndown> {
75 let (_board_dir, repo, _config) = open_board(project_dir).await?;
76 let sprint = SprintRepository::load(&repo, sprint_id).await?;
77
78 let (start, end) = match (sprint.start, sprint.end) {
79 (Some(s), Some(e)) => (s.date_naive(), e.date_naive()),
80 _ => return Err(Error::SprintPeriodUnset(sprint_id.clone())),
81 };
82 if end < start {
84 return Err(Error::InvalidSprintPeriod { start, end });
85 }
86
87 let mut items = BacklogItemRepository::list(&repo).await?;
88 items.retain(|it| it.sprint.as_deref() == Some(sprint_id.as_str()));
89 if items.is_empty() {
90 return Err(Error::SprintEmpty(sprint_id.clone()));
91 }
92
93 Ok(compute_burndown(
94 sprint.id,
95 sprint.title,
96 start,
97 end,
98 &items,
99 ))
100}
101
102fn compute_burndown(
109 sprint_id: SprintId,
110 sprint_title: String,
111 start: NaiveDate,
112 end: NaiveDate,
113 items: &[BacklogItem],
114) -> Burndown {
115 let metric = if !items.is_empty() && items.iter().all(|it| it.points.is_some()) {
116 BurndownMetric::Points
117 } else {
118 BurndownMetric::Count
119 };
120 let weight = |it: &BacklogItem| -> u32 {
121 match metric {
122 BurndownMetric::Points => it.points.unwrap_or(0),
123 BurndownMetric::Count => 1,
124 }
125 };
126
127 let total: u32 = items.iter().map(weight).sum();
128 let last = (end - start).num_days().max(0);
130
131 let days = (0..=last)
132 .map(|i| {
133 let date = start + Duration::days(i);
134 let completed: u32 = items
136 .iter()
137 .filter(|it| it.done_at.is_some_and(|t| t.date_naive() <= date))
138 .map(weight)
139 .sum();
140 let remaining = total.saturating_sub(completed);
141 let ideal = if last == 0 {
142 0.0
143 } else {
144 f64::from(total) * (last - i) as f64 / last as f64
145 };
146 BurndownDay {
147 date,
148 remaining,
149 ideal,
150 }
151 })
152 .collect();
153
154 Burndown {
155 sprint_id,
156 sprint_title,
157 metric,
158 total,
159 days,
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::backlog::{ItemId, Status};
167 use crate::rank::Rank;
168 use crate::service::test_support::init_temp;
169 use crate::service::{NewItem, add_item, assign_sprint, create_sprint, move_item};
170 use chrono::{DateTime, Utc};
171
172 fn sid(s: &str) -> SprintId {
173 SprintId::new(s).expect("valid sprint id")
174 }
175
176 fn day(y: i32, m: u32, d: u32) -> NaiveDate {
177 NaiveDate::from_ymd_opt(y, m, d).expect("valid date")
178 }
179
180 fn utc(y: i32, m: u32, d: u32) -> DateTime<Utc> {
181 day(y, m, d)
182 .and_hms_opt(12, 0, 0)
183 .expect("valid time")
184 .and_utc()
185 }
186
187 fn item(n: u32, points: Option<u32>, done_at: Option<DateTime<Utc>>) -> BacklogItem {
189 let mut it = BacklogItem::new(
190 ItemId::new("T", n),
191 format!("Task {n}"),
192 Status::new("todo"),
193 Rank::between(None, None).expect("open bounds produce a rank"),
194 Utc::now(),
195 )
196 .expect("valid item");
197 it.points = points;
198 it.done_at = done_at;
199 it
200 }
201
202 #[test]
205 fn uses_points_metric_when_every_item_is_estimated() {
206 let items = [item(1, Some(3), None), item(2, Some(5), None)];
207 let b = compute_burndown(
208 sid("S-1"),
209 "S".into(),
210 day(2026, 7, 6),
211 day(2026, 7, 8),
212 &items,
213 );
214 assert_eq!(b.metric, BurndownMetric::Points);
215 assert_eq!(b.total, 8);
216 }
217
218 #[test]
219 fn falls_back_to_count_metric_when_any_item_unestimated() {
220 let items = [item(1, Some(3), None), item(2, None, None)];
221 let b = compute_burndown(
222 sid("S-1"),
223 "S".into(),
224 day(2026, 7, 6),
225 day(2026, 7, 8),
226 &items,
227 );
228 assert_eq!(b.metric, BurndownMetric::Count);
229 assert_eq!(b.total, 2, "count metric weights each item as 1");
230 }
231
232 #[test]
233 fn spans_every_day_inclusive() {
234 let items = [item(1, None, None)];
235 let b = compute_burndown(
236 sid("S-1"),
237 "S".into(),
238 day(2026, 7, 6),
239 day(2026, 7, 20),
240 &items,
241 );
242 assert_eq!(b.days.len(), 15, "6th..20th inclusive is 15 days");
243 assert_eq!(b.days.first().unwrap().date, day(2026, 7, 6));
244 assert_eq!(b.days.last().unwrap().date, day(2026, 7, 20));
245 }
246
247 #[test]
248 fn remaining_drops_as_items_complete() {
249 let items = [
251 item(1, None, Some(utc(2026, 7, 7))),
252 item(2, None, Some(utc(2026, 7, 8))),
253 item(3, None, Some(utc(2026, 7, 8))),
254 ];
255 let b = compute_burndown(
256 sid("S-1"),
257 "S".into(),
258 day(2026, 7, 6),
259 day(2026, 7, 8),
260 &items,
261 );
262 let remaining: Vec<u32> = b.days.iter().map(|d| d.remaining).collect();
263 assert_eq!(remaining, [3, 2, 0]);
264 }
265
266 #[test]
267 fn points_metric_burns_down_by_points() {
268 let items = [
269 item(1, Some(3), Some(utc(2026, 7, 7))),
270 item(2, Some(5), None),
271 ];
272 let b = compute_burndown(
273 sid("S-1"),
274 "S".into(),
275 day(2026, 7, 6),
276 day(2026, 7, 8),
277 &items,
278 );
279 let remaining: Vec<u32> = b.days.iter().map(|d| d.remaining).collect();
280 assert_eq!(remaining, [8, 5, 5]);
282 }
283
284 #[test]
285 fn ideal_line_is_linear_from_total_to_zero() {
286 let items: Vec<BacklogItem> = (1..=4).map(|n| item(n, None, None)).collect();
287 let b = compute_burndown(
288 sid("S-1"),
289 "S".into(),
290 day(2026, 7, 6),
291 day(2026, 7, 10),
292 &items,
293 );
294 let ideals: Vec<f64> = b.days.iter().map(|d| d.ideal).collect();
295 assert_eq!(ideals, [4.0, 3.0, 2.0, 1.0, 0.0]);
297 }
298
299 #[test]
300 fn single_day_sprint_has_one_point_with_zero_ideal() {
301 let items = [item(1, None, None)];
302 let b = compute_burndown(
303 sid("S-1"),
304 "S".into(),
305 day(2026, 7, 6),
306 day(2026, 7, 6),
307 &items,
308 );
309 assert_eq!(b.days.len(), 1);
310 assert_eq!(b.days[0].ideal, 0.0);
311 assert_eq!(b.days[0].remaining, 1);
312 }
313
314 #[tokio::test]
317 async fn burndown_reports_period_unset_when_dates_missing() {
318 let dir = init_temp().await;
319 create_sprint(dir.path(), &sid("S-1"), "Sprint", None, None)
320 .await
321 .unwrap();
322 let item = add_item(dir.path(), "Task", NewItem::default())
323 .await
324 .unwrap();
325 assign_sprint(dir.path(), &sid("S-1"), &item.id)
326 .await
327 .unwrap();
328
329 let err = burndown(dir.path(), &sid("S-1")).await.unwrap_err();
330 assert_eq!(err, Error::SprintPeriodUnset(sid("S-1")));
331 }
332
333 #[tokio::test]
334 async fn burndown_reports_empty_when_no_items_assigned() {
335 let dir = init_temp().await;
336 create_sprint(
337 dir.path(),
338 &sid("S-1"),
339 "Sprint",
340 None,
341 Some((utc(2026, 7, 6), utc(2026, 7, 20))),
342 )
343 .await
344 .unwrap();
345
346 let err = burndown(dir.path(), &sid("S-1")).await.unwrap_err();
347 assert_eq!(err, Error::SprintEmpty(sid("S-1")));
348 }
349
350 #[tokio::test]
351 async fn burndown_missing_sprint_returns_not_found() {
352 let dir = init_temp().await;
353 let err = burndown(dir.path(), &sid("S-9")).await.unwrap_err();
354 assert_eq!(err, Error::SprintNotFound(sid("S-9")));
355 }
356
357 #[tokio::test]
358 async fn burndown_aggregates_assigned_items_only() {
359 let dir = init_temp().await;
360 let now = Utc::now();
364 let start = now - Duration::days(2);
365 let end = now;
366 create_sprint(dir.path(), &sid("S-1"), "Sprint", None, Some((start, end)))
367 .await
368 .unwrap();
369 let a = add_item(dir.path(), "A", NewItem::default()).await.unwrap();
370 let b = add_item(dir.path(), "B", NewItem::default()).await.unwrap();
371 let _c = add_item(dir.path(), "C", NewItem::default()).await.unwrap();
373 assign_sprint(dir.path(), &sid("S-1"), &a.id).await.unwrap();
374 assign_sprint(dir.path(), &sid("S-1"), &b.id).await.unwrap();
375 move_item(dir.path(), &a.id, "done").await.unwrap();
377
378 let result = burndown(dir.path(), &sid("S-1")).await.unwrap();
379 assert_eq!(result.metric, BurndownMetric::Count);
380 assert_eq!(result.total, 2, "only the two assigned items count");
381 assert_eq!(result.days.last().unwrap().remaining, 1);
383 }
384}