From 767ce3bc7ee0b88ac240867d0363bf2e68573099 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 16:54:20 +0200 Subject: [PATCH] fix(tasks): reset description checklist when a recurring task recurs (#2941) --- pkg/models/tasks.go | 19 +++++++++++++++++++ pkg/models/tasks_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ec6200c48..978a0f850 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1747,6 +1747,20 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) { newTask.Done = false } +var ( + checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`) + checklistInputCheckedRegex = regexp.MustCompile(`(]*type=["']checkbox["'][^>]*?)\s+checked(?:=["'][^"']*["'])?`) +) + +// resetDescriptionChecklist unchecks every checklist item in a TipTap HTML description +// (descriptions are always stored as HTML, never markdown) without touching other content, +// so a recurring task's next occurrence does not inherit checked items. +func resetDescriptionChecklist(description string) string { + description = checklistTiptapCheckedRegex.ReplaceAllString(description, "${1}false${2}") + description = checklistInputCheckedRegex.ReplaceAllString(description, "$1") + return description +} + // This helper function updates the reminders, doneAt, start, end and due dates of the *old* task // and saves the new values in the newTask object. // We make a few assumptions here: @@ -1766,6 +1780,11 @@ func updateDone(oldTask *Task, newTask *Task) (updateDoneAt bool) { setTaskDatesDefault(oldTask, newTask) } + // A recurring task reopens for its next occurrence, so its checklist starts fresh. + if oldTask.isRepeating() && !newTask.Done { + newTask.Description = resetDescriptionChecklist(newTask.Description) + } + newTask.DoneAt = time.Now() } diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 7219b5ab8..433bee18a 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -986,6 +986,45 @@ func TestUpdateDone(t *testing.T) { assert.False(t, newTask.Done) }) }) + t.Run("reset checklist on recurrence", func(t *testing.T) { + const checked = `beforeafter` + const unchecked = `beforeafter` + + oldTask := &Task{ + Done: false, + RepeatAfter: 8600, + DueDate: time.Unix(1550000000, 0), + } + newTask := &Task{ + Done: true, + Description: checked, + } + + updateDone(oldTask, newTask) + + assert.False(t, newTask.Done) + assert.True(t, newTask.DueDate.After(oldTask.DueDate)) + assert.Equal(t, unchecked, newTask.Description) + }) + t.Run("non-recurring description untouched", func(t *testing.T) { + const checked = `beforeafter` + + oldTask := &Task{ + Done: false, + RepeatAfter: 0, + RepeatMode: TaskRepeatModeDefault, + DueDate: time.Unix(1550000000, 0), + } + newTask := &Task{ + Done: true, + Description: checked, + } + + updateDone(oldTask, newTask) + + assert.True(t, newTask.Done) + assert.Equal(t, checked, newTask.Description) + }) }) }