Move panel shell into Layout, serve panel content via fragment fetch
Panel markup was conditional in Dashboard. Moving the shell into Layout
makes it available on every page, letting Alpine fetch content on demand
rather than server-rendering it inline.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index aa8405f..520eaa9 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -4,8 +4,6 @@ require "phlex"
require_relative "layout"
require_relative "task_list"
-require_relative "series_detail"
-require_relative "user_form"
module Views
INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
@@ -19,17 +17,28 @@ module Views
end
def view_template
- has_panel = @series || @panel == :user
- render Layout.new(current_user: @current_user, panel_open: has_panel) do
- div(class: "dashboard") do
+ render Layout.new(current_user: @current_user) do
+ div(
+ class: "dashboard",
+ **dashboard_data_attrs
+ ) do
render_main_column
- render_panel if has_panel
end
end
end
private
+ def dashboard_data_attrs
+ if @series
+ { "data-open-series": @series.id.to_s }
+ elsif @panel == :user
+ { "data-open-user": @current_user[:id].to_s }
+ else
+ {}
+ end
+ end
+
def render_main_column
overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
upcoming = @current_user.upcoming_tasks.all
@@ -43,24 +52,5 @@ module Views
)
end
end
-
- def render_panel
- div(
- class: "panel",
- id: "panel",
- "x-data": "panel",
- "x-bind:class": "open && 'panel--open'"
- ) do
- div(class: "panel-backdrop", "x-on:click": "close()")
-
- div(class: "panel-content") do
- if @series
- render SeriesDetail.new(series: @series, csrf: @csrf)
- elsif @panel == :user
- render UserForm.new(current_user: @current_user, csrf: @csrf)
- end
- end
- end
- end
end
end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 76be6fd..129f0cd 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -4,10 +4,9 @@ require "phlex"
module Views
class Layout < Phlex::HTML
- def initialize(current_user:, title: "Ketchup", panel_open: false)
+ def initialize(current_user:, title: "Ketchup")
@current_user = current_user
@title = title
- @panel_open = panel_open
end
def view_template(&)
@@ -32,7 +31,7 @@ module Views
integrity: "sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi",
crossorigin: "anonymous", defer: true)
end
- body(class: @panel_open ? "has-panel" : nil) do
+ body do
header(class: "site-header") do
a(href: "/", class: "site-name") { "Ketchup" }
nav(class: "site-nav") do
@@ -41,6 +40,15 @@ module Views
end
end
yield
+ div(
+ id: "panel",
+ class: "panel",
+ "x-data": "panel",
+ "x-bind:class": "open && 'panel--open'"
+ ) do
+ div(class: "panel-backdrop", "x-show": "open", "x-on:click": "close()")
+ div(class: "panel-content", "x-ref": "content")
+ end
render_footer
end
end
diff --git a/lib/ketchup/views/user_panel.rb b/lib/ketchup/views/user_panel.rb
new file mode 100644
index 0000000..405c0e2
--- /dev/null
+++ b/lib/ketchup/views/user_panel.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "user_form"
+
+module Views
+ class UserPanel < Phlex::HTML
+ def initialize(current_user:, csrf:)
+ @current_user = current_user
+ @csrf = csrf
+ end
+
+ def view_template
+ render UserForm.new(current_user: @current_user, csrf: @csrf)
+ end
+ end
+end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 71ad83f..40ca6ba 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -7,6 +7,7 @@ require_relative "models"
require_relative "views/dashboard"
require_relative "views/series/new"
require_relative "views/series_panel"
+require_relative "views/user_panel"
class Web < Roda
plugin :halt
@@ -45,6 +46,10 @@ class Web < Roda
r.on "users", Integer do |user_id|
r.halt 404 unless @user.id == user_id
+ r.get "panel" do
+ Views::UserPanel.new(current_user: @user, csrf: method(:csrf_token)).call
+ end
+
r.get do
Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), panel: :user).call
end
diff --git a/test/test_web.rb b/test/test_web.rb
index 50c5e4a..5e98971 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -261,14 +261,14 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, 'name="_csrf"'
end
- def test_series_sidebar_has_new_link
+ def test_get_series_page_has_data_attribute
create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
first_due_date: "2026-03-01")
series = DB[:series].first
get "/series/#{series[:id]}", {}, auth_headers
- assert_includes last_response.body, "New"
- assert_includes last_response.body, 'href="/"'
+ assert last_response.ok?
+ assert_includes last_response.body, "data-open-series"
end
def test_get_series_shows_sidebar
@@ -278,12 +278,11 @@ class TestWeb < Minitest::Test
series = DB[:series].first
get "/series/#{series[:id]}", {}, auth_headers
assert last_response.ok?
- assert_includes last_response.body, "2 weeks"
- assert_includes last_response.body, "2026-03-01"
assert_includes last_response.body, "task-card--selected"
+ assert_includes last_response.body, "data-open-series=\"#{series[:id]}\""
end
- def test_get_series_shows_completed_history
+ def test_get_series_panel_shows_completed_history
create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
first_due_date: "2026-03-01")
@@ -293,7 +292,8 @@ class TestWeb < Minitest::Test
completed_task = DB[:tasks].first(id: task[:id])
patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, auth_headers
- get "/series/#{series[:id]}", {}, auth_headers
+
+ get "/series/#{series[:id]}/panel", {}, auth_headers
assert last_response.ok?
assert_includes last_response.body, "Left a message"
assert_includes last_response.body, "task-history"
@@ -450,21 +450,31 @@ class TestWeb < Minitest::Test
assert_equal Date.new(2026, 3, 1), task[:due_date]
end
- def test_get_user_shows_email_form
+ def test_get_user_page_has_data_attribute
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, "data-open-user=\"#{user_id}\""
+ end
+
+ def test_get_user_panel_shows_email_form
+ get "/", {}, auth_headers # create user
+ user_id = DB[:users].first(login: "alice@example.com")[:id]
+
+ get "/users/#{user_id}/panel", {}, auth_headers
+ assert last_response.ok?
assert_includes last_response.body, "alice@example.com"
assert_includes last_response.body, 'name="email"'
+ refute_includes last_response.body, "<!DOCTYPE"
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
+ get "/users/#{user_id}/panel", {}, 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?
@@ -477,7 +487,7 @@ class TestWeb < Minitest::Test
get "/", {}, auth_headers
user_id = DB[:users].first(login: "alice@example.com")[:id]
- get "/users/#{user_id}", {}, auth_headers
+ get "/users/#{user_id}/panel", {}, auth_headers
token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
post "/users/#{user_id}/email", { "_csrf" => token, email: "" }, auth_headers
@@ -519,6 +529,12 @@ class TestWeb < Minitest::Test
assert_equal 404, last_response.status
end
+ def test_layout_includes_panel_shell
+ get "/", {}, auth_headers
+ assert_includes last_response.body, 'id="panel"'
+ assert_includes last_response.body, 'x-data="panel"'
+ end
+
def test_csrf_rejects_post_without_token
get "/", {}, auth_headers # establish session
post "/series", {