Simplify completion: remove completed-on field, isolate form
The due date input was inside the complete form, so pressing Enter
completed the task. Restructured so the form wraps only the submit
button. Backdating is already supported via history date editing.

Assisted-by: Claude Opus 4.6 via Claude Code
change vmnzyyuuwmkvvqnlroswmvsxxsusxnzz
commit 335364b598514db859b4b4be74525219ddb852fd
author Alpha Chen <alpha@kejadlen.dev>
date
parent lyulpqml
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index b262260..c0c495f 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -85,16 +85,18 @@ module Views
 
                 if active_task
                   complete_path = "/series/#{@series.id}/tasks/#{active_task.id}/complete"
-                  form(method: "post", action: complete_path, class: "current-task") do
-                    input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
-                    input(type: "hidden", name: "return_to", value: "/series/#{@series.id}")
+                  div(class: "current-task") do
                     div(class: "section-header") do
                       h2(class: "section-title") do
                         span(class: "section-title-text") { "Current task" }
                       end
-                      button(type: "submit", class: "section-edit-btn") { "Complete" }
+                      form(method: "post", action: complete_path, class: "complete-form") do
+                        input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
+                        input(type: "hidden", name: "return_to", value: "/series/#{@series.id}")
+                        button(type: "submit", class: "section-edit-btn") { "Complete" }
+                      end
                     end
-                    dl(class: "detail-fields", "x-data": "dueDateEditor(#{@series.id}, '#{active_task[:due_date]}')") do
+                    dl(class: "detail-fields", "x-data": "dueDateEditor(#{@series.id}, #{active_task[:id]}, '#{active_task[:due_date]}')") do
                       dt { "Due date" }
                       dd do
                         span(
@@ -119,17 +121,6 @@ module Views
                         dt(class: "detail-overdue") { "Urgency" }
                         dd(class: "detail-overdue") { "#{format("%.1f", active_task.urgency)}x" }
                       end
-
-                      dt { "Completed on" }
-                      dd do
-                        input(
-                          type: "date",
-                          name: "completed_date",
-                          class: "detail-input detail-input-date",
-                          "x-data": true,
-                          "x-init": "$el.value = new Date().toISOString().slice(0, 10)"
-                        )
-                      end
                     end
                   end
                 end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index b7b13d3..182f396 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -118,26 +118,10 @@ class Web < Roda
               updates[:interval_unit] = interval_unit
             end
 
-            if r.params.key?("due_date")
-              begin
-                due_date = Date.parse(r.params["due_date"].to_s)
-              rescue Date::Error
-                r.halt 422
-              end
-            end
-
-            DB.transaction do
-              @series.update(updates) unless updates.empty?
-              if due_date
-                active = @series.active_task
-                active.update(due_date: due_date) if active
-              end
-            end
+            @series.update(updates) unless updates.empty?
 
             response["content-type"] = "application/json"
-            result = updates.transform_keys(&:to_s)
-            result["due_date"] = due_date.to_s if due_date
-            result.to_json
+            updates.transform_keys(&:to_s).to_json
           end
         end
 
@@ -148,13 +132,7 @@ class Web < Roda
             r.post do
               r.halt 422 unless @task[:completed_at].nil?
 
-              completed_date = if (d = r.params["completed_date"]) && !d.empty?
-                Date.parse(d)
-              else
-                Date.today
-              end
-
-              @task.complete!(completed_on: completed_date)
+              @task.complete!(completed_on: Date.today)
 
               note_title = @series.note.lines.first&.strip || @series.note
               complete_path = "/series/#{series_id}/tasks/#{@task.id}/complete"
@@ -178,8 +156,6 @@ class Web < Roda
 
           r.is do
             r.patch do
-              r.halt 422 if @task[:completed_at].nil?
-
               begin
                 body = JSON.parse(r.body.read)
               rescue JSON::ParserError
@@ -189,13 +165,26 @@ class Web < Roda
               updates = {}
               result = {}
 
+              if body.key?("due_date")
+                r.halt 422 unless @task[:completed_at].nil?
+                begin
+                  due_date = Date.parse(body["due_date"].to_s)
+                rescue Date::Error
+                  r.halt 422
+                end
+                updates[:due_date] = due_date
+                result["due_date"] = due_date.to_s
+              end
+
               if body.key?("note")
+                r.halt 422 if @task[:completed_at].nil?
                 note = body["note"].to_s.strip
                 updates[:note] = note.empty? ? nil : note
                 result["note"] = note
               end
 
               if body.key?("completed_at")
+                r.halt 422 if @task[:completed_at].nil?
                 begin
                   completed_date = Date.parse(body["completed_at"].to_s)
                 rescue Date::Error
diff --git a/public/js/app.js b/public/js/app.js
index 2575a91..a1b789d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -145,7 +145,7 @@ document.addEventListener("alpine:init", () => {
     },
   }))
 
-  Alpine.data("dueDateEditor", (seriesId, initialDate) => ({
+  Alpine.data("dueDateEditor", (seriesId, taskId, initialDate) => ({
     editingDate: false,
     dueDate: initialDate,
 
@@ -154,8 +154,11 @@ document.addEventListener("alpine:init", () => {
         this.editingDate = false
         return
       }
-      saveSeriesField(seriesId, "due_date", this.dueDate)
-        .then(() => location.reload())
+      fetch(`/series/${seriesId}/tasks/${taskId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ due_date: this.dueDate }),
+      }).then(() => location.reload())
     },
   }))
 
diff --git a/test/test_web.rb b/test/test_web.rb
index aedc651..a575fbb 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -422,13 +422,15 @@ class TestWeb < Minitest::Test
     assert_equal "month", updated[:interval_unit]
   end
 
-  def test_patch_series_updates_due_date
+  def test_patch_task_updates_due_date
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
     task = DB[:tasks].first(series_id: series[:id])
-    patch "/series/#{series[:id]}", { due_date: "2026-04-15" }, auth_headers
+    patch "/series/#{series[:id]}/tasks/#{task[:id]}",
+          JSON.generate(due_date: "2026-04-15"),
+          auth_headers.merge("CONTENT_TYPE" => "application/json")
     assert last_response.ok?
 
     body = JSON.parse(last_response.body)
@@ -436,6 +438,21 @@ class TestWeb < Minitest::Test
     assert_equal Date.new(2026, 4, 15), DB[:tasks].first(id: task[:id])[:due_date]
   end
 
+  def test_patch_task_due_date_rejects_completed_task
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  first_due_date: "2026-03-01")
+
+    series = DB[:series].first
+    task = DB[:tasks].first(series_id: series[:id])
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+    completed_task = DB[:tasks].first(id: task[:id])
+    refute_nil completed_task[:completed_at]
+
+    patch_task series[:id], task[:id], { due_date: "2026-04-15" }
+    assert_equal 422, last_response.status
+  end
+
   def test_patch_series_rejects_invalid_interval_unit
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")