summaryrefslogtreecommitdiff
path: root/internal/store
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store')
-rw-r--r--internal/store/native_tasks.go20
-rw-r--r--internal/store/native_tasks_test.go82
-rw-r--r--internal/store/sqlite_test.go3
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