diff options
| -rw-r--r-- | internal/handlers/atoms.go | 5 | ||||
| -rw-r--r-- | internal/models/recurrence.go | 40 | ||||
| -rw-r--r-- | internal/models/recurrence_test.go | 40 |
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)) + } +} |
