1use std::collections::{BTreeMap, HashMap};
2use std::path::Path;
3
4use chrono::{DateTime, Datelike, IsoWeek, NaiveDate, Utc};
5use serde::Serialize;
6
7use super::{Board, Priority};
8
9#[derive(Debug, Serialize)]
11pub struct BoardMetrics {
12 pub wip_per_column: Vec<WipEntry>,
14 pub active_wip_total: usize,
16 pub blocked_count: u32,
18 pub blocked_pct: f64,
20 pub throughput_per_week: Vec<(String, u32)>,
22 pub throughput_stddev: Option<f64>,
24 pub arrival_per_week: Vec<(String, u32)>,
26 pub time_stats: Option<TimeStats>,
28 pub priority_breakdown: Vec<(Priority, u32)>,
30 pub total_completed: u32,
32 pub since: DateTime<Utc>,
34 pub work_item_age: Option<WorkItemAgeStats>,
36 pub stage_times: Option<StageTimeStats>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct WipEntry {
43 pub name: String,
44 pub count: usize,
45 pub wip_limit: Option<u32>,
46 pub is_active: bool,
48}
49
50#[derive(Debug, Serialize)]
52pub struct TimeStats {
53 pub lead_avg_days: f64,
55 pub lead_median_days: f64,
56 pub lead_min_days: f64,
57 pub lead_max_days: f64,
58 pub lead_p85_days: f64,
59 pub cycle_avg_days: f64,
61 pub cycle_median_days: f64,
62 pub cycle_min_days: f64,
63 pub cycle_max_days: f64,
64 pub cycle_p85_days: f64,
65}
66
67#[derive(Debug, Serialize)]
69pub struct WorkItemAgeStats {
70 pub count: usize,
71 pub avg_age_days: f64,
72 pub aging_cards: Vec<AgingCard>,
73}
74
75#[derive(Debug, Serialize)]
77pub struct AgingCard {
78 pub id: String,
79 pub title: String,
80 pub column: String,
81 pub age_days: f64,
82}
83
84#[derive(Debug, Serialize)]
86pub struct StageTimeStats {
87 pub columns: Vec<StageTimeEntry>,
88 pub card_count: usize,
89}
90
91#[derive(Debug, Serialize)]
93pub struct StageTimeEntry {
94 pub name: String,
95 pub avg_days: f64,
96 pub median_days: f64,
97 pub p85_days: f64,
98 pub sample_count: usize,
99 pub is_active: bool,
100}
101
102struct MoveEvent {
104 ts: DateTime<Utc>,
105 card_id: String,
106 from: String,
107 to: String,
108}
109
110pub fn compute_metrics(board: &Board, since: Option<DateTime<Utc>>, kando_dir: Option<&Path>) -> BoardMetrics {
112 let now = Utc::now();
113
114 let done_col_idx = board.columns.iter().position(|c| c.slug == "done");
116
117 let wip_per_column: Vec<WipEntry> = board
118 .columns
119 .iter()
120 .enumerate()
121 .map(|(idx, col)| {
122 let is_active = idx > 0 && done_col_idx.is_none_or(|d| idx < d);
123 WipEntry {
124 name: col.name.clone(),
125 count: col.cards.len(),
126 wip_limit: col.wip_limit,
127 is_active,
128 }
129 })
130 .collect();
131
132 let active_wip_total: usize = wip_per_column
133 .iter()
134 .filter(|w| w.is_active)
135 .map(|w| w.count)
136 .sum();
137
138 let blocked_count: u32 = board
140 .columns
141 .iter()
142 .enumerate()
143 .filter(|(idx, col)| *idx > 0 && col.slug != "done")
144 .flat_map(|(_, col)| col.cards.iter())
145 .filter(|card| card.is_blocked())
146 .count() as u32;
147
148 let blocked_pct = if active_wip_total > 0 {
149 blocked_count as f64 / active_wip_total as f64 * 100.0
150 } else {
151 0.0
152 };
153
154 let effective_since = since
156 .or(board.created_at)
157 .or_else(|| oldest_card_created(board))
158 .unwrap_or_else(Utc::now);
159
160 let done_cards: Vec<_> = board
162 .columns
163 .iter()
164 .filter(|col| col.slug == "done")
165 .flat_map(|col| col.cards.iter())
166 .filter(|card| {
167 card.completed
168 .is_some_and(|completed| completed >= effective_since)
169 })
170 .collect();
171
172 let total_completed = done_cards.len() as u32;
173
174 let mut week_counts: BTreeMap<IsoWeek, u32> = BTreeMap::new();
176 for card in &done_cards {
177 if let Some(completed) = card.completed {
178 let week = completed.date_naive().iso_week();
179 *week_counts.entry(week).or_insert(0) += 1;
180 }
181 }
182
183 let throughput_per_week = if week_counts.is_empty() {
184 Vec::new()
185 } else {
186 fill_week_gaps(&week_counts)
187 };
188
189 let throughput_stddev = if throughput_per_week.len() >= 2 {
191 let counts: Vec<f64> = throughput_per_week.iter().map(|(_, c)| *c as f64).collect();
192 let mean = counts.iter().sum::<f64>() / counts.len() as f64;
193 let variance = counts.iter().map(|c| (c - mean).powi(2)).sum::<f64>() / counts.len() as f64;
194 Some(variance.sqrt())
195 } else {
196 None
197 };
198
199 let mut arrival_counts: BTreeMap<IsoWeek, u32> = BTreeMap::new();
201 for col in &board.columns {
202 for card in &col.cards {
203 if card.created >= effective_since {
204 let week = card.created.date_naive().iso_week();
205 *arrival_counts.entry(week).or_insert(0) += 1;
206 }
207 }
208 }
209 let arrival_per_week = if arrival_counts.is_empty() {
210 Vec::new()
211 } else {
212 fill_week_gaps(&arrival_counts)
213 };
214
215 let mut lead_days: Vec<f64> = done_cards
217 .iter()
218 .filter_map(|card| {
219 let completed = card.completed?;
220 let hours = (completed - card.created).num_hours();
221 Some(hours as f64 / 24.0)
222 })
223 .collect();
224
225 let mut cycle_days: Vec<f64> = done_cards
226 .iter()
227 .filter_map(|card| {
228 let completed = card.completed?;
229 let start = card.started.unwrap_or(card.created);
230 let hours = (completed - start).num_hours();
231 Some(hours as f64 / 24.0)
232 })
233 .collect();
234
235 let time_stats = if lead_days.is_empty() {
236 None
237 } else {
238 lead_days.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
239 cycle_days.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
240
241 Some(TimeStats {
242 lead_avg_days: avg(&lead_days),
243 lead_median_days: median(&lead_days),
244 lead_min_days: lead_days[0],
245 lead_max_days: lead_days[lead_days.len() - 1],
246 lead_p85_days: percentile(&lead_days, 85),
247 cycle_avg_days: avg(&cycle_days),
248 cycle_median_days: median(&cycle_days),
249 cycle_min_days: cycle_days[0],
250 cycle_max_days: cycle_days[cycle_days.len() - 1],
251 cycle_p85_days: percentile(&cycle_days, 85),
252 })
253 };
254
255 let mut priority_counts = [0u32; 4]; for card in &done_cards {
258 priority_counts[card.priority.sort_key() as usize] += 1;
259 }
260 let priority_breakdown = Priority::ALL
261 .iter()
262 .map(|p| (*p, priority_counts[p.sort_key() as usize]))
263 .filter(|(_, count)| *count > 0)
264 .collect();
265
266 let active_cards: Vec<_> = board
268 .columns
269 .iter()
270 .enumerate()
271 .filter(|(idx, col)| *idx > 0 && col.slug != "done")
272 .flat_map(|(_, col)| col.cards.iter().map(move |card| (col.name.clone(), card)))
273 .collect();
274
275 let work_item_age = if active_cards.is_empty() {
276 None
277 } else {
278 let ages: Vec<f64> = active_cards
279 .iter()
280 .map(|(_, card)| {
281 let start = card.started.unwrap_or(card.created);
282 (now - start).num_hours() as f64 / 24.0
283 })
284 .collect();
285 let avg_age = avg(&ages);
286
287 let p85_threshold = time_stats.as_ref().map(|ts| ts.cycle_p85_days);
288
289 let aging_cards: Vec<AgingCard> = active_cards
290 .iter()
291 .zip(ages.iter())
292 .filter(|(_, age)| p85_threshold.is_some_and(|p| **age > p))
293 .map(|((col_name, card), age)| AgingCard {
294 id: card.id.clone(),
295 title: card.title.clone(),
296 column: col_name.clone(),
297 age_days: *age,
298 })
299 .collect();
300
301 Some(WorkItemAgeStats {
302 count: active_cards.len(),
303 avg_age_days: avg_age,
304 aging_cards,
305 })
306 };
307
308 let stage_times = kando_dir.and_then(|dir| compute_stage_times(board, dir));
309
310 BoardMetrics {
311 wip_per_column,
312 active_wip_total,
313 blocked_count,
314 blocked_pct,
315 throughput_per_week,
316 throughput_stddev,
317 arrival_per_week,
318 time_stats,
319 priority_breakdown,
320 total_completed,
321 since: effective_since,
322 work_item_age,
323 stage_times,
324 }
325}
326
327fn parse_move_events(kando_dir: &Path) -> Vec<MoveEvent> {
331 let log_path = kando_dir.join("activity.log");
332 let content = match std::fs::read_to_string(&log_path) {
333 Ok(c) => c,
334 Err(_) => return Vec::new(),
335 };
336
337 let mut events = Vec::new();
338 for line in content.lines() {
339 let v: serde_json::Value = match serde_json::from_str(line) {
340 Ok(v) => v,
341 Err(_) => continue,
342 };
343 if v.get("action").and_then(|a| a.as_str()) != Some("move") {
344 continue;
345 }
346 let Some(ts_str) = v.get("ts").and_then(|t| t.as_str()) else {
347 continue;
348 };
349 let Ok(ts) = ts_str.parse::<DateTime<Utc>>() else {
350 continue;
351 };
352 let Some(card_id) = v.get("id").and_then(|i| i.as_str()) else {
353 continue;
354 };
355 let Some(from) = v.get("from").and_then(|f| f.as_str()) else {
356 continue;
357 };
358 let Some(to) = v.get("to").and_then(|t| t.as_str()) else {
359 continue;
360 };
361 events.push(MoveEvent {
362 ts,
363 card_id: card_id.to_string(),
364 from: from.to_string(),
365 to: to.to_string(),
366 });
367 }
368 events.sort_by_key(|e| e.ts);
369 events
370}
371
372fn build_name_rename_map(kando_dir: &Path) -> HashMap<String, String> {
376 let log_path = kando_dir.join("activity.log");
377 let content = match std::fs::read_to_string(&log_path) {
378 Ok(c) => c,
379 Err(_) => return HashMap::new(),
380 };
381
382 let mut renames: Vec<(String, String)> = Vec::new();
384 for line in content.lines() {
385 let v: serde_json::Value = match serde_json::from_str(line) {
386 Ok(v) => v,
387 Err(_) => continue,
388 };
389 if v.get("action").and_then(|a| a.as_str()) != Some("col-rename") {
390 continue;
391 }
392 let Some(from_name) = v.get("from_name").and_then(|f| f.as_str()) else {
393 continue; };
395 let Some(to_name) = v.get("title").and_then(|t| t.as_str()) else {
396 continue;
397 };
398 renames.push((from_name.to_string(), to_name.to_string()));
399 }
400
401 let mut map: HashMap<String, String> = HashMap::new();
403 for (old, new) in renames {
404 for val in map.values_mut() {
406 if *val == old {
407 *val = new.clone();
408 }
409 }
410 if old != new {
412 map.insert(old, new);
413 }
414 }
415 map.retain(|k, v| k != v);
417 map
418}
419
420fn compute_stage_times(board: &Board, kando_dir: &Path) -> Option<StageTimeStats> {
422 let events = parse_move_events(kando_dir);
423 if events.is_empty() {
424 return None;
425 }
426
427 let mut events_by_card: HashMap<&str, Vec<&MoveEvent>> = HashMap::new();
429 for event in &events {
430 events_by_card
431 .entry(&event.card_id)
432 .or_default()
433 .push(event);
434 }
435
436 let mut completed_cards: HashMap<&str, (&super::Card, DateTime<Utc>)> = HashMap::new();
438 for col in &board.columns {
439 for card in &col.cards {
440 if let Some(completed) = card.completed {
441 completed_cards.insert(&card.id, (card, completed));
442 }
443 }
444 }
445
446 let mut durations: HashMap<String, Vec<f64>> = HashMap::new();
448 let mut card_count = 0usize;
449
450 for (card_id, moves) in &events_by_card {
451 let Some(&(card, completed)) = completed_cards.get(card_id) else {
452 continue;
453 };
454
455 card_count += 1;
456
457 let first_move = moves[0];
459 let dt = (first_move.ts - card.created).num_seconds().max(0) as f64 / 86400.0;
460 durations
461 .entry(first_move.from.clone())
462 .or_default()
463 .push(dt);
464
465 for pair in moves.windows(2) {
467 let dt = (pair[1].ts - pair[0].ts).num_seconds().max(0) as f64 / 86400.0;
468 durations.entry(pair[0].to.clone()).or_default().push(dt);
469 }
470
471 let last_move = moves.last().unwrap();
473 let dt = (completed - last_move.ts).num_seconds().max(0) as f64 / 86400.0;
474 durations
475 .entry(last_move.to.clone())
476 .or_default()
477 .push(dt);
478 }
479
480 if card_count == 0 {
481 return None;
482 }
483
484 let rename_map = build_name_rename_map(kando_dir);
486 if !rename_map.is_empty() {
487 let mut remapped: HashMap<String, Vec<f64>> = HashMap::new();
488 for (name, values) in durations {
489 let current_name = rename_map.get(&name).cloned().unwrap_or(name);
490 remapped.entry(current_name).or_default().extend(values);
491 }
492 durations = remapped;
493 }
494
495 let done_col_idx = board.columns.iter().position(|c| c.slug == "done");
497
498 let mut entries: Vec<StageTimeEntry> = Vec::new();
500
501 for (idx, col) in board.columns.iter().enumerate() {
502 if let Some(mut sorted) = durations.remove(&col.name) {
503 sort_f64(&mut sorted);
504 let is_active = idx > 0 && done_col_idx.is_none_or(|d| idx < d);
505 entries.push(StageTimeEntry {
506 name: col.name.clone(),
507 avg_days: avg(&sorted),
508 median_days: median(&sorted),
509 p85_days: percentile(&sorted, 85),
510 sample_count: sorted.len(),
511 is_active,
512 });
513 }
514 }
515
516 let mut historical: Vec<(String, Vec<f64>)> = durations.into_iter().collect();
518 historical.sort_by(|(a, _), (b, _)| a.cmp(b));
519 for (name, mut sorted) in historical {
520 sort_f64(&mut sorted);
521 entries.push(StageTimeEntry {
522 name,
523 avg_days: avg(&sorted),
524 median_days: median(&sorted),
525 p85_days: percentile(&sorted, 85),
526 sample_count: sorted.len(),
527 is_active: false,
528 });
529 }
530
531 Some(StageTimeStats {
532 columns: entries,
533 card_count,
534 })
535}
536
537fn sort_f64(values: &mut [f64]) {
540 values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
541}
542
543fn avg(values: &[f64]) -> f64 {
544 if values.is_empty() {
545 return 0.0;
546 }
547 values.iter().sum::<f64>() / values.len() as f64
548}
549
550fn median(sorted: &[f64]) -> f64 {
551 let n = sorted.len();
552 if n == 0 {
553 return 0.0;
554 }
555 if n % 2 == 0 {
556 (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
557 } else {
558 sorted[n / 2]
559 }
560}
561
562fn percentile(sorted: &[f64], pct: usize) -> f64 {
563 let n = sorted.len();
564 if n == 0 {
565 return 0.0;
566 }
567 sorted[(n * pct / 100).min(n - 1)]
568}
569
570fn oldest_card_created(board: &Board) -> Option<DateTime<Utc>> {
574 board
575 .columns
576 .iter()
577 .flat_map(|col| col.cards.iter())
578 .map(|card| card.created)
579 .min()
580}
581
582fn format_week(week: IsoWeek) -> String {
584 format!("{}-W{:02}", week.year(), week.week())
585}
586
587fn fill_week_gaps(counts: &BTreeMap<IsoWeek, u32>) -> Vec<(String, u32)> {
589 let first_week = *counts.keys().next().unwrap();
590 let last_week = *counts.keys().next_back().unwrap();
591
592 let mut result = Vec::new();
593 let mut current = monday_of_week(first_week);
594 let end = monday_of_week(last_week);
595
596 while current <= end {
597 let week = current.iso_week();
598 let count = counts.get(&week).copied().unwrap_or(0);
599 result.push((format_week(week), count));
600 current += chrono::TimeDelta::weeks(1);
601 }
602
603 result
604}
605
606fn monday_of_week(week: IsoWeek) -> NaiveDate {
608 NaiveDate::from_isoywd_opt(week.year(), week.week(), chrono::Weekday::Mon)
609 .expect("valid ISO week")
610}
611
612pub fn format_text(metrics: &BoardMetrics) -> String {
616 let mut out = String::new();
617
618 out.push_str(&format!("Board Metrics (since {})\n", metrics.since.format("%Y-%m-%d")));
620 out.push_str(&format!("Total completed: {}\n", metrics.total_completed));
621 out.push('\n');
622
623 if let Some(ref ts) = metrics.time_stats {
625 out.push_str("Lead Time (created → done)\n");
626 out.push_str(&format!(" Average: {:.1} days\n", ts.lead_avg_days));
627 out.push_str(&format!(" Median: {:.1} days\n", ts.lead_median_days));
628 out.push_str(&format!(" P85: {:.1} days\n", ts.lead_p85_days));
629 out.push_str(&format!(" Min: {:.1} days\n", ts.lead_min_days));
630 out.push_str(&format!(" Max: {:.1} days\n", ts.lead_max_days));
631 out.push('\n');
632
633 out.push_str("Cycle Time (started → done)\n");
634 out.push_str(&format!(" Average: {:.1} days\n", ts.cycle_avg_days));
635 out.push_str(&format!(" Median: {:.1} days\n", ts.cycle_median_days));
636 out.push_str(&format!(" P85: {:.1} days\n", ts.cycle_p85_days));
637 out.push_str(&format!(" Min: {:.1} days\n", ts.cycle_min_days));
638 out.push_str(&format!(" Max: {:.1} days\n", ts.cycle_max_days));
639 out.push('\n');
640 }
641
642 if let Some(ref st) = metrics.stage_times {
644 out.push_str(&format!("Stage Time ({} cards with move data)\n", st.card_count));
645 let max_name = st.columns.iter().map(|e| e.name.len()).max().unwrap_or(0).max(10);
646 out.push_str(&format!(
647 " {:<width$} {:>6} {:>6} {:>6}\n",
648 "", "Avg", "Median", "P85",
649 width = max_name,
650 ));
651 for entry in &st.columns {
652 out.push_str(&format!(
653 " {:<nw$} {:>5.1}d {:>5.1}d {:>5.1}d (n={})\n",
654 entry.name,
655 entry.avg_days,
656 entry.median_days,
657 entry.p85_days,
658 entry.sample_count,
659 nw = max_name,
660 ));
661 }
662 out.push('\n');
663 }
664
665 out.push_str(&format!("WIP per Column (Active: {})\n", metrics.active_wip_total));
667 let max_name_len = metrics
668 .wip_per_column
669 .iter()
670 .map(|w| w.name.len())
671 .max()
672 .unwrap_or(0)
673 .max(10);
674 for entry in &metrics.wip_per_column {
675 if let Some(limit) = entry.wip_limit {
676 out.push_str(&format!(" {:<width$} {}/{}\n", entry.name, entry.count, limit, width = max_name_len));
677 } else {
678 out.push_str(&format!(" {:<width$} {}\n", entry.name, entry.count, width = max_name_len));
679 }
680 }
681 if metrics.blocked_count > 0 {
682 out.push_str(&format!(" Blocked: {} ({:.1}% of active WIP)\n", metrics.blocked_count, metrics.blocked_pct));
683 }
684 out.push('\n');
685
686 if !metrics.throughput_per_week.is_empty() {
688 out.push_str("Throughput (cards/week)\n");
689 let max_count = metrics.throughput_per_week.iter().map(|(_, c)| *c).max().unwrap_or(1);
690 let bar_max = 30;
691 for (i, (label, count)) in metrics.throughput_per_week.iter().enumerate() {
692 let bar_len = if max_count > 0 {
693 (*count as usize * bar_max) / max_count as usize
694 } else {
695 0
696 };
697 let bar: String = "\u{2588}".repeat(bar_len);
698 let arrival = metrics.arrival_per_week.get(i).map_or(0, |(_, c)| *c);
699 out.push_str(&format!(" {:<10} {bar} {count} (arrived: {arrival})\n", label));
700 }
701 if let Some(stddev) = metrics.throughput_stddev {
702 out.push_str(&format!(" Variability: stddev {:.1} cards/week\n", stddev));
703 }
704 out.push('\n');
705 }
706
707 if let Some(ref wia) = metrics.work_item_age {
709 out.push_str("Work Item Age\n");
710 out.push_str(&format!(" In-progress items: {}\n", wia.count));
711 out.push_str(&format!(" Average age: {:.1} days\n", wia.avg_age_days));
712 if !wia.aging_cards.is_empty() {
713 out.push_str(&format!(" Aging (>P85): {} card(s)\n", wia.aging_cards.len()));
714 for card in &wia.aging_cards {
715 out.push_str(&format!(" #{} \"{}\" in {} ({:.1} days)\n", card.id, card.title, card.column, card.age_days));
716 }
717 }
718 out.push('\n');
719 }
720
721 if !metrics.priority_breakdown.is_empty() {
723 out.push_str("Priority Breakdown\n");
724 for (priority, count) in &metrics.priority_breakdown {
725 out.push_str(&format!(" {}: {count}\n", priority.as_str()));
726 }
727 out.push('\n');
728 }
729
730 out
731}
732
733pub fn format_csv(metrics: &BoardMetrics) -> String {
735 let mut out = String::new();
736
737 out.push_str("week,completed,arrived\n");
739 for (i, (label, count)) in metrics.throughput_per_week.iter().enumerate() {
740 let arrival = metrics.arrival_per_week.get(i).map_or(0, |(_, c)| *c);
741 out.push_str(&format!("{label},{count},{arrival}\n"));
742 }
743 out.push('\n');
744
745 out.push_str("metric,value\n");
747 out.push_str(&format!("total_completed,{}\n", metrics.total_completed));
748 out.push_str(&format!("active_wip,{}\n", metrics.active_wip_total));
749 out.push_str(&format!("blocked_count,{}\n", metrics.blocked_count));
750 out.push_str(&format!("blocked_pct,{:.1}\n", metrics.blocked_pct));
751 if let Some(ref ts) = metrics.time_stats {
752 out.push_str(&format!("lead_avg_days,{:.1}\n", ts.lead_avg_days));
753 out.push_str(&format!("lead_median_days,{:.1}\n", ts.lead_median_days));
754 out.push_str(&format!("lead_p85_days,{:.1}\n", ts.lead_p85_days));
755 out.push_str(&format!("lead_min_days,{:.1}\n", ts.lead_min_days));
756 out.push_str(&format!("lead_max_days,{:.1}\n", ts.lead_max_days));
757 out.push_str(&format!("cycle_avg_days,{:.1}\n", ts.cycle_avg_days));
758 out.push_str(&format!("cycle_median_days,{:.1}\n", ts.cycle_median_days));
759 out.push_str(&format!("cycle_p85_days,{:.1}\n", ts.cycle_p85_days));
760 out.push_str(&format!("cycle_min_days,{:.1}\n", ts.cycle_min_days));
761 out.push_str(&format!("cycle_max_days,{:.1}\n", ts.cycle_max_days));
762 }
763 if let Some(stddev) = metrics.throughput_stddev {
764 out.push_str(&format!("throughput_stddev,{:.1}\n", stddev));
765 }
766
767 out.push('\n');
769 out.push_str("column,wip,limit,active\n");
770 for entry in &metrics.wip_per_column {
771 let limit = entry.wip_limit.map_or("-".to_string(), |l| l.to_string());
772 out.push_str(&format!("{},{},{},{}\n", entry.name, entry.count, limit, entry.is_active));
773 }
774
775 if let Some(ref st) = metrics.stage_times {
777 out.push('\n');
778 out.push_str("stage_column,avg_days,median_days,p85_days,samples,is_active\n");
779 for entry in &st.columns {
780 out.push_str(&format!(
781 "{},{:.1},{:.1},{:.1},{},{}\n",
782 entry.name,
783 entry.avg_days,
784 entry.median_days,
785 entry.p85_days,
786 entry.sample_count,
787 entry.is_active,
788 ));
789 }
790 }
791
792 out
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798 use crate::board::{Card, Column, Policies};
799
800 fn make_board(columns: Vec<Column>) -> Board {
801 Board {
802 name: "Test".into(),
803 next_card_id: 100,
804 policies: Policies::default(),
805 sync_branch: None,
806
807 nerd_font: false,
808 created_at: None,
809 columns,
810 }
811 }
812
813 fn make_column(slug: &str, name: &str, cards: Vec<Card>) -> Column {
814 Column {
815 slug: slug.into(),
816 name: name.into(),
817 order: 0,
818 wip_limit: None,
819 hidden: false,
820 cards,
821 }
822 }
823
824 fn make_column_with_limit(slug: &str, name: &str, cards: Vec<Card>, limit: u32) -> Column {
825 Column {
826 slug: slug.into(),
827 name: name.into(),
828 order: 0,
829 wip_limit: Some(limit),
830 hidden: false,
831 cards,
832 }
833 }
834
835 fn card_completed_at(id: &str, created: DateTime<Utc>, completed: DateTime<Utc>) -> Card {
836 let mut c = Card::new(id.into(), format!("Card {id}"));
837 c.created = created;
838 c.updated = completed;
839 c.completed = Some(completed);
840 c
841 }
842
843 fn card_started_completed(id: &str, created: DateTime<Utc>, started: DateTime<Utc>, completed: DateTime<Utc>) -> Card {
844 let mut c = Card::new(id.into(), format!("Card {id}"));
845 c.created = created;
846 c.started = Some(started);
847 c.updated = completed;
848 c.completed = Some(completed);
849 c
850 }
851
852 #[test]
855 fn empty_board_yields_zero_metrics() {
856 let board = make_board(vec![
857 make_column("backlog", "Backlog", vec![]),
858 make_column("done", "Done", vec![]),
859 ]);
860 let metrics = compute_metrics(&board, None, None);
861 assert_eq!(metrics.total_completed, 0);
862 assert!(metrics.time_stats.is_none());
863 assert!(metrics.throughput_per_week.is_empty());
864 assert!(metrics.priority_breakdown.is_empty());
865 }
866
867 #[test]
868 fn wip_counts_match_column_cards() {
869 let board = make_board(vec![
870 make_column("backlog", "Backlog", vec![
871 Card::new("1".into(), "A".into()),
872 Card::new("2".into(), "B".into()),
873 ]),
874 make_column("in-progress", "In Progress", vec![
875 Card::new("3".into(), "C".into()),
876 ]),
877 make_column("done", "Done", vec![]),
878 ]);
879 let metrics = compute_metrics(&board, None, None);
880 assert_eq!(metrics.wip_per_column[0].count, 2);
881 assert_eq!(metrics.wip_per_column[1].count, 1);
882 assert_eq!(metrics.wip_per_column[2].count, 0);
883 }
884
885 #[test]
886 fn completed_cards_counted_correctly() {
887 let now = Utc::now();
888 let five_days_ago = now - chrono::TimeDelta::days(5);
889 let three_days_ago = now - chrono::TimeDelta::days(3);
890
891 let board = make_board(vec![
892 make_column("backlog", "Backlog", vec![]),
893 make_column("done", "Done", vec![
894 card_completed_at("1", five_days_ago - chrono::TimeDelta::days(2), five_days_ago),
895 card_completed_at("2", three_days_ago - chrono::TimeDelta::days(1), three_days_ago),
896 ]),
897 ]);
898 let metrics = compute_metrics(&board, None, None);
899 assert_eq!(metrics.total_completed, 2);
900 }
901
902 #[test]
903 fn lead_time_math_correct() {
904 let now = Utc::now();
905 let created = now - chrono::TimeDelta::days(5);
906
907 let board = make_board(vec![
908 make_column("done", "Done", vec![
909 card_completed_at("1", created, now),
910 ]),
911 ]);
912 let metrics = compute_metrics(&board, None, None);
913 let ts = metrics.time_stats.unwrap();
914 assert!((ts.lead_avg_days - 5.0).abs() < 0.1, "expected ~5.0, got {}", ts.lead_avg_days);
915 assert!((ts.lead_median_days - 5.0).abs() < 0.1);
916 assert!((ts.lead_min_days - 5.0).abs() < 0.1);
917 assert!((ts.lead_max_days - 5.0).abs() < 0.1);
918 }
919
920 #[test]
921 fn cards_without_completed_excluded() {
922 let mut card = Card::new("1".into(), "Incomplete".into());
923 card.completed = None;
924
925 let board = make_board(vec![
926 make_column("done", "Done", vec![card]),
927 ]);
928 let metrics = compute_metrics(&board, None, None);
929 assert_eq!(metrics.total_completed, 0);
930 assert!(metrics.time_stats.is_none());
931 }
932
933 #[test]
934 fn lookback_filters_old_completions() {
935 let now = Utc::now();
936 let two_weeks_ago = now - chrono::TimeDelta::weeks(2);
937 let five_weeks_ago = now - chrono::TimeDelta::weeks(5);
938
939 let board = make_board(vec![
940 make_column("done", "Done", vec![
941 card_completed_at("1", five_weeks_ago - chrono::TimeDelta::days(3), five_weeks_ago),
942 card_completed_at("2", two_weeks_ago - chrono::TimeDelta::days(1), two_weeks_ago),
943 ]),
944 ]);
945
946 let since = now - chrono::TimeDelta::weeks(3);
947 let metrics = compute_metrics(&board, Some(since), None);
948 assert_eq!(metrics.total_completed, 1);
949 }
950
951 #[test]
952 fn priority_breakdown_counts() {
953 let now = Utc::now();
954 let created = now - chrono::TimeDelta::days(1);
955
956 let mut high_card = card_completed_at("1", created, now);
957 high_card.priority = Priority::High;
958 let mut urgent_card = card_completed_at("2", created, now);
959 urgent_card.priority = Priority::Urgent;
960 let normal_card = card_completed_at("3", created, now);
961
962 let board = make_board(vec![
963 make_column("done", "Done", vec![high_card, urgent_card, normal_card]),
964 ]);
965 let metrics = compute_metrics(&board, None, None);
966
967 assert_eq!(metrics.total_completed, 3);
968 let breakdown: std::collections::HashMap<Priority, u32> =
969 metrics.priority_breakdown.into_iter().collect();
970 assert_eq!(breakdown.get(&Priority::High), Some(&1));
971 assert_eq!(breakdown.get(&Priority::Urgent), Some(&1));
972 assert_eq!(breakdown.get(&Priority::Normal), Some(&1));
973 }
974
975 #[test]
976 fn throughput_fills_week_gaps() {
977 let now = Utc::now();
978 let three_weeks_ago = now - chrono::TimeDelta::weeks(3);
979
980 let board = make_board(vec![
981 make_column("done", "Done", vec![
982 card_completed_at("1", three_weeks_ago - chrono::TimeDelta::days(1), three_weeks_ago),
983 card_completed_at("2", now - chrono::TimeDelta::days(1), now),
984 ]),
985 ]);
986
987 let metrics = compute_metrics(&board, None, None);
988 assert!(
989 metrics.throughput_per_week.len() >= 3,
990 "expected >= 3 weeks, got {}",
991 metrics.throughput_per_week.len()
992 );
993 assert!(metrics.throughput_per_week.first().unwrap().1 > 0);
994 assert!(metrics.throughput_per_week.last().unwrap().1 > 0);
995 }
996
997 #[test]
998 fn format_text_contains_key_sections() {
999 let now = Utc::now();
1000 let created = now - chrono::TimeDelta::days(3);
1001
1002 let board = make_board(vec![
1003 make_column("backlog", "Backlog", vec![Card::new("1".into(), "A".into())]),
1004 make_column("done", "Done", vec![card_completed_at("2", created, now)]),
1005 ]);
1006 let metrics = compute_metrics(&board, None, None);
1007 let text = format_text(&metrics);
1008
1009 assert!(text.contains("Board Metrics"));
1010 assert!(text.contains("Total completed: 1"));
1011 assert!(text.contains("Lead Time"));
1012 assert!(text.contains("Cycle Time"));
1013 assert!(text.contains("WIP per Column"));
1014 }
1015
1016 #[test]
1017 fn format_csv_has_headers() {
1018 let board = make_board(vec![
1019 make_column("done", "Done", vec![]),
1020 ]);
1021 let metrics = compute_metrics(&board, None, None);
1022 let csv = format_csv(&metrics);
1023
1024 assert!(csv.contains("week,completed,arrived"));
1025 assert!(csv.contains("metric,value"));
1026 assert!(csv.contains("column,wip,limit,active"));
1027 }
1028
1029 #[test]
1030 fn median_even_number_of_cards() {
1031 let now = Utc::now();
1032 let board = make_board(vec![
1033 make_column("done", "Done", vec![
1034 card_completed_at("1", now - chrono::TimeDelta::days(2), now),
1035 card_completed_at("2", now - chrono::TimeDelta::days(4), now),
1036 card_completed_at("3", now - chrono::TimeDelta::days(6), now),
1037 card_completed_at("4", now - chrono::TimeDelta::days(8), now),
1038 ]),
1039 ]);
1040 let metrics = compute_metrics(&board, None, None);
1041 let ts = metrics.time_stats.unwrap();
1042 assert!((ts.lead_avg_days - 5.0).abs() < 0.1);
1043 assert!((ts.lead_median_days - 5.0).abs() < 0.1);
1044 assert!((ts.lead_min_days - 2.0).abs() < 0.1);
1045 assert!((ts.lead_max_days - 8.0).abs() < 0.1);
1046 }
1047
1048 #[test]
1049 fn board_created_at_used_as_effective_since() {
1050 let now = Utc::now();
1051 let created_at = now - chrono::TimeDelta::weeks(4);
1052 let old_completion = now - chrono::TimeDelta::weeks(6);
1053 let recent_completion = now - chrono::TimeDelta::weeks(2);
1054
1055 let mut board = make_board(vec![
1056 make_column("done", "Done", vec![
1057 card_completed_at("1", old_completion - chrono::TimeDelta::days(1), old_completion),
1058 card_completed_at("2", recent_completion - chrono::TimeDelta::days(1), recent_completion),
1059 ]),
1060 ]);
1061 board.created_at = Some(created_at);
1062
1063 let metrics = compute_metrics(&board, None, None);
1064 assert_eq!(metrics.total_completed, 1);
1065 }
1066
1067 #[test]
1068 fn multiple_completions_same_week() {
1069 let now = Utc::now();
1070 let board = make_board(vec![
1071 make_column("done", "Done", vec![
1072 card_completed_at("1", now - chrono::TimeDelta::days(2), now),
1073 card_completed_at("2", now - chrono::TimeDelta::days(3), now),
1074 card_completed_at("3", now - chrono::TimeDelta::days(1), now),
1075 ]),
1076 ]);
1077 let metrics = compute_metrics(&board, None, None);
1078 assert_eq!(metrics.total_completed, 3);
1079 assert_eq!(metrics.throughput_per_week.len(), 1);
1080 assert_eq!(metrics.throughput_per_week[0].1, 3);
1081 }
1082
1083 #[test]
1084 fn cards_in_non_done_columns_not_counted() {
1085 let now = Utc::now();
1086 let mut rogue_card = Card::new("1".into(), "Rogue".into());
1087 rogue_card.completed = Some(now);
1088
1089 let board = make_board(vec![
1090 make_column("backlog", "Backlog", vec![rogue_card]),
1091 make_column("done", "Done", vec![]),
1092 ]);
1093 let metrics = compute_metrics(&board, None, None);
1094 assert_eq!(metrics.total_completed, 0);
1095 }
1096
1097 #[test]
1098 fn format_csv_includes_time_metrics() {
1099 let now = Utc::now();
1100 let created = now - chrono::TimeDelta::days(3);
1101
1102 let board = make_board(vec![
1103 make_column("done", "Done", vec![card_completed_at("1", created, now)]),
1104 ]);
1105 let metrics = compute_metrics(&board, None, None);
1106 let csv = format_csv(&metrics);
1107
1108 assert!(csv.contains("lead_avg_days,"));
1109 assert!(csv.contains("lead_p85_days,"));
1110 assert!(csv.contains("cycle_avg_days,"));
1111 assert!(csv.contains("cycle_p85_days,"));
1112 }
1113
1114 #[test]
1115 fn format_text_no_throughput_when_no_completions() {
1116 let board = make_board(vec![
1117 make_column("backlog", "Backlog", vec![Card::new("1".into(), "A".into())]),
1118 make_column("done", "Done", vec![]),
1119 ]);
1120 let metrics = compute_metrics(&board, None, None);
1121 let text = format_text(&metrics);
1122
1123 assert!(text.contains("Total completed: 0"));
1124 assert!(!text.contains("Throughput"));
1125 }
1126
1127 #[test]
1130 fn lead_time_uses_created() {
1131 let now = Utc::now();
1132 let created = now - chrono::TimeDelta::days(10);
1133 let started = now - chrono::TimeDelta::days(5);
1134
1135 let board = make_board(vec![
1136 make_column("done", "Done", vec![
1137 card_started_completed("1", created, started, now),
1138 ]),
1139 ]);
1140 let metrics = compute_metrics(&board, None, None);
1141 let ts = metrics.time_stats.unwrap();
1142 assert!((ts.lead_avg_days - 10.0).abs() < 0.1, "lead time should use created, got {}", ts.lead_avg_days);
1143 }
1144
1145 #[test]
1146 fn cycle_time_uses_started() {
1147 let now = Utc::now();
1148 let created = now - chrono::TimeDelta::days(10);
1149 let started = now - chrono::TimeDelta::days(5);
1150
1151 let board = make_board(vec![
1152 make_column("done", "Done", vec![
1153 card_started_completed("1", created, started, now),
1154 ]),
1155 ]);
1156 let metrics = compute_metrics(&board, None, None);
1157 let ts = metrics.time_stats.unwrap();
1158 assert!((ts.cycle_avg_days - 5.0).abs() < 0.1, "cycle time should use started, got {}", ts.cycle_avg_days);
1159 }
1160
1161 #[test]
1162 fn cycle_time_falls_back_to_created() {
1163 let now = Utc::now();
1164 let created = now - chrono::TimeDelta::days(7);
1165
1166 let board = make_board(vec![
1168 make_column("done", "Done", vec![
1169 card_completed_at("1", created, now),
1170 ]),
1171 ]);
1172 let metrics = compute_metrics(&board, None, None);
1173 let ts = metrics.time_stats.unwrap();
1174 assert!((ts.cycle_avg_days - ts.lead_avg_days).abs() < 0.01);
1176 }
1177
1178 #[test]
1179 fn lead_and_cycle_differ_when_started_set() {
1180 let now = Utc::now();
1181 let created = now - chrono::TimeDelta::days(10);
1182 let started = now - chrono::TimeDelta::days(3);
1183
1184 let board = make_board(vec![
1185 make_column("done", "Done", vec![
1186 card_started_completed("1", created, started, now),
1187 ]),
1188 ]);
1189 let metrics = compute_metrics(&board, None, None);
1190 let ts = metrics.time_stats.unwrap();
1191 assert!((ts.lead_avg_days - 10.0).abs() < 0.1);
1192 assert!((ts.cycle_avg_days - 3.0).abs() < 0.1);
1193 assert!(ts.lead_avg_days > ts.cycle_avg_days);
1194 }
1195
1196 #[test]
1199 fn p85_calculation_correct() {
1200 let now = Utc::now();
1201 let cards: Vec<Card> = (1..=20).map(|d| {
1203 card_completed_at(&d.to_string(), now - chrono::TimeDelta::days(d), now)
1204 }).collect();
1205
1206 let board = make_board(vec![make_column("done", "Done", cards)]);
1207 let metrics = compute_metrics(&board, None, None);
1208 let ts = metrics.time_stats.unwrap();
1209 assert!((ts.lead_p85_days - 18.0).abs() < 0.2, "p85 expected ~18, got {}", ts.lead_p85_days);
1211 }
1212
1213 #[test]
1214 fn p85_single_card() {
1215 let now = Utc::now();
1216 let created = now - chrono::TimeDelta::days(5);
1217
1218 let board = make_board(vec![
1219 make_column("done", "Done", vec![card_completed_at("1", created, now)]),
1220 ]);
1221 let metrics = compute_metrics(&board, None, None);
1222 let ts = metrics.time_stats.unwrap();
1223 assert!((ts.lead_p85_days - 5.0).abs() < 0.1);
1224 }
1225
1226 #[test]
1229 fn active_wip_excludes_backlog_and_done() {
1230 let board = make_board(vec![
1231 make_column("backlog", "Backlog", vec![
1232 Card::new("1".into(), "A".into()),
1233 Card::new("2".into(), "B".into()),
1234 ]),
1235 make_column("in-progress", "In Progress", vec![
1236 Card::new("3".into(), "C".into()),
1237 ]),
1238 make_column("review", "Review", vec![
1239 Card::new("4".into(), "D".into()),
1240 Card::new("5".into(), "E".into()),
1241 ]),
1242 make_column("done", "Done", vec![
1243 Card::new("6".into(), "F".into()),
1244 ]),
1245 ]);
1246 let metrics = compute_metrics(&board, None, None);
1247 assert_eq!(metrics.active_wip_total, 3);
1249 assert!(!metrics.wip_per_column[0].is_active); assert!(metrics.wip_per_column[1].is_active); assert!(metrics.wip_per_column[2].is_active); assert!(!metrics.wip_per_column[3].is_active); }
1254
1255 #[test]
1256 fn wip_entry_includes_limit() {
1257 let board = make_board(vec![
1258 make_column("backlog", "Backlog", vec![]),
1259 make_column_with_limit("in-progress", "In Progress", vec![
1260 Card::new("1".into(), "A".into()),
1261 ], 3),
1262 make_column("done", "Done", vec![]),
1263 ]);
1264 let metrics = compute_metrics(&board, None, None);
1265 assert_eq!(metrics.wip_per_column[1].wip_limit, Some(3));
1266 assert_eq!(metrics.wip_per_column[1].count, 1);
1267 }
1268
1269 #[test]
1270 fn wip_over_limit_detection() {
1271 let board = make_board(vec![
1272 make_column("backlog", "Backlog", vec![]),
1273 make_column_with_limit("in-progress", "In Progress", vec![
1274 Card::new("1".into(), "A".into()),
1275 Card::new("2".into(), "B".into()),
1276 Card::new("3".into(), "C".into()),
1277 Card::new("4".into(), "D".into()),
1278 ], 3),
1279 make_column("done", "Done", vec![]),
1280 ]);
1281 let metrics = compute_metrics(&board, None, None);
1282 let entry = &metrics.wip_per_column[1];
1283 assert_eq!(entry.count, 4);
1284 assert_eq!(entry.wip_limit, Some(3));
1285 assert!(entry.wip_limit.is_some_and(|l| entry.count as u32 >= l));
1286 }
1287
1288 #[test]
1291 fn blocked_count_only_active_columns() {
1292 let mut blocked_in_backlog = Card::new("1".into(), "A".into());
1293 blocked_in_backlog.blocked = Some(String::new());
1294 let mut blocked_in_progress = Card::new("2".into(), "B".into());
1295 blocked_in_progress.blocked = Some(String::new());
1296 let not_blocked = Card::new("3".into(), "C".into());
1297
1298 let board = make_board(vec![
1299 make_column("backlog", "Backlog", vec![blocked_in_backlog]),
1300 make_column("in-progress", "In Progress", vec![blocked_in_progress, not_blocked]),
1301 make_column("done", "Done", vec![]),
1302 ]);
1303 let metrics = compute_metrics(&board, None, None);
1304 assert_eq!(metrics.blocked_count, 1);
1306 }
1307
1308 #[test]
1309 fn blocked_pct_calculation() {
1310 let mut blocked = Card::new("1".into(), "A".into());
1311 blocked.blocked = Some(String::new());
1312
1313 let board = make_board(vec![
1314 make_column("backlog", "Backlog", vec![]),
1315 make_column("in-progress", "In Progress", vec![
1316 blocked,
1317 Card::new("2".into(), "B".into()),
1318 Card::new("3".into(), "C".into()),
1319 Card::new("4".into(), "D".into()),
1320 Card::new("5".into(), "E".into()),
1321 ]),
1322 make_column("done", "Done", vec![]),
1323 ]);
1324 let metrics = compute_metrics(&board, None, None);
1325 assert_eq!(metrics.blocked_count, 1);
1326 assert!((metrics.blocked_pct - 20.0).abs() < 0.1, "expected 20%, got {}", metrics.blocked_pct);
1327 }
1328
1329 #[test]
1330 fn blocked_zero_when_no_active_wip() {
1331 let board = make_board(vec![
1332 make_column("backlog", "Backlog", vec![Card::new("1".into(), "A".into())]),
1333 make_column("done", "Done", vec![]),
1334 ]);
1335 let metrics = compute_metrics(&board, None, None);
1336 assert_eq!(metrics.blocked_count, 0);
1337 assert!((metrics.blocked_pct - 0.0).abs() < 0.01);
1338 }
1339
1340 #[test]
1343 fn work_item_age_calculates_from_started() {
1344 let now = Utc::now();
1345 let mut card = Card::new("1".into(), "Active".into());
1346 card.created = now - chrono::TimeDelta::days(10);
1347 card.started = Some(now - chrono::TimeDelta::days(5));
1348
1349 let board = make_board(vec![
1350 make_column("backlog", "Backlog", vec![]),
1351 make_column("in-progress", "In Progress", vec![card]),
1352 make_column("done", "Done", vec![]),
1353 ]);
1354 let metrics = compute_metrics(&board, None, None);
1355 let wia = metrics.work_item_age.unwrap();
1356 assert_eq!(wia.count, 1);
1357 assert!((wia.avg_age_days - 5.0).abs() < 0.2, "age should use started, got {}", wia.avg_age_days);
1358 }
1359
1360 #[test]
1361 fn work_item_age_falls_back_to_created() {
1362 let now = Utc::now();
1363 let mut card = Card::new("1".into(), "Active".into());
1364 card.created = now - chrono::TimeDelta::days(7);
1365 let board = make_board(vec![
1368 make_column("backlog", "Backlog", vec![]),
1369 make_column("in-progress", "In Progress", vec![card]),
1370 make_column("done", "Done", vec![]),
1371 ]);
1372 let metrics = compute_metrics(&board, None, None);
1373 let wia = metrics.work_item_age.unwrap();
1374 assert!((wia.avg_age_days - 7.0).abs() < 0.2, "age should fall back to created, got {}", wia.avg_age_days);
1375 }
1376
1377 #[test]
1378 fn aging_cards_flagged_when_exceeding_p85() {
1379 let now = Utc::now();
1380
1381 let done_cards: Vec<Card> = (1..=10).map(|d| {
1383 let created = now - chrono::TimeDelta::days(d);
1384 let started = created + chrono::TimeDelta::days(0); card_started_completed(&d.to_string(), created, started, now)
1386 }).collect();
1387
1388 let mut old_card = Card::new("99".into(), "Old task".into());
1391 old_card.created = now - chrono::TimeDelta::days(15);
1392 old_card.started = Some(now - chrono::TimeDelta::days(15));
1393
1394 let board = make_board(vec![
1395 make_column("backlog", "Backlog", vec![]),
1396 make_column("in-progress", "In Progress", vec![old_card]),
1397 make_column("done", "Done", done_cards),
1398 ]);
1399 let metrics = compute_metrics(&board, None, None);
1400 let wia = metrics.work_item_age.unwrap();
1401 assert_eq!(wia.aging_cards.len(), 1);
1402 assert_eq!(wia.aging_cards[0].id, "99");
1403 }
1404
1405 #[test]
1406 fn no_aging_when_no_time_stats() {
1407 let mut card = Card::new("1".into(), "Active".into());
1409 card.started = Some(Utc::now() - chrono::TimeDelta::days(100));
1410
1411 let board = make_board(vec![
1412 make_column("backlog", "Backlog", vec![]),
1413 make_column("in-progress", "In Progress", vec![card]),
1414 make_column("done", "Done", vec![]),
1415 ]);
1416 let metrics = compute_metrics(&board, None, None);
1417 let wia = metrics.work_item_age.unwrap();
1418 assert!(wia.aging_cards.is_empty(), "no aging without p85 threshold");
1419 }
1420
1421 #[test]
1424 fn arrival_rate_counts_all_columns() {
1425 let now = Utc::now();
1426 let mut card_backlog = Card::new("1".into(), "A".into());
1427 card_backlog.created = now - chrono::TimeDelta::days(1);
1428 let mut card_ip = Card::new("2".into(), "B".into());
1429 card_ip.created = now - chrono::TimeDelta::days(1);
1430 let mut card_done = Card::new("3".into(), "C".into());
1431 card_done.created = now - chrono::TimeDelta::days(1);
1432 card_done.completed = Some(now);
1433
1434 let board = make_board(vec![
1435 make_column("backlog", "Backlog", vec![card_backlog]),
1436 make_column("in-progress", "In Progress", vec![card_ip]),
1437 make_column("done", "Done", vec![card_done]),
1438 ]);
1439 let metrics = compute_metrics(&board, None, None);
1440 let total_arrived: u32 = metrics.arrival_per_week.iter().map(|(_, c)| *c).sum();
1442 assert_eq!(total_arrived, 3);
1443 }
1444
1445 #[test]
1446 fn arrival_rate_respects_since_window() {
1447 let now = Utc::now();
1448 let mut old_card = Card::new("1".into(), "Old".into());
1449 old_card.created = now - chrono::TimeDelta::weeks(10);
1450 let mut recent_card = Card::new("2".into(), "New".into());
1451 recent_card.created = now - chrono::TimeDelta::days(1);
1452
1453 let board = make_board(vec![
1454 make_column("backlog", "Backlog", vec![old_card, recent_card]),
1455 make_column("done", "Done", vec![]),
1456 ]);
1457 let since = now - chrono::TimeDelta::weeks(2);
1458 let metrics = compute_metrics(&board, Some(since), None);
1459 let total_arrived: u32 = metrics.arrival_per_week.iter().map(|(_, c)| *c).sum();
1460 assert_eq!(total_arrived, 1, "only cards after since should be counted");
1461 }
1462
1463 #[test]
1466 fn throughput_stddev_calculation() {
1467 let now = Utc::now();
1468 let week1 = now - chrono::TimeDelta::weeks(1);
1470 let week2 = now;
1471
1472 let board = make_board(vec![
1473 make_column("done", "Done", vec![
1474 card_completed_at("1", week1 - chrono::TimeDelta::days(5), week1),
1475 card_completed_at("2", week1 - chrono::TimeDelta::days(3), week1),
1476 card_completed_at("3", week2 - chrono::TimeDelta::days(2), week2),
1477 card_completed_at("4", week2 - chrono::TimeDelta::days(1), week2),
1478 card_completed_at("5", week2 - chrono::TimeDelta::days(1), week2),
1479 card_completed_at("6", week2 - chrono::TimeDelta::days(1), week2),
1480 ]),
1481 ]);
1482 let metrics = compute_metrics(&board, None, None);
1483 assert!(metrics.throughput_stddev.is_some());
1484 }
1485
1486 #[test]
1487 fn throughput_stddev_none_for_single_week() {
1488 let now = Utc::now();
1489 let board = make_board(vec![
1490 make_column("done", "Done", vec![
1491 card_completed_at("1", now - chrono::TimeDelta::days(1), now),
1492 ]),
1493 ]);
1494 let metrics = compute_metrics(&board, None, None);
1495 assert!(metrics.throughput_stddev.is_none());
1496 }
1497
1498 #[test]
1501 fn avg_empty_returns_zero() {
1502 assert_eq!(avg(&[]), 0.0);
1503 }
1504
1505 #[test]
1506 fn avg_single_value() {
1507 assert_eq!(avg(&[5.0]), 5.0);
1508 }
1509
1510 #[test]
1511 fn avg_multiple_values() {
1512 assert!((avg(&[2.0, 4.0, 6.0]) - 4.0).abs() < f64::EPSILON);
1513 }
1514
1515 #[test]
1516 fn median_empty_returns_zero() {
1517 assert_eq!(median(&[]), 0.0);
1518 }
1519
1520 #[test]
1521 fn median_single_value() {
1522 assert_eq!(median(&[5.0]), 5.0);
1523 }
1524
1525 #[test]
1526 fn median_odd_count() {
1527 assert_eq!(median(&[1.0, 2.0, 3.0]), 2.0);
1528 }
1529
1530 #[test]
1531 fn percentile_empty_returns_zero() {
1532 assert_eq!(percentile(&[], 85), 0.0);
1533 }
1534
1535 #[test]
1536 fn percentile_single_value() {
1537 assert_eq!(percentile(&[10.0], 85), 10.0);
1538 }
1539
1540 #[test]
1541 fn percentile_0_returns_first() {
1542 assert_eq!(percentile(&[1.0, 2.0, 3.0], 0), 1.0);
1543 }
1544
1545 #[test]
1546 fn percentile_100_returns_last() {
1547 assert_eq!(percentile(&[1.0, 2.0, 3.0], 100), 3.0);
1548 }
1549
1550 #[test]
1553 fn format_week_output() {
1554 let d = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); assert_eq!(format_week(d.iso_week()), "2025-W02");
1556 }
1557
1558 #[test]
1559 fn monday_of_week_is_monday() {
1560 let d = NaiveDate::from_ymd_opt(2025, 6, 12).unwrap(); let mon = monday_of_week(d.iso_week());
1562 assert_eq!(mon.weekday(), chrono::Weekday::Mon);
1563 assert_eq!(mon, NaiveDate::from_ymd_opt(2025, 6, 9).unwrap());
1564 }
1565
1566 #[test]
1567 fn fill_week_gaps_inserts_missing() {
1568 use std::collections::BTreeMap;
1569 let d1 = NaiveDate::from_ymd_opt(2025, 6, 2).unwrap(); let d3 = NaiveDate::from_ymd_opt(2025, 6, 16).unwrap(); let mut counts = BTreeMap::new();
1572 counts.insert(d1.iso_week(), 3);
1573 counts.insert(d3.iso_week(), 1);
1574 let result = fill_week_gaps(&counts);
1575 assert_eq!(result.len(), 3); assert_eq!(result[0].1, 3);
1577 assert_eq!(result[1].1, 0); assert_eq!(result[2].1, 1);
1579 }
1580
1581 #[test]
1582 fn oldest_card_created_empty_board() {
1583 let board = make_board(vec![make_column("backlog", "Backlog", vec![])]);
1584 assert!(oldest_card_created(&board).is_none());
1585 }
1586
1587 fn write_activity_log(kando_dir: &std::path::Path, events: &[(&str, &str, &str, &str)]) {
1591 let mut content = String::new();
1593 for (ts, id, from, to) in events {
1594 content.push_str(&format!(
1595 r#"{{"ts":"{ts}","action":"move","id":"{id}","title":"Card {id}","from":"{from}","to":"{to}"}}"#,
1596 ));
1597 content.push('\n');
1598 }
1599 std::fs::write(kando_dir.join("activity.log"), content).unwrap();
1600 }
1601
1602 fn setup_kando_dir() -> tempfile::TempDir {
1603 let dir = tempfile::tempdir().unwrap();
1604 std::fs::create_dir(dir.path().join(".kando")).unwrap();
1605 dir
1606 }
1607
1608 #[test]
1609 fn stage_times_none_when_no_activity_log() {
1610 let dir = setup_kando_dir();
1611 let board = make_board(vec![
1612 make_column("backlog", "Backlog", vec![]),
1613 make_column("done", "Done", vec![]),
1614 ]);
1615 let result = compute_stage_times(&board, &dir.path().join(".kando"));
1616 assert!(result.is_none());
1617 }
1618
1619 #[test]
1620 fn stage_times_none_when_no_move_events() {
1621 let dir = setup_kando_dir();
1622 let kando_dir = dir.path().join(".kando");
1623 std::fs::write(
1625 kando_dir.join("activity.log"),
1626 r#"{"ts":"2025-06-01T10:00:00Z","action":"create","id":"1","title":"Card 1"}"#,
1627 ).unwrap();
1628 let board = make_board(vec![
1629 make_column("backlog", "Backlog", vec![]),
1630 make_column("done", "Done", vec![]),
1631 ]);
1632 let result = compute_stage_times(&board, &kando_dir);
1633 assert!(result.is_none());
1634 }
1635
1636 #[test]
1637 fn stage_times_single_card_linear_flow() {
1638 let dir = setup_kando_dir();
1639 let kando_dir = dir.path().join(".kando");
1640
1641 let created = Utc::now() - chrono::TimeDelta::days(10);
1643 let move1_ts = created + chrono::TimeDelta::days(1);
1644 let move2_ts = created + chrono::TimeDelta::days(3);
1645 let completed = created + chrono::TimeDelta::days(4);
1646
1647 write_activity_log(&kando_dir, &[
1648 (&move1_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "In Progress"),
1649 (&move2_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "In Progress", "Done"),
1650 ]);
1651
1652 let mut card = card_completed_at("1", created, completed);
1653 card.started = Some(move1_ts);
1654
1655 let board = make_board(vec![
1656 make_column("backlog", "Backlog", vec![]),
1657 make_column("in-progress", "In Progress", vec![]),
1658 make_column("done", "Done", vec![card]),
1659 ]);
1660
1661 let result = compute_stage_times(&board, &kando_dir).unwrap();
1662 assert_eq!(result.card_count, 1);
1663 assert_eq!(result.columns.len(), 3);
1664
1665 assert_eq!(result.columns[0].name, "Backlog");
1667 assert!((result.columns[0].avg_days - 1.0).abs() < 0.1);
1668
1669 assert_eq!(result.columns[1].name, "In Progress");
1671 assert!((result.columns[1].avg_days - 2.0).abs() < 0.1);
1672
1673 assert_eq!(result.columns[2].name, "Done");
1675 assert!((result.columns[2].avg_days - 1.0).abs() < 0.1);
1676 }
1677
1678 #[test]
1679 fn stage_times_ignores_incomplete_cards() {
1680 let dir = setup_kando_dir();
1681 let kando_dir = dir.path().join(".kando");
1682
1683 let now = Utc::now();
1684 let ts = now - chrono::TimeDelta::days(1);
1685
1686 write_activity_log(&kando_dir, &[
1687 (&ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "In Progress"),
1688 ]);
1689
1690 let mut card = Card::new("1".into(), "Card 1".into());
1692 card.created = now - chrono::TimeDelta::days(3);
1693
1694 let board = make_board(vec![
1695 make_column("backlog", "Backlog", vec![]),
1696 make_column("in-progress", "In Progress", vec![card]),
1697 make_column("done", "Done", vec![]),
1698 ]);
1699
1700 let result = compute_stage_times(&board, &kando_dir);
1701 assert!(result.is_none(), "incomplete cards should be excluded");
1702 }
1703
1704 #[test]
1705 fn stage_times_is_active_flags_correct() {
1706 let dir = setup_kando_dir();
1707 let kando_dir = dir.path().join(".kando");
1708
1709 let created = Utc::now() - chrono::TimeDelta::days(6);
1710 let m1 = created + chrono::TimeDelta::days(1);
1711 let m2 = created + chrono::TimeDelta::days(3);
1712 let m3 = created + chrono::TimeDelta::days(5);
1713 let completed = created + chrono::TimeDelta::days(6);
1714
1715 write_activity_log(&kando_dir, &[
1716 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Dev"),
1717 (&m2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Dev", "Review"),
1718 (&m3.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Review", "Done"),
1719 ]);
1720
1721 let card = card_completed_at("1", created, completed);
1722 let board = make_board(vec![
1723 make_column("backlog", "Backlog", vec![]),
1724 make_column("dev", "Dev", vec![]),
1725 make_column("review", "Review", vec![]),
1726 make_column("done", "Done", vec![card]),
1727 ]);
1728
1729 let result = compute_stage_times(&board, &kando_dir).unwrap();
1730 assert!(!result.columns[0].is_active, "Backlog should not be active");
1731 assert!(result.columns[1].is_active, "Dev should be active");
1732 assert!(result.columns[2].is_active, "Review should be active");
1733 assert!(!result.columns[3].is_active, "Done should not be active");
1734 }
1735
1736 #[test]
1737 fn stage_times_historical_column_appended() {
1738 let dir = setup_kando_dir();
1739 let kando_dir = dir.path().join(".kando");
1740
1741 let created = Utc::now() - chrono::TimeDelta::days(5);
1742 let m1 = created + chrono::TimeDelta::days(1);
1743 let m2 = created + chrono::TimeDelta::days(2);
1744 let m3 = created + chrono::TimeDelta::days(4);
1745 let completed = created + chrono::TimeDelta::days(5);
1746
1747 write_activity_log(&kando_dir, &[
1749 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "QA"),
1750 (&m2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "QA", "In Progress"),
1751 (&m3.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "In Progress", "Done"),
1752 ]);
1753
1754 let card = card_completed_at("1", created, completed);
1755 let board = make_board(vec![
1756 make_column("backlog", "Backlog", vec![]),
1757 make_column("in-progress", "In Progress", vec![]),
1758 make_column("done", "Done", vec![card]),
1759 ]);
1760
1761 let result = compute_stage_times(&board, &kando_dir).unwrap();
1762 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
1764 assert_eq!(names, vec!["Backlog", "In Progress", "Done", "QA"]);
1765 assert!(!result.columns[3].is_active);
1767 }
1768
1769 #[test]
1770 fn stage_times_skips_malformed_log_lines() {
1771 let dir = setup_kando_dir();
1772 let kando_dir = dir.path().join(".kando");
1773
1774 let created = Utc::now() - chrono::TimeDelta::days(3);
1775 let m1 = created + chrono::TimeDelta::days(1);
1776 let completed = created + chrono::TimeDelta::days(3);
1777
1778 let mut content = String::new();
1779 content.push_str("not json at all\n");
1780 content.push_str(&format!(
1781 r#"{{"ts":"{}","action":"move","id":"1","title":"Card 1","from":"Backlog","to":"Done"}}"#,
1782 m1.format("%Y-%m-%dT%H:%M:%SZ"),
1783 ));
1784 content.push('\n');
1785 content.push_str(r#"{"ts":"bad-date","action":"move","id":"2","title":"Card 2","from":"A","to":"B"}"#);
1786 content.push('\n');
1787 std::fs::write(kando_dir.join("activity.log"), content).unwrap();
1788
1789 let card = card_completed_at("1", created, completed);
1790 let board = make_board(vec![
1791 make_column("backlog", "Backlog", vec![]),
1792 make_column("done", "Done", vec![card]),
1793 ]);
1794
1795 let result = compute_stage_times(&board, &kando_dir).unwrap();
1796 assert_eq!(result.card_count, 1);
1797 }
1798
1799 #[test]
1800 fn stage_times_card_skips_column() {
1801 let dir = setup_kando_dir();
1802 let kando_dir = dir.path().join(".kando");
1803
1804 let created = Utc::now() - chrono::TimeDelta::days(3);
1806 let m1 = created + chrono::TimeDelta::days(2);
1807 let completed = created + chrono::TimeDelta::days(3);
1808
1809 write_activity_log(&kando_dir, &[
1810 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Done"),
1811 ]);
1812
1813 let card = card_completed_at("1", created, completed);
1814 let board = make_board(vec![
1815 make_column("backlog", "Backlog", vec![]),
1816 make_column("in-progress", "In Progress", vec![]),
1817 make_column("done", "Done", vec![card]),
1818 ]);
1819
1820 let result = compute_stage_times(&board, &kando_dir).unwrap();
1821 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
1822 assert_eq!(names, vec!["Backlog", "Done"]);
1824 }
1825
1826 #[test]
1827 fn stage_times_card_moves_backward() {
1828 let dir = setup_kando_dir();
1829 let kando_dir = dir.path().join(".kando");
1830
1831 let created = Utc::now() - chrono::TimeDelta::days(10);
1833 let m1 = created + chrono::TimeDelta::days(1);
1834 let m2 = created + chrono::TimeDelta::days(3);
1835 let m3 = created + chrono::TimeDelta::days(4);
1836 let m4 = created + chrono::TimeDelta::days(8);
1837 let completed = created + chrono::TimeDelta::days(10);
1838
1839 write_activity_log(&kando_dir, &[
1840 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "In Progress"),
1841 (&m2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "In Progress", "Backlog"),
1842 (&m3.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "In Progress"),
1843 (&m4.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "In Progress", "Done"),
1844 ]);
1845
1846 let card = card_completed_at("1", created, completed);
1847 let board = make_board(vec![
1848 make_column("backlog", "Backlog", vec![]),
1849 make_column("in-progress", "In Progress", vec![]),
1850 make_column("done", "Done", vec![card]),
1851 ]);
1852
1853 let result = compute_stage_times(&board, &kando_dir).unwrap();
1854
1855 let backlog = result.columns.iter().find(|e| e.name == "Backlog").unwrap();
1857 assert_eq!(backlog.sample_count, 2);
1858 assert!((backlog.avg_days - 1.0).abs() < 0.1);
1859
1860 let ip = result.columns.iter().find(|e| e.name == "In Progress").unwrap();
1862 assert_eq!(ip.sample_count, 2);
1863 assert!((ip.avg_days - 3.0).abs() < 0.1);
1864
1865 let done = result.columns.iter().find(|e| e.name == "Done").unwrap();
1867 assert_eq!(done.sample_count, 1);
1868 assert!((done.avg_days - 2.0).abs() < 0.1);
1869 }
1870
1871 #[test]
1872 fn stage_times_multiple_cards_averages() {
1873 let dir = setup_kando_dir();
1874 let kando_dir = dir.path().join(".kando");
1875
1876 let now = Utc::now();
1877 let c1 = now - chrono::TimeDelta::days(10);
1878 let c2 = now - chrono::TimeDelta::days(8);
1879 let m1 = c1 + chrono::TimeDelta::days(2); let m2 = c2 + chrono::TimeDelta::days(4); let comp1 = c1 + chrono::TimeDelta::days(3);
1882 let comp2 = c2 + chrono::TimeDelta::days(5);
1883
1884 write_activity_log(&kando_dir, &[
1885 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Done"),
1886 (&m2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2", "Backlog", "Done"),
1887 ]);
1888
1889 let card1 = card_completed_at("1", c1, comp1);
1890 let card2 = card_completed_at("2", c2, comp2);
1891 let board = make_board(vec![
1892 make_column("backlog", "Backlog", vec![]),
1893 make_column("done", "Done", vec![card1, card2]),
1894 ]);
1895
1896 let result = compute_stage_times(&board, &kando_dir).unwrap();
1897 assert_eq!(result.card_count, 2);
1898
1899 let backlog = result.columns.iter().find(|e| e.name == "Backlog").unwrap();
1900 assert_eq!(backlog.sample_count, 2);
1901 assert!((backlog.avg_days - 3.0).abs() < 0.1);
1903 assert!((backlog.median_days - 3.0).abs() < 0.1);
1905 }
1906
1907 #[test]
1908 fn stage_times_zero_duration_no_panic() {
1909 let dir = setup_kando_dir();
1910 let kando_dir = dir.path().join(".kando");
1911
1912 let t = Utc::now();
1914 write_activity_log(&kando_dir, &[
1915 (&t.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Done"),
1916 ]);
1917
1918 let card = card_completed_at("1", t, t);
1919 let board = make_board(vec![
1920 make_column("backlog", "Backlog", vec![]),
1921 make_column("done", "Done", vec![card]),
1922 ]);
1923
1924 let result = compute_stage_times(&board, &kando_dir).unwrap();
1925 assert_eq!(result.card_count, 1);
1926 for col in &result.columns {
1927 assert!((col.avg_days - 0.0).abs() < 0.01, "{}: expected ~0, got {}", col.name, col.avg_days);
1928 assert!((col.median_days - 0.0).abs() < 0.01);
1929 assert!((col.p85_days - 0.0).abs() < 0.01);
1930 }
1931 }
1932
1933 #[test]
1934 fn format_text_includes_stage_time() {
1935 let dir = setup_kando_dir();
1936 let kando_dir = dir.path().join(".kando");
1937
1938 let created = Utc::now() - chrono::TimeDelta::days(4);
1939 let m1 = created + chrono::TimeDelta::days(1);
1940 let completed = created + chrono::TimeDelta::days(4);
1941
1942 write_activity_log(&kando_dir, &[
1943 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Done"),
1944 ]);
1945
1946 let card = card_completed_at("1", created, completed);
1947 let board = make_board(vec![
1948 make_column("backlog", "Backlog", vec![]),
1949 make_column("done", "Done", vec![card]),
1950 ]);
1951 let metrics = compute_metrics(&board, None, Some(&kando_dir));
1952 let text = format_text(&metrics);
1953
1954 assert!(text.contains("Stage Time"), "should contain Stage Time section");
1955 assert!(text.contains("1 cards with move data"), "should show card count");
1956 assert!(text.contains("Backlog"), "should list Backlog column");
1957 }
1958
1959 #[test]
1960 fn format_text_omits_stage_time_when_none() {
1961 let board = make_board(vec![
1962 make_column("backlog", "Backlog", vec![]),
1963 make_column("done", "Done", vec![]),
1964 ]);
1965 let metrics = compute_metrics(&board, None, None);
1966 let text = format_text(&metrics);
1967 assert!(!text.contains("Stage Time"), "should not contain Stage Time when kando_dir is None");
1968 }
1969
1970 #[test]
1971 fn format_csv_includes_stage_time() {
1972 let dir = setup_kando_dir();
1973 let kando_dir = dir.path().join(".kando");
1974
1975 let created = Utc::now() - chrono::TimeDelta::days(3);
1976 let m1 = created + chrono::TimeDelta::days(1);
1977 let completed = created + chrono::TimeDelta::days(3);
1978
1979 write_activity_log(&kando_dir, &[
1980 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Done"),
1981 ]);
1982
1983 let card = card_completed_at("1", created, completed);
1984 let board = make_board(vec![
1985 make_column("backlog", "Backlog", vec![]),
1986 make_column("done", "Done", vec![card]),
1987 ]);
1988 let metrics = compute_metrics(&board, None, Some(&kando_dir));
1989 let csv = format_csv(&metrics);
1990
1991 assert!(csv.contains("stage_column,avg_days,median_days,p85_days,samples,is_active"));
1992 assert!(csv.contains("Backlog,"));
1993 assert!(csv.contains("Done,"));
1994 }
1995
1996 #[test]
1997 fn format_csv_omits_stage_time_when_none() {
1998 let board = make_board(vec![
1999 make_column("done", "Done", vec![]),
2000 ]);
2001 let metrics = compute_metrics(&board, None, None);
2002 let csv = format_csv(&metrics);
2003 assert!(!csv.contains("stage_column"), "should not contain stage_column header");
2004 }
2005
2006 #[test]
2007 fn stage_times_column_ordering_follows_board() {
2008 let dir = setup_kando_dir();
2009 let kando_dir = dir.path().join(".kando");
2010
2011 let created = Utc::now() - chrono::TimeDelta::days(5);
2012 let m1 = created + chrono::TimeDelta::days(1);
2013 let m2 = created + chrono::TimeDelta::days(2);
2014 let m3 = created + chrono::TimeDelta::days(3);
2015 let completed = created + chrono::TimeDelta::days(5);
2016
2017 write_activity_log(&kando_dir, &[
2018 (&m1.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Dev"),
2019 (&m2.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Dev", "Review"),
2020 (&m3.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Review", "Done"),
2021 ]);
2022
2023 let card = card_completed_at("1", created, completed);
2024 let board = make_board(vec![
2025 make_column("backlog", "Backlog", vec![]),
2026 make_column("dev", "Dev", vec![]),
2027 make_column("review", "Review", vec![]),
2028 make_column("done", "Done", vec![card]),
2029 ]);
2030
2031 let result = compute_stage_times(&board, &kando_dir).unwrap();
2032 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
2033 assert_eq!(names, vec!["Backlog", "Dev", "Review", "Done"]);
2034 }
2035
2036 fn append_activity_lines(kando_dir: &std::path::Path, lines: &[&str]) {
2040 use std::io::Write;
2041 let path = kando_dir.join("activity.log");
2042 let mut file = std::fs::OpenOptions::new()
2043 .append(true)
2044 .create(true)
2045 .open(path)
2046 .unwrap();
2047 for line in lines {
2048 writeln!(file, "{line}").unwrap();
2049 }
2050 }
2051
2052 #[test]
2053 fn rename_map_simple_rename() {
2054 let dir = setup_kando_dir();
2055 let kando_dir = dir.path().join(".kando");
2056
2057 let created = Utc::now() - chrono::TimeDelta::days(5);
2058 let move_ts = created + chrono::TimeDelta::days(1);
2059 let completed = created + chrono::TimeDelta::days(3);
2060
2061 write_activity_log(&kando_dir, &[
2062 (&move_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Review", "Done"),
2063 ]);
2064 append_activity_lines(&kando_dir, &[
2065 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"code-review","title":"Code Review","from":"review","from_name":"Review"}"#,
2066 ]);
2067
2068 let card = card_completed_at("1", created, completed);
2069 let board = make_board(vec![
2070 make_column("code-review", "Code Review", vec![]),
2071 make_column("done", "Done", vec![card]),
2072 ]);
2073
2074 let result = compute_stage_times(&board, &kando_dir).unwrap();
2075 let cr_entry = result.columns.iter().find(|e| e.name == "Code Review");
2076 assert!(cr_entry.is_some(), "old 'Review' should map to 'Code Review'");
2077 assert_eq!(cr_entry.unwrap().sample_count, 1);
2078 assert!(!result.columns.iter().any(|e| e.name == "Review"), "'Review' should not appear as historical");
2079 }
2080
2081 #[test]
2082 fn rename_map_chained_renames() {
2083 let dir = setup_kando_dir();
2084 let kando_dir = dir.path().join(".kando");
2085
2086 let created = Utc::now() - chrono::TimeDelta::days(5);
2087 let move_ts = created + chrono::TimeDelta::days(1);
2088 let completed = created + chrono::TimeDelta::days(3);
2089
2090 write_activity_log(&kando_dir, &[
2091 (&move_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Alpha", "Done"),
2092 ]);
2093 append_activity_lines(&kando_dir, &[
2094 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"beta","title":"Beta","from":"alpha","from_name":"Alpha"}"#,
2095 r#"{"ts":"2025-07-02T10:00:00Z","action":"col-rename","id":"gamma","title":"Gamma","from":"beta","from_name":"Beta"}"#,
2096 ]);
2097
2098 let card = card_completed_at("1", created, completed);
2099 let board = make_board(vec![
2100 make_column("gamma", "Gamma", vec![]),
2101 make_column("done", "Done", vec![card]),
2102 ]);
2103
2104 let result = compute_stage_times(&board, &kando_dir).unwrap();
2105 let gamma_entry = result.columns.iter().find(|e| e.name == "Gamma");
2106 assert!(gamma_entry.is_some(), "chained rename should resolve Alpha→Gamma");
2107 assert_eq!(gamma_entry.unwrap().sample_count, 1);
2108 assert!(!result.columns.iter().any(|e| e.name == "Alpha"), "'Alpha' should not appear");
2109 assert!(!result.columns.iter().any(|e| e.name == "Beta"), "'Beta' should not appear");
2110 }
2111
2112 #[test]
2113 fn rename_map_skips_entries_without_from_name() {
2114 let dir = setup_kando_dir();
2115 let kando_dir = dir.path().join(".kando");
2116
2117 let created = Utc::now() - chrono::TimeDelta::days(5);
2118 let move_ts = created + chrono::TimeDelta::days(1);
2119 let completed = created + chrono::TimeDelta::days(3);
2120
2121 write_activity_log(&kando_dir, &[
2122 (&move_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "OldName", "Done"),
2123 ]);
2124 append_activity_lines(&kando_dir, &[
2126 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"new-slug","title":"NewName","from":"old-slug"}"#,
2127 ]);
2128
2129 let card = card_completed_at("1", created, completed);
2130 let board = make_board(vec![
2131 make_column("new-slug", "NewName", vec![]),
2132 make_column("done", "Done", vec![card]),
2133 ]);
2134
2135 let result = compute_stage_times(&board, &kando_dir).unwrap();
2136 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
2137 assert!(names.contains(&"OldName"), "legacy entry skipped, OldName should be historical: {names:?}");
2139 }
2140
2141 #[test]
2142 fn rename_map_self_rename_ignored() {
2143 let dir = setup_kando_dir();
2144 let kando_dir = dir.path().join(".kando");
2145
2146 let created = Utc::now() - chrono::TimeDelta::days(5);
2147 let move_ts = created + chrono::TimeDelta::days(1);
2148 let completed = created + chrono::TimeDelta::days(3);
2149
2150 write_activity_log(&kando_dir, &[
2151 (&move_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Review", "Done"),
2152 ]);
2153 append_activity_lines(&kando_dir, &[
2155 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"review","title":"Review","from":"review","from_name":"Review"}"#,
2156 ]);
2157
2158 let card = card_completed_at("1", created, completed);
2159 let board = make_board(vec![
2160 make_column("review", "Review", vec![]),
2161 make_column("done", "Done", vec![card]),
2162 ]);
2163
2164 let result = compute_stage_times(&board, &kando_dir).unwrap();
2165 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
2166 assert!(names.contains(&"Review"), "self-rename should not break normal attribution: {names:?}");
2167 }
2168
2169 #[test]
2170 fn rename_map_cycle_a_b_a() {
2171 let dir = setup_kando_dir();
2172 let kando_dir = dir.path().join(".kando");
2173
2174 let created1 = Utc::now() - chrono::TimeDelta::days(10);
2175 let move1_ts = created1 + chrono::TimeDelta::days(1);
2176 let completed1 = created1 + chrono::TimeDelta::days(3);
2177
2178 let created2 = Utc::now() - chrono::TimeDelta::days(5);
2179 let move2_ts = created2 + chrono::TimeDelta::days(1);
2180 let completed2 = created2 + chrono::TimeDelta::days(3);
2181
2182 write_activity_log(&kando_dir, &[
2184 (&move1_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Alpha", "Done"),
2185 (&move2_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2", "Beta", "Done"),
2186 ]);
2187 append_activity_lines(&kando_dir, &[
2189 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"beta","title":"Beta","from":"alpha","from_name":"Alpha"}"#,
2190 r#"{"ts":"2025-07-02T10:00:00Z","action":"col-rename","id":"alpha","title":"Alpha","from":"beta","from_name":"Beta"}"#,
2191 ]);
2192
2193 let card1 = card_completed_at("1", created1, completed1);
2194 let card2 = card_completed_at("2", created2, completed2);
2195 let board = make_board(vec![
2196 make_column("alpha", "Alpha", vec![]),
2197 make_column("done", "Done", vec![card1, card2]),
2198 ]);
2199
2200 let result = compute_stage_times(&board, &kando_dir).unwrap();
2202 let alpha_entry = result.columns.iter().find(|e| e.name == "Alpha");
2203 assert!(alpha_entry.is_some(), "cycle should resolve both to Alpha");
2204 assert_eq!(alpha_entry.unwrap().sample_count, 2, "both cards consolidated under Alpha");
2205 assert!(!result.columns.iter().any(|e| e.name == "Beta"), "'Beta' should not appear");
2206 }
2207
2208 #[test]
2209 fn rename_map_consolidates_old_and_new_durations() {
2210 let dir = setup_kando_dir();
2211 let kando_dir = dir.path().join(".kando");
2212
2213 let created1 = Utc::now() - chrono::TimeDelta::days(10);
2214 let move1_ts = created1 + chrono::TimeDelta::days(1);
2215 let completed1 = created1 + chrono::TimeDelta::days(3);
2216
2217 let created2 = Utc::now() - chrono::TimeDelta::days(5);
2218 let move2_ts = created2 + chrono::TimeDelta::days(1);
2219 let completed2 = created2 + chrono::TimeDelta::days(3);
2220
2221 write_activity_log(&kando_dir, &[
2223 (&move1_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Dev", "Done"),
2224 (&move2_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2", "Development", "Done"),
2225 ]);
2226 append_activity_lines(&kando_dir, &[
2227 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"development","title":"Development","from":"dev","from_name":"Dev"}"#,
2228 ]);
2229
2230 let card1 = card_completed_at("1", created1, completed1);
2231 let card2 = card_completed_at("2", created2, completed2);
2232 let board = make_board(vec![
2233 make_column("development", "Development", vec![]),
2234 make_column("done", "Done", vec![card1, card2]),
2235 ]);
2236
2237 let result = compute_stage_times(&board, &kando_dir).unwrap();
2238 assert_eq!(result.card_count, 2);
2239 let dev_entry = result.columns.iter().find(|e| e.name == "Development");
2240 assert!(dev_entry.is_some(), "should have consolidated 'Development' entry");
2241 assert_eq!(dev_entry.unwrap().sample_count, 2, "both cards' samples merged");
2242 assert!(!result.columns.iter().any(|e| e.name == "Dev"), "'Dev' should not appear separately");
2243 }
2244
2245 #[test]
2246 fn rename_map_preserves_board_column_ordering() {
2247 let dir = setup_kando_dir();
2248 let kando_dir = dir.path().join(".kando");
2249
2250 let created = Utc::now() - chrono::TimeDelta::days(10);
2251 let move1_ts = created + chrono::TimeDelta::days(1);
2252 let move2_ts = created + chrono::TimeDelta::days(2);
2253 let move3_ts = created + chrono::TimeDelta::days(3);
2254 let completed = created + chrono::TimeDelta::days(4);
2255
2256 write_activity_log(&kando_dir, &[
2257 (&move1_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Backlog", "Dev"),
2258 (&move2_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Dev", "Review"),
2259 (&move3_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "Review", "Done"),
2260 ]);
2261 append_activity_lines(&kando_dir, &[
2262 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"development","title":"Development","from":"dev","from_name":"Dev"}"#,
2263 ]);
2264
2265 let mut card = card_completed_at("1", created, completed);
2266 card.started = Some(move1_ts);
2267 let board = make_board(vec![
2268 make_column("backlog", "Backlog", vec![]),
2269 make_column("development", "Development", vec![]),
2270 make_column("review", "Review", vec![]),
2271 make_column("done", "Done", vec![card]),
2272 ]);
2273
2274 let result = compute_stage_times(&board, &kando_dir).unwrap();
2275 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
2276 assert_eq!(names, vec!["Backlog", "Development", "Review", "Done"],
2277 "renamed column should appear at board position, not as historical");
2278 }
2279
2280 #[test]
2281 fn rename_map_divergent_renames() {
2282 let dir = setup_kando_dir();
2283 let kando_dir = dir.path().join(".kando");
2284
2285 let created = Utc::now() - chrono::TimeDelta::days(5);
2286 let move_ts = created + chrono::TimeDelta::days(1);
2287 let completed = created + chrono::TimeDelta::days(3);
2288
2289 write_activity_log(&kando_dir, &[
2291 (&move_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "1", "ColA", "Done"),
2292 (&move_ts.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2", "ColB", "Done"),
2293 ]);
2294 append_activity_lines(&kando_dir, &[
2295 r#"{"ts":"2025-07-01T10:00:00Z","action":"col-rename","id":"col-a2","title":"ColA2","from":"col-a","from_name":"ColA"}"#,
2296 r#"{"ts":"2025-07-02T10:00:00Z","action":"col-rename","id":"col-b2","title":"ColB2","from":"col-b","from_name":"ColB"}"#,
2297 ]);
2298
2299 let card1 = card_completed_at("1", created, completed);
2300 let card2 = card_completed_at("2", created, completed);
2301 let board = make_board(vec![
2302 make_column("col-a2", "ColA2", vec![]),
2303 make_column("col-b2", "ColB2", vec![]),
2304 make_column("done", "Done", vec![card1, card2]),
2305 ]);
2306
2307 let result = compute_stage_times(&board, &kando_dir).unwrap();
2308 let names: Vec<&str> = result.columns.iter().map(|e| e.name.as_str()).collect();
2309 assert!(names.contains(&"ColA2"), "ColA should map to ColA2: {names:?}");
2310 assert!(names.contains(&"ColB2"), "ColB should map to ColB2: {names:?}");
2311 assert!(!names.contains(&"ColA"), "ColA should not appear: {names:?}");
2312 assert!(!names.contains(&"ColB"), "ColB should not appear: {names:?}");
2313 }
2314
2315 #[test]
2316 fn compute_metrics_kando_dir_none_gives_no_stage_times() {
2317 let now = Utc::now();
2318 let created = now - chrono::TimeDelta::days(3);
2319 let board = make_board(vec![
2320 make_column("done", "Done", vec![card_completed_at("1", created, now)]),
2321 ]);
2322 let metrics = compute_metrics(&board, None, None);
2323 assert!(metrics.stage_times.is_none());
2324 }
2325}