Unify new and edit series sidebar layout
The new-series form used a stacked label-over-input layout while the edit
sidebar used a compact dl/dd grid. Both now share the same structure so
the app feels consistent. The Create button moves to the header, matching
the Edit/Done position, and stays disabled until the note has content.

Fixes a KeyError when viewing an overdue series—urgency now falls back
to the series association when interval columns aren't joined.

Assisted-by: Claude Opus 4.6 via Claude Code
change xpkxnsvwmvlynzkpysqxksqnoxwnwnrs
commit dae77b30794e001b9733365c933a76587ff0ada1
author Alpha Chen <alpha@kejadlen.dev>
date
parent wzszkvru
diff --git a/BACKLOG.md b/BACKLOG.md
index b3b589d..c7820fe 100644
--- a/BACKLOG.md
+++ b/BACKLOG.md
@@ -51,3 +51,4 @@
 - Fixed-schedule recurrence
 - Personal/shared toggle
 - CLI interface
+- Sentry
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index ee36254..8b75bda 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -49,7 +49,9 @@ class Task < Sequel::Model
     days_overdue = Date.today - self[:due_date]
     return 0 if days_overdue <= 0
 
-    interval = self[:interval_count] * INTERVAL_DAYS.fetch(self[:interval_unit])
+    count = self[:interval_count] || series.interval_count
+    unit = self[:interval_unit] || series.interval_unit
+    interval = count * INTERVAL_DAYS.fetch(unit)
     days_overdue.to_f / interval
   end
 
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 3bb60d3..9b3bb17 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -66,18 +66,18 @@ module Views
       div(class: "column column-aside", "x-data": "{ editing: false }") do
         div(class: "column-header") do
           h2(class: "aside-heading") do
-            a(href: "/", class: "aside-heading-action") { "+ New" }
-          end
-          nav(class: "sort-toggle") do
-            button(
-              "x-show": "!editing",
-              "x-on:click": "editing = true; $dispatch('start-editing')"
-            ) { "Edit" }
-            button(
-              "x-show": "editing",
-              "x-on:click": "editing = false; $dispatch('stop-editing')"
-            ) { "Done" }
+            a(href: "/", class: "aside-heading-action") { "New" }
           end
+          button(
+            class: "aside-heading-action",
+            "x-show": "!editing",
+            "x-on:click": "editing = true; $dispatch('start-editing')"
+          ) { "Edit" }
+          button(
+            class: "aside-heading-action",
+            "x-show": "editing",
+            "x-on:click": "editing = false; $dispatch('stop-editing')"
+          ) { "Done" }
         end
 
         div(class: "task-detail") do
@@ -88,7 +88,7 @@ module Views
             "data-series-id": @selected_series.id.to_s
           )
           dl(class: "task-detail-fields") do
-            dt { "Interval" }
+            dt { "Repeat every" }
             dd("x-show": "!editing") do
               plain interval_text(@selected_series.interval_count, @selected_series.interval_unit)
             end
@@ -135,8 +135,8 @@ module Views
               end
 
               if @selected_task.urgency > 0
-                dt("x-show": "!editing") { "Urgency" }
-                dd("x-show": "!editing") { "#{format("%.1f", @selected_task.urgency)}x" }
+                dt { "Urgency" }
+                dd { "#{format("%.1f", @selected_task.urgency)}x" }
               end
             end
           end
@@ -186,43 +186,39 @@ module Views
     def new_series_sidebar
       div(class: "column column-aside") do
         div(class: "column-header") do
-          h2(class: "aside-heading") do
-            span { "New Series" }
-          end
+          h2 { "New Series" }
+          button(type: "submit", form: "new-series-form", id: "create-series-btn", class: "aside-heading-action", disabled: true) { "Create" }
         end
 
-        form(method: "post", action: "/series") do
-          div(class: "field") do
-            label(for: "note") { "Note" }
-            div(id: "series-note-editor")
-          end
+        form(method: "post", action: "/series", id: "new-series-form", class: "task-detail") do
+          div(id: "series-note-editor", class: "task-detail-note")
 
-          div(class: "field") do
-            label(for: "interval_count") { "Repeat every" }
-            div(class: "interval") do
+          dl(class: "task-detail-fields") do
+            dt { "Repeat every" }
+            dd(class: "detail-edit-interval") do
               input(
-                type: "number", id: "interval_count", name: "interval_count",
+                type: "number", name: "interval_count",
+                class: "detail-input detail-input-count",
                 min: 1, value: 1, required: true
               )
-              select(id: "interval_unit", name: "interval_unit", required: true) do
-                option(value: "day") { "day(s)" }
+              select(name: "interval_unit", class: "detail-input detail-input-unit", required: true) do
+                option(value: "day", selected: true) { "day(s)" }
                 option(value: "week") { "week(s)" }
                 option(value: "month") { "month(s)" }
                 option(value: "quarter") { "quarter(s)" }
                 option(value: "year") { "year(s)" }
               end
             end
