Skip to main content

kando_core/board/
metrics.rs

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/// Aggregate board metrics for a given time window.
10#[derive(Debug, Serialize)]
11pub struct BoardMetrics {
12    /// Per-column WIP info including limits and active status.
13    pub wip_per_column: Vec<WipEntry>,
14    /// Total cards in active columns (between backlog and done).
15    pub active_wip_total: usize,
16    /// Blocked card count in active columns.
17    pub blocked_count: u32,
18    /// Blocked as percentage of active WIP.
19    pub blocked_pct: f64,
20    /// (week label "YYYY-Www", completed count) ordered chronologically.
21    pub throughput_per_week: Vec<(String, u32)>,
22    /// Standard deviation of throughput counts (None if < 2 weeks).
23    pub throughput_stddev: Option<f64>,
24    /// (week label, created count) for arrival rate.
25    pub arrival_per_week: Vec<(String, u32)>,
26    /// Lead time and cycle time statistics for completed cards.
27    pub time_stats: Option<TimeStats>,
28    /// Number of cards per priority level (only for completed cards in window).
29    pub priority_breakdown: Vec<(Priority, u32)>,
30    /// Total number of completed cards in the window.
31    pub total_completed: u32,
32    /// The start of the lookback window.
33    pub since: DateTime<Utc>,
34    /// Work item age for in-progress cards.
35    pub work_item_age: Option<WorkItemAgeStats>,
36    /// Per-stage cycle time statistics from activity log.
37    pub stage_times: Option<StageTimeStats>,
38}
39
40/// Per-column WIP entry with limit and active status.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
42pub struct WipEntry {
43    pub name: String,
44    pub count: usize,
45    pub wip_limit: Option<u32>,
46    /// True for columns between backlog (idx 0) and done.
47    pub is_active: bool,
48}
49
50/// Descriptive statistics for lead time and cycle time in days.
51#[derive(Debug, Serialize)]
52pub struct TimeStats {
53    // Lead time: completed - created (total time in system)
54    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    // Cycle time: completed - started (active work time)
60    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/// Work item age statistics for in-progress cards.
68#[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/// A card that exceeds the P85 cycle time threshold.
76#[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/// Per-stage cycle time statistics computed from the activity log.
85#[derive(Debug, Serialize)]
86pub struct StageTimeStats {
87    pub columns: Vec<StageTimeEntry>,
88    pub card_count: usize,
89}
90
91/// Cycle time entry for a single column/stage.
92#[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
102/// A move event parsed from the activity log.
103struct MoveEvent {
104    ts: DateTime<Utc>,
105    card_id: String,
106    from: String,
107    to: String,
108}
109
110/// Compute board metrics, optionally filtered to cards completed after `since`.
111pub fn compute_metrics(board: &Board, since: Option<DateTime<Utc>>, kando_dir: Option<&Path>) -> BoardMetrics {
112    let now = Utc::now();
113
114    // 1. WIP per column with limits and active status
115    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    // 2. Blocked cards in active columns
139    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    // 3. Determine effective `since`
155    let effective_since = since
156        .or(board.created_at)
157        .or_else(|| oldest_card_created(board))
158        .unwrap_or_else(Utc::now);
159
160    // 4. Collect completed cards in window from the "done" column
161    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    // 5. Throughput per week (bucketed by ISO week)
175    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    // 5b. Throughput standard deviation
190    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    // 6. Arrival rate per week (cards created across ALL columns)
200    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    // 7. Lead time (completed - created) and Cycle time (completed - started)
216    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    // 8. Priority breakdown (completed cards only)
256    let mut priority_counts = [0u32; 4]; // indexed by sort_key: Urgent=0, High=1, Normal=2, Low=3
257    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    // 9. Work item age for in-progress cards
267    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
327// ── Stage time helpers ──
328
329/// Parse move events from the activity log (JSONL format).
330fn 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
372/// Build a mapping from old column display names to their current display names
373/// by parsing col-rename events from the activity log. Chains are resolved so
374/// that if A→B and B→C both occurred, A maps directly to C.
375fn 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    // Collect raw renames in chronological order.
383    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; // pre-upgrade entry without from_name — skip
394        };
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    // Build forward map and resolve chains.
402    let mut map: HashMap<String, String> = HashMap::new();
403    for (old, new) in renames {
404        // Update any existing entries that pointed to old → make them point to new.
405        for val in map.values_mut() {
406            if *val == old {
407                *val = new.clone();
408            }
409        }
410        // Don't create a self-mapping.
411        if old != new {
412            map.insert(old, new);
413        }
414    }
415    // Clean up self-mappings created by rename-back sequences (e.g. A→B→A).
416    map.retain(|k, v| k != v);
417    map
418}
419
420/// Compute per-stage cycle time statistics from the activity log.
421fn 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    // Group move events by card ID (borrow keys to avoid cloning)
428    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    // Collect all completed cards across the board (keyed by id)
437    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    // Accumulate durations per column name
447    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        // First segment: card.created → first move's ts, column = first move's `from`
458        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        // Middle segments: between consecutive moves
466        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        // Final segment: last move's ts → card.completed, column = last move's `to`
472        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    // Remap old column names to current names using col-rename history.
485    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    // Determine active columns (same logic as WIP)
496    let done_col_idx = board.columns.iter().position(|c| c.slug == "done");
497
498    // Order by current board columns first, then append any historical columns
499    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    // Append remaining (historical) columns not on current board, sorted by name
517    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
537// ── Math helpers ──
538
539fn 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
570// ── Week helpers ──
571
572/// Find the oldest `card.created` timestamp across the entire board.
573fn 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
582/// Format an ISO week as "YYYY-Www".
583fn format_week(week: IsoWeek) -> String {
584    format!("{}-W{:02}", week.year(), week.week())
585}
586
587/// Fill gaps in a BTreeMap of week counts so every week between min and max is present.
588fn 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
606/// Get the Monday (NaiveDate) of a given ISO week.
607fn 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
612// ── Text/CSV formatting ──
613
614/// Human-readable multi-line text output for the CLI.
615pub fn format_text(metrics: &BoardMetrics) -> String {
616    let mut out = String::new();
617
618    // Summary
619    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    // Lead time
624    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    // Stage time
643    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    // WIP
666    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    // Throughput
687    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    // Work item age
708    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    // Priority breakdown
722    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
733/// CSV output for piping to other tools.
734pub fn format_csv(metrics: &BoardMetrics) -> String {
735    let mut out = String::new();
736
737    // Throughput + arrival section
738    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    // Summary
746    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    // WIP
768    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    // Stage time
776    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    // ── Existing tests (adapted for new struct) ──
853
854    #[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    // ── Lead time vs cycle time tests ──
1128
1129    #[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        // Card without started (legacy card)
1167        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        // cycle time should fall back to lead time
1175        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    // ── P85 tests ──
1197
1198    #[test]
1199    fn p85_calculation_correct() {
1200        let now = Utc::now();
1201        // 20 cards with lead times 1..=20 days
1202        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        // sorted: [1, 2, ..., 20]. p85 index = (20 * 85 / 100).min(19) = 17 → value = 18
1210        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    // ── Active WIP tests ──
1227
1228    #[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        // Active = in-progress (1) + review (2) = 3
1248        assert_eq!(metrics.active_wip_total, 3);
1249        assert!(!metrics.wip_per_column[0].is_active); // backlog
1250        assert!(metrics.wip_per_column[1].is_active);  // in-progress
1251        assert!(metrics.wip_per_column[2].is_active);  // review
1252        assert!(!metrics.wip_per_column[3].is_active); // done
1253    }
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    // ── Blocked tests ──
1289
1290    #[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        // Only the one in in-progress counts (backlog excluded)
1305        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    // ── Work item age tests ──
1341
1342    #[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        // No started set
1366
1367        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        // Create done cards with known cycle times (1..=10 days) to establish p85
1382        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); // started = created, so cycle = lead
1385            card_started_completed(&d.to_string(), created, started, now)
1386        }).collect();
1387
1388        // p85 of [1,2,3,4,5,6,7,8,9,10] = sorted[8] = 9 days
1389        // Active card with age 15 days should be flagged
1390        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        // No done cards → no p85 → no aging threshold
1408        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    // ── Arrival rate tests ──
1422
1423    #[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        // All 3 cards created in the same week
1441        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    // ── Throughput variability tests ──
1464
1465    #[test]
1466    fn throughput_stddev_calculation() {
1467        let now = Utc::now();
1468        // 2 cards in week 1, 4 cards in week 2 → counts [2, 4], mean=3, stddev=1
1469        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    // ── Math helper tests ──
1499
1500    #[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    // ── Week helper tests ──
1551
1552    #[test]
1553    fn format_week_output() {
1554        let d = NaiveDate::from_ymd_opt(2025, 1, 6).unwrap(); // Monday of W02
1555        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(); // Thursday
1561        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(); // W23
1570        let d3 = NaiveDate::from_ymd_opt(2025, 6, 16).unwrap(); // W25
1571        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); // W23, W24, W25
1576        assert_eq!(result[0].1, 3);
1577        assert_eq!(result[1].1, 0); // gap filled
1578        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    // ── Stage time tests ──
1588
1589    /// Write JSONL move events to a temp `.kando/activity.log`.
1590    fn write_activity_log(kando_dir: &std::path::Path, events: &[(&str, &str, &str, &str)]) {
1591        // events: (ts, card_id, from, to)
1592        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        // Write a non-move event
1624        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        // Card created at T=0, moved Backlog→InProgress at T+1d, InProgress→Done at T+3d, completed at T+4d
1642        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        // Backlog: 1 day (created → move1)
1666        assert_eq!(result.columns[0].name, "Backlog");
1667        assert!((result.columns[0].avg_days - 1.0).abs() < 0.1);
1668
1669        // In Progress: 2 days (move1 → move2)
1670        assert_eq!(result.columns[1].name, "In Progress");
1671        assert!((result.columns[1].avg_days - 2.0).abs() < 0.1);
1672
1673        // Done: 1 day (move2 → completed)
1674        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        // Card is NOT completed — still in-progress
1691        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        // Card went through a "QA" column that no longer exists
1748        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        // Board columns first, then historical
1763        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        // Historical column should not be active
1766        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        // Card goes directly Backlog → Done, skipping In Progress
1805        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        // In Progress should NOT appear since card never went there
1823        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        // Card: Backlog→IP→Backlog→IP→Done
1832        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        // Backlog: 1d (created→m1) + 1d (m2→m3) = two samples: [1.0, 1.0]
1856        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        // In Progress: 2d (m1→m2) + 4d (m3→m4) = two samples: [2.0, 4.0]
1861        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        // Done: 2d (m4→completed) = one sample: [2.0]
1866        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); // card1: 2d in Backlog
1880        let m2 = c2 + chrono::TimeDelta::days(4); // card2: 4d in Backlog
1881        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        // avg of [2.0, 4.0] = 3.0
1902        assert!((backlog.avg_days - 3.0).abs() < 0.1);
1903        // median of [2.0, 4.0] = 3.0
1904        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        // Card created, moved, and completed at the same instant
1913        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    // ── Rename map tests ──
2037
2038    /// Append raw JSON lines to `.kando/activity.log`.
2039    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        // Legacy entry without from_name — should be skipped
2125        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        // "OldName" should appear as historical since the legacy entry was skipped
2138        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        // Self-rename: same name
2154        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        // Card1 moved through "Alpha" (before any rename), card2 through "Beta" (after first rename)
2183        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        // Rename A→B, then B→A (cycle)
2188        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        // Should not panic; both cards' durations should resolve to current "Alpha"
2201        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        // Card1 moved through old name "Dev", card2 through new name "Development"
2222        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        // Two cards moved through different old-named columns
2290        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}