Skip to main content

batty_cli/team/
board_health.rs

1//! Board health dashboard — task counts, age, blocked chains, throughput.
2
3use std::collections::HashMap;
4use std::path::Path;
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8
9use super::events::read_events;
10use crate::task::{Task, load_tasks_from_dir};
11
12/// Per-status statistics for the board health dashboard.
13#[derive(Debug, Clone, PartialEq)]
14pub struct StatusStats {
15    pub status: String,
16    pub count: usize,
17    pub avg_age_hours: f64,
18}
19
20/// Full board health snapshot.
21#[derive(Debug, Clone, PartialEq)]
22pub struct BoardHealth {
23    pub status_stats: Vec<StatusStats>,
24    pub total_tasks: usize,
25    pub max_blocked_chain: usize,
26    pub review_queue_age_hours: f64,
27    pub throughput_per_hour: f64,
28}
29
30/// Ordered list of statuses for display.
31const STATUSES: &[&str] = &[
32    "backlog",
33    "todo",
34    "in-progress",
35    "review",
36    "blocked",
37    "done",
38];
39
40/// Compute board health from task files and event log.
41pub fn compute_health(board_dir: &Path, events_path: &Path) -> Result<BoardHealth> {
42    let tasks_dir = board_dir.join("tasks");
43    let tasks = if tasks_dir.is_dir() {
44        load_tasks_from_dir(&tasks_dir)?
45    } else {
46        Vec::new()
47    };
48
49    let now = Utc::now();
50    let total_tasks = tasks.len();
51
52    // Group tasks by status.
53    let mut by_status: HashMap<String, Vec<&Task>> = HashMap::new();
54    for task in &tasks {
55        by_status.entry(task.status.clone()).or_default().push(task);
56    }
57
58    // Compute per-status counts and average age.
59    let mut status_stats = Vec::new();
60    for &status in STATUSES {
61        let group = by_status.get(status);
62        let count = group.map_or(0, |g| g.len());
63        let avg_age_hours = if count == 0 {
64            0.0
65        } else {
66            let total_hours: f64 = group.unwrap().iter().map(|t| task_age_hours(t, now)).sum();
67            total_hours / count as f64
68        };
69        status_stats.push(StatusStats {
70            status: status.to_string(),
71            count,
72            avg_age_hours,
73        });
74    }
75
76    // Blocked chain depth: walk depends_on links to find the longest chain.
77    let max_blocked_chain = compute_max_chain_depth(&tasks);
78
79    // Review queue age: average age of tasks in "review" status.
80    let review_queue_age_hours = status_stats
81        .iter()
82        .find(|s| s.status == "review")
83        .map_or(0.0, |s| s.avg_age_hours);
84
85    // Throughput: task_completed events in last hour.
86    let throughput_per_hour = compute_throughput(events_path, now)?;
87
88    Ok(BoardHealth {
89        status_stats,
90        total_tasks,
91        max_blocked_chain,
92        review_queue_age_hours,
93        throughput_per_hour,
94    })
95}
96
97/// Format the board health as a human-readable table.
98pub fn format_health(health: &BoardHealth) -> String {
99    let mut out = String::new();
100
101    out.push_str("Board Health Dashboard\n");
102    out.push_str("======================\n\n");
103
104    // Status table.
105    out.push_str(&format!(
106        "{:<14} {:>5} {:>10}\n",
107        "STATUS", "COUNT", "AVG AGE"
108    ));
109    out.push_str(&format!("{:-<14} {:->5} {:->10}\n", "", "", ""));
110    for stat in &health.status_stats {
111        let age_display = format_age(stat.avg_age_hours);
112        out.push_str(&format!(
113            "{:<14} {:>5} {:>10}\n",
114            stat.status, stat.count, age_display
115        ));
116    }
117    out.push_str(&format!("{:-<14} {:->5} {:->10}\n", "", "", ""));
118    out.push_str(&format!("{:<14} {:>5}\n", "TOTAL", health.total_tasks));
119
120    out.push('\n');
121
122    // Metrics.
123    out.push_str(&format!(
124        "Blocked chain depth:  {}\n",
125        health.max_blocked_chain
126    ));
127    out.push_str(&format!(
128        "Review queue age:     {}\n",
129        format_age(health.review_queue_age_hours)
130    ));
131    out.push_str(&format!(
132        "Throughput (1h):      {:.1} tasks/hour\n",
133        health.throughput_per_hour
134    ));
135
136    out
137}
138
139/// Compute age of a task in hours using filesystem mtime as fallback.
140fn task_age_hours(task: &Task, now: DateTime<Utc>) -> f64 {
141    let mtime = std::fs::metadata(&task.source_path)
142        .and_then(|m| m.modified())
143        .ok();
144
145    match mtime {
146        Some(mtime) => {
147            let mtime_dt: DateTime<Utc> = mtime.into();
148            let age = now.signed_duration_since(mtime_dt);
149            age.num_minutes().max(0) as f64 / 60.0
150        }
151        None => 0.0,
152    }
153}
154
155/// Walk depends_on links to find the longest dependency chain.
156pub fn compute_max_chain_depth(tasks: &[Task]) -> usize {
157    let id_to_deps: HashMap<u32, &[u32]> = tasks
158        .iter()
159        .map(|t| (t.id, t.depends_on.as_slice()))
160        .collect();
161
162    let mut max_depth = 0;
163    let mut memo: HashMap<u32, usize> = HashMap::new();
164
165    for task in tasks {
166        let depth = chain_depth(task.id, &id_to_deps, &mut memo, &mut Vec::new());
167        if depth > max_depth {
168            max_depth = depth;
169        }
170    }
171
172    max_depth
173}
174
175fn chain_depth(
176    id: u32,
177    id_to_deps: &HashMap<u32, &[u32]>,
178    memo: &mut HashMap<u32, usize>,
179    visiting: &mut Vec<u32>,
180) -> usize {
181    if let Some(&cached) = memo.get(&id) {
182        return cached;
183    }
184
185    // Cycle detection.
186    if visiting.contains(&id) {
187        return 0;
188    }
189
190    let deps = match id_to_deps.get(&id) {
191        Some(deps) => *deps,
192        None => return 0,
193    };
194
195    if deps.is_empty() {
196        memo.insert(id, 0);
197        return 0;
198    }
199
200    visiting.push(id);
201    let max_child = deps
202        .iter()
203        .map(|&dep| chain_depth(dep, id_to_deps, memo, visiting))
204        .max()
205        .unwrap_or(0);
206    visiting.pop();
207
208    let depth = 1 + max_child;
209    memo.insert(id, depth);
210    depth
211}
212
213/// Count task_completed events in the last hour.
214fn compute_throughput(events_path: &Path, now: DateTime<Utc>) -> Result<f64> {
215    let events = read_events(events_path)?;
216    let one_hour_ago = now.timestamp() as u64 - 3600;
217
218    let completed_count = events
219        .iter()
220        .filter(|e| e.event == "task_completed" && e.ts >= one_hour_ago)
221        .count();
222
223    Ok(completed_count as f64)
224}
225
226/// Format hours into a human-readable string.
227fn format_age(hours: f64) -> String {
228    if hours < 1.0 {
229        format!("{:.0}m", hours * 60.0)
230    } else if hours < 24.0 {
231        format!("{:.1}h", hours)
232    } else {
233        let days = hours / 24.0;
234        format!("{:.1}d", days)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use std::fs;
242    use std::path::PathBuf;
243    use tempfile::tempdir;
244
245    fn make_task(id: u32, status: &str, depends_on: Vec<u32>) -> Task {
246        Task {
247            id,
248            title: format!("Task {id}"),
249            status: status.to_string(),
250            priority: "medium".to_string(),
251            claimed_by: None,
252            claimed_at: None,
253            claim_ttl_secs: None,
254            claim_expires_at: None,
255            last_progress_at: None,
256            claim_warning_sent_at: None,
257            claim_extensions: None,
258            last_output_bytes: None,
259            blocked: None,
260            tags: Vec::new(),
261            depends_on,
262            review_owner: None,
263            blocked_on: None,
264            worktree_path: None,
265            branch: None,
266            commit: None,
267            artifacts: Vec::new(),
268            next_action: None,
269            scheduled_for: None,
270            cron_schedule: None,
271            cron_last_run: None,
272            completed: None,
273            description: String::new(),
274            batty_config: None,
275            source_path: PathBuf::from("/tmp/fake.md"),
276        }
277    }
278
279    fn write_task_file(dir: &Path, id: u32, status: &str, depends_on: &[u32]) {
280        let deps_str = if depends_on.is_empty() {
281            String::new()
282        } else {
283            let items: Vec<String> = depends_on.iter().map(|d| format!("  - {d}")).collect();
284            format!("depends_on:\n{}\n", items.join("\n"))
285        };
286        let content = format!(
287            "---\nid: {id}\ntitle: Task {id}\nstatus: {status}\npriority: medium\n{deps_str}---\n\nTask body\n"
288        );
289        fs::write(dir.join(format!("{id:04}.md")), content).unwrap();
290    }
291
292    fn write_events(path: &Path, events: &[(&str, u64)]) {
293        let lines: Vec<String> = events
294            .iter()
295            .map(|(event, ts)| format!(r#"{{"event":"{event}","ts":{ts}}}"#))
296            .collect();
297        fs::write(path, lines.join("\n") + "\n").unwrap();
298    }
299
300    #[test]
301    fn count_by_status_correct() {
302        let tmp = tempdir().unwrap();
303        let board_dir = tmp.path().to_path_buf();
304        let tasks_dir = board_dir.join("tasks");
305        fs::create_dir_all(&tasks_dir).unwrap();
306
307        write_task_file(&tasks_dir, 1, "backlog", &[]);
308        write_task_file(&tasks_dir, 2, "in-progress", &[]);
309        write_task_file(&tasks_dir, 3, "in-progress", &[]);
310        write_task_file(&tasks_dir, 4, "done", &[]);
311        write_task_file(&tasks_dir, 5, "review", &[]);
312
313        let events_path = board_dir.join("events.jsonl");
314        fs::write(&events_path, "").unwrap();
315
316        let health = compute_health(&board_dir, &events_path).unwrap();
317
318        assert_eq!(health.total_tasks, 5);
319
320        let find = |s: &str| {
321            health
322                .status_stats
323                .iter()
324                .find(|st| st.status == s)
325                .unwrap()
326        };
327        assert_eq!(find("backlog").count, 1);
328        assert_eq!(find("in-progress").count, 2);
329        assert_eq!(find("done").count, 1);
330        assert_eq!(find("review").count, 1);
331        assert_eq!(find("todo").count, 0);
332        assert_eq!(find("blocked").count, 0);
333    }
334
335    #[test]
336    fn average_age_calculation() {
337        let tmp = tempdir().unwrap();
338        let board_dir = tmp.path().to_path_buf();
339        let tasks_dir = board_dir.join("tasks");
340        fs::create_dir_all(&tasks_dir).unwrap();
341
342        write_task_file(&tasks_dir, 1, "in-progress", &[]);
343        write_task_file(&tasks_dir, 2, "in-progress", &[]);
344
345        let events_path = board_dir.join("events.jsonl");
346        fs::write(&events_path, "").unwrap();
347
348        let health = compute_health(&board_dir, &events_path).unwrap();
349
350        let in_progress = health
351            .status_stats
352            .iter()
353            .find(|s| s.status == "in-progress")
354            .unwrap();
355        assert!(
356            in_progress.avg_age_hours < 1.0,
357            "expected age < 1h, got {:.2}h",
358            in_progress.avg_age_hours
359        );
360    }
361
362    #[test]
363    fn blocked_chain_depth_linear() {
364        // Chain: 4 -> 3 -> 2 -> 1 (depth 3 from task 4).
365        let tasks = vec![
366            make_task(1, "backlog", vec![]),
367            make_task(2, "backlog", vec![1]),
368            make_task(3, "backlog", vec![2]),
369            make_task(4, "backlog", vec![3]),
370        ];
371
372        assert_eq!(compute_max_chain_depth(&tasks), 3);
373    }
374
375    #[test]
376    fn blocked_chain_with_cycle() {
377        let tasks = vec![
378            make_task(1, "backlog", vec![3]),
379            make_task(2, "backlog", vec![1]),
380            make_task(3, "backlog", vec![2]),
381        ];
382
383        let depth = compute_max_chain_depth(&tasks);
384        assert!(depth <= 3);
385    }
386
387    #[test]
388    fn throughput_from_events() {
389        let tmp = tempdir().unwrap();
390        let events_path = tmp.path().join("events.jsonl");
391        let now = Utc::now();
392        let now_ts = now.timestamp() as u64;
393
394        write_events(
395            &events_path,
396            &[
397                ("task_completed", now_ts - 100),
398                ("task_completed", now_ts - 200),
399                ("task_completed", now_ts - 300),
400                ("task_completed", now_ts - 7200), // 2 hours ago
401                ("daemon_started", now_ts - 50),   // Not task_completed
402            ],
403        );
404
405        let throughput = compute_throughput(&events_path, now).unwrap();
406        assert!((throughput - 3.0).abs() < 0.01);
407    }
408
409    #[test]
410    fn handles_empty_board() {
411        let tmp = tempdir().unwrap();
412        let board_dir = tmp.path().to_path_buf();
413
414        let events_path = board_dir.join("events.jsonl");
415        fs::write(&events_path, "").unwrap();
416
417        let health = compute_health(&board_dir, &events_path).unwrap();
418        assert_eq!(health.total_tasks, 0);
419        assert_eq!(health.max_blocked_chain, 0);
420        assert!((health.throughput_per_hour - 0.0).abs() < 0.01);
421
422        for stat in &health.status_stats {
423            assert_eq!(stat.count, 0);
424            assert!((stat.avg_age_hours - 0.0).abs() < 0.01);
425        }
426    }
427
428    #[test]
429    fn handles_missing_events_file() {
430        let tmp = tempdir().unwrap();
431        let board_dir = tmp.path().to_path_buf();
432        let tasks_dir = board_dir.join("tasks");
433        fs::create_dir_all(&tasks_dir).unwrap();
434
435        write_task_file(&tasks_dir, 1, "todo", &[]);
436
437        let events_path = board_dir.join("nonexistent.jsonl");
438
439        let health = compute_health(&board_dir, &events_path).unwrap();
440        assert_eq!(health.total_tasks, 1);
441        assert!((health.throughput_per_hour - 0.0).abs() < 0.01);
442    }
443
444    #[test]
445    fn format_health_output() {
446        let health = BoardHealth {
447            status_stats: vec![
448                StatusStats {
449                    status: "backlog".to_string(),
450                    count: 5,
451                    avg_age_hours: 48.0,
452                },
453                StatusStats {
454                    status: "in-progress".to_string(),
455                    count: 3,
456                    avg_age_hours: 2.5,
457                },
458                StatusStats {
459                    status: "review".to_string(),
460                    count: 1,
461                    avg_age_hours: 0.5,
462                },
463            ],
464            total_tasks: 9,
465            max_blocked_chain: 2,
466            review_queue_age_hours: 0.5,
467            throughput_per_hour: 4.0,
468        };
469
470        let output = format_health(&health);
471        assert!(output.contains("Board Health Dashboard"));
472        assert!(output.contains("backlog"));
473        assert!(output.contains("in-progress"));
474        assert!(output.contains("TOTAL"));
475        assert!(output.contains("9"));
476        assert!(output.contains("Blocked chain depth:  2"));
477        assert!(output.contains("Throughput (1h):      4.0 tasks/hour"));
478    }
479
480    #[test]
481    fn format_age_displays_correctly() {
482        assert_eq!(format_age(0.0), "0m");
483        assert_eq!(format_age(0.5), "30m");
484        assert_eq!(format_age(2.5), "2.5h");
485        assert_eq!(format_age(48.0), "2.0d");
486    }
487
488    #[test]
489    fn chain_depth_no_deps() {
490        let tasks = vec![make_task(1, "todo", vec![]), make_task(2, "todo", vec![])];
491        assert_eq!(compute_max_chain_depth(&tasks), 0);
492    }
493
494    #[test]
495    fn chain_depth_diamond() {
496        // Diamond: 4 -> {2, 3} -> 1.
497        let tasks = vec![
498            make_task(1, "todo", vec![]),
499            make_task(2, "todo", vec![1]),
500            make_task(3, "todo", vec![1]),
501            make_task(4, "todo", vec![2, 3]),
502        ];
503        assert_eq!(compute_max_chain_depth(&tasks), 2);
504    }
505}