diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-03 00:45:53 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-03 00:45:53 +0000 |
| commit | f295fd12e6ffc8a7c023a83b3ac1223664255e90 (patch) | |
| tree | b4e9a3e3f28591524a78f456940b4798aa92623b /internal/store/native_tasks_test.go | |
| parent | 96518adb706b65670508b170b50f4bd5e1aeeed8 (diff) | |
Abandon recurring occurrences superseded by their next iteration
A weekly task not done before the next one lands is stale, not still
owed. The scheduler already created the successor regardless of
completion (deliberately, so skipping one doesn't stall the series), but
nothing retired the occurrence it replaced -- so every skipped occurrence
stayed open forever as a permanent overdue item. Live: "Bills" and
"Bones's meds" each sat in the list twice.
AdvanceDueRecurringTasks now marks the superseded occurrence abandoned
when it creates the successor. A separate flag rather than `completed`,
because these were never done and crediting them would corrupt
completion history. All four open-task queries filter it out; migration
030 adds the column and backfills the occurrences already superseded
(exactly the 2 above, verified against production before deploying).
Supersedes the earlier view-level collapse (10f9fcd, reverted in
96518ad): that kept the *earliest* open instance, which is backwards --
the stale one should go and the current one stand -- and it only hid the
row in the flat list while leaving it overdue in the timeline and widget,
which is where it was actually being seen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/store/native_tasks_test.go')
| -rw-r--r-- | internal/store/native_tasks_test.go | 82 |
1 files changed, 81 insertions, 1 deletions
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) + } +} |
