Task detail sidebar with click-to-select and toggleable new form
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index a51699c..8298379 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -47,9 +47,34 @@ module Views
upcoming_list(@upcoming)
end
- div(class: "column column-aside") do
- h2(class: "aside-heading") { "New Series" }
- form(method: "post", action: "/series") do
+ div(class: "column column-aside", "x-data": "") do
+ div(class: "column-header") do
+ h2(class: "aside-heading") do
+ span("x-show": "$store.sidebar.mode === 'form'") { "New Series" }
+ end
+ nav(class: "sort-toggle") do
+ button(
+ "x-on:click": "$store.sidebar.toggleForm()",
+ "x-bind:class": "$store.sidebar.mode === 'form' && 'sort-active'"
+ ) { "+ New" }
+ end
+ end
+
+ div(class: "task-detail", "x-show": "$store.sidebar.mode === 'task'") do
+ p(class: "task-detail-note", "x-text": "$store.sidebar.taskNote")
+ dl(class: "task-detail-fields") do
+ dt { "Interval" }
+ dd("x-text": "$store.sidebar.taskInterval")
+
+ dt { "Due date" }
+ dd("x-text": "$store.sidebar.taskDueDate")
+
+ dt("x-show": "$store.sidebar.taskUrgency !== ''") { "Urgency" }
+ dd("x-show": "$store.sidebar.taskUrgency !== ''", "x-text": "$store.sidebar.taskUrgency")
+ end
+ end
+
+ form(method: "post", action: "/series", "x-show": "$store.sidebar.mode === 'form'") do
div(class: "field") do
label(for: "note") { "Note" }
textarea(id: "note", name: "note", rows: 2, required: true)
@@ -166,25 +191,38 @@ module Views
name = task[:note].lines.first&.strip || task[:note]
overdue = task[:due_date] < Date.today
- div(class: ["task-card", ("task-overdue" if overdue)]) do
+ div(
+ class: ["task-card", ("task-overdue" if overdue)],
+ "x-on:click": "$store.sidebar.showTask($el)",
+ "x-bind:class": "$store.sidebar.taskId == '#{task[:id]}' && 'task-selected'",
+ "data-task-id": task[:id].to_s,
+ "data-task-name": name,
+ "data-task-note": task[:note],
+ "data-task-interval": interval_text(task[:interval_count], task[:interval_unit]),
+ "data-task-due-date": task[:due_date].to_s,
+ "data-task-urgency": task.urgency > 0 ? "#{format("%.1f", task.urgency)}x" : "",
+ "data-task-overdue": overdue.to_s
+ ) do
form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
- button(type: "submit", title: "Complete", **{ "aria-label": "Complete #{name}" }) { "✓" }
+ button(
+ type: "submit", title: "Complete",
+ "x-on:click.stop": "",
+ **{ "aria-label": "Complete #{name}" }
+ ) { "✓" }
end
div(class: "task-body") do
span(class: "task-name") { name }
- div(class: "task-meta") do
- span(class: "task-interval") do
- count = task[:interval_count]
- unit = task[:interval_unit]
- plain "Every #{count} #{count == 1 ? unit : "#{unit}s"}"
- end
- if task.urgency > 0
- span(class: "task-meta-sep") { "\u00B7" }
+ if overdue && task.urgency > 0
+ div(class: "task-meta") do
span(class: "task-urgency") { "#{format("%.1f", task.urgency)}x" }
end
end
end
end
end
+
+ def interval_text(count, unit)
+ "Every #{count} #{count == 1 ? unit : "#{unit}s"}"
+ end
end
end
diff --git a/public/css/app.css b/public/css/app.css
index add4363..2ddfbaf 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -101,14 +101,17 @@ h2 { font-size: var(--step-0); font-weight: 600; }
}
.column-aside {
- max-width: 16rem;
+ width: 16rem;
}
.aside-heading {
- font-size: var(--step--1);
color: #999;
}
+.aside-heading::before {
+ content: "\200b";
+}
+
.column-aside form {
font-size: var(--step--1);
}
@@ -165,6 +168,17 @@ h2 { font-size: var(--step-0); font-weight: 600; }
grid-template-columns: auto 1fr;
align-items: start;
gap: var(--space-2xs);
+ cursor: pointer;
+ margin-inline: calc(-1 * var(--space-3xs));
+ padding-inline: var(--space-3xs);
+}
+
+.task-card:hover {
+ background: #f8f8f8;
+}
+
+.task-selected {
+ box-shadow: -2px 0 0 #1a1a1a;
}
.task-body {
@@ -212,6 +226,32 @@ h2 { font-size: var(--step-0); font-weight: 600; }
font-weight: 600;
}
+.task-detail {
+ font-size: var(--step--1);
+}
+
+.task-detail-note {
+ color: #444;
+ white-space: pre-line;
+ margin-block-end: var(--space-s);
+}
+
+.task-detail-fields {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: var(--space-3xs) var(--space-s);
+ font-size: var(--step--2);
+}
+
+.task-detail-fields dt {
+ color: #999;
+}
+
+.task-detail-fields dd {
+ margin: 0;
+ color: #444;
+}
+
.calendar-horizon {
padding-block: var(--space-s) var(--space-2xs);
border-block-end: 1px solid #ccc;
diff --git a/public/js/app.js b/public/js/app.js
index 6f1a7d8..a2e713c 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -26,6 +26,42 @@ document.addEventListener("alpine:init", () => {
},
}))
+ Alpine.store("sidebar", {
+ mode: "",
+ taskId: null,
+ taskName: "",
+ taskNote: "",
+ taskInterval: "",
+ taskDueDate: "",
+ taskUrgency: "",
+ taskOverdue: false,
+
+ showTask(el) {
+ this.taskId = el.dataset.taskId
+ this.taskName = el.dataset.taskName
+ this.taskNote = el.dataset.taskNote
+ this.taskInterval = el.dataset.taskInterval
+ this.taskDueDate = el.dataset.taskDueDate
+ this.taskUrgency = el.dataset.taskUrgency
+ this.taskOverdue = el.dataset.taskOverdue === "true"
+ this.mode = "task"
+ },
+
+ showForm() {
+ this.mode = "form"
+ this.taskId = null
+ },
+
+ toggleForm() {
+ if (this.mode === "form") {
+ this.mode = ""
+ this.taskId = null
+ } else {
+ this.showForm()
+ }
+ },
+ })
+
Alpine.data("upcoming", () => ({
showEmpty: localStorage.getItem("upcoming-show-empty") !== "false",
diff --git a/test/test_web.rb b/test/test_web.rb
index cdcd030..111d41d 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -21,7 +21,7 @@ class TestWeb < Minitest::Test
def test_root_shows_new_series_form
get "/", {}, tailscale_headers
assert last_response.ok?
- assert_includes last_response.body, '<form method="post" action="/series">'
+ assert_includes last_response.body, 'method="post" action="/series"'
assert_includes last_response.body, 'name="note"'
assert_includes last_response.body, 'name="interval_count"'
assert_includes last_response.body, 'name="interval_unit"'
@@ -218,6 +218,24 @@ class TestWeb < Minitest::Test
assert_equal 403, last_response.status
end
+ def test_task_card_has_data_attributes
+ post "/series", {
+ note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01"
+ }, tailscale_headers
+
+ get "/", {}, tailscale_headers
+ assert_includes last_response.body, 'data-task-name="Call Mom"'
+ assert_includes last_response.body, 'data-task-note="Call Mom"'
+ assert_includes last_response.body, 'data-task-interval="Every 2 weeks"'
+ assert_includes last_response.body, 'data-task-due-date="2026-03-01"'
+ end
+
+ def test_sidebar_has_new_button
+ get "/", {}, tailscale_headers
+ assert_includes last_response.body, "+ New"
+ end
+
private
def tailscale_headers(