From 06f34745698665be807d59cd143d6e8c5174115c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 16 Jul 2026 06:31:26 +0000 Subject: Infer a default estimate from same-project/label averages --- internal/store/estimate_inference.go | 70 ++++++++++++++++++++++++ internal/store/estimate_inference_test.go | 90 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 internal/store/estimate_inference.go create mode 100644 internal/store/estimate_inference_test.go (limited to 'internal/store') diff --git a/internal/store/estimate_inference.go b/internal/store/estimate_inference.go new file mode 100644 index 0000000..d0a0034 --- /dev/null +++ b/internal/store/estimate_inference.go @@ -0,0 +1,70 @@ +package store + +import "encoding/json" + +// AverageEstimateForProject returns the rounded average estimated_minutes +// across all user-estimated (estimated_minutes > 0) tasks under projectID. +// ok is false when no such task exists -- there's no signal to infer from. +func (s *Store) AverageEstimateForProject(projectID string) (int, bool, error) { + var sum, count int + rows, err := s.db.Query(`SELECT estimated_minutes FROM native_tasks WHERE project_id = ? AND estimated_minutes > 0`, projectID) + if err != nil { + return 0, false, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var minutes int + if err := rows.Scan(&minutes); err != nil { + return 0, false, err + } + sum += minutes + count++ + } + if err := rows.Err(); err != nil { + return 0, false, err + } + if count == 0 { + return 0, false, nil + } + return sum / count, true, nil +} + +// AverageEstimateForLabel returns the rounded average estimated_minutes +// across all user-estimated tasks carrying the given label. Labels are +// stored as a JSON array column, not a joinable table, so this scans every +// estimated task and filters in Go rather than risking a SQL substring +// false-positive (e.g. LIKE '%"run"%' matching a task labeled "running"). +func (s *Store) AverageEstimateForLabel(label string) (int, bool, error) { + rows, err := s.db.Query(`SELECT labels, estimated_minutes FROM native_tasks WHERE estimated_minutes > 0`) + if err != nil { + return 0, false, err + } + defer func() { _ = rows.Close() }() + + var sum, count int + for rows.Next() { + var labelsJSON string + var minutes int + if err := rows.Scan(&labelsJSON, &minutes); err != nil { + return 0, false, err + } + var labels []string + if err := json.Unmarshal([]byte(labelsJSON), &labels); err != nil { + continue + } + for _, l := range labels { + if l == label { + sum += minutes + count++ + break + } + } + } + if err := rows.Err(); err != nil { + return 0, false, err + } + if count == 0 { + return 0, false, nil + } + return sum / count, true, nil +} diff --git a/internal/store/estimate_inference_test.go b/internal/store/estimate_inference_test.go new file mode 100644 index 0000000..9321f11 --- /dev/null +++ b/internal/store/estimate_inference_test.go @@ -0,0 +1,90 @@ +// internal/store/estimate_inference_test.go +package store + +import ( + "testing" + "time" + + "task-dashboard/internal/models" +) + +func TestAverageEstimateForProject_AveragesUserEnteredEstimates(t *testing.T) { + s := newNativeTasksTestStore(t) + project, err := s.CreateProject("Sailing prep", "#3B82F6") + if err != nil { + t.Fatal(err) + } + due := time.Now() + for _, minutes := range []int{30, 60} { + task := models.Task{ID: newTaskID(), Content: "task", ProjectID: project.ID, EstimatedMinutes: minutes, DueDate: &due} + if err := s.CreateNativeTask(task); err != nil { + t.Fatal(err) + } + } + // An unestimated task under the same project must not skew the average. + if err := s.CreateNativeTask(models.Task{ID: "t-unestimated", Content: "no estimate", ProjectID: project.ID}); err != nil { + t.Fatal(err) + } + + avg, ok, err := s.AverageEstimateForProject(project.ID) + if err != nil { + t.Fatalf("AverageEstimateForProject: %v", err) + } + if !ok { + t.Fatal("expected ok = true") + } + if avg != 45 { + t.Errorf("avg = %d, want 45", avg) + } +} + +func TestAverageEstimateForProject_NoEstimatedTasks_ReturnsNotOK(t *testing.T) { + s := newNativeTasksTestStore(t) + project, err := s.CreateProject("Empty", "#111111") + if err != nil { + t.Fatal(err) + } + + _, ok, err := s.AverageEstimateForProject(project.ID) + if err != nil { + t.Fatal(err) + } + if ok { + t.Error("expected ok = false when no tasks have an estimate") + } +} + +func TestAverageEstimateForLabel_AveragesUserEnteredEstimates(t *testing.T) { + s := newNativeTasksTestStore(t) + for _, minutes := range []int{20, 40} { + task := models.Task{ID: newTaskID(), Content: "task", Labels: []string{"errands"}, EstimatedMinutes: minutes} + if err := s.CreateNativeTask(task); err != nil { + t.Fatal(err) + } + } + if err := s.CreateNativeTask(models.Task{ID: "t-other-label", Content: "other", Labels: []string{"unrelated"}, EstimatedMinutes: 100}); err != nil { + t.Fatal(err) + } + + avg, ok, err := s.AverageEstimateForLabel("errands") + if err != nil { + t.Fatalf("AverageEstimateForLabel: %v", err) + } + if !ok { + t.Fatal("expected ok = true") + } + if avg != 30 { + t.Errorf("avg = %d, want 30", avg) + } +} + +func TestAverageEstimateForLabel_NoMatches_ReturnsNotOK(t *testing.T) { + s := newNativeTasksTestStore(t) + _, ok, err := s.AverageEstimateForLabel("nonexistent") + if err != nil { + t.Fatal(err) + } + if ok { + t.Error("expected ok = false") + } +} -- cgit v1.2.3