-          end
 
-          div(class: "field") do
-            label(for: "first_due_date") { "First due date" }
-            input(
-              type: "date", id: "first_due_date", name: "first_due_date",
-              value: Date.today.to_s, required: true
-            )
+            dt { "First due date" }
+            dd do
+              input(
+                type: "date", name: "first_due_date",
+                class: "detail-input detail-input-date",
+                value: Date.today.to_s, required: true
+              )
+            end
           end
-
-          button(type: "submit") { "Create" }
         end
       end
     end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 9fc3d20..557bd60 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -25,7 +25,7 @@ module Views
         end
         body do
           header(class: "site-header") do
-            span(class: "site-name") { "Ketchup" }
+            a(href: "/", class: "site-name") { "Ketchup" }
             span(class: "user") { @current_user[:name] || @current_user[:login] }
           end
           yield
diff --git a/public/css/app.css b/public/css/app.css
index 15c0c10..a670771 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -83,6 +83,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   display: flex;
   justify-content: space-between;
   align-items: baseline;
+  gap: var(--space-2xs);
   margin-block-end: var(--space-xs);
   padding-block-end: var(--space-3xs);
   border-block-end: 1px dotted #ccc;
@@ -105,6 +106,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .aside-heading {
+  flex: 1;
   color: #999;
 }
 
@@ -113,21 +115,20 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .aside-heading-action {
+  all: unset;
   font-size: var(--step--1);
   font-weight: 400;
+  color: #999;
   cursor: default;
 }
 
-.column-aside form {
-  font-size: var(--step--1);
-}
-
-.column-aside label {
-  font-size: var(--step--2);
+.aside-heading-action:hover {
+  color: #1a1a1a;
 }
 
-.column-aside button[type="submit"] {
-  font-size: var(--step--1);
+.aside-heading-action:disabled {
+  color: #ccc;
+  cursor: default;
 }
 
 #series-note-editor {
@@ -139,6 +140,14 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   border-color: #999;
 }
 
+.column-aside form {
+  font-size: var(--step--1);
+}
+
+.column-aside button[type="submit"] {
+  font-size: var(--step--1);
+}
+
 /* -------------------- */
 /* Task list */
 /* -------------------- */
@@ -230,6 +239,15 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   border-radius: 3px;
 }
 
+#series-note-editor {
+  margin-block-end: 0;
+  border-color: #ccc;
+}
+
+#series-note-editor:focus-within {
+  border-color: #999;
+}
+
 .task-detail-note:hover {
   border-color: transparent;
 }
@@ -258,6 +276,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 .detail-input {
   font-size: var(--step--2);
   padding: 1px 4px;
+  margin: -2px -5px;
   border: 1px solid transparent;
   border-radius: 3px;
   background: transparent;
@@ -479,7 +498,7 @@ textarea {
   gap: var(--space-3xs);
 }
 
-button[type="submit"]:not(.complete-btn) {
+button[type="submit"]:not(.complete-btn):not(.aside-heading-action) {
   padding: var(--space-3xs) var(--space-s);
   border: 1px solid #1a1a1a;
   border-radius: 3px;
@@ -490,7 +509,7 @@ button[type="submit"]:not(.complete-btn) {
   cursor: pointer;
 }
 
-button[type="submit"]:not(.complete-btn):hover {
+button[type="submit"]:not(.complete-btn):not(.aside-heading-action):hover {
   background: #333;
 }
 
diff --git a/public/js/app.js b/public/js/app.js
index 79bdc9f..ae5b693 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -198,11 +198,17 @@ document.addEventListener("alpine:init", () => {
   // New series form editor
   const newNoteEl = document.getElementById("series-note-editor")
   if (newNoteEl) {
-    new OverType("#series-note-editor", {
+    const editor = new OverType("#series-note-editor", {
       placeholder: "What needs doing...",
       textareaProps: { name: "note", required: true },
       autoResize: true,
     })
+    const createBtn = document.getElementById("create-series-btn")
+    if (createBtn) {
+      newNoteEl.addEventListener("input", () => {
+        createBtn.disabled = !editor.value.trim()
+      })
+    }
   }
 
   Alpine.data("upcoming", () => ({
diff --git a/test/test_web.rb b/test/test_web.rb
index 0703079..815c458 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -236,7 +236,7 @@ class TestWeb < Minitest::Test
 
     series = DB[:series].first
     get "/series/#{series[:id]}", {}, tailscale_headers
-    assert_includes last_response.body, "+ New"
+    assert_includes last_response.body, "New"
     assert_includes last_response.body, 'href="/"'
   end