From c21f891954f98d6df3769d5063f9528240b5b20a Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Thu, 16 Jul 2026 06:20:00 +0000 Subject: Add estimated_minutes to native task read/write/recurrence paths --- internal/store/native_tasks.go | 38 ++++++++++------ internal/store/native_tasks_test.go | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 13 deletions(-) (limited to 'internal/store') diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index 1f898aa..5d03bde 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -24,7 +24,7 @@ var ErrNativeTaskNotFound = errors.New("native task not found") func (s *Store) GetNativeTasks() ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, - recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes FROM native_tasks WHERE completed = 0 ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC @@ -43,7 +43,7 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) { func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, - recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes FROM native_tasks WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? ORDER BY due_date ASC, priority DESC @@ -64,7 +64,7 @@ func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, - recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes FROM native_tasks WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ? ORDER BY due_date ASC, priority DESC @@ -80,7 +80,7 @@ func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) { func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, - recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes FROM native_tasks WHERE completed = 0 AND due_date IS NULL ORDER BY priority DESC, created_at ASC @@ -96,7 +96,7 @@ func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) { func (s *Store) GetNativeTaskByID(id string) (*models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, - recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes FROM native_tasks WHERE id = ? `, id) @@ -118,9 +118,9 @@ func (s *Store) GetNativeTaskByID(id string) (*models.Task, error) { func (s *Store) CreateNativeTask(task models.Task) error { labelsJSON, _ := json.Marshal(task.Labels) _, err := s.db.Exec(` - INSERT INTO native_tasks (id, content, description, project_name, project_id, due_date, priority, labels, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - `, task.ID, task.Content, task.Description, task.ProjectName, task.ProjectID, task.DueDate, task.Priority, string(labelsJSON)) + INSERT INTO native_tasks (id, content, description, project_name, project_id, due_date, priority, labels, estimated_minutes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `, task.ID, task.Content, task.Description, task.ProjectName, task.ProjectID, task.DueDate, task.Priority, string(labelsJSON), task.EstimatedMinutes) return err } @@ -211,6 +211,18 @@ func (s *Store) RescheduleNativeTask(id string, dueDate time.Time) error { return checkRowsAffected(result) } +// SetTaskEstimate sets a task's estimated duration in minutes. Returns +// ErrNativeTaskNotFound if id doesn't match any row. +func (s *Store) SetTaskEstimate(id string, minutes int) error { + result, err := s.db.Exec(` + UPDATE native_tasks SET estimated_minutes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, minutes, id) + if err != nil { + return err + } + return checkRowsAffected(result) +} + // UncompleteNativeTask marks a task as not completed. Returns // ErrNativeTaskNotFound if id doesn't match any row. func (s *Store) UncompleteNativeTask(id string) error { @@ -245,17 +257,17 @@ func (s *Store) CreateNextIteration(old models.Task) error { labelsJSON, _ := json.Marshal(old.Labels) _, err := s.db.Exec(` INSERT INTO native_tasks ( - id, content, description, project_name, project_id, due_date, priority, labels, + id, content, description, project_name, project_id, due_date, priority, labels, estimated_minutes, recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, created_at, updated_at ) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP WHERE NOT EXISTS ( SELECT 1 FROM native_tasks WHERE recurrence_series_id = ? AND (due_date > ? OR (due_date = ? AND created_at > ?)) ) - `, newTaskID(), old.Content, old.Description, old.ProjectName, old.ProjectID, nextDue, old.Priority, string(labelsJSON), + `, newTaskID(), old.Content, old.Description, old.ProjectName, old.ProjectID, nextDue, old.Priority, string(labelsJSON), old.EstimatedMinutes, old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID, old.RecurrenceSeriesID, old.DueDate, old.DueDate, old.CreatedAt) return err @@ -322,7 +334,7 @@ func (s *Store) SetNextOccurrenceOverride(id string, date time.Time) error { func (s *Store) GetSeriesNeedingNextIteration(now time.Time) ([]models.Task, error) { rows, err := s.db.Query(` SELECT id, content, description, project_name, project_id, due_date, priority, completed, labels, created_at, - recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override + recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override, estimated_minutes FROM native_tasks t1 WHERE recurrence_series_id != '' AND due_date IS NOT NULL AND due_date <= ? @@ -385,7 +397,7 @@ func scanNativeTasks(rows interface { var nextOverrideStr string if err := rows.Scan( &t.ID, &t.Content, &t.Description, &t.ProjectName, &t.ProjectID, &dueDateStr, &t.Priority, &t.Completed, &labelsJSON, &t.CreatedAt, - &t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, + &t.RecurrenceFreq, &t.RecurrenceInterval, &weekdaysStr, &t.RecurrenceSeriesID, &nextOverrideStr, &t.EstimatedMinutes, ); err != nil { return nil, err } diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go index 1b4bdf2..00785c2 100644 --- a/internal/store/native_tasks_test.go +++ b/internal/store/native_tasks_test.go @@ -582,3 +582,91 @@ func TestCreateNextIteration_InheritsProjectIDAndLabels(t *testing.T) { t.Errorf("next iteration Labels = %v, want [chore garden]", next.Labels) } } + +func TestCreateNativeTask_PersistsEstimatedMinutes(t *testing.T) { + s := newNativeTasksTestStore(t) + + task := models.Task{ID: "t-est", Content: "Estimated task", EstimatedMinutes: 45} + if err := s.CreateNativeTask(task); err != nil { + t.Fatalf("CreateNativeTask: %v", err) + } + + got, err := s.GetNativeTaskByID("t-est") + if err != nil { + t.Fatalf("GetNativeTaskByID: %v", err) + } + if got.EstimatedMinutes != 45 { + t.Errorf("EstimatedMinutes = %d, want 45", got.EstimatedMinutes) + } +} + +func TestSetTaskEstimate_UpdatesMinutes(t *testing.T) { + s := newNativeTasksTestStore(t) + + if err := s.SetTaskEstimate("real-1", 30); err != nil { + t.Fatalf("SetTaskEstimate: %v", err) + } + + got, err := s.GetNativeTaskByID("real-1") + if err != nil { + t.Fatalf("GetNativeTaskByID: %v", err) + } + if got.EstimatedMinutes != 30 { + t.Errorf("EstimatedMinutes = %d, want 30", got.EstimatedMinutes) + } +} + +func TestSetTaskEstimate_UnknownID_ReturnsErrNotFound(t *testing.T) { + s := newNativeTasksTestStore(t) + + err := s.SetTaskEstimate("does-not-exist", 30) + if !errors.Is(err, ErrNativeTaskNotFound) { + t.Errorf("err = %v, want ErrNativeTaskNotFound", err) + } +} + +func TestCreateNextIteration_CarriesEstimatedMinutesForward(t *testing.T) { + s := newNativeTasksTestStore(t) + + due := time.Now() + old := models.Task{ + ID: "t-series", Content: "Weekly review", DueDate: &due, + EstimatedMinutes: 60, + RecurrenceFreq: "weekly", + RecurrenceInterval: 1, + RecurrenceSeriesID: "series-1", + CreatedAt: time.Now().Add(-time.Hour), + } + if err := s.CreateNativeTask(old); err != nil { + t.Fatalf("CreateNativeTask: %v", err) + } + if _, err := s.db.Exec(`UPDATE native_tasks SET recurrence_freq = ?, recurrence_interval = ?, recurrence_series_id = ? WHERE id = ?`, + old.RecurrenceFreq, old.RecurrenceInterval, old.RecurrenceSeriesID, old.ID); err != nil { + t.Fatal(err) + } + stored, err := s.GetNativeTaskByID("t-series") + if err != nil { + t.Fatal(err) + } + + if err := s.CreateNextIteration(*stored); err != nil { + t.Fatalf("CreateNextIteration: %v", err) + } + + series, err := s.GetSeriesNeedingNextIteration(time.Now().Add(365 * 24 * time.Hour)) + if err != nil { + t.Fatal(err) + } + var next *models.Task + for i := range series { + if series[i].ID != "t-series" { + next = &series[i] + } + } + if next == nil { + t.Fatal("expected a next iteration row") + } + if next.EstimatedMinutes != 60 { + t.Errorf("EstimatedMinutes = %d, want 60 (carried forward)", next.EstimatedMinutes) + } +} -- cgit v1.2.3