Archive a series (soft delete)
Archived series are hidden from the dashboard but the detail page
remains accessible. Archive button on the series detail page.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/db/migrate/006_add_archived_at_to_series.rb b/db/migrate/006_add_archived_at_to_series.rb
new file mode 100644
index 0000000..962c54c
--- /dev/null
+++ b/db/migrate/006_add_archived_at_to_series.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ add_column :series, :archived_at, DateTime
+ end
+end
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index cfbfce6..9db70ac 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -14,6 +14,7 @@ module Ketchup
def active_tasks
tasks_dataset
.where(completed_at: nil)
+ .where(Sequel[:series][:archived_at] => nil)
.select_all(:tasks)
.select_append(
Sequel[:series][:note],
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 7ca63e9..bba6c1f 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -189,6 +189,14 @@ module Ketchup
wait_for(".series-note--editable")
entries << snap("series-editing", selector: ".main-column")
+ # Cancel editing, show archive button, then click to reveal confirmation
+ wait_for(".section-edit-btn--cancel").click
+ entries << snap("series-archive-button", selector: ".main-column")
+
+ @browser.execute("document.querySelector('form[action$=\"/archive\"] button').click()")
+ wait_for(".archive-confirm")
+ entries << snap("series-archive-confirm", selector: ".main-column")
+
# ── Series detail (with history) ──
series_with_history = find_series_with_mixed_history
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index fe83f3b..2e16e82 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -12,6 +12,7 @@ module Ketchup
@series = series
@current_user = current_user
@csrf = csrf
+ @archive_path = "/series/#{series.id}/archive"
end
def view_template
@@ -48,6 +49,19 @@ module Ketchup
) do
plain "Save"
end
+ form(method: "post", action: @archive_path, class: "complete-form",
+ "x-show": "!editing", "x-data": "{ confirming: false }") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(@archive_path))
+ span("x-show": "!confirming") do
+ button(type: "button", class: "section-edit-btn",
+ "x-on:click": "confirming = true") { "Archive" }
+ end
+ span(class: "archive-confirm", "x-show": "confirming", "x-cloak": true) do
+ button(type: "submit", class: "section-edit-btn") { "Archive" }
+ button(type: "button", class: "section-edit-btn",
+ "x-on:click": "confirming = false") { "Cancel" }
+ end
+ end
end
div(class: "series-note", id: "series-note-detail",
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index cfe2111..94090bc 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -96,6 +96,14 @@ module Ketchup
r.on Integer do |series_id|
@series = @user.series_dataset.where(id: series_id).sole
+ r.post "archive" do
+ DB.transaction do
+ @series.active_task&.destroy
+ @series.update(archived_at: Time.now)
+ end
+ r.redirect "/"
+ end
+
r.is do
r.get do
Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token)).call
diff --git a/public/css/app.css b/public/css/app.css
index 628d704..ef1401a 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -370,6 +370,21 @@ h2 { font-size: var(--step-0); font-weight: 600; }
color: var(--color-text-secondary);
}
+.archive-confirm,
+form[action$="/archive"] > span {
+ display: inline-flex;
+ gap: 0.5em;
+ order: 1;
+}
+
+.archive-confirm [type="submit"] {
+ color: #c04040;
+}
+
+.archive-confirm [type="submit"]:hover {
+ color: #a02020;
+}
+
.section-edit-btn:disabled {
color: #ccc;
cursor: default;
diff --git a/test/test_web.rb b/test/test_web.rb
index 68f62fd..4b05911 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -721,6 +721,77 @@ class TestWeb < Minitest::Test
assert_includes [403, 404], last_response.status
end
+ def test_archive_series
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: (Date.today - 3).to_s)
+
+ series = Ketchup::DB[:series].first
+ archive_series(series[:id])
+ assert last_response.redirect?
+ assert_equal "/", URI.parse(last_response["Location"]).path
+
+ updated = Ketchup::DB[:series].first(id: series[:id])
+ refute_nil updated[:archived_at]
+ end
+
+ def test_archive_hides_from_dashboard
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: (Date.today - 3).to_s)
+
+ series = Ketchup::DB[:series].first
+ archive_series(series[:id])
+
+ get "/", {}, auth_headers
+ refute_includes last_response.body, "Call Mom"
+ end
+
+ def test_archived_series_detail_still_accessible
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+
+ series = Ketchup::DB[:series].first
+ archive_series(series[:id])
+
+ get "/series/#{series[:id]}", {}, auth_headers
+ assert last_response.ok?
+ assert_includes last_response.body, "Call Mom"
+ end
+
+ def test_archive_deletes_active_task
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: (Date.today - 3).to_s)
+
+ series = Ketchup::DB[:series].first
+ task = Ketchup::DB[:tasks].first(series_id: series[:id])
+ refute_nil task
+ assert_nil task[:completed_at]
+
+ archive_series(series[:id])
+
+ assert_nil Ketchup::DB[:tasks].first(id: task[:id])
+ end
+
+ def test_archive_requires_own_series
+ create_series(
+ note: "Alice task", interval_unit: "day", interval_count: "1",
+ first_due_date: "2026-03-01",
+ headers: auth_headers(login: "alice@example.com")
+ )
+
+ series = Ketchup::DB[:series].first
+ archive_series(series[:id], login: "bob@example.com")
+ assert_includes [403, 404], last_response.status
+ end
+
+ def test_series_detail_has_archive_button
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+
+ series = Ketchup::DB[:series].first
+ get "/series/#{series[:id]}", {}, auth_headers
+ assert_includes last_response.body, "/series/#{series[:id]}/archive"
+ end
+
private
def csrf_post(path, params = {}, headers = auth_headers)
@@ -746,6 +817,15 @@ class TestWeb < Minitest::Test
auth_headers(login: login).merge("CONTENT_TYPE" => "application/json")
end
+ def archive_series(series_id, login: "alice@example.com")
+ headers = auth_headers(login: login)
+ get "/series/#{series_id}", {}, headers
+ archive_path = "/series/#{series_id}/archive"
+ escaped = Regexp.escape(archive_path)
+ token = last_response.body[/action="#{escaped}".*?name="_csrf" value="([^"]+)"/m, 1]
+ post archive_path, { "_csrf" => token }, headers
+ end
+
def auth_headers(login: "alice@example.com")
{ "HTTP_REMOTE_USER" => login }
end