fix(tasks): reset description checklist when a recurring task recurs

When a recurring task with inline checklist items in its description
(TipTap `- [ ]` checkboxes) was marked done, Vikunja advanced the due
date and reopened it for the next occurrence, but the description's
checkboxes stayed CHECKED. The recurrence path in updateDone advances
dates/reminders and resets Done, but nothing touched the Description
field, so the checked checklist HTML carried over verbatim.

Reset the checklist checked-state on the recurrence transition only,
covering the TipTap form (`data-checked="true"` and `checked` on
`<input type="checkbox">`) and Markdown `- [x]`/`- [X]` defensively.

Fixes #2709
This commit is contained in:
kolaente 2026-06-19 15:56:17 +02:00
parent f3c6312a9e
commit 409747eca0
2 changed files with 75 additions and 0 deletions

View File

@ -1747,6 +1747,22 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) {
newTask.Done = false
}
var (
checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`)
checklistInputCheckedRegex = regexp.MustCompile(`(<input[^>]*type=["']checkbox["'][^>]*?)\s+checked(?:=["'][^"']*["'])?`)
checklistMarkdownCheckedRegex = regexp.MustCompile(`(?m)^(\s*[-*]\s+\[)[xX](\])`)
)
// resetDescriptionChecklist unchecks every checklist item in a description without
// touching any other content. A recurring task carries its description verbatim into
// the next occurrence, so checked checklist items would otherwise stay checked.
func resetDescriptionChecklist(description string) string {
description = checklistTiptapCheckedRegex.ReplaceAllString(description, "${1}false${2}")
description = checklistInputCheckedRegex.ReplaceAllString(description, "$1")
description = checklistMarkdownCheckedRegex.ReplaceAllString(description, "$1 $2")
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 +1782,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()
}

View File

@ -986,6 +986,60 @@ func TestUpdateDone(t *testing.T) {
assert.False(t, newTask.Done)
})
})
t.Run("reset checklist on recurrence", func(t *testing.T) {
const checked = `before<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Item</p></li></ul>after`
const unchecked = `before<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Item</p></li></ul>after`
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("reset markdown checklist on recurrence", func(t *testing.T) {
oldTask := &Task{
Done: false,
RepeatAfter: 8600,
DueDate: time.Unix(1550000000, 0),
}
newTask := &Task{
Done: true,
Description: "- [x] one\n- [X] two\n- [ ] three\nnot a [x] checklist",
}
updateDone(oldTask, newTask)
assert.Equal(t, "- [ ] one\n- [ ] two\n- [ ] three\nnot a [x] checklist", newTask.Description)
})
t.Run("non-recurring description untouched", func(t *testing.T) {
const checked = `before<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Item</p></li></ul>after`
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)
})
})
}