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