Skip to main content

pinto/service/
cycletime.rs

1//! Cycle Time / Lead Time aggregation service.
2//!
3//! Calculate Cycle Time (start → completion) and Lead Time (creation → completion) from completed
4//! item timestamps (`created`, `start_at`, and `done_at`). Return compact summary statistics. The
5//! CLI renders the results; this module remains a pure, terminal-independent aggregation service.
6
7use super::open_board;
8use crate::backlog::{BacklogItem, ItemId};
9use crate::error::Result;
10use crate::storage::BacklogItemRepository;
11use chrono::{DateTime, Duration, Utc};
12use std::path::Path;
13
14/// Optional filters for Cycle/Lead Time aggregation.
15///
16/// `sprint` selects an assigned sprint; `since` and `until` bound the completion time (`done_at`),
17/// inclusively. An unknown sprint produces an empty result rather than an error, matching
18/// `list --sprint` and `board --sprint`.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct CycleTimeFilter {
21    /// Assigned sprint to include, or all sprints when `None`.
22    pub sprint: Option<String>,
23    /// Inclusive lower bound for completion time (`done_at >= since`).
24    pub since: Option<DateTime<Utc>>,
25    /// Inclusive upper bound for completion time (`done_at <= until`).
26    pub until: Option<DateTime<Utc>>,
27}
28
29/// Summary statistics for a [`Duration`] sample.
30///
31/// Created only when at least one item contributes to the metric; otherwise the corresponding
32/// [`CycleTimeReport`] field is `None`.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DurationSummary {
35    /// Number of items in the sample (at least one).
36    pub count: usize,
37    /// Mean duration, truncated to whole seconds.
38    pub mean: Duration,
39    /// Median duration; for an even sample, average the two middle values and truncate to whole
40    /// seconds.
41    pub median: Duration,
42    /// Minimum duration.
43    pub min: Duration,
44    /// Maximum duration.
45    pub max: Duration,
46}
47
48/// Results of Cycle Time / Lead Time analysis.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct CycleTimeReport {
51    /// Cycle Time summary (`start_at` → `done_at`), or `None` when no item qualifies.
52    pub cycle: Option<DurationSummary>,
53    /// Lead Time summary (`created` → `done_at`), or `None` when no item qualifies.
54    pub lead: Option<DurationSummary>,
55    /// Completed PBIs missing `start_at`, so Cycle Time cannot be calculated; IDs are ascending.
56    ///
57    /// These items still contribute to Lead Time and are reported so missing timestamps are visible.
58    pub missing_start: Vec<ItemId>,
59    /// Number of completed PBIs after filtering (`done_at` is set).
60    pub completed: usize,
61}
62
63/// Aggregate Cycle/Lead Time for completed PBIs matching `filter` in `project_dir`.
64///
65/// Return [`crate::error::Error::NotInitialized`] for an uninitialized board. Reading and pure
66/// aggregation are kept separate in `compute_report`.
67pub async fn cycle_time(project_dir: &Path, filter: &CycleTimeFilter) -> Result<CycleTimeReport> {
68    let (_board_dir, repo, _config) = open_board(project_dir).await?;
69    let items = BacklogItemRepository::list(&repo).await?;
70    Ok(compute_report(&items, filter))
71}
72
73/// Aggregate Cycle/Lead Time from completed PBIs without performing I/O.
74///
75/// Include items with `done_at` that match the sprint and completion-time filters. Cycle Time
76/// requires `start_at`; missing values go to `missing_start`. Lead Time uses `created`, which every
77/// completed item has.
78fn compute_report(items: &[BacklogItem], filter: &CycleTimeFilter) -> CycleTimeReport {
79    let mut cycle: Vec<Duration> = Vec::new();
80    let mut lead: Vec<Duration> = Vec::new();
81    let mut missing_start: Vec<ItemId> = Vec::new();
82    let mut completed = 0usize;
83
84    for it in items {
85        // Only completed PBIs contribute to either metric.
86        let Some(done) = it.done_at else { continue };
87        // Apply the optional sprint filter.
88        if let Some(sprint) = filter.sprint.as_deref()
89            && it.sprint.as_deref() != Some(sprint)
90        {
91            continue;
92        }
93        // Apply the inclusive completion-time bounds.
94        if filter.since.is_some_and(|since| done < since) {
95            continue;
96        }
97        if filter.until.is_some_and(|until| done > until) {
98            continue;
99        }
100
101        completed += 1;
102        lead.push(done - it.created);
103        match it.start_at {
104            Some(start) => cycle.push(done - start),
105            None => missing_start.push(it.id.clone()),
106        }
107    }
108
109    // Sort IDs by prefix and number to make the warning order deterministic.
110    missing_start.sort_by(|a, b| {
111        a.prefix()
112            .cmp(b.prefix())
113            .then_with(|| a.number().cmp(&b.number()))
114    });
115
116    CycleTimeReport {
117        cycle: summarize(&mut cycle),
118        lead: summarize(&mut lead),
119        missing_start,
120        completed,
121    }
122}
123
124/// Create summary statistics from `durations`, returning `None` when it is empty. Sorts the slice
125/// in place to calculate the median; callers should not rely on the original order afterward.
126fn summarize(durations: &mut [Duration]) -> Option<DurationSummary> {
127    if durations.is_empty() {
128        return None;
129    }
130    durations.sort_unstable();
131    let count = durations.len();
132    let secs: Vec<i64> = durations.iter().map(Duration::num_seconds).collect();
133    let sum: i64 = secs.iter().sum();
134    let mean = Duration::seconds(sum / count as i64);
135    let mid = count / 2;
136    let median_secs = if count % 2 == 1 {
137        secs[mid]
138    } else {
139        (secs[mid - 1] + secs[mid]) / 2
140    };
141    Some(DurationSummary {
142        count,
143        mean,
144        median: Duration::seconds(median_secs),
145        min: durations[0],
146        max: durations[count - 1],
147    })
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::backlog::Status;
154    use crate::rank::Rank;
155
156    fn utc(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
157        chrono::NaiveDate::from_ymd_opt(y, m, d)
158            .expect("valid date")
159            .and_hms_opt(h, 0, 0)
160            .expect("valid time")
161            .and_utc()
162    }
163
164    /// Create a completed PBI fixture with explicit `created`, `start_at`, and `done_at` values.
165    fn item(
166        n: u32,
167        created: DateTime<Utc>,
168        start_at: Option<DateTime<Utc>>,
169        done_at: Option<DateTime<Utc>>,
170    ) -> BacklogItem {
171        let mut it = BacklogItem::new(
172            ItemId::new("T", n),
173            format!("Task {n}"),
174            Status::new("todo"),
175            Rank::between(None, None).expect("open bounds produce a rank"),
176            created,
177        )
178        .expect("valid item");
179        it.start_at = start_at;
180        it.done_at = done_at;
181        it
182    }
183
184    fn hours(h: i64) -> Duration {
185        Duration::hours(h)
186    }
187
188    #[test]
189    fn computes_cycle_and_lead_from_completed_items() {
190        // First item: created 7/1 0h, started 7/1 0h, completed 7/2 0h → cycle 24h, lead 24h.
191        // Second item: created 7/1 0h, started 7/2 0h, completed 7/4 0h → cycle 48h, lead 72h.
192        let items = [
193            item(
194                1,
195                utc(2026, 7, 1, 0),
196                Some(utc(2026, 7, 1, 0)),
197                Some(utc(2026, 7, 2, 0)),
198            ),
199            item(
200                2,
201                utc(2026, 7, 1, 0),
202                Some(utc(2026, 7, 2, 0)),
203                Some(utc(2026, 7, 4, 0)),
204            ),
205        ];
206        let r = compute_report(&items, &CycleTimeFilter::default());
207        assert_eq!(r.completed, 2);
208        let cycle = r.cycle.expect("cycle summary");
209        assert_eq!(cycle.count, 2);
210        assert_eq!(cycle.min, hours(24));
211        assert_eq!(cycle.max, hours(48));
212        assert_eq!(cycle.mean, hours(36));
213        let lead = r.lead.expect("lead summary");
214        assert_eq!(lead.min, hours(24));
215        assert_eq!(lead.max, hours(72));
216        assert_eq!(lead.mean, hours(48));
217        assert!(r.missing_start.is_empty());
218    }
219
220    #[test]
221    fn ignores_items_that_are_not_completed() {
222        let items = [
223            item(
224                1,
225                utc(2026, 7, 1, 0),
226                Some(utc(2026, 7, 1, 0)),
227                Some(utc(2026, 7, 2, 0)),
228            ),
229            // Uncompleted items (no done_at) are not included in the aggregation.
230            item(2, utc(2026, 7, 1, 0), Some(utc(2026, 7, 1, 0)), None),
231        ];
232        let r = compute_report(&items, &CycleTimeFilter::default());
233        assert_eq!(r.completed, 1);
234        assert_eq!(r.cycle.expect("cycle").count, 1);
235        assert_eq!(r.lead.expect("lead").count, 1);
236    }
237
238    #[test]
239    fn missing_start_is_listed_and_excluded_from_cycle_but_kept_in_lead() {
240        let items = [
241            // This item has no start_at: Cycle Time is unavailable, but Lead Time still applies.
242            item(7, utc(2026, 7, 1, 0), None, Some(utc(2026, 7, 3, 0))),
243            item(
244                3,
245                utc(2026, 7, 1, 0),
246                Some(utc(2026, 7, 1, 0)),
247                Some(utc(2026, 7, 2, 0)),
248            ),
249        ];
250        let r = compute_report(&items, &CycleTimeFilter::default());
251        assert_eq!(r.completed, 2);
252        // There is only one Cycle with start_at.
253        assert_eq!(r.cycle.expect("cycle").count, 1);
254        // Lead completed all 2 items.
255        assert_eq!(r.lead.expect("lead").count, 2);
256        assert_eq!(r.missing_start, vec![ItemId::new("T", 7)]);
257    }
258
259    #[test]
260    fn median_of_even_count_averages_the_two_middle_values() {
261        // cycle: 10h, 20h, 30h, 40h → median (20+30)/2 = 25h
262        let items = [
263            item(
264                1,
265                utc(2026, 7, 1, 0),
266                Some(utc(2026, 7, 1, 0)),
267                Some(utc(2026, 7, 1, 10)),
268            ),
269            item(
270                2,
271                utc(2026, 7, 1, 0),
272                Some(utc(2026, 7, 1, 0)),
273                Some(utc(2026, 7, 1, 20)),
274            ),
275            item(
276                3,
277                utc(2026, 7, 1, 0),
278                Some(utc(2026, 7, 1, 0)),
279                Some(utc(2026, 7, 2, 6)),
280            ),
281            item(
282                4,
283                utc(2026, 7, 1, 0),
284                Some(utc(2026, 7, 1, 0)),
285                Some(utc(2026, 7, 2, 16)),
286            ),
287        ];
288        let r = compute_report(&items, &CycleTimeFilter::default());
289        assert_eq!(r.cycle.expect("cycle").median, Duration::hours(25));
290    }
291
292    #[test]
293    fn median_of_odd_count_is_the_middle_value() {
294        let items = [
295            item(
296                1,
297                utc(2026, 7, 1, 0),
298                Some(utc(2026, 7, 1, 0)),
299                Some(utc(2026, 7, 1, 10)),
300            ),
301            item(
302                2,
303                utc(2026, 7, 1, 0),
304                Some(utc(2026, 7, 1, 0)),
305                Some(utc(2026, 7, 1, 20)),
306            ),
307            item(
308                3,
309                utc(2026, 7, 1, 0),
310                Some(utc(2026, 7, 1, 0)),
311                Some(utc(2026, 7, 2, 6)),
312            ),
313        ];
314        let r = compute_report(&items, &CycleTimeFilter::default());
315        assert_eq!(r.cycle.expect("cycle").median, Duration::hours(20));
316    }
317
318    #[test]
319    fn filters_by_sprint() {
320        let mut a = item(
321            1,
322            utc(2026, 7, 1, 0),
323            Some(utc(2026, 7, 1, 0)),
324            Some(utc(2026, 7, 2, 0)),
325        );
326        a.sprint = Some("S-1".into());
327        let mut b = item(
328            2,
329            utc(2026, 7, 1, 0),
330            Some(utc(2026, 7, 1, 0)),
331            Some(utc(2026, 7, 2, 0)),
332        );
333        b.sprint = Some("S-2".into());
334        let items = [a, b];
335        let filter = CycleTimeFilter {
336            sprint: Some("S-1".into()),
337            ..Default::default()
338        };
339        let r = compute_report(&items, &filter);
340        assert_eq!(r.completed, 1, "only S-1 items counted");
341    }
342
343    #[test]
344    fn filters_by_completion_period() {
345        let items = [
346            item(
347                1,
348                utc(2026, 7, 1, 0),
349                Some(utc(2026, 7, 1, 0)),
350                Some(utc(2026, 7, 2, 0)),
351            ),
352            item(
353                2,
354                utc(2026, 7, 1, 0),
355                Some(utc(2026, 7, 1, 0)),
356                Some(utc(2026, 7, 10, 0)),
357            ),
358        ];
359        // Only those completed after 7/5 → T-2 only.
360        let filter = CycleTimeFilter {
361            since: Some(utc(2026, 7, 5, 0)),
362            ..Default::default()
363        };
364        let r = compute_report(&items, &filter);
365        assert_eq!(r.completed, 1);
366        // Only those completed before 7/3 → T-1 only.
367        let filter = CycleTimeFilter {
368            until: Some(utc(2026, 7, 3, 0)),
369            ..Default::default()
370        };
371        let r = compute_report(&items, &filter);
372        assert_eq!(r.completed, 1);
373    }
374
375    #[test]
376    fn no_completed_items_yields_empty_summaries() {
377        let items = [item(1, utc(2026, 7, 1, 0), Some(utc(2026, 7, 1, 0)), None)];
378        let r = compute_report(&items, &CycleTimeFilter::default());
379        assert_eq!(r.completed, 0);
380        assert_eq!(r.cycle, None);
381        assert_eq!(r.lead, None);
382        assert!(r.missing_start.is_empty());
383    }
384}