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
change ryprnutltytxmuooozstpzrsqmwpknuv
commit f066eabf94ad8e4092b48ec99c397b2f24f1bdac
author Alpha Chen <alpha@kejadlen.dev>
date
parent ypusvxlw
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