1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct CycleTimeFilter {
21 pub sprint: Option<String>,
23 pub since: Option<DateTime<Utc>>,
25 pub until: Option<DateTime<Utc>>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DurationSummary {
35 pub count: usize,
37 pub mean: Duration,
39 pub median: Duration,
42 pub min: Duration,
44 pub max: Duration,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct CycleTimeReport {
51 pub cycle: Option<DurationSummary>,
53 pub lead: Option<DurationSummary>,
55 pub missing_start: Vec<ItemId>,
59 pub completed: usize,
61}
62
63pub 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
73fn 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 let Some(done) = it.done_at else { continue };
87 if let Some(sprint) = filter.sprint.as_deref()
89 && it.sprint.as_deref() != Some(sprint)
90 {
91 continue;
92 }
93 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 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
124fn 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 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 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 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 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 assert_eq!(r.cycle.expect("cycle").count, 1);
254 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 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 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 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}