Add user email with edit-in-place sidebar
Drop name column from users — login is the sole identity from the
auth header. Add email column for future notifications.
GET /users/:id renders the dashboard with a user settings sidebar
(click-to-edit, matching the series detail pattern). POST
/users/:id/email updates the email. Header links login to the
user page.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/Rakefile b/Rakefile
index 504fa0c..1977f67 100644
--- a/Rakefile
+++ b/Rakefile
@@ -31,7 +31,7 @@ task :seed do
user = User.first || abort("No users yet — visit the app first to create one")
Ketchup::Seed.call(user: user, series: Ketchup::Seed::DATA)
- puts "Seeded #{Ketchup::Seed::DATA.length} series for #{user.name} (#{user.login})"
+ puts "Seeded #{Ketchup::Seed::DATA.length} series for #{user.login}"
end
namespace :snapshots do
diff --git a/db/migrate/005_add_email_remove_name_from_users.rb b/db/migrate/005_add_email_remove_name_from_users.rb
new file mode 100644
index 0000000..69fd3aa
--- /dev/null
+++ b/db/migrate/005_add_email_remove_name_from_users.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ alter_table(:users) do
+ add_column :email, String
+ drop_column :name
+ end
+ end
+end
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index edc5ddc..3ee122a 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -315,7 +315,7 @@ module Ketchup
$stderr.reopen(saved_err)
url = "http://127.0.0.1:#{launcher.connected_ports.first}"
- user = User.find_or_create(login: "snapshot@example.com") { |u| u.name = "Snapshot User" }
+ user = User.find_or_create(login: "snapshot@example.com")
Ketchup::Seed.call(user: user, series: Ketchup::Seed::DATA)
yield url
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index b4a1d43..e6afd5a 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -7,15 +7,17 @@ require_relative "overdue_column"
require_relative "upcoming_column"
require_relative "series_detail"
require_relative "new_series_form"
+require_relative "user_form"
module Views
INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
class Dashboard < Phlex::HTML
- def initialize(current_user:, csrf:, series: nil)
+ def initialize(current_user:, csrf:, series: nil, panel: nil)
@current_user = current_user
@csrf = csrf
@series = series
+ @panel = panel
end
def view_template
@@ -34,6 +36,8 @@ module Views
if @series
render SeriesDetail.new(series: @series)
+ elsif @panel == :user
+ render UserForm.new(current_user: @current_user, csrf: @csrf)
else
render NewSeriesForm.new(csrf: @csrf)
end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index fa4990e..b534bad 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -35,7 +35,7 @@ module Views
header(class: "site-header") do
a(href: "/", class: "site-name") { "Ketchup" }
a(href: "/series/new", class: "mobile-new-link") { "+ New" }
- span(class: "user") { @current_user[:name] || @current_user[:login] }
+ a(href: "/users/#{@current_user[:id]}", class: "user") { @current_user[:login] }
end
yield
render_footer
diff --git a/lib/ketchup/views/user_form.rb b/lib/ketchup/views/user_form.rb
new file mode 100644
index 0000000..cf976ed
--- /dev/null
+++ b/lib/ketchup/views/user_form.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module Views
+ class UserForm < Phlex::HTML
+ def initialize(current_user:, csrf:)
+ @current_user = current_user
+ @csrf = csrf
+ end
+
+ def view_template
+ email_path = "/users/#{@current_user[:id]}/email"
+ email = @current_user[:email]
+
+ div(class: "column column-aside", "x-data": "{ editing: false }") do
+ div(class: "column-header") do
+ h2 { @current_user[:login] }
+ button(
+ class: "aside-heading-action",
+ "x-show": "!editing",
+ "x-on:click": "editing = true"
+ ) { "Edit" }
+ button(
+ type: "submit",
+ form: "user-form",
+ class: "aside-heading-action",
+ "x-show": "editing",
+ "x-cloak": true
+ ) { "Save" }
+ end
+
+ form(method: "post", action: email_path, id: "user-form", class: "task-detail") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(email_path))
+
+ dl(class: "task-detail-fields") do
+ dt { "Email" }
+ dd("x-show": "!editing") do
+ if email
+ plain email
+ else
+ span(class: "detail-placeholder") { "not set" }
+ end
+ end
+ dd("x-show": "editing", "x-cloak": true) do
+ input(
+ type: "email", name: "email",
+ class: "detail-input",
+ value: email,
+ placeholder: "for notifications"
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index a4a61aa..df4c887 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -25,7 +25,7 @@ class Web < Roda
login = env[rack_header]
return unless login
- User.find_or_create(login: login) { |u| u.name = login }
+ User.find_or_create(login: login)
end
route do |r|
@@ -38,6 +38,20 @@ class Web < Roda
Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token)).call
end
+ r.on "users", Integer do |user_id|
+ r.halt 404 unless @user.id == user_id
+
+ r.get do
+ Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), panel: :user).call
+ end
+
+ r.post "email" do
+ email = r.params["email"].to_s.strip
+ @user.update(email: email.empty? ? nil : email)
+ r.redirect "/users/#{user_id}"
+ end
+ end
+
r.on "series" do
r.get "new" do
Views::Series::New.new(current_user: @user, csrf: method(:csrf_token)).call
diff --git a/public/css/app.css b/public/css/app.css
index 1e7bd3e..b8f32fb 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -325,7 +325,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
font-size: var(--step--2);
padding: 1px 4px;
margin: -2px -5px;
- border: 1px solid transparent;
+ border: 1px solid #ddd;
border-radius: 3px;
background: transparent;
color: #444;
@@ -342,6 +342,11 @@ h2 { font-size: var(--step-0); font-weight: 600; }
background: #fff;
}
+.detail-placeholder {
+ color: #999;
+ font-size: var(--step--2);
+}
+
.detail-edit-interval {
display: flex;
gap: 4px;
diff --git a/test/test_db.rb b/test/test_db.rb
index 3c94d3c..c4aa056 100644
--- a/test/test_db.rb
+++ b/test/test_db.rb
@@ -12,8 +12,8 @@ class TestDB < Minitest::Test
DB[:series].delete
@now = Time.now
DB[:users]
- .insert_conflict(target: :login, update: { name: "Test", updated_at: @now })
- .insert(login: "test@example.com", name: "Test", created_at: @now, updated_at: @now)
+ .insert_conflict(target: :login, update: { updated_at: @now })
+ .insert(login: "test@example.com", created_at: @now, updated_at: @now)
@user_id = DB[:users].first(login: "test@example.com")[:id]
end
diff --git a/test/test_seed.rb b/test/test_seed.rb
index 49a8bbd..1435300 100644
--- a/test/test_seed.rb
+++ b/test/test_seed.rb
@@ -14,7 +14,7 @@ class TestSeed < Minitest::Test
end
def test_seed_creates_series_and_tasks
- user = User.create(login: "test@example.com", name: "Test")
+ user = User.create(login: "test@example.com")
series_data = [
{
note: "Call Mom",
@@ -37,7 +37,7 @@ class TestSeed < Minitest::Test
end
def test_seed_creates_completed_history
- user = User.create(login: "test@example.com", name: "Test")
+ user = User.create(login: "test@example.com")
series_data = [
{
note: "Water plants",
diff --git a/test/test_sole.rb b/test/test_sole.rb
index 1b60c7c..c5bdc7b 100644
--- a/test/test_sole.rb
+++ b/test/test_sole.rb
@@ -14,9 +14,9 @@ class TestSole < Minitest::Test
end
def test_sole_returns_single_record
- User.create(login: "alice@example.com", name: "Alice")
+ User.create(login: "alice@example.com")
user = User.where(login: "alice@example.com").sole
- assert_equal "Alice", user.name
+ assert_equal "alice@example.com", user.login
end
def test_sole_raises_on_no_records
@@ -26,8 +26,8 @@ class TestSole < Minitest::Test
end
def test_sole_raises_on_multiple_records
- User.create(login: "alice@example.com", name: "Alice")
- User.create(login: "bob@example.com", name: "Bob")
+ User.create(login: "alice@example.com")
+ User.create(login: "bob@example.com")
assert_raises(Sequel::Plugins::Sole::TooManyRows) do
User.dataset.sole
diff --git a/test/test_web.rb b/test/test_web.rb
index 133b7ad..67f4aed 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -65,7 +65,7 @@ class TestWeb < Minitest::Test
def test_root_creates_user_record
get "/", {}, auth_headers(login: "bob@example.com")
user = DB[:users].first(login: "bob@example.com")
- assert_equal "bob@example.com", user[:name]
+ assert user
end
def test_root_requires_auth
@@ -444,6 +444,50 @@ class TestWeb < Minitest::Test
assert_equal Date.new(2026, 3, 1), task[:due_date]
end
+ def test_get_user_shows_email_form
+ get "/", {}, auth_headers # create user
+ user_id = DB[:users].first(login: "alice@example.com")[:id]
+
+ get "/users/#{user_id}", {}, auth_headers
+ assert last_response.ok?
+ assert_includes last_response.body, "alice@example.com"
+ assert_includes last_response.body, 'name="email"'
+ end
+
+ def test_post_user_email
+ get "/", {}, auth_headers # create user
+ user_id = DB[:users].first(login: "alice@example.com")[:id]
+
+ get "/users/#{user_id}", {}, auth_headers
+ token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
+ post "/users/#{user_id}/email", { "_csrf" => token, email: "alice@example.org" }, auth_headers
+ assert last_response.redirect?
+
+ user = DB[:users].first(id: user_id)
+ assert_equal "alice@example.org", user[:email]
+ end
+
+ def test_post_user_email_clears_empty
+ get "/", {}, auth_headers
+ user_id = DB[:users].first(login: "alice@example.com")[:id]
+
+ get "/users/#{user_id}", {}, auth_headers
+ token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
+ post "/users/#{user_id}/email", { "_csrf" => token, email: "" }, auth_headers
+
+ user = DB[:users].first(id: user_id)
+ assert_nil user[:email]
+ end
+
+ def test_get_user_rejects_other_user
+ get "/", {}, auth_headers # create alice
+ get "/", {}, auth_headers(login: "bob@example.com") # create bob
+ bob_id = DB[:users].first(login: "bob@example.com")[:id]
+
+ get "/users/#{bob_id}", {}, auth_headers
+ assert_equal 404, last_response.status
+ end
+
def test_csrf_rejects_post_without_token
get "/", {}, auth_headers # establish session
post "/series", {
@@ -456,13 +500,11 @@ class TestWeb < Minitest::Test
private
def csrf_post(path, params = {}, headers = auth_headers)
- get "/", {}, headers # establish session and get tokens
+ get "/", {}, headers # establish session
token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
post path, params.merge("_csrf" => token), headers
end
-
-
def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: auth_headers)
get "/", {}, headers # establish session and get tokens
token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]