Skip to main content

pinto/service/
burndown.rs

1//! Sprint burndown aggregation service.
2//!
3//! Calculate daily remaining work and the ideal burndown line from the sprint period and assigned
4//! item completion times (`done_at`). Rendering belongs to the CLI layer; this module stays a pure,
5//! terminal-independent aggregation service.
6
7use 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/// The metric a burndown chart tracks.
16///
17/// When every assigned PBI has points, remaining work is measured in
18/// [`Points`](Self::Points); if even one lacks an estimate, it falls back to
19/// [`Count`](Self::Count) so a missing estimate cannot distort the chart.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum BurndownMetric {
22    /// Remaining story points (when all PBIs have points).
23    Points,
24    /// Number of remaining items (when there are PBIs with no points set).
25    Count,
26}
27
28impl BurndownMetric {
29    /// Short name for display/JSON (`points` / `items`).
30    #[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/// Observation points for one day of burndown.
40#[derive(Debug, Clone, PartialEq)]
41pub struct BurndownDay {
42    /// Target date (UTC).
43    pub date: NaiveDate,
44    /// Amount of work remaining at the end of the day (total of unfinished work).
45    pub remaining: u32,
46    /// The remaining work on the ideal line (when consumed linearly over the period).
47    pub ideal: f64,
48}
49
50/// Sprint burndown summary.
51#[derive(Debug, Clone, PartialEq)]
52pub struct Burndown {
53    /// ID of the target sprint.
54    pub sprint_id: SprintId,
55    /// Display title of the target sprint.
56    pub sprint_title: String,
57    /// Aggregated metrics (points or counts).
58    pub metric: BurndownMetric,
59    /// Total amount of work on the period start date (burndown starting point).
60    pub total: u32,
61    /// Daily observation points (in ascending order from start date to end date, inclusive).
62    pub days: Vec<BurndownDay>,
63}
64
65/// Calculate the burndown for sprint `sprint_id` in `project_dir`.
66///
67/// Use the sprint's planned schedule (`start` / `end`). Return a guidance error when required data
68/// is missing:
69/// - [`Error::SprintPeriodUnset`] if start and end are not set.
70/// - [`Error::SprintEmpty`] if there is no assigned PBI.
71///
72/// Return [`Error::NotInitialized`] for an uninitialized board or [`Error::SprintNotFound`] when
73/// the sprint does not exist.
74pub 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    // Recheck the period in case a user edited the stored sprint manually.
83    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
102/// Construct daily burndown data for `start..=end` from the assigned PBIs.
103///
104/// Use [`BurndownMetric::Points`] only when every PBI has points; otherwise use
105/// [`BurndownMetric::Count`]. Subtract work completed by each date (`done_at` on or before the
106/// target date). The ideal line interpolates linearly from the total to zero; a one-day period has
107/// an ideal value of zero at its only point. Assume `start <= end`.
108fn 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    // 0-based final index (= number of days in period - 1). 0 for a single day.
129    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            // Total completed (UTC date of done_at is before the target date) as of the end of the target date.
135            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    /// Create a test PBI (specify points and done_at arbitrarily).
188    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    // --- Pure aggregation logic ---
203
204    #[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        // 3 completed, 0 completed on the first day, 1 completed on the 2nd day, remaining 2 completed on the 3rd day.
250        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        // Total 8 points. 3pt completed on 7/7 → 8, 5, 5.
281        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        // Linear digestion of 4 items in 5 days (0..4): 4, 3, 2, 1, 0.
296        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    // --- Service layer (information on data shortage/actual data aggregation) ---
315
316    #[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        // The period is a relative date that includes "today" as the last day. `move_item` is done_at
361        // Since the execution date (today) is set, the completion will not be counted unless the final day is set to today.
362        // remaining breaks depending on execution date.
363        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        // C is not assigned (not subject to aggregation).
372        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 a to completion.
376        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        // 1 completed, 1 left on the last day.
382        assert_eq!(result.days.last().unwrap().remaining, 1);
383    }
384}