summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-03 00:40:47 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-03 00:40:47 +0000
commit10f9fcd271bbac4ff26f32880db212b5bd3b7f00 (patch)
tree83b7e8320255dec7c2304988a0660b9420bf7284 /internal
parent46dfe32326a7e15d1591939d30ffb070eeec406c (diff)
Show one row per recurring chore in the flat task list
The recurrence scheduler materializes a series' next instance once the current one comes due, regardless of whether it was completed -- that's deliberate, so ignoring an occurrence doesn't stall the series. The side effect is that the flat task list shows an unfinished chore and its already-generated successor at the same time: "Bills" appears twice right now (Sep 1 overdue + Oct 1), as does "Bones's meds". CollapseRecurringSeries keeps only the earliest-due open instance per series, which is the one actually owed. Applied to the atoms list only -- the timeline and widget are date-anchored, where each instance correctly belongs to its own day, so they keep all of them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal')
-rw-r--r--internal/handlers/atoms.go5
-rw-r--r--internal/models/recurrence.go40
-rw-r--r--internal/models/recurrence_test.go40
3 files changed, 85 insertions, 0 deletions
diff --git a/internal/handlers/atoms.go b/internal/handlers/atoms.go
index 16cd003..131a3d0 100644
--- a/internal/handlers/atoms.go
+++ b/internal/handlers/atoms.go
@@ -27,6 +27,11 @@ func BuildUnifiedAtomList(s *store.Store, claudomator api.ClaudomatorClient) ([]
if err != nil {
log.Printf("Warning: failed to fetch native tasks: %v", err)
}
+ // This is the flat "what's open" list, so a recurring chore belongs in it
+ // once, not once per already-materialized future instance -- see
+ // CollapseRecurringSeries. The timeline and widget deliberately don't do
+ // this: there, each instance sits on its own day.
+ nativeTasks = models.CollapseRecurringSeries(nativeTasks)
atoms := make([]models.Atom, 0, len(gTasks)+len(nativeTasks))
diff --git a/internal/models/recurrence.go b/internal/models/recurrence.go
index f42183c..f407688 100644
--- a/internal/models/recurrence.go
+++ b/internal/models/recurrence.go
@@ -55,3 +55,43 @@ func nextWeeklyOccurrence(due time.Time, interval int, weekdays []int) time.Time
daysToNextWeekStart := 7 - dueWeekday
return due.AddDate(0, 0, daysToNextWeekStart+sorted[0]+7*(interval-1))
}
+
+// CollapseRecurringSeries reduces each recurrence series to its single
+// earliest-due instance, preserving input order otherwise.
+//
+// The scheduler deliberately materializes a series' next instance once the
+// current one comes due, whether or not it was completed, so that ignoring one
+// occurrence doesn't stall the series (see
+// GetSeriesNeedingNextIteration). The side effect is that an unfinished
+// recurring chore and its already-generated successor both sit in the flat task
+// list at once -- "Bills" appearing twice, once overdue from Sep 1 and once for
+// Oct 1. Only the earliest open one is a thing you actually owe right now.
+//
+// This is for flat "what's open" views only. Date-anchored views (timeline,
+// widget) should keep every instance, since there each one legitimately belongs
+// to its own day.
+func CollapseRecurringSeries(tasks []Task) []Task {
+ earliest := make(map[string]*Task)
+ for i := range tasks {
+ t := &tasks[i]
+ if t.RecurrenceSeriesID == "" || t.DueDate == nil {
+ continue
+ }
+ cur, ok := earliest[t.RecurrenceSeriesID]
+ if !ok || t.DueDate.Before(*cur.DueDate) {
+ earliest[t.RecurrenceSeriesID] = t
+ }
+ }
+
+ out := make([]Task, 0, len(tasks))
+ for i := range tasks {
+ t := tasks[i]
+ // An undated recurring row has no position in the series ordering, so it
+ // is never collapsed away -- only dated instances compete.
+ if keep, ok := earliest[t.RecurrenceSeriesID]; ok && t.DueDate != nil && keep.ID != t.ID {
+ continue
+ }
+ out = append(out, t)
+ }
+ return out
+}
diff --git a/internal/models/recurrence_test.go b/internal/models/recurrence_test.go
index 8d18996..affba56 100644
--- a/internal/models/recurrence_test.go
+++ b/internal/models/recurrence_test.go
@@ -49,3 +49,43 @@ func TestComputeNextOccurrence(t *testing.T) {
})
}
}
+
+func TestCollapseRecurringSeries_KeepsOnlyEarliestOpenInstance(t *testing.T) {
+ sep1 := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ oct1 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+
+ tasks := []Task{
+ {ID: "bills-sep", Content: "Bills", DueDate: &sep1, RecurrenceSeriesID: "bills"},
+ {ID: "bills-oct", Content: "Bills", DueDate: &oct1, RecurrenceSeriesID: "bills"},
+ {ID: "one-off", Content: "Fix curtain holder"},
+ }
+
+ got := CollapseRecurringSeries(tasks)
+
+ var ids []string
+ for _, task := range got {
+ ids = append(ids, task.ID)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d tasks (%v), want 2: one row per recurring series plus the one-off", len(got), ids)
+ }
+ if got[0].ID != "bills-sep" {
+ t.Errorf("kept %q for the Bills series, want bills-sep (the earliest open instance is the one you actually owe)", got[0].ID)
+ }
+ if got[1].ID != "one-off" {
+ t.Errorf("non-recurring task was dropped or reordered: got %q", got[1].ID)
+ }
+}
+
+func TestCollapseRecurringSeries_UndatedAndDistinctSeriesUntouched(t *testing.T) {
+ due := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
+ tasks := []Task{
+ {ID: "a", RecurrenceSeriesID: "series-a", DueDate: &due},
+ {ID: "b", RecurrenceSeriesID: "series-b", DueDate: &due},
+ {ID: "c", RecurrenceSeriesID: "series-c"}, // recurring but undated
+ }
+
+ if got := CollapseRecurringSeries(tasks); len(got) != 3 {
+ t.Errorf("len = %d, want 3 -- distinct series must not collapse into each other", len(got))
+ }
+}