Implement shared series
See docs/plans/2026-05-02-shared-series-design.md.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change xouttxusxmslwvuwxnrxvmsklkvoltvv
commit 1a34d2e4577a125410dafc3cd33b779e1ea638ba
author Alpha Chen <alpha@kejadlen.dev>
date
parent vozsrqqu
diff --git a/db/migrate/007_add_sharing.rb b/db/migrate/007_add_sharing.rb
new file mode 100644
index 0000000..f98e9f8
--- /dev/null
+++ b/db/migrate/007_add_sharing.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  change do
+    alter_table(:series) do
+      add_column :shared, TrueClass, default: false, null: false
+    end
+    alter_table(:tasks) do
+      add_foreign_key :completed_by_user_id, :users, null: true
+    end
+    from(:tasks).exclude(completed_at: nil).update(
+      completed_by_user_id: from(:series).where(id: Sequel[:tasks][:series_id]).select(:user_id)
+    )
+    alter_table(:tasks) do
+      add_constraint(:tasks_completed_by_consistency) do
+        Sequel.lit("(completed_at IS NULL AND completed_by_user_id IS NULL) OR (completed_at IS NOT NULL AND completed_by_user_id IS NOT NULL)")
+      end
+      add_index [:series_id], unique: true, where: Sequel.lit("completed_at IS NULL"), name: :one_active_task_per_series
+    end
+  end
+end
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index 9db70ac..fc234e2 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -11,15 +11,20 @@ module Ketchup
     one_to_many :series
     many_through_many :tasks, [[:series, :user_id, :id]], right_primary_key: :series_id
 
+    def visible_series_dataset
+      Series.where(user_id: id).or(shared: true)
+    end
+
     def active_tasks
-      tasks_dataset
+      visible_tasks
         .where(completed_at: nil)
         .where(Sequel[:series][:archived_at] => nil)
         .select_all(:tasks)
         .select_append(
           Sequel[:series][:note],
           Sequel[:series][:interval_unit],
-          Sequel[:series][:interval_count]
+          Sequel[:series][:interval_count],
+          Sequel[:series][:shared]
         )
     end
 
@@ -30,6 +35,14 @@ module Ketchup
     def upcoming_tasks
       active_tasks.where { due_date >= Date.today }.order(:due_date)
     end
+
+    private
+
+    def visible_tasks
+      Task.join(:series, id: :series_id).where(
+        Sequel[:series][:user_id] => id
+      ).or(Sequel[:series][:shared] => true)
+    end
   end
 
   class Series < Sequel::Model
@@ -81,13 +94,14 @@ module Ketchup
       end
     end
 
-    def self.create_with_first_task(user:, note:, interval_unit:, interval_count:, first_due_date:)
+    def self.create_with_first_task(user:, note:, interval_unit:, interval_count:, first_due_date:, shared: false)
       DB.transaction do
         series = create(
           user_id: user.id,
           note: note,
           interval_unit: interval_unit,
-          interval_count: interval_count
+          interval_count: interval_count,
+          shared: shared
         )
 
         Task.create(
@@ -121,9 +135,14 @@ module Ketchup
       days_overdue.to_f / interval
     end
 
-    def complete!(completed_on:)
+    many_to_one :completed_by, class: :User, key: :completed_by_user_id
+
+    def complete!(completed_on:, by:)
       DB.transaction do
-        update(completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day))
+        update(
+          completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day),
+          completed_by_user_id: by.id
+        )
         Task.create(series_id: series.id, due_date: series.next_due_date(completed_on))
       end
     end
@@ -132,7 +151,7 @@ module Ketchup
       DB.transaction do
         next_task = series.active_task
         next_task.destroy if next_task
-        update(completed_at: nil)
+        update(completed_at: nil, completed_by_user_id: nil)
       end
     end
   end
diff --git a/lib/ketchup/seed.rb b/lib/ketchup/seed.rb
index a8ed1a1..1cb9143 100644
--- a/lib/ketchup/seed.rb
+++ b/lib/ketchup/seed.rb
@@ -11,6 +11,7 @@ module Ketchup
         interval_unit: "day",
         interval_count: 3,
         due_date: Date.today - 6,
