summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-03 00:45:53 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-03 00:45:53 +0000
commitf295fd12e6ffc8a7c023a83b3ac1223664255e90 (patch)
treeb4e9a3e3f28591524a78f456940b4798aa92623b
parent96518adb706b65670508b170b50f4bd5e1aeeed8 (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
-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
-rw-r--r--migrations/030_abandon_stale_recurrences.sql23
4 files changed, 122 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
diff --git a/migrations/030_abandon_stale_recurrences.sql b/migrations/030_abandon_stale_recurrences.sql
new file mode 100644
index 0000000..f8ec225
--- /dev/null
+++ b/migrations/030_abandon_stale_recurrences.sql
@@ -0,0 +1,23 @@
+-- A recurring task that wasn't done before its next iteration arrived is stale,
+-- not still-owed: the next one supersedes it. Without a way to record that, every
+-- skipped occurrence stayed open forever and piled up as permanent overdue items
+-- (found live: "Bills" and "Bones's meds" each sitting in the list twice, an
+-- unfinished instance alongside its already-generated successor).
+--
+-- Deliberately a separate flag rather than reusing `completed`: these were never
+-- done, and marking them completed would falsely credit them and pollute
+-- completion history.
+ALTER TABLE native_tasks ADD COLUMN abandoned BOOLEAN DEFAULT 0;
+
+-- Backfill: any uncompleted recurring instance that already has a later sibling
+-- in its series was superseded before this flag existed.
+UPDATE native_tasks
+SET abandoned = 1
+WHERE completed = 0
+ AND recurrence_series_id != ''
+ AND due_date IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM native_tasks later
+ WHERE later.recurrence_series_id = native_tasks.recurrence_series_id
+ AND later.due_date > native_tasks.due_date
+ );