summaryrefslogtreecommitdiff
path: root/internal/store
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-16 06:31:26 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-16 06:31:26 +0000
commit06f34745698665be807d59cd143d6e8c5174115c (patch)
tree41f3ea8aa35cf18ad63fa2186425993c16a3553b /internal/store
parent73c3440c579e645ada20f761bbc3eb1c43927e7f (diff)
Infer a default estimate from same-project/label averages
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/estimate_inference.go70
-rw-r--r--internal/store/estimate_inference_test.go90
2 files changed, 160 insertions, 0 deletions
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")
+ }
+}