Add undo completion flash instead of redirecting to series
After completing a task, redirect back to the dashboard (/) instead of
the series page and show a flash bar with an Undo button. Clicking Undo
reverses the completion by deleting the auto-created next task and
clearing completed_at on the original task.

- Add Task#undo_complete! model method
- Add POST /series/:id/tasks/:id/undo_complete route
- Store flash data in session, consumed on next page render
- Render flash bar in Layout with CSRF-protected undo form
- Pass flash and csrf through all views to Layout

https://claude.ai/code/session_01NLGBcJgbiyWGDBGFh8DRas
change
commit 1b68d1bb165cbd8133c852af91725758870096ef
author Claude <noreply@anthropic.com>
date
parent a5395faa
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index bc66adb..910e3d2 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -122,4 +122,12 @@ class Task < Sequel::Model
       Task.create(series_id: series.id, due_date: next_date)
     end
   end
+
+  def undo_complete!
+    DB.transaction do
+      next_task = series.active_task
+      next_task.destroy if next_task
+      update(completed_at: nil)
+    end
+  end
 end
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 2ed2e35..8d47e36 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -11,16 +11,17 @@ module Views
   AGENDA_DAYS = 7
 
   class Dashboard < Phlex::HTML
-    def initialize(current_user:, csrf:)
+    def initialize(current_user:, csrf:, flash: nil)
       @current_user = current_user
       @csrf = csrf
+      @flash = flash
     end
 
     def view_template
       overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
       upcoming = @current_user.upcoming_tasks.all
 
-      render Layout.new(current_user: @current_user) do
+      render Layout.new(current_user: @current_user, flash: @flash, csrf: @csrf) do
         div(class: "dashboard") do
           div(class: "column-overdue") do
             render_focus(overdue)
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 134c379..22cb9c9 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -17,10 +17,12 @@ module Views
       }.freeze
     end
 
-    def initialize(current_user:, title: "Ketchup", active_view: nil)
+    def initialize(current_user:, title: "Ketchup", active_view: nil, flash: nil, csrf: nil)
       @current_user = current_user
       @title = title
       @active_view = active_view
+      @flash = flash
+      @csrf = csrf
     end
 
     def view_template(&)
@@ -60,6 +62,7 @@ module Views
             end
             a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
           end
+          render_flash
           yield
           render_footer
         end
@@ -68,6 +71,22 @@ module Views
 
     private
 
+    def render_flash
+      return unless @flash
+
+      undo_path = @flash["undo_path"]
+
+      div(class: "flash-bar") do
+        span(class: "flash-message") { @flash["message"] }
+        if undo_path && @csrf
+          form(method: "post", action: undo_path, class: "flash-undo-form") do
+            input(type: "hidden", name: "_csrf", value: @csrf.call(undo_path))
+            button(type: "submit", class: "flash-undo-btn") { "Undo" }
+          end
+        end
+      end
+    end
+
     def asset_path(path)
       version = ASSET_VERSIONS[path]
       version ? "#{path}?v=#{version}" : path
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index a5611dc..7b74bc1 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -7,13 +7,14 @@ require_relative "../layout"
 module Views
   module Series
     class New < Phlex::HTML
-      def initialize(current_user:, csrf:)
+      def initialize(current_user:, csrf:, flash: nil)
         @current_user = current_user
         @csrf = csrf
+        @flash = flash
       end
 
       def view_template
-        render Layout.new(current_user: @current_user, title: "New Series — Ketchup", active_view: :new) do
+        render Layout.new(current_user: @current_user, title: "New Series — Ketchup", active_view: :new, flash: @flash, csrf: @csrf) do
           div(class: "dashboard") do
             div(class: "main-column") do
               section(class: "section") do
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index e1d6794..a888494 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -7,16 +7,17 @@ require_relative "../layout"
 module Views
   module Series
     class Show < Phlex::HTML
-      def initialize(series:, current_user:, csrf:)
+      def initialize(series:, current_user:, csrf:, flash: nil)
         @series = series
         @current_user = current_user
         @csrf = csrf
+        @flash = flash
       end
 
       def view_template
         active_task = @series.active_task
 
-        render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
+        render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil, flash: @flash, csrf: @csrf) do
           div(class: "dashboard") do
             div(class: "main-column") do
               section(class: "section", "x-data": "{ editing: false }") do
diff --git a/lib/ketchup/views/user/show.rb b/lib/ketchup/views/user/show.rb
index 564d06d..169f960 100644
--- a/lib/ketchup/views/user/show.rb
+++ b/lib/ketchup/views/user/show.rb
@@ -7,16 +7,17 @@ require_relative "../layout"
 module Views
   module User
     class Show < Phlex::HTML
-      def initialize(current_user:, csrf:)
+      def initialize(current_user:, csrf:, flash: nil)
         @current_user = current_user
         @csrf = csrf
+        @flash = flash
       end
 
       def view_template
         email_path = "/users/#{@current_user[:id]}/email"
         email = @current_user[:email]
 
-        render Layout.new(current_user: @current_user, title: "Settings — Ketchup", active_view: nil) do
+        render Layout.new(current_user: @current_user, title: "Settings — Ketchup", active_view: nil, flash: @flash, csrf: @csrf) do
           div(class: "dashboard") do
             div(class: "main-column") do
               section(class: "section", "x-data": "{ editing: false }") do
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 55c635d..68ad391 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -40,14 +40,16 @@ class Web < Roda
     check_csrf!
 
     r.root do
-      Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token)).call
+      flash = session.delete("flash")
+      Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), flash: flash).call
     end
 
     r.on "users", Integer do |user_id|
       r.halt 404 unless @user.id == user_id
 
       r.get do
