Editable completion date on completed tasks
Replace PATCH .../tasks/:id/note with a general PATCH .../tasks/:id
that accepts a JSON body with note and/or completed_at. Clicking a
completion date in the history list reveals an inline date picker.
Allows correcting historical completion dates that affect on-time
stats.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index 19f6cd8..e1d6794 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -116,13 +116,30 @@ module Views
end
ul do
@series.completed_tasks.each do |ct|
+ completed_date = ct[:completed_at].strftime("%Y-%m-%d")
li(
class: "task-history-item",
- "x-data": "historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"})"
+ "x-data": "{ ...historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"}), ...completedDateEditor(#{@series.id}, #{ct[:id]}, '#{completed_date}') }"
) do
div(class: "task-history-row") do
span(class: "task-history-check") { "✓" }
- span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
+ span(
+ class: "task-history-date",
+ "x-show": "!editingDate",
+ "x-on:click": "editingDate = true; $nextTick(() => $refs.dateInput.focus())",
+ "x-text": "new Date(completedDate + 'T00:00').toLocaleDateString()"
+ ) { completed_date }
+ input(
+ type: "date",
+ class: "task-history-date-input",
+ "x-show": "editingDate",
+ "x-cloak": true,
+ "x-model": "completedDate",
+ "x-ref": "dateInput",
+ "x-on:blur": "save()",
+ "x-on:keydown.enter": "$el.blur()",
+ "x-on:keydown.escape": "completedDate = '#{completed_date}'; editingDate = false"
+ )
span(
class: "task-history-add-note",
"x-show": "!hasNote && !editing",
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 793da60..55c635d 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -155,14 +155,40 @@ class Web < Roda
end
end
- r.patch "note" do
- r.halt 422 if @task[:completed_at].nil?
+ r.is do
+ r.patch do
+ r.halt 422 if @task[:completed_at].nil?
- note = r.params["note"].to_s.strip
- Task.where(id: task_id).update(note: note.empty? ? nil : note)
+ begin
+ body = JSON.parse(r.body.read)
+ rescue JSON::ParserError
+ r.halt 422
+ end
- response["content-type"] = "application/json"
- { note: note }.to_json
+ updates = {}
+ result = {}
+
+ if body.key?("note")
+ note = body["note"].to_s.strip
+ updates[:note] = note.empty? ? nil : note
+ result["note"] = note
+ end
+
+ if body.key?("completed_at")
+ begin
+ completed_date = Date.parse(body["completed_at"].to_s)
+ rescue Date::Error
+ r.halt 422
+ end
+ updates[:completed_at] = Time.new(completed_date.year, completed_date.month, completed_date.day)
+ result["completed_at"] = completed_date.to_s
+ end
+
+ Task.where(id: task_id).update(updates) unless updates.empty?
+
+ response["content-type"] = "application/json"
+ result.to_json
+ end
end
end
end
diff --git a/public/css/app.css b/public/css/app.css
index 12995aa..1b7a5e5 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -563,6 +563,25 @@ h2 { font-size: var(--step-0); font-weight: 600; }
color: #27ae60;
}
+.task-history-date {
+ cursor: pointer;
+ border-bottom: 1px dashed transparent;
+}
+
+.task-history-date:hover {
+ border-bottom-color: #999;
+}
+
+.task-history-date-input {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ background: transparent;
+ border: 1px solid #999;
+ border-radius: 3px;
+ padding: 0 var(--space-3xs);
+}
+
.task-history-add-note {
font-size: var(--step--2);
color: #ccc;
diff --git a/public/js/app.js b/public/js/app.js
index f42c173..61e9940 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -168,6 +168,14 @@ document.addEventListener("alpine:init", () => {
this.$nextTick(() => this._initEditor())
},
+ _patchTask(data) {
+ return fetch(`/series/${seriesId}/tasks/${taskId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ })
+ },
+
_initEditor() {
const el = this.$refs.editor
if (!el || this._editor) return
@@ -195,11 +203,7 @@ document.addEventListener("alpine:init", () => {
return
}
- fetch(`/series/${seriesId}/tasks/${taskId}/note`, {
- method: "PATCH",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `note=${encodeURIComponent(note)}`,
- })
+ this._patchTask({ note })
el.dataset.value = note
this.hasNote = !!note
this.editing = false
@@ -208,6 +212,28 @@ document.addEventListener("alpine:init", () => {
},
}))
+ Alpine.data("completedDateEditor", (seriesId, taskId, initialDate) => ({
+ editingDate: false,
+ completedDate: initialDate,
+
+ save() {
+ if (this.completedDate === initialDate) {
+ this.editingDate = false
+ return
+ }
+ fetch(`/series/${seriesId}/tasks/${taskId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ completed_at: this.completedDate }),
+ }).then((r) => {
+ if (r.ok) {
+ initialDate = this.completedDate
+ this.editingDate = false
+ }
+ })
+ },
+ }))
+
// Series detail page editor
initPanelEditors(document)
diff --git a/test/test_web.rb b/test/test_web.rb
index 1dc2942..8951db7 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -298,7 +298,7 @@ class TestWeb < Minitest::Test
assert_equal 404, last_response.status
end
- def test_patch_note_saves_on_completed_task
+ def test_patch_task_saves_note
create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
first_due_date: (Date.today - 3).to_s)
@@ -307,7 +307,7 @@ class TestWeb < Minitest::Test
csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
completed_task = DB[:tasks].first(id: task[:id])
- patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, auth_headers
+ patch_task series[:id], completed_task[:id], { note: "Called, all good" }
assert last_response.ok?
body = JSON.parse(last_response.body)
@@ -315,17 +315,68 @@ class TestWeb < Minitest::Test
assert_equal "Called, all good", DB[:tasks].first(id: completed_task[:id])[:note]
end
- def test_patch_note_rejects_active_task
+ def test_patch_task_saves_completed_at
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: (Date.today - 3).to_s)
+
+ task = DB[:tasks].first
+ series = DB[:series].first
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch_task series[:id], completed_task[:id], { completed_at: "2026-01-15" }
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ assert_equal "2026-01-15", body["completed_at"]
+ assert_equal Date.new(2026, 1, 15), DB[:tasks].first(id: completed_task[:id])[:completed_at].to_date
+ end
+
+ def test_patch_task_saves_both_note_and_completed_at
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: (Date.today - 3).to_s)
+
+ task = DB[:tasks].first
+ series = DB[:series].first
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch_task series[:id], completed_task[:id], { note: "Backdated", completed_at: "2026-02-01" }
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ assert_equal "Backdated", body["note"]
+ assert_equal "2026-02-01", body["completed_at"]
+
+ updated = DB[:tasks].first(id: completed_task[:id])
+ assert_equal "Backdated", updated[:note]
+ assert_equal Date.new(2026, 2, 1), updated[:completed_at].to_date
+ end
+
+ def test_patch_task_rejects_invalid_completed_at
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: (Date.today - 3).to_s)
+
+ task = DB[:tasks].first
+ series = DB[:series].first
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch_task series[:id], completed_task[:id], { completed_at: "not-a-date" }
+ assert_equal 422, last_response.status
+ end
+
+ def test_patch_task_rejects_active_task
create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
first_due_date: "2026-03-01")
task = DB[:tasks].first
series = DB[:series].first
- patch "/series/#{series[:id]}/tasks/#{task[:id]}/note", { note: "nope" }, auth_headers
+ patch_task series[:id], task[:id], { note: "nope" }
assert_equal 422, last_response.status
end
- def test_patch_note_requires_own_task
+ def test_patch_task_requires_own_task
create_series(
note: "Alice task", interval_unit: "day", interval_count: "1",
first_due_date: (Date.today - 1).to_s,
@@ -337,7 +388,7 @@ class TestWeb < Minitest::Test
csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "alice@example.com")
completed_task = DB[:tasks].first(id: task[:id])
- patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "hacked" }, auth_headers(login: "bob@example.com")
+ patch_task series[:id], completed_task[:id], { note: "hacked" }, login: "bob@example.com"
assert_equal 404, last_response.status
end
@@ -550,6 +601,12 @@ class TestWeb < Minitest::Test
}, headers
end
+ def patch_task(series_id, task_id, data, login: "alice@example.com")
+ patch "/series/#{series_id}/tasks/#{task_id}",
+ data.to_json,
+ auth_headers(login: login).merge("CONTENT_TYPE" => "application/json")
+ end
+
def auth_headers(login: "alice@example.com")
{ "HTTP_REMOTE_USER" => login }
end