+        shared: true,
         history: [
           { due_date: Date.today - 9, completed_at: (Date.today - 9).to_time, note: "Done, no issues" },
           { due_date: Date.today - 12, completed_at: (Date.today - 11).to_time, note: nil },
@@ -22,6 +23,7 @@ module Ketchup
         interval_unit: "week",
         interval_count: 1,
         due_date: Date.today - 4,
+        shared: true,
         history: []
       },
       # Overdue — low urgency (long interval, many days past)
@@ -70,6 +72,7 @@ module Ketchup
         interval_unit: "quarter",
         interval_count: 1,
         due_date: Date.today + 18,
+        shared: true,
         history: [
           { due_date: Date.today - 73, completed_at: (Date.today - 73).to_time, note: "All good\n\n- Changed filter\n- Reset thermostat" },
         ]
@@ -91,6 +94,7 @@ module Ketchup
         interval_unit: "year",
         interval_count: 1,
         due_date: Date.today + 140,
+        shared: true,
         history: [
           { due_date: Date.today - 225, completed_at: (Date.today - 225).to_time, note: "Replaced batteries in **hallway** unit" },
           { due_date: Date.today - 590, completed_at: (Date.today - 589).to_time, note: nil },
@@ -113,7 +117,8 @@ module Ketchup
           note: s[:note],
           interval_unit: s[:interval_unit],
           interval_count: s[:interval_count],
-          first_due_date: s[:due_date]
+          first_due_date: s[:due_date],
+          shared: s[:shared] || false
         )
 
         s[:history].each do |h|
@@ -121,6 +126,7 @@ module Ketchup
             series_id: created.id,
             due_date: h[:due_date],
             completed_at: h[:completed_at],
+            completed_by_user_id: h[:completed_at] ? user.id : nil,
             note: h[:note]
           )
         end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 2c61617..eb769b8 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -256,6 +256,22 @@ module Ketchup
           wait_for(".task-history")
         end
 
+        # ── Dashboard with shared series ──
+
+        shared_series = Series.create_with_first_task(
+          user: User.first(login: "snapshot@example.com"),
+          note: "Team standup\n\n*Monday* at **10am**",
+          interval_unit: "week",
+          interval_count: 1,
+          first_due_date: Date.today - 1,
+          shared: true
+        )
+
+        entries << snap("dashboard-shared") do
+          goto @base
+          wait_for(".dashboard")
+        end
+
         # ── User settings ──
 
         user = User.first(login: "snapshot@example.com")
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 9c692d1..c732e02 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -3,6 +3,7 @@
 require "phlex"
 
 require_relative "layout"
+require_relative "shared_icon"
 require_relative "task_card"
 
 module Ketchup
@@ -53,7 +54,7 @@ module Ketchup
               span(class: "section-title-text") { "Next up" }
             end
           end
-          render TaskCard.new(task: task, csrf: @csrf, overdue: true)
+          render TaskCard.new(task: task, csrf: @csrf, overdue: true, shared: task[:shared])
         end
       end
 
@@ -70,7 +71,7 @@ module Ketchup
           ul(class: "task-list") do
             tasks.each do |task|
               li(class: "task-item") do
-                render TaskCard.new(task: task, csrf: @csrf, overdue: true)
+                render TaskCard.new(task: task, csrf: @csrf, overdue: true, shared: task[:shared])
               end
             end
           end
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index 25471fb..0c698f4 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -56,6 +56,13 @@ module Ketchup
                         value: Date.today.to_s, required: true
                       )
                     end
+
+                    div(class: "field") do
+                      label(class: "detail-checkbox-label") do
+                        input(type: "checkbox", name: "shared", value: "1")
+                        plain "Shared with everyone"
+                      end
+                    end
                   end
                 end
               end
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index 9e0b4a9..baa4c0f 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -3,6 +3,7 @@
 require "phlex"
 
 require_relative "../layout"
+require_relative "../shared_icon"
 
 module Ketchup
   module Views
@@ -88,6 +89,30 @@ module Ketchup
                       end
                     end
 
