Implement shared series
See docs/plans/2026-05-02-shared-series-design.md.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
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")