diff options
Diffstat (limited to 'internal/store')
| -rw-r--r-- | internal/store/native_tasks.go | 20 | ||||
| -rw-r--r-- | internal/store/native_tasks_test.go | 82 | ||||
| -rw-r--r-- | internal/store/sqlite_test.go | 3 |
3 files changed, 99 insertions, 6 deletions
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go index ea5212c..955b7ac 100644 --- a/internal/store/native_tasks.go +++ b/internal/store/native_tasks.go @@ -43,7 +43,7 @@ func (s *Store) GetNativeTasks() ([]models.Task, error) { chain_id, chain_position, chain_unlocked, bucket_id, bucket_state, bucket_last_active_at FROM native_tasks - WHERE completed = 0 + WHERE completed = 0 AND abandoned = 0 ORDER BY CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC, priority DESC `) if err != nil { @@ -64,7 +64,7 @@ func (s *Store) GetNativeTasksByDateRange(start, end time.Time) ([]models.Task, chain_id, chain_position, chain_unlocked, bucket_id, bucket_state, bucket_last_active_at FROM native_tasks t - WHERE completed = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? + WHERE completed = 0 AND abandoned = 0 AND due_date IS NOT NULL AND due_date >= ? AND due_date < ? AND `+visibleChainTaskClause+` ORDER BY due_date ASC, priority DESC `, start, end) @@ -88,7 +88,7 @@ func (s *Store) GetOverdueNativeTasks(before time.Time) ([]models.Task, error) { chain_id, chain_position, chain_unlocked, bucket_id, bucket_state, bucket_last_active_at FROM native_tasks t - WHERE completed = 0 AND due_date IS NOT NULL AND due_date < ? + WHERE completed = 0 AND abandoned = 0 AND due_date IS NOT NULL AND due_date < ? AND `+visibleChainTaskClause+` ORDER BY due_date ASC, priority DESC `, before) @@ -107,7 +107,7 @@ func (s *Store) GetUndatedNativeTasks() ([]models.Task, error) { chain_id, chain_position, chain_unlocked, bucket_id, bucket_state, bucket_last_active_at FROM native_tasks t - WHERE completed = 0 AND due_date IS NULL + WHERE completed = 0 AND abandoned = 0 AND due_date IS NULL AND `+visibleChainTaskClause+` AND bucket_state != 'dormant' ORDER BY priority DESC, created_at ASC @@ -493,6 +493,18 @@ func (s *Store) AdvanceDueRecurringTasks(now time.Time) (int, error) { if err := s.CreateNextIteration(task); err != nil { return 0, err } + // The occurrence being superseded was never done, and now its + // replacement is here -- it's stale, not still owed. Left open it would + // sit in the list as a permanent overdue item and every skipped + // occurrence would add another. Not marked `completed`, since that would + // falsely credit work that never happened. + if !task.Completed { + if _, err := s.db.Exec(` + UPDATE native_tasks SET abandoned = 1, updated_at = ? WHERE id = ? + `, config.Now(), task.ID); err != nil { + return 0, err + } + } } return len(series), nil } diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go index 9a7e55f..9453c86 100644 --- a/internal/store/native_tasks_test.go +++ b/internal/store/native_tasks_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "task-dashboard/internal/config" "task-dashboard/internal/models" _ "github.com/mattn/go-sqlite3" @@ -48,7 +49,8 @@ func newNativeTasksTestStore(t *testing.T) *Store { chain_unlocked BOOLEAN DEFAULT 0, bucket_id TEXT DEFAULT '', bucket_state TEXT DEFAULT '', - bucket_last_active_at DATETIME + bucket_last_active_at DATETIME, + abandoned BOOLEAN DEFAULT 0 ) `); err != nil { t.Fatal(err) @@ -718,3 +720,81 @@ func TestDeleteNativeTask_UnknownID_ReturnsErrNotFound(t *testing.T) { t.Errorf("err = %v, want ErrNativeTaskNotFound", err) } } + +// A weekly task not done before its next iteration lands is stale, not still +// owed -- the successor supersedes it. Before this, the skipped instance stayed +// open forever and showed up as a permanent overdue item, so ignoring one +// occurrence cost a list slot indefinitely. +func TestAdvanceDueRecurringTasks_AbandonsSupersededInstance(t *testing.T) { + s := newNativeTasksTestStore(t) + + due := config.Now().Add(-24 * time.Hour) + task := models.Task{ + ID: "weekly-1", Content: "Take out trash", DueDate: &due, + RecurrenceFreq: "weekly", RecurrenceInterval: 1, + } + if err := s.CreateNativeTask(task); err != nil { + t.Fatal(err) + } + if err := s.SetTaskRecurrence("weekly-1", "weekly", 1, nil); err != nil { + t.Fatal(err) + } + + if _, err := s.AdvanceDueRecurringTasks(config.Now()); err != nil { + t.Fatalf("AdvanceDueRecurringTasks: %v", err) + } + + // The superseded original must no longer be an open, actionable task. + overdue, err := s.GetOverdueNativeTasks(config.Now()) + if err != nil { + t.Fatal(err) + } + for _, o := range overdue { + if o.ID == "weekly-1" { + t.Error("superseded instance weekly-1 is still showing as overdue; it should have been abandoned when its successor was created") + } + } + + // ...and the series should be left with exactly one live occurrence: the + // successor. Scoped to this series because the test store seeds an + // unrelated task of its own. + var seriesID string + if err := s.db.QueryRow(`SELECT recurrence_series_id FROM native_tasks WHERE id = 'weekly-1'`).Scan(&seriesID); err != nil { + t.Fatal(err) + } + var open int + if err := s.db.QueryRow(` + SELECT COUNT(*) FROM native_tasks + WHERE recurrence_series_id = ? AND completed = 0 AND abandoned = 0 + `, seriesID).Scan(&open); err != nil { + t.Fatal(err) + } + if open != 1 { + t.Errorf("live occurrences in series = %d, want exactly 1 (the successor); a skipped occurrence must not keep its slot", open) + } +} + +// Completing normally must not mark anything abandoned -- only the scheduler's +// supersede path does that. +func TestCompleteNativeTask_DoesNotAbandonAnything(t *testing.T) { + s := newNativeTasksTestStore(t) + + due := config.Now() + if err := s.CreateNativeTask(models.Task{ID: "w1", Content: "Chore", DueDate: &due}); err != nil { + t.Fatal(err) + } + if err := s.SetTaskRecurrence("w1", "weekly", 1, nil); err != nil { + t.Fatal(err) + } + if err := s.CompleteNativeTask("w1"); err != nil { + t.Fatal(err) + } + + var abandoned int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE abandoned = 1`).Scan(&abandoned); err != nil { + t.Fatal(err) + } + if abandoned != 0 { + t.Errorf("abandoned = %d, want 0 -- a completed occurrence was done, not skipped", abandoned) + } +} diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go index 95996b3..2110423 100644 --- a/internal/store/sqlite_test.go +++ b/internal/store/sqlite_test.go @@ -191,7 +191,8 @@ func setupTestStoreWithNativeTasks(t *testing.T) *Store { chain_unlocked BOOLEAN DEFAULT 0, bucket_id TEXT DEFAULT '', bucket_state TEXT DEFAULT '', - bucket_last_active_at DATETIME + bucket_last_active_at DATETIME, + abandoned BOOLEAN DEFAULT 0 ); -- The task queries join against this to check the owning chain's status -- (see visibleChainTaskClause), so it has to exist even for tests that |