+                    dt { "Shared" }
+                    dd("x-show": "!editing") do
+                      if @series.shared
+                        plain "Yes"
+                        render SharedIcon.new
+                      else
+                        plain "No"
+                      end
+                    end
+                    dd(
+                      "x-show": "editing",
+                      "x-cloak": true,
+                      "x-data": "sharedEditor(#{@series.id}, #{@series.shared})"
+                    ) do
+                      label(class: "detail-checkbox-label") do
+                        input(
+                          type: "checkbox",
+                          class: "detail-checkbox",
+                          "x-model": "shared",
+                          "x-on:change": "save()"
+                        )
+                        plain "Shared with everyone"
+                      end
+                    end
                   end
 
                   if active_task
diff --git a/lib/ketchup/views/shared_icon.rb b/lib/ketchup/views/shared_icon.rb
new file mode 100644
index 0000000..27eb791
--- /dev/null
+++ b/lib/ketchup/views/shared_icon.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module Ketchup
+  module Views
+    class SharedIcon < Phlex::HTML
+      def view_template
+        span(class: "task-shared", role: "img", "aria-label": "Shared") { "👥" }
+      end
+    end
+  end
+end
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index d550110..c8cb682 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -2,13 +2,16 @@
 
 require "phlex"
 
+require_relative "shared_icon"
+
 module Ketchup
   module Views
     class TaskCard < Phlex::HTML
-      def initialize(task:, csrf:, overdue: false)
+      def initialize(task:, csrf:, overdue: false, shared: false)
         @task = task
         @csrf = csrf
         @overdue = overdue
+        @shared = shared
       end
 
       def view_template
@@ -29,9 +32,10 @@ module Ketchup
               href: "/series/#{@task[:series_id]}",
               class: "task-name stretched-link"
             ) { name }
-            if @overdue
-              span(class: "task-meta") do
-                plain meta_text
+            if @shared || @overdue
+              div(class: "task-meta") do
+                render SharedIcon.new if @shared
+                plain meta_text if @overdue
               end
             end
           end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 1a94e4d..ea2eedd 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -81,12 +81,15 @@ module Ketchup
               r.halt 422
             end
 
+            shared = r.params["shared"] == "1"
+
             series = Series.create_with_first_task(
               user: @user,
               note: note,
               interval_unit: interval_unit,
               interval_count: interval_count,
-              first_due_date: due_date
+              first_due_date: due_date,
+              shared: shared
             )
 
             r.redirect "/series/#{series.id}"
@@ -94,7 +97,7 @@ module Ketchup
         end
 
         r.on Integer do |series_id|
-          @series = @user.series_dataset.where(id: series_id).sole
+          @series = @user.visible_series_dataset.where(id: series_id).sole
 
           r.on "archive" do
             r.post do
@@ -150,6 +153,10 @@ module Ketchup
                 updates[:interval_unit] = interval_unit
               end
 
+              if r.params.key?("shared")
+                updates[:shared] = r.params["shared"] == "1"
+              end
+
               @series.update(updates) unless updates.empty?
 
               response["content-type"] = "application/json"
@@ -164,7 +171,7 @@ module Ketchup
               r.post do
                 r.halt 422 unless @task[:completed_at].nil?
 
-                @task.complete!(completed_on: Date.today)
+                @task.complete!(completed_on: Date.today, by: @user)
 
                 note_title = @series.note.lines.first&.strip || @series.note
                 complete_path = "/series/#{series_id}/tasks/#{@task.id}/complete"
diff --git a/public/css/app.css b/public/css/app.css
index 0e2755e..c818138 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -410,6 +410,28 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   padding-block: var(--space-2xs);
 }
 
+/* -------------------- */
+/* Shared icon */
+/* -------------------- */
+
+.task-shared {
+  font-size: 1.15em;
+  line-height: 1;
+}
+
+.detail-checkbox-label {
+  display: flex;
+  align-items: center;
+  gap: var(--space-3xs);
+  font-size: var(--step--2);
+  font-weight: normal;
+  cursor: pointer;
+}
+
+.detail-checkbox {
+  width: auto;
+}
+
 /* -------------------- */
 /* Task card */
 /* -------------------- */
@@ -457,7 +479,9 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-meta {
-  display: block;
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-3xs);
   font-size: var(--step--2);
   color: var(--color-text-secondary);
   margin-block-start: 1px;
diff --git a/public/js/app.js b/public/js/app.js
index ea30e47..54aebd0 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -251,6 +251,18 @@ document.addEventListener("alpine:init", () => {
     },
   }))
 
