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
change mswpxwtlzuuksuklvqquppvpurtzuqkn
commit 22bd11fb57f3001c682b66418a1c4d53a571de36
author Alpha Chen <alpha@kejadlen.dev>
date
parent 189146a0
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