Skip to main content

batty_cli/team/
estimation.rs

1//! Task time estimation from telemetry — median cycle time by tag set.
2//!
3//! Queries completed task durations from the telemetry database, groups them
4//! by tag set (from the board), and computes median cycle times. For in-progress
5//! tasks, finds the best-matching tag set to estimate remaining time.
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use anyhow::Result;
11use rusqlite::Connection;
12use tracing::warn;
13
14/// A completed task's cycle time (seconds) and associated tags.
15#[derive(Debug, Clone)]
16pub(crate) struct CompletedTaskSample {
17    pub duration_secs: u64,
18    pub tags: Vec<String>,
19}
20
21/// Estimated time for a single in-progress task.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub(crate) enum TaskEstimate {
24    /// Estimated remaining seconds and total estimated seconds.
25    Remaining {
26        remaining_secs: i64,
27        total_secs: u64,
28    },
29    /// No historical data to estimate from.
30    NoData,
31}
32
33/// Load completed task cycle times from the telemetry database.
34///
35/// Returns tasks that have both `started_at` and `completed_at` set.
36pub(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
53/// Build completed task samples by joining telemetry durations with board tags.
54///
55/// `tag_map` maps task ID strings to their tag lists (from the board).
56pub(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
69/// Compute median cycle time (seconds) for each unique tag set.
70///
71/// Tags are sorted and joined with "," as the key. Tasks with no tags
72/// use the key `""` (empty string).
73pub(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
89/// Compute the global median across all samples (fallback when no tag match).
90pub(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
99/// Estimate remaining time for an in-progress task.
100///
101/// Strategy: find median for exact tag set match, then fall back to global median.
102/// `elapsed_secs` is how long the task has been running.
103pub(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    // Try exact tag set match first.
112    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    // Fall back to global median.
120    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
130/// Format a task estimate for display in the status table.
131pub(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
148/// Build a tag-set-key map from loaded board tasks.
149pub(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
173/// Compute ETA strings for a set of in-progress tasks.
174///
175/// Returns a map from task_id to formatted ETA string.
176pub(crate) fn compute_etas(
177    project_root: &Path,
178    active_task_ids: &[(u32, u64)], // (task_id, elapsed_secs)
179) -> 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
224// ---------------------------------------------------------------------------
225// Internal helpers
226// ---------------------------------------------------------------------------
227
228fn 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(); // no match
358        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        // task 3 has no tags in the map
403
404        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); // median of [100, 200, 300]
433        assert_eq!(medians["feature"], 1000); // single value
434    }
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        // Insert task_assigned and task_completed events to populate task_metrics.
465        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        // Only assigned, never completed.
484        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        // Exactly on time — show ~0s.
498        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); // median of [500, 700]
515    }
516}