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
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")