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
change uxzxwusppnvrswuzuxvxonqwuymyupqm
commit 2595023bb1a5a5ba8ed71937222165497700825c
author Alpha Chen <alpha@kejadlen.dev>
date
parent mrnmtqow
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