+  Alpine.data("sharedEditor", (seriesId, initialShared) => ({
+    shared: initialShared,
+
+    save() {
+      fetch(`/series/${seriesId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `shared=${encodeURIComponent(this.shared ? "1" : "0")}`,
+      }).then((r) => { if (r.ok) location.reload() })
+    },
+  }))
+
   // Series detail page editor
   initPanelEditors(document)
 
diff --git a/test/test_db.rb b/test/test_db.rb
index 65530f0..83fa433 100644
--- a/test/test_db.rb
+++ b/test/test_db.rb
@@ -28,7 +28,7 @@ class TestDB < Minitest::Test
   def test_completed_task_allows_new_active_task
     series_id = create_series
 
-    Ketchup::DB[:tasks].where(series_id: series_id).update(completed_at: @now)
+    Ketchup::DB[:tasks].where(series_id: series_id).update(completed_at: @now, completed_by_user_id: @user_id)
 
     Ketchup::DB[:tasks].insert(series_id: series_id, due_date: Date.new(2026, 3, 15), created_at: @now, updated_at: @now)
 
diff --git a/test/test_web.rb b/test/test_web.rb
index 2fb2238..b5644fa 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -207,7 +207,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     task = Ketchup::Task.first
-    task.complete!(completed_on: Date.new(2026, 4, 1))
+    task.complete!(completed_on: Date.new(2026, 4, 1), by: Ketchup::User.first)
 
     new_task = Ketchup::Task.where(completed_at: nil).first
     assert_equal Date.new(2026, 4, 15), new_task.due_date
@@ -840,6 +840,241 @@ class TestWeb < Minitest::Test
     assert_includes last_response.body, "/series/#{series[:id]}/archive"
   end
 
+  # --- Shared series tests ---
+
+  def test_create_shared_series
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01", shared: true
+    )
+    assert last_response.redirect?
+
+    series = Ketchup::DB[:series].first
+    assert_equal true, series[:shared]
+  end
+
+  def test_create_private_series_by_default
+    create_series(
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    )
+
+    series = Ketchup::DB[:series].first
+    assert_equal false, series[:shared]
+  end
+
+  def test_shared_series_visible_on_other_users_dashboard
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: (Date.today - 1).to_s,
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    get "/", {}, auth_headers(login: "bob@example.com")
+    assert_includes last_response.body, "Team standup"
+  end
+
+  def test_private_series_invisible_to_other_users
+    create_series(
+      note: "Secret task", interval_unit: "week", interval_count: "1",
+      first_due_date: (Date.today - 1).to_s,
+      headers: auth_headers(login: "alice@example.com")
+    )
+
+    get "/", {}, auth_headers(login: "bob@example.com")
+    refute_includes last_response.body, "Secret task"
+  end
+
+  def test_non_creator_can_complete_shared_series_task
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: (Date.today - 1).to_s,
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers(login: "bob@example.com")
+    assert last_response.redirect?
+
+    completed = Ketchup::DB[:tasks].first(id: task[:id])
+    refute_nil completed[:completed_at]
+    bob = Ketchup::DB[:users].first(login: "bob@example.com")
+    assert_equal bob[:id], completed[:completed_by_user_id]
+  end
+
+  def test_non_creator_can_edit_shared_series
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    series = Ketchup::DB[:series].first
+    patch "/series/#{series[:id]}", { note: "Updated standup" }, auth_headers(login: "bob@example.com")
+    assert last_response.ok?
+
+    assert_equal "Updated standup", Ketchup::DB[:series].first(id: series[:id])[:note]
+  end
+
+  def test_non_creator_can_archive_shared_series
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    series = Ketchup::DB[:series].first
+    archive_series(series[:id], login: "bob@example.com")
+    assert last_response.redirect?
+
+    refute_nil Ketchup::DB[:series].first(id: series[:id])[:archived_at]
+  end
+
+  def test_non_creator_can_unarchive_shared_series
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    series = Ketchup::DB[:series].first
+    archive_series(series[:id], login: "bob@example.com")
+
+    delete "/series/#{series[:id]}/archive", {}, auth_headers(login: "bob@example.com")
+    assert_equal 204, last_response.status
+
+    assert_nil Ketchup::DB[:series].first(id: series[:id])[:archived_at]
+  end
+
+  def test_non_creator_can_toggle_shared_off
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    series = Ketchup::DB[:series].first
+    patch "/series/#{series[:id]}", { shared: "0" }, auth_headers(login: "bob@example.com")
+    assert last_response.ok?
+
+    assert_equal false, Ketchup::DB[:series].first(id: series[:id])[:shared]
+  end
+
+  def test_non_creator_can_view_shared_series_detail
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      headers: auth_headers(login: "alice@example.com"),
+      shared: true
+    )
+
+    series = Ketchup::DB[:series].first
+    get "/series/#{series[:id]}", {}, auth_headers(login: "bob@example.com")
+    assert last_response.ok?
+    assert_includes last_response.body, "Team standup"
+  end
+
+  def test_non_creator_cannot_view_private_series
+    create_series(
+      note: "Private", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      headers: auth_headers(login: "alice@example.com")
+    )
+
+    series = Ketchup::DB[:series].first
+    get "/series/#{series[:id]}", {}, auth_headers(login: "bob@example.com")
+    assert_equal 404, last_response.status
+  end
+
+  def test_task_card_shows_shared_icon_for_shared_series
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: (Date.today - 1).to_s,
+      shared: true
+    )
+
+    get "/", {}, auth_headers
+    assert_includes last_response.body, "aria-label=\"Shared\""
+  end
+
+  def test_task_card_no_shared_icon_for_private_series
+    create_series(
+      note: "Private task", interval_unit: "week", interval_count: "1",
+      first_due_date: (Date.today - 1).to_s
+    )
+
+    get "/", {}, auth_headers
+    refute_includes last_response.body, "aria-label=\"Shared\""
+  end
+
+  def test_series_detail_shows_shared_status
+    create_series(
+      note: "Team standup", interval_unit: "week", interval_count: "1",
+      first_due_date: "2026-03-01",
+      shared: true
+    )
+
+    series = Ketchup::DB[:series].first
+    get "/series/#{series[:id]}", {}, auth_headers
+    assert last_response.ok?
+    assert_includes last_response.body, "Shared"
+  end
+
+  def test_complete_task_records_completed_by
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  first_due_date: (Date.today - 3).to_s)
+
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+    completed = Ketchup::DB[:tasks].first(id: task[:id])
+    user = Ketchup::DB[:users].first(login: "alice@example.com")
+    refute_nil completed[:completed_at]
+    assert_equal user[:id], completed[:completed_by_user_id]
+  end
+
+  def test_undo_complete_clears_completed_by
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  first_due_date: (Date.today - 3).to_s)
+
+    task = Ketchup::DB[:tasks].first
+    series = Ketchup::DB[:series].first
+    csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+    delete "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+    assert_equal 204, last_response.status
+
+    restored = Ketchup::DB[:tasks].first(id: task[:id])
+    assert_nil restored[:completed_at]
+    assert_nil restored[:completed_by_user_id]
+  end
+
+  def test_new_series_page_has_shared_checkbox
+    get "/series/new", {}, auth_headers
+    assert last_response.ok?
+    assert_includes last_response.body, 'name="shared"'
+    assert_includes last_response.body, "Shared with everyone"
+  end
+
+  def test_patch_series_updates_shared
+    create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+                  first_due_date: "2026-03-01")
+
+    series = Ketchup::DB[:series].first
+    patch "/series/#{series[:id]}", { shared: "1" }, auth_headers
+    assert last_response.ok?
+
+    assert_equal true, Ketchup::DB[:series].first(id: series[:id])[:shared]
+  end
+
   private
 
   def csrf_post(path, params = {}, headers = auth_headers)
@@ -849,14 +1084,15 @@ class TestWeb < Minitest::Test
     post path, params.merge("_csrf" => token), headers
   end
 
-  def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: auth_headers)
+  def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: auth_headers, shared: false)
     get "/series/new", {}, headers  # establish session and get CSRF token
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/series", {
       _csrf: token,
       note: note, interval_unit: interval_unit, interval_count: interval_count,
-      first_due_date: first_due_date
-    }, headers
+      first_due_date: first_due_date,
+      shared: shared ? "1" : nil
+    }.compact, headers
   end
 
   def patch_task(series_id, task_id, data, login: "alice@example.com")