Replace slide-over panel with full series detail page
Series detail is now a standalone page at /series/:id using the
same dashboard/main-column/section layout. Removes the panel
component, backdrop, and all panel-related Alpine JS. Task card
links are plain <a> tags again. Agenda and calendar links also
simplified to regular navigation.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/agenda.rb b/lib/ketchup/views/agenda.rb
index 26ee417..41be289 100644
--- a/lib/ketchup/views/agenda.rb
+++ b/lib/ketchup/views/agenda.rb
@@ -70,8 +70,7 @@ module Views
name = task[:note].lines.first&.strip || task[:note]
a(
href: "/series/#{task[:series_id]}",
- class: ["agenda-pill", ("agenda-pill--overdue" if overdue)],
- "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{task[:series_id]} })"
+ class: ["agenda-pill", ("agenda-pill--overdue" if overdue)]
) { name }
end
end
diff --git a/lib/ketchup/views/calendar.rb b/lib/ketchup/views/calendar.rb
index a76e88c..5ca8e3d 100644
--- a/lib/ketchup/views/calendar.rb
+++ b/lib/ketchup/views/calendar.rb
@@ -55,8 +55,7 @@ module Views
task_name = task[:note].lines.first&.strip || task[:note]
a(
href: "/series/#{task[:series_id]}",
- class: ["calendar-pill", ("calendar-pill--overdue" if is_overdue)],
- "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{task[:series_id]} })"
+ class: ["calendar-pill", ("calendar-pill--overdue" if is_overdue)]
) { task_name }
end
end
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 7f41e02..e307772 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -9,19 +9,14 @@ module Views
INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
class Dashboard < Phlex::HTML
- def initialize(current_user:, csrf:, series: nil, open_user: false)
+ def initialize(current_user:, csrf:)
@current_user = current_user
@csrf = csrf
- @series = series
- @open_user = open_user
end
def view_template
render Layout.new(current_user: @current_user) do
- div(
- class: "dashboard",
- **dashboard_data_attrs
- ) do
+ div(class: "dashboard") do
render_main_column
end
end
@@ -29,16 +24,6 @@ module Views
private
- def dashboard_data_attrs
- if @series
- { "data-open-series": @series.id.to_s }
- elsif @open_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
@@ -47,7 +32,6 @@ module Views
render TaskList.new(
overdue: overdue,
upcoming: upcoming,
- selected_series: @series,
csrf: @csrf
)
end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index a00f570..3c85f78 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -54,15 +54,6 @@ module Views
a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
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/series/show.rb b/lib/ketchup/views/series/show.rb
new file mode 100644
index 0000000..23e72b8
--- /dev/null
+++ b/lib/ketchup/views/series/show.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "../layout"
+
+module Views
+ module Series
+ class Show < Phlex::HTML
+ def initialize(series:, current_user:, csrf:)
+ @series = series
+ @current_user = current_user
+ @csrf = csrf
+ end
+
+ def view_template
+ active_task = @series.active_task
+
+ render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
+ div(class: "dashboard") do
+ div(class: "main-column") do
+ section(class: "section") do
+ div(class: "section-header") do
+ h2(class: "section-title") do
+ span(class: "section-title-text") { note_title }
+ end
+ end
+
+ div(class: "series-note", id: "series-note-detail",
+ "data-value": @series.note || "",
+ "data-series-id": @series.id.to_s)
+
+ dl(class: "detail-fields") do
+ dt { "Repeat every" }
+ dd { interval_text(@series.interval_count, @series.interval_unit) }
+
+ if active_task
+ dt { "Due date" }
+ dd { active_task[:due_date].to_s }
+
+ if active_task.urgency > 0
+ dt { "Urgency" }
+ dd { "#{format("%.1f", active_task.urgency)}×" }
+ end
+ end
+ end
+
+ unless @series.completed_tasks.empty?
+ stats = @series.completion_stats
+ dl(class: "detail-fields") do
+ dt { "Streak" }
+ dd { stats[:streak].to_s }
+ dt { "On-time" }
+ dd { "#{stats[:on_time_pct]}%" }
+ end
+
+ div(class: "task-history") do
+ div(class: "section-header") do
+ h2(class: "section-title") do
+ span(class: "section-title-text") { "History" }
+ end
+ end
+ ul do
+ @series.completed_tasks.each do |ct|
+ li(
+ class: "task-history-item",
+ "x-data": "historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"})"
+ ) do
+ div(class: "task-history-row") do
+ span(class: "task-history-check") { "✓" }
+ span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
+ span(
+ class: "task-history-add-note",
+ "x-show": "!hasNote && !editing",
+ "x-on:click": "edit()"
+ ) { "add a note..." }
+ end
+ div(
+ class: "task-history-note-editor",
+ "data-value": ct[:note] || "",
+ "x-show": "hasNote || editing",
+ "x-ref": "editor"
+ )
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def note_title
+ @series.note&.lines&.first&.strip || "Series"
+ end
+
+ def interval_text(count, unit)
+ "#{count} #{count == 1 ? unit : "#{unit}s"}"
+ end
+ end
+ end
+end
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index 9acfbe0..43831a6 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -4,10 +4,9 @@ require "phlex"
module Views
class TaskCard < Phlex::HTML
- def initialize(task:, csrf:, selected: false, overdue: false, date_label: nil)
+ def initialize(task:, csrf:, overdue: false, date_label: nil)
@task = task
@csrf = csrf
- @selected = selected
@overdue = overdue
@date_label = date_label
end
@@ -28,19 +27,7 @@ module Views
div(class: "task-body") do
a(
href: "/series/#{@task[:series_id]}",
- class: "task-name stretched-link",
- "x-on:click.prevent": "
- const panel = Alpine.$data(document.getElementById('panel'));
- if (panel.open && panel.currentSeriesId === '#{@task[:series_id]}') {
- panel.close();
- history.pushState(null, '', '/');
- } else {
- panel.show('#{@task[:series_id]}');
- history.pushState(null, '', '/series/#{@task[:series_id]}');
- }
- document.querySelectorAll('.task-card--selected').forEach(el => el.classList.remove('task-card--selected'));
- if (panel.open) $el.closest('.task-card').classList.add('task-card--selected');
- "
+ class: "task-name stretched-link"
) { name }
if @overdue
span(class: "task-meta") do
@@ -61,7 +48,7 @@ module Views
def task_classes
classes = ["task-card"]
classes << "task-card--overdue" if @overdue
- classes << "task-card--selected" if @selected
+
classes
end
diff --git a/lib/ketchup/views/task_list.rb b/lib/ketchup/views/task_list.rb
index 8e1b741..25e0669 100644
--- a/lib/ketchup/views/task_list.rb
+++ b/lib/ketchup/views/task_list.rb
@@ -6,15 +6,14 @@ require_relative "task_card"
module Views
class TaskList < Phlex::HTML
- def initialize(overdue:, upcoming:, csrf:, selected_series: nil)
+ def initialize(overdue:, upcoming:, csrf:)
@overdue = overdue
@upcoming = upcoming
@csrf = csrf
- @selected_series = selected_series
end
def view_template
- div(class: "task-list-container", "x-data": "") do
+ div(class: "task-list-container") do
render_overdue
render_upcoming
render_completed_today
@@ -42,7 +41,7 @@ module Views
ul(class: "task-list") do
@overdue.each do |task|
li(class: "task-item") do
- render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), overdue: true)
+ render TaskCard.new(task: task, csrf: @csrf, overdue: true)
end
end
end
@@ -66,7 +65,7 @@ module Views
li(class: "task-item") do
render TaskCard.new(
task: task, csrf: @csrf,
- selected: selected?(task), overdue: false,
+ overdue: false,
date_label: friendly_date(task[:due_date])
)
end
@@ -94,8 +93,6 @@ module Views
end
end
- def selected?(task)
- @selected_series && @selected_series.id == task[:series_id]
- end
+
end
end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index e66fad9..4c43e7c 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -9,6 +9,7 @@ require_relative "views/focus"
require_relative "views/calendar"
require_relative "views/agenda"
require_relative "views/series/new"
+require_relative "views/series/show"
require_relative "views/series_panel"
require_relative "views/user_panel"
@@ -82,7 +83,7 @@ class Web < Roda
end
r.get do
- Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), open_user: true).call
+ Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token)).call
end
r.post "email" do
@@ -135,7 +136,7 @@ class Web < Roda
r.is do
r.get do
- Views::Dashboard.new(current_user: @user, series: @series, csrf: method(:csrf_token)).call
+ Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token)).call
end
r.patch do
diff --git a/public/css/app.css b/public/css/app.css
index eb95c95..8999f7f 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -130,8 +130,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
max-width: 32rem;
margin-inline: auto;
padding: var(--space-m) var(--space-l);
- position: relative;
- z-index: 100;
}
/* -------------------- */
@@ -587,7 +585,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.task-history-note-editor {
- margin-top: 2px;
+ margin-top: var(--space-3xs);
margin-left: 16px;
border: 1px solid transparent;
border-radius: 4px;
diff --git a/public/js/app.js b/public/js/app.js
index f82a75a..f42c173 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -56,7 +56,32 @@ function saveSeriesField(seriesId, field, value) {
})
}
-OverType.setTheme({ name: "ketchup", colors: { text: "#1a1a1a" } })
+OverType.setTheme({
+ name: "ketchup",
+ colors: {
+ bgPrimary: "transparent",
+ bgSecondary: "transparent",
+ text: "#1a1a1a",
+ textPrimary: "#1a1a1a",
+ textSecondary: "#888",
+ h1: "#c00",
+ h2: "#c00",
+ h3: "#c00",
+ strong: "#1a1a1a",
+ em: "#666",
+ link: "#c00",
+ code: "#888",
+ codeBg: "rgba(0, 0, 0, 0.05)",
+ blockquote: "#666",
+ hr: "#ddd",
+ syntaxMarker: "rgba(0, 0, 0, 0.3)",
+ syntax: "#999",
+ cursor: "#c00",
+ selection: "rgba(204, 0, 0, 0.15)",
+ listMarker: "#c00",
+ border: "#ddd",
+ },
+})
function initPanelEditors(container) {
const noteDetail = container.querySelector("#series-note-detail")
@@ -107,68 +132,6 @@ function initPanelEditors(container) {
}
document.addEventListener("alpine:init", () => {
- // Panel open/close
- Alpine.data("panel", () => ({
- open: false,
- loading: false,
-
- init() {
- // Auto-open panel if server set data-open-series on the dashboard
- const dashboard = document.querySelector("[data-open-series]")
- if (dashboard) {
- const seriesId = dashboard.dataset.openSeries
- if (seriesId) this.show(seriesId)
- }
-
- // Auto-open user panel
- const userDash = document.querySelector("[data-open-user]")
- if (userDash) {
- const userId = userDash.dataset.openUser
- if (userId) this.showUser(userId)
- }
-
- // Listen for custom event from other views (agenda, calendar)
- window.addEventListener("open-panel", (e) => {
- this.show(e.detail.seriesId)
- })
- },
-
- async show(seriesId) {
- this.currentSeriesId = String(seriesId)
- this.loading = true
- this.open = true
- try {
- const resp = await fetch(`/series/${seriesId}/panel`)
- if (!resp.ok) return
- this.$refs.content.innerHTML = await resp.text()
- initPanelEditors(this.$refs.content)
- } finally {
- this.loading = false
- }
- },
-
- async showUser(userId) {
- this.currentSeriesId = null
- this.loading = true
- this.open = true
- try {
- const resp = await fetch(`/users/${userId}/panel`)
- if (!resp.ok) return
- this.$refs.content.innerHTML = await resp.text()
- } finally {
- this.loading = false
- }
- },
-
- close() {
- this.currentSeriesId = null
- this.open = false
- setTimeout(() => {
- if (this.$refs.content) this.$refs.content.innerHTML = ""
- }, 250)
- },
- }))
-
Alpine.data("intervalEditor", (seriesId, initialCount, initialUnit) => ({
count: initialCount,
unit: initialUnit,
@@ -215,7 +178,7 @@ document.addEventListener("alpine:init", () => {
placeholder: "Add a note...",
autoResize: true,
minHeight: 14,
- fontSize: "11px",
+ fontSize: "14px",
padding: "0 4px",
})
@@ -245,6 +208,9 @@ document.addEventListener("alpine:init", () => {
},
}))
+ // Series detail page editor
+ initPanelEditors(document)
+
// New series form editor
const newNoteEl = document.getElementById("series-note-editor")
if (newNoteEl) {
diff --git a/test/test_web.rb b/test/test_web.rb
index 0429233..0831db2 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -261,25 +261,15 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, 'name="_csrf"'
end
- def test_get_series_page_has_data_attribute
+ def test_get_series_page_shows_detail
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 last_response.ok?
- assert_includes last_response.body, "data-open-series"
- end
-
- def test_get_series_shows_sidebar
- 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 last_response.ok?
- assert_includes last_response.body, "task-card--selected"
- assert_includes last_response.body, "data-open-series=\"#{series[:id]}\""
+ assert_includes last_response.body, "Call Mom"
+ assert_includes last_response.body, "2 weeks"
end
def test_get_series_panel_shows_completed_history
@@ -450,13 +440,13 @@ class TestWeb < Minitest::Test
assert_equal Date.new(2026, 3, 1), task[:due_date]
end
- def test_get_user_page_has_data_attribute
+ def test_get_user_page_shows_dashboard
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}\""
+ assert_includes last_response.body, "alice@example.com"
end
def test_get_user_panel_shows_email_form
@@ -529,10 +519,9 @@ class TestWeb < Minitest::Test
assert_equal 404, last_response.status
end
- def test_layout_includes_panel_shell
+ def test_layout_includes_footer
get "/", {}, auth_headers
- assert_includes last_response.body, 'id="panel"'
- assert_includes last_response.body, 'x-data="panel"'
+ assert_includes last_response.body, "site-footer"
end
def test_header_shows_view_nav