1use std::collections::HashMap;
8use std::path::Path;
9
10use anyhow::Result;
11use rusqlite::Connection;
12use tracing::warn;
13
14#[derive(Debug, Clone)]
16pub(crate) struct CompletedTaskSample {
17 pub duration_secs: u64,
18 pub tags: Vec<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) enum TaskEstimate {
24 Remaining {
26 remaining_secs: i64,
27 total_secs: u64,
28 },
29 NoData,
31}
32
33pub(crate) fn load_completed_samples(conn: &Connection) -> Result<Vec<(String, u64)>> {
37 let mut stmt = conn.prepare(
38 "SELECT task_id, completed_at - started_at
39 FROM task_metrics
40 WHERE started_at IS NOT NULL AND completed_at IS NOT NULL
41 AND completed_at > started_at",
42 )?;
43 let rows = stmt
44 .query_map([], |row| {
45 let task_id: String = row.get(0)?;
46 let duration: i64 = row.get(1)?;
47 Ok((task_id, duration as u64))
48 })?
49 .collect::<std::result::Result<Vec<_>, _>>()?;
50 Ok(rows)
51}
52
53pub(crate) fn build_samples(
57 durations: &[(String, u64)],
58 tag_map: &HashMap<String, Vec<String>>,
59) -> Vec<CompletedTaskSample> {
60 durations
61 .iter()
62 .map(|(task_id, duration)| CompletedTaskSample {
63 duration_secs: *duration,
64 tags: tag_map.get(task_id).cloned().unwrap_or_default(),
65 })
66 .collect()
67}
68
69pub(crate) fn median_by_tag_set(samples: &[CompletedTaskSample]) -> HashMap<String, u64> {
74 let mut grouped: HashMap<String, Vec<u64>> = HashMap::new();
75 for sample in samples {
76 let key = tag_set_key(&sample.tags);
77 grouped.entry(key).or_default().push(sample.duration_secs);
78 }
79
80 let mut result = HashMap::new();
81 for (key, mut durations) in grouped {
82 durations.sort_unstable();
83 let median = compute_median(&durations);
84 result.insert(key, median);
85 }
86 result
87}
88
89pub(crate) fn global_median(samples: &[CompletedTaskSample]) -> Option<u64> {
91 if samples.is_empty() {
92 return None;
93 }
94 let mut durations: Vec<u64> = samples.iter().map(|s| s.duration_secs).collect();
95 durations.sort_unstable();
96 Some(compute_median(&durations))
97}
98
99pub(crate) fn estimate_task(
104 task_tags: &[String],
105 elapsed_secs: u64,
106 medians: &HashMap<String, u64>,
107 fallback_median: Option<u64>,
108) -> TaskEstimate {
109 let key = tag_set_key(task_tags);
110
111 if let Some(&median) = medians.get(&key) {
113 return TaskEstimate::Remaining {
114 remaining_secs: median as i64 - elapsed_secs as i64,
115 total_secs: median,
116 };
117 }
118
119 if let Some(median) = fallback_median {
121 return TaskEstimate::Remaining {
122 remaining_secs: median as i64 - elapsed_secs as i64,
123 total_secs: median,
124 };
125 }
126
127 TaskEstimate::NoData
128}
129
130pub(crate) fn format_estimate(estimate: &TaskEstimate) -> String {
132 match estimate {
133 TaskEstimate::NoData => "n/a".to_string(),
134 TaskEstimate::Remaining {
135 remaining_secs,
136 total_secs: _,
137 } => {
138 if *remaining_secs < 0 {
139 let overdue = (-remaining_secs) as u64;
140 format!("overdue +{}", format_duration(overdue))
141 } else {
142 format!("~{}", format_duration(*remaining_secs as u64))
143 }
144 }
145 }
146}
147
148pub(crate) fn build_tag_map(project_root: &Path) -> HashMap<String, Vec<String>> {
150 let tasks_dir = project_root
151 .join(".batty")
152 .join("team_config")
153 .join("board")
154 .join("tasks");
155 if !tasks_dir.is_dir() {
156 return HashMap::new();
157 }
158
159 let tasks = match crate::task::load_tasks_from_dir(&tasks_dir) {
160 Ok(tasks) => tasks,
161 Err(error) => {
162 warn!(error = %error, "failed to load board tasks for estimation");
163 return HashMap::new();
164 }
165 };
166
167 tasks
168 .into_iter()
169 .map(|task| (task.id.to_string(), task.tags))
170 .collect()
171}
172
173pub(crate) fn compute_etas(
177 project_root: &Path,
178 active_task_ids: &[(u32, u64)], ) -> HashMap<u32, String> {
180 if active_task_ids.is_empty() {
181 return HashMap::new();
182 }
183
184 let conn = match super::telemetry_db::open(project_root) {
185 Ok(conn) => conn,
186 Err(error) => {
187 warn!(error = %error, "failed to open telemetry db for estimation");
188 return active_task_ids
189 .iter()
190 .map(|(id, _)| (*id, "n/a".to_string()))
191 .collect();
192 }
193 };
194
195 let durations = match load_completed_samples(&conn) {
196 Ok(d) => d,
197 Err(error) => {
198 warn!(error = %error, "failed to load completed samples for estimation");
199 return active_task_ids
200 .iter()
201 .map(|(id, _)| (*id, "n/a".to_string()))
202 .collect();
203 }
204 };
205
206 let tag_map = build_tag_map(project_root);
207 let samples = build_samples(&durations, &tag_map);
208 let medians = median_by_tag_set(&samples);
209 let fallback = global_median(&samples);
210
211 active_task_ids
212 .iter()
213 .map(|(task_id, elapsed)| {
214 let tags = tag_map
215 .get(&task_id.to_string())
216 .cloned()
217 .unwrap_or_default();
218 let estimate = estimate_task(&tags, *elapsed, &medians, fallback);
219 (*task_id, format_estimate(&estimate))
220 })
221 .collect()
222}
223
224fn tag_set_key(tags: &[String]) -> String {
229 let mut sorted = tags.to_vec();
230 sorted.sort();
231 sorted.join(",")
232}
233
234fn compute_median(sorted: &[u64]) -> u64 {
235 let len = sorted.len();
236 if len == 0 {
237 return 0;
238 }
239 if len % 2 == 0 {
240 (sorted[len / 2 - 1] + sorted[len / 2]) / 2
241 } else {
242 sorted[len / 2]
243 }
244}
245
246fn format_duration(secs: u64) -> String {
247 if secs < 60 {
248 format!("{secs}s")
249 } else if secs < 3600 {
250 format!("{}m", secs / 60)
251 } else {
252 let hours = secs / 3600;
253 let mins = (secs % 3600) / 60;
254 if mins == 0 {
255 format!("{hours}h")
256 } else {
257 format!("{hours}h{mins}m")
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn median_of_odd_count() {
268 assert_eq!(compute_median(&[100, 200, 300]), 200);
269 }
270
271 #[test]
272 fn median_of_even_count() {
273 assert_eq!(compute_median(&[100, 200, 300, 400]), 250);
274 }
275
276 #[test]
277 fn median_of_single() {
278 assert_eq!(compute_median(&[42]), 42);
279 }
280
281 #[test]
282 fn median_of_empty() {
283 assert_eq!(compute_median(&[]), 0);
284 }
285
286 #[test]
287 fn tag_set_key_sorts() {
288 let tags = vec!["feature".into(), "daemon".into(), "bugfix".into()];
289 assert_eq!(tag_set_key(&tags), "bugfix,daemon,feature");
290 }
291
292 #[test]
293 fn tag_set_key_empty() {
294 assert_eq!(tag_set_key(&[]), "");
295 }
296
297 #[test]
298 fn format_duration_seconds() {
299 assert_eq!(format_duration(45), "45s");
300 }
301
302 #[test]
303 fn format_duration_minutes() {
304 assert_eq!(format_duration(300), "5m");
305 }
306
307 #[test]
308 fn format_duration_hours_and_minutes() {
309 assert_eq!(format_duration(5400), "1h30m");
310 }
311
312 #[test]
313 fn format_duration_exact_hours() {
314 assert_eq!(format_duration(7200), "2h");
315 }
316
317 #[test]
318 fn format_estimate_no_data() {
319 assert_eq!(format_estimate(&TaskEstimate::NoData), "n/a");
320 }
321
322 #[test]
323 fn format_estimate_remaining() {
324 let est = TaskEstimate::Remaining {
325 remaining_secs: 1800,
326 total_secs: 3600,
327 };
328 assert_eq!(format_estimate(&est), "~30m");
329 }
330
331 #[test]
332 fn format_estimate_overdue() {
333 let est = TaskEstimate::Remaining {
334 remaining_secs: -600,
335 total_secs: 3600,
336 };
337 assert_eq!(format_estimate(&est), "overdue +10m");
338 }
339
340 #[test]
341 fn estimate_task_exact_tag_match() {
342 let mut medians = HashMap::new();
343 medians.insert("bugfix,daemon".to_string(), 3600);
344 let tags = vec!["daemon".to_string(), "bugfix".to_string()];
345 let result = estimate_task(&tags, 1800, &medians, Some(7200));
346 assert_eq!(
347 result,
348 TaskEstimate::Remaining {
349 remaining_secs: 1800,
350 total_secs: 3600,
351 }
352 );
353 }
354
355 #[test]
356 fn estimate_task_falls_back_to_global() {
357 let medians = HashMap::new(); let tags = vec!["newfeature".to_string()];
359 let result = estimate_task(&tags, 300, &medians, Some(1800));
360 assert_eq!(
361 result,
362 TaskEstimate::Remaining {
363 remaining_secs: 1500,
364 total_secs: 1800,
365 }
366 );
367 }
368
369 #[test]
370 fn estimate_task_no_data() {
371 let medians = HashMap::new();
372 let tags = vec!["newfeature".to_string()];
373 let result = estimate_task(&tags, 300, &medians, None);
374 assert_eq!(result, TaskEstimate::NoData);
375 }
376
377 #[test]
378 fn estimate_task_overdue() {
379 let mut medians = HashMap::new();
380 medians.insert("bugfix".to_string(), 1000);
381 let tags = vec!["bugfix".to_string()];
382 let result = estimate_task(&tags, 2000, &medians, None);
383 assert_eq!(
384 result,
385 TaskEstimate::Remaining {
386 remaining_secs: -1000,
387 total_secs: 1000,
388 }
389 );
390 }
391
392 #[test]
393 fn build_samples_joins_tags() {
394 let durations = vec![
395 ("1".to_string(), 100),
396 ("2".to_string(), 200),
397 ("3".to_string(), 300),
398 ];
399 let mut tag_map = HashMap::new();
400 tag_map.insert("1".to_string(), vec!["bugfix".into()]);
401 tag_map.insert("2".to_string(), vec!["feature".into(), "daemon".into()]);
402 let samples = build_samples(&durations, &tag_map);
405 assert_eq!(samples.len(), 3);
406 assert_eq!(samples[0].tags, vec!["bugfix"]);
407 assert_eq!(samples[1].tags, vec!["feature", "daemon"]);
408 assert!(samples[2].tags.is_empty());
409 }
410
411 #[test]
412 fn median_by_tag_set_groups_correctly() {
413 let samples = vec![
414 CompletedTaskSample {
415 duration_secs: 100,
416 tags: vec!["bugfix".into()],
417 },
418 CompletedTaskSample {
419 duration_secs: 300,
420 tags: vec!["bugfix".into()],
421 },
422 CompletedTaskSample {
423 duration_secs: 200,
424 tags: vec!["bugfix".into()],
425 },
426 CompletedTaskSample {
427 duration_secs: 1000,
428 tags: vec!["feature".into()],
429 },
430 ];
431 let medians = median_by_tag_set(&samples);
432 assert_eq!(medians["bugfix"], 200); assert_eq!(medians["feature"], 1000); }
435
436 #[test]
437 fn global_median_across_all_samples() {
438 let samples = vec![
439 CompletedTaskSample {
440 duration_secs: 100,
441 tags: vec![],
442 },
443 CompletedTaskSample {
444 duration_secs: 500,
445 tags: vec![],
446 },
447 CompletedTaskSample {
448 duration_secs: 300,
449 tags: vec![],
450 },
451 ];
452 assert_eq!(global_median(&samples), Some(300));
453 }
454
455 #[test]
456 fn global_median_empty() {
457 assert_eq!(global_median(&[]), None);
458 }
459
460 #[test]
461 fn load_completed_samples_from_telemetry() {
462 let conn = super::super::telemetry_db::open_in_memory().unwrap();
463
464 let mut assign = crate::team::events::TeamEvent::task_assigned("eng-1", "10");
466 assign.ts = 1000;
467 super::super::telemetry_db::insert_event(&conn, &assign).unwrap();
468
469 let mut complete = crate::team::events::TeamEvent::task_completed("eng-1", Some("10"));
470 complete.ts = 1600;
471 super::super::telemetry_db::insert_event(&conn, &complete).unwrap();
472
473 let samples = load_completed_samples(&conn).unwrap();
474 assert_eq!(samples.len(), 1);
475 assert_eq!(samples[0].0, "10");
476 assert_eq!(samples[0].1, 600);
477 }
478
479 #[test]
480 fn load_completed_samples_skips_incomplete() {
481 let conn = super::super::telemetry_db::open_in_memory().unwrap();
482
483 let assign = crate::team::events::TeamEvent::task_assigned("eng-1", "11");
485 super::super::telemetry_db::insert_event(&conn, &assign).unwrap();
486
487 let samples = load_completed_samples(&conn).unwrap();
488 assert!(samples.is_empty());
489 }
490
491 #[test]
492 fn format_estimate_zero_remaining() {
493 let est = TaskEstimate::Remaining {
494 remaining_secs: 0,
495 total_secs: 3600,
496 };
497 assert_eq!(format_estimate(&est), "~0s");
499 }
500
501 #[test]
502 fn median_by_tag_set_empty_tags_use_empty_key() {
503 let samples = vec![
504 CompletedTaskSample {
505 duration_secs: 500,
506 tags: vec![],
507 },
508 CompletedTaskSample {
509 duration_secs: 700,
510 tags: vec![],
511 },
512 ];
513 let medians = median_by_tag_set(&samples);
514 assert_eq!(medians[""], 600); }
516}