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
change kolspktxkvttsvrtmkozqyltnmmomsns
commit f940897ccf9f4958a4ea4ed990ea22a1868c02fa
author Alpha Chen <alpha@kejadlen.dev>
date
parent kqrtzzol
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]