Nest task routes under their series
Bare r.post/r.patch match any same-method request regardless
of remaining path. Wrapping in r.is makes them terminal.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 0c22622..88bc481 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -147,7 +147,7 @@ module Views
@series.completed_tasks.each do |ct|
li(
class: "task-history-item",
- "x-data": "historyNote(#{ct[:id]}, #{ct[:note] ? "true" : "false"})"
+ "x-data": "historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"})"
) do
div(class: "task-history-row") do
span(class: "task-history-check") { "✓" }
@@ -294,7 +294,7 @@ module Views
selected = @series && @series.id == task[:series_id]
div(class: ["task-card", ("task-overdue" if overdue), ("task-selected" if selected)]) do
- form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
+ form(method: "post", action: "/series/#{task[:series_id]}/tasks/#{task[:id]}/complete", class: "complete-form") do
button(
type: "submit", title: "Complete",
class: "complete-btn",
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index b5b14f3..1331120 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -33,100 +33,104 @@ class Web < Roda
Views::Dashboard.new(current_user: @user).call
end
- r.on "tasks", Integer do |task_id|
- @task = @user.tasks_dataset.where(Sequel[:tasks][:id] => task_id).sole
+ r.on "series" do
+ r.is do
+ r.post do
+ note = r.params["note"].to_s.strip
+ interval_unit = r.params["interval_unit"].to_s
+ interval_count = r.params["interval_count"].to_i
+ first_due_date = r.params["first_due_date"].to_s
+
+ r.halt 422 if note.empty?
+ r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
+ r.halt 422 unless interval_count >= 1
+
+ begin
+ due_date = Date.parse(first_due_date)
+ rescue Date::Error
+ r.halt 422
+ end
- r.post "complete" do
- r.halt 422 unless @task[:completed_at].nil?
- @task.complete!
+ series = Series.create_with_first_task(
+ user: @user,
+ note: note,
+ interval_unit: interval_unit,
+ interval_count: interval_count,
+ first_due_date: due_date
+ )
- r.redirect "/series/#{@task[:series_id]}"
+ r.redirect "/series/#{series.id}"
+ end
end
- r.patch "note" do
- r.halt 422 if @task[:completed_at].nil?
+ r.on Integer do |series_id|
+ @series = @user.series_dataset.where(id: series_id).sole
- note = r.params["note"].to_s.strip
- Task.where(id: task_id).update(note: note.empty? ? nil : note)
+ r.is do
+ r.get do
+ Views::Dashboard.new(current_user: @user, series: @series).call
+ end
- response["content-type"] = "application/json"
- { note: note }.to_json
- end
- end
+ r.patch do
+ updates = {}
- r.on "series" do
- r.post do
- note = r.params["note"].to_s.strip
- interval_unit = r.params["interval_unit"].to_s
- interval_count = r.params["interval_count"].to_i
- first_due_date = r.params["first_due_date"].to_s
-
- r.halt 422 if note.empty?
- r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
- r.halt 422 unless interval_count >= 1
-
- begin
- due_date = Date.parse(first_due_date)
- rescue Date::Error
- r.halt 422
- end
+ if r.params.key?("note")
+ note = r.params["note"].to_s.strip
+ r.halt 422 if note.empty?
+ updates[:note] = note
+ end
- series = Series.create_with_first_task(
- user: @user,
- note: note,
- interval_unit: interval_unit,
- interval_count: interval_count,
- first_due_date: due_date
- )
+ if r.params.key?("interval_count") || r.params.key?("interval_unit")
+ interval_count = r.params.key?("interval_count") ? r.params["interval_count"].to_i : @series.interval_count
+ interval_unit = r.params.key?("interval_unit") ? r.params["interval_unit"].to_s : @series.interval_unit
+ r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
+ r.halt 422 unless interval_count >= 1
+ updates[:interval_count] = interval_count
+ updates[:interval_unit] = interval_unit
+ end
- r.redirect "/series/#{series.id}"
- 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
- r.on Integer do |series_id|
- series = @user.series_dataset.where(id: series_id).sole
+ 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
- r.get do
- Views::Dashboard.new(current_user: @user, series:).call
+ response["content-type"] = "application/json"
+ result = updates.transform_keys(&:to_s)
+ result["due_date"] = due_date.to_s if due_date
+ result.to_json
+ end
end
- r.patch do
- updates = {}
+ r.on "tasks", Integer do |task_id|
+ @task = @series.tasks_dataset.where(id: task_id).sole
- if r.params.key?("note")
- note = r.params["note"].to_s.strip
- r.halt 422 if note.empty?
- updates[:note] = note
- end
+ r.post "complete" do
+ r.halt 422 unless @task[:completed_at].nil?
+ @task.complete!
- if r.params.key?("interval_count") || r.params.key?("interval_unit")
- interval_count = r.params.key?("interval_count") ? r.params["interval_count"].to_i : series.interval_count
- interval_unit = r.params.key?("interval_unit") ? r.params["interval_unit"].to_s : series.interval_unit
- r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
- r.halt 422 unless interval_count >= 1
- updates[:interval_count] = interval_count
- updates[:interval_unit] = interval_unit
+ r.redirect "/series/#{series_id}"
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
+ r.patch "note" do
+ r.halt 422 if @task[:completed_at].nil?
- 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
+ note = r.params["note"].to_s.strip
+ Task.where(id: task_id).update(note: note.empty? ? nil : note)
- response["content-type"] = "application/json"
- result = updates.transform_keys(&:to_s)
- result["due_date"] = due_date.to_s if due_date
- result.to_json
+ response["content-type"] = "application/json"
+ { note: note }.to_json
+ end
end
end
end
diff --git a/public/js/app.js b/public/js/app.js
index bc68567..1ca82b5 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -105,7 +105,7 @@ document.addEventListener("alpine:init", () => {
},
}))
- Alpine.data("historyNote", (taskId, hasNote) => ({
+ Alpine.data("historyNote", (seriesId, taskId, hasNote) => ({
hasNote,
editing: false,
_editor: null,
@@ -146,7 +146,7 @@ document.addEventListener("alpine:init", () => {
return
}
- fetch(`/tasks/${taskId}/note`, {
+ fetch(`/series/${seriesId}/tasks/${taskId}/note`, {
method: "PATCH",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `note=${encodeURIComponent(note)}`,
diff --git a/test/test_web.rb b/test/test_web.rb
index b76c3b1..99db768 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -158,7 +158,7 @@ class TestWeb < Minitest::Test
first_due_date: "2026-03-01")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
assert last_response.redirect?
series = DB[:series].first
@@ -176,7 +176,7 @@ class TestWeb < Minitest::Test
first_due_date: "2026-01-31")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
new_task = DB[:tasks].where(completed_at: nil).first
assert_equal Date.today >> 3, new_task[:due_date]
@@ -189,7 +189,7 @@ class TestWeb < Minitest::Test
}, tailscale_headers(login: "alice@example.com", name: "Alice")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "bob@example.com", name: "Bob")
assert_equal 404, last_response.status
end
@@ -198,7 +198,7 @@ class TestWeb < Minitest::Test
first_due_date: "2026-03-01")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete"
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete"
assert_equal 403, last_response.status
end
@@ -226,7 +226,8 @@ class TestWeb < Minitest::Test
task = DB[:tasks].first
get "/", {}, tailscale_headers
- assert_includes last_response.body, "action=\"/tasks/#{task[:id]}/complete\""
+ series = DB[:series].first
+ assert_includes last_response.body, "action=\"/series/#{series[:id]}/tasks/#{task[:id]}/complete\""
end
@@ -257,12 +258,11 @@ class TestWeb < Minitest::Test
first_due_date: "2026-03-01")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
completed_task = DB[:tasks].first(id: task[:id])
- patch "/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
-
series = DB[:series].first
+ patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
get "/series/#{series[:id]}", {}, tailscale_headers
assert last_response.ok?
assert_includes last_response.body, "Left a message"
@@ -290,10 +290,11 @@ class TestWeb < Minitest::Test
first_due_date: "2026-03-01")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers
completed_task = DB[:tasks].first(id: task[:id])
- patch "/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, tailscale_headers
+ series = DB[:series].first
+ patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, tailscale_headers
assert last_response.ok?
body = JSON.parse(last_response.body)
@@ -306,7 +307,8 @@ class TestWeb < Minitest::Test
first_due_date: "2026-03-01")
task = DB[:tasks].first
- patch "/tasks/#{task[:id]}/note", { note: "nope" }, tailscale_headers
+ series = DB[:series].first
+ patch "/series/#{series[:id]}/tasks/#{task[:id]}/note", { note: "nope" }, tailscale_headers
assert_equal 422, last_response.status
end
@@ -317,10 +319,11 @@ class TestWeb < Minitest::Test
}, tailscale_headers(login: "alice@example.com", name: "Alice")
task = DB[:tasks].first
- post "/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
+ post "/series/#{DB[:series].first[:id]}/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
completed_task = DB[:tasks].first(id: task[:id])
- patch "/tasks/#{completed_task[:id]}/note", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
+ series = DB[:series].first
+ patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
assert_equal 404, last_response.status
end