-        Views::User::Show.new(current_user: @user, csrf: method(:csrf_token)).call
+        flash = session.delete("flash")
+        Views::User::Show.new(current_user: @user, csrf: method(:csrf_token), flash: flash).call
       end
 
       r.post "email" do
@@ -59,7 +61,8 @@ class Web < Roda
 
     r.on "series" do
       r.get "new" do
-        Views::Series::New.new(current_user: @user, csrf: method(:csrf_token)).call
+        flash = session.delete("flash")
+        Views::Series::New.new(current_user: @user, csrf: method(:csrf_token), flash: flash).call
       end
 
       r.is do
@@ -96,7 +99,8 @@ class Web < Roda
 
         r.is do
           r.get do
-            Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token)).call
+            flash = session.delete("flash")
+            Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token), flash: flash).call
           end
 
           r.patch do
@@ -147,14 +151,24 @@ class Web < Roda
             r.halt 422 unless @task[:completed_at].nil?
             @task.complete!(today: Date.today)
 
+            note_title = @series.note.lines.first&.strip || @series.note
+            undo_path = "/series/#{series_id}/tasks/#{@task.id}/undo_complete"
+            session["flash"] = { "message" => "Completed \u201c#{note_title}\u201d", "undo_path" => undo_path }
+
             return_to = r.params["return_to"]
             if return_to && return_to.start_with?("/")
               r.redirect return_to
             else
-              r.redirect "/series/#{series_id}"
+              r.redirect "/"
             end
           end
 
+          r.post "undo_complete" do
+            r.halt 422 if @task[:completed_at].nil?
+            @task.undo_complete!
+            r.redirect "/"
+          end
+
           r.is do
             r.patch do
               r.halt 422 if @task[:completed_at].nil?
diff --git a/public/css/app.css b/public/css/app.css
index 98a87ae..904bf91 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -102,6 +102,39 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   text-align: center;
 }
 
+/* -------------------- */
+/* Flash bar */
+/* -------------------- */
+
+.flash-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: var(--space-xs);
+  padding: var(--space-2xs) var(--space-l);
+  background: #1a1a1a;
+  color: #fff;
+  font-size: var(--step--1);
+}
+
+.flash-undo-form {
+  display: inline;
+  padding: 0;
+  gap: 0;
+}
+
+.flash-undo-btn {
+  all: unset;
+  text-decoration: underline;
+  cursor: pointer;
+  font-weight: 600;
+  color: #fff;
+}
+
+.flash-undo-btn:hover {
+  opacity: 0.8;
+}
+
 /* -------------------- */
 /* Dashboard layout */
 /* -------------------- */
diff --git a/test/test_web.rb b/test/test_web.rb
index 8951db7..0271ded 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -181,7 +181,7 @@ class TestWeb < Minitest::Test
     csrf_post complete_path, {}, auth_headers
     assert last_response.redirect?
 
-    assert_includes last_response["Location"], "/series/#{series[:id]}"
+    assert_equal "/", URI.parse(last_response["Location"]).path
 
     old_task = DB[:tasks].first(id: task[:id])
     refute_nil old_task[:completed_at]
@@ -571,7 +571,7 @@ class TestWeb < Minitest::Test
     assert_includes last_response.body, "agenda-week"
   end
 
-  def test_complete_from_dashboard_redirects_to_series
+  def test_complete_from_dashboard_redirects_home
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: (Date.today - 3).to_s)
 
@@ -579,7 +579,83 @@ class TestWeb < Minitest::Test
     series = DB[:series].first
     csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
     assert last_response.redirect?
-    assert_includes last_response["Location"], "/series/#{series[:id]}"
+    assert_equal "/", URI.parse(last_response["Location"]).path
+  end
+
+  def test_complete_shows_flash_with_undo
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  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
+
+    get "/", {}, auth_headers
+    assert last_response.ok?
+    assert_includes last_response.body, "Completed"
+    assert_includes last_response.body, "Call Mom"
+    assert_includes last_response.body, "Undo"
+    assert_includes last_response.body, "undo_complete"
+  end
+
+  def test_flash_is_cleared_after_display
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  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
+
+    get "/", {}, auth_headers
+    assert_includes last_response.body, "Undo"
+
+    get "/", {}, auth_headers
+    refute_includes last_response.body, "flash-bar"
+  end
+
+  def test_undo_complete_restores_task
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  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
+
+    assert_equal 2, DB[:tasks].where(series_id: series[:id]).count
+    refute_nil DB[:tasks].first(id: task[:id])[:completed_at]
+
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/undo_complete", {}, auth_headers
+    assert last_response.redirect?
+
+    assert_nil DB[:tasks].first(id: task[:id])[:completed_at]
+    assert_equal 1, DB[:tasks].where(series_id: series[:id]).count
+  end
+
+  def test_undo_complete_rejects_active_task
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  first_due_date: "2026-03-01")
+
+    task = DB[:tasks].first
+    series = DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/undo_complete", {}, auth_headers
+    # No undo form exists for an active task, so CSRF rejects (403) before
+    # the route-level 422 check. Either way, the request is blocked.
+    assert_includes [403, 422], last_response.status
+  end
+
+  def test_undo_complete_requires_own_task
+    create_series(
+      note: "Alice task", interval_unit: "day", interval_count: "1",
+      first_due_date: (Date.today - 1).to_s,
+      headers: auth_headers(login: "alice@example.com")
+    )
+
+    task = DB[:tasks].first
+    series = DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "alice@example.com")
+
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/undo_complete", {}, auth_headers(login: "bob@example.com")
+    assert_includes [403, 404], last_response.status
   end
 
   private