summaryrefslogtreecommitdiff
path: root/internal/models/recurrence.go
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/models/recurrence.go
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/models/recurrence.go')
-rw-r--r--internal/models/recurrence.go40
1 files changed, 40 insertions, 0 deletions
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
+}