1use 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#[derive(Debug, Clone, PartialEq)]
14pub struct StatusStats {
15 pub status: String,
16 pub count: usize,
17 pub avg_age_hours: f64,
18}
19
20#[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
30const STATUSES: &[&str] = &[
32 "backlog",
33 "todo",
34 "in-progress",
35 "review",
36 "blocked",
37 "done",
38];
39
40pub 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 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 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 let max_blocked_chain = compute_max_chain_depth(&tasks);
78
79 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 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
97pub 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 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 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
139fn 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
155pub 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 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
213fn 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
226fn 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 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), ("daemon_started", now_ts - 50), ],
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 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}