Task detail sidebar with click-to-select and toggleable new form
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
change luvtmrltqlvwvpsvrsvmlmrsxmyoxvzr
commit 2d7c7df138cfbe8515e8400988327ca616e7e932
author Alpha Chen <alpha@kejadlen.dev>
date
parent uxlwnpmx
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(