Edit series fields inline from the sidebar
The sidebar was read-only — changing interval, due date, or the note
required a separate workflow. An edit/done toggle now exposes inline
controls, backed by a new PATCH /series/:id endpoint. The series note
uses a full OverType editor instance (with the same compact min-height
hack as history notes) so it can be edited in place.

Assisted-by: Claude Opus 4.6 via Claude Code
change ozqmxrymnusrkqzwosxlmpxwtnssxprk
commit 77b26ad0a47a21bae8a708db907d2d0c87aa7cb9
author Alpha Chen <alpha@kejadlen.dev>
date
parent qrvqxqno
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index b3e4549..a2e1648 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -51,26 +51,63 @@ module Views
             div(class: "column-header") do
               h2(class: "aside-heading") do
                 span("x-show": "$store.sidebar.mode === 'form'") { "New Series" }
+                span(
+                  "x-show": "$store.sidebar.mode !== 'form'",
+                  class: "aside-heading-action",
+                  "x-on:click": "$store.sidebar.toggleForm()"
+                ) { "+ New" }
               end
               nav(class: "sort-toggle") do
                 button(
-                  "x-on:click": "$store.sidebar.toggleForm()",
-                  "x-bind:class": "$store.sidebar.mode === 'form' && 'sort-active'"
-                ) { "+ New" }
+                  "x-show": "$store.sidebar.mode === 'task' && !$store.sidebar.editing",
+                  "x-on:click": "$store.sidebar.startEditing()"
+                ) { "Edit" }
+                button(
+                  "x-show": "$store.sidebar.mode === 'task' && $store.sidebar.editing",
+                  "x-on:click": "$store.sidebar.stopEditing()"
+                ) { "Done" }
               end
             end
 
             div(class: "task-detail", "x-show": "$store.sidebar.mode === 'task'") do
-              div(id: "series-note-preview", class: "task-detail-note")
+              div(id: "series-note-detail", class: "task-detail-note")
               dl(class: "task-detail-fields") do
                 dt { "Interval" }
-                dd("x-text": "$store.sidebar.taskInterval")
+                dd("x-show": "!$store.sidebar.editing", "x-text": "$store.sidebar.taskInterval")
+                dd(class: "detail-edit-interval", "x-show": "$store.sidebar.editing", "x-cloak": true) do
+                  input(
+                    type: "number",
+                    class: "detail-input detail-input-count",
+                    min: 1,
+                    "x-model.number": "$store.sidebar.intervalCount",
+                    "x-on:change": "$store.sidebar.saveInterval()"
+                  )
+                  select(
+                    class: "detail-input detail-input-unit",
+                    "x-model": "$store.sidebar.intervalUnit",
+                    "x-on:change": "$store.sidebar.saveInterval()"
+                  ) do
+                    option(value: "day") { "day(s)" }
+                    option(value: "week") { "week(s)" }
+                    option(value: "month") { "month(s)" }
+                    option(value: "quarter") { "quarter(s)" }
+                    option(value: "year") { "year(s)" }
+                  end
+                end
 
                 dt { "Due date" }
-                dd("x-text": "$store.sidebar.taskDueDate")
+                dd("x-show": "!$store.sidebar.editing", "x-text": "$store.sidebar.taskDueDate")
+                dd("x-show": "$store.sidebar.editing", "x-cloak": true) do
+                  input(
+                    type: "date",
+                    class: "detail-input detail-input-date",
+                    "x-model": "$store.sidebar.taskDueDate",
+                    "x-on:change": "$store.sidebar.saveSeriesField('due_date', $store.sidebar.taskDueDate)"
+                  )
+                end
 
-                dt("x-show": "$store.sidebar.taskUrgency !== ''") { "Urgency" }
-                dd("x-show": "$store.sidebar.taskUrgency !== ''", "x-text": "$store.sidebar.taskUrgency")
+                dt("x-show": "$store.sidebar.taskUrgency !== '' && !$store.sidebar.editing") { "Urgency" }
+                dd("x-show": "$store.sidebar.taskUrgency !== '' && !$store.sidebar.editing", "x-text": "$store.sidebar.taskUrgency")
               end
 
               template("x-if": "$store.sidebar.completedTasks.length > 0") do
@@ -228,6 +265,8 @@ module Views
         "data-task-name": name,
         "data-task-note": task[:note],
         "data-task-interval": interval_text(task[:interval_count], task[:interval_unit]),
+        "data-interval-count": task[:interval_count].to_s,
+        "data-interval-unit": 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
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 8f32aee..080b5bf 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -64,6 +64,46 @@ class Web < Roda
       series = Series.where(id: series_id, user_id: current_user.id).first
       r.halt 404 unless series
 
+      r.patch do
+        updates = {}
+
+        if r.params.key?("note")
+          note = r.params["note"].to_s.strip
+          r.halt 422 if note.empty?
+          updates[:note] = note
+        end
+
+        if r.params.key?("interval_count") || r.params.key?("interval_unit")
+          interval_count = r.params.key?("interval_count") ? r.params["interval_count"].to_i : series.interval_count
+          interval_unit = r.params.key?("interval_unit") ? r.params["interval_unit"].to_s : series.interval_unit
+          r.halt 422 unless Series::INTERVAL_UNITS.include?(interval_unit)
+          r.halt 422 unless interval_count >= 1
+          updates[:interval_count] = interval_count
+          updates[:interval_unit] = interval_unit
+        end
+
+        if r.params.key?("due_date")
+          begin
+            due_date = Date.parse(r.params["due_date"].to_s)
+          rescue Date::Error
+            r.halt 422
+          end
+        end
+
+        DB.transaction do
+          series.update(updates) unless updates.empty?
+          if due_date
+            active = series.active_task
+            active.update(due_date: due_date) if active
+          end
+        end
+
+        response["content-type"] = "application/json"
+        result = updates.transform_keys(&:to_s)
+        result["due_date"] = due_date.to_s if due_date
+        result.to_json
+      end
+
       r.get "completed" do
         completed = series.tasks_dataset
           .exclude(completed_at: nil)
diff --git a/public/css/app.css b/public/css/app.css
index bbcc947..fc8759c 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -112,6 +112,12 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   content: "\200b";
 }
 
+.aside-heading-action {
+  font-size: var(--step--1);
+  font-weight: 400;
+  cursor: default;
+}
+
 .column-aside form {
   font-size: var(--step--1);
 }
@@ -137,7 +143,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 .sort-toggle button {
   all: unset;
   color: #999;
-  cursor: pointer;
+  cursor: default;
 }
 
 .sort-toggle button:hover {
@@ -147,7 +153,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 .sort-toggle button.sort-active {
   color: #1a1a1a;
   font-weight: 600;
-  cursor: default;
 }
 
 .sort-active {
@@ -211,8 +216,20 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 .task-detail-note {
   color: #444;
   margin-block-end: var(--space-s);
+  line-height: 1.3;
+  border: 1px solid transparent;
+  border-radius: 3px;
+}
+
+.task-detail-note:hover {
+  border-color: #ddd;
+}
+
+.task-detail-note:focus-within {
+  border-color: #999;
 }
 
+
 .task-detail-fields {
   display: grid;
   grid-template-columns: auto 1fr;
@@ -229,6 +246,51 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   color: #444;
 }
 
+.detail-input {
+  font-size: var(--step--2);
+  padding: 1px 4px;
+  border: 1px solid transparent;
+  border-radius: 3px;
+  background: transparent;
+  color: #444;
+  font-family: inherit;
+}
+
+.detail-input:hover {
+  border-color: #ddd;
+}
+
+.detail-input:focus {
+  border-color: #999;
+  outline: none;
+  background: #fff;
+}
+
+.detail-edit-interval {
+  display: flex;
+  gap: 4px;
+}
+
+.detail-input-count {
+  width: 3.5em;
+  -moz-appearance: textfield;
+}
+
+.detail-input-count::-webkit-inner-spin-button,
+.detail-input-count::-webkit-outer-spin-button {
+  -webkit-appearance: none;
+  margin: 0;
+}
+
+.detail-input-unit {
+  flex: 1;
+  min-width: 0;
+}
+
+.detail-input-date {
+  width: 100%;
+}
+
 .task-history {
   margin-block-start: var(--space-s);
 }
diff --git a/public/js/app.js b/public/js/app.js
index 4518a69..04f0780 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -29,14 +29,20 @@ document.addEventListener("alpine:init", () => {
   Alpine.store("sidebar", {
     mode: "",
     taskId: null,
+    seriesId: null,
     taskName: "",
     taskNote: "",
     taskInterval: "",
+    intervalCount: 1,
+    intervalUnit: "day",
     taskDueDate: "",
     taskUrgency: "",
     taskOverdue: false,
     completedTasks: [],
+    editing: false,
     addingNoteId: null,
+    _seriesNoteEditor: null,
+    _resizeSeriesNote: null,
 
     init() {
       const seriesId = sessionStorage.getItem("showSeries")
@@ -49,21 +55,21 @@ document.addEventListener("alpine:init", () => {
 
     showTask(el) {
       this.taskId = el.dataset.taskId
+      this.seriesId = el.dataset.seriesId
       this.taskName = el.dataset.taskName
       this.taskNote = el.dataset.taskNote
       this.taskInterval = el.dataset.taskInterval
+      this.intervalCount = parseInt(el.dataset.intervalCount, 10) || 1
+      this.intervalUnit = el.dataset.intervalUnit || "day"
       this.taskDueDate = el.dataset.taskDueDate
       this.taskUrgency = el.dataset.taskUrgency
       this.taskOverdue = el.dataset.taskOverdue === "true"
       this.completedTasks = []
       this.addingNoteId = null
+      this.editing = false
       this.mode = "task"
 
-      const noteEl = document.getElementById("series-note-preview")
-      if (noteEl) {
-        noteEl.innerHTML = ""
-        noteEl.innerHTML = OverType.MarkdownParser.parse(this.taskNote)
-      }
+      requestAnimationFrame(() => this._initSeriesNote())
 
       fetch(`/series/${el.dataset.seriesId}/completed`)
         .then((r) => r.json())
@@ -84,6 +90,28 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
+    startEditing() {
+      this.editing = true
+      const ta = document.querySelector("#series-note-detail textarea")
+      if (ta) {
+        ta.style.pointerEvents = ""
+        ta.readOnly = false
+        ta.focus()
+      }
+      if (this._resizeSeriesNote) requestAnimationFrame(this._resizeSeriesNote)
+    },
+
+    stopEditing() {
+      this._saveSeriesNote()
+      const ta = document.querySelector("#series-note-detail textarea")
+      if (ta) {
+        ta.style.pointerEvents = "none"
+        ta.readOnly = true
+      }
+      this.editing = false
+      if (this._resizeSeriesNote) requestAnimationFrame(this._resizeSeriesNote)
+    },
+
     initNoteEditor(el, taskId, initialNote) {
       if (!el) return
       const [editor] = new OverType(el, {
@@ -134,6 +162,95 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
+    _initSeriesNote() {
+      const el = document.getElementById("series-note-detail")
+      if (!el) return
+
+      if (this._seriesNoteEditor) {
+        this._seriesNoteEditor.destroy()
+        this._seriesNoteEditor = null
+      }
+      el.innerHTML = ""
+
+      const [editor] = new OverType(el, {
+        value: this.taskNote || "",
+        placeholder: "Series note...",
+        autoResize: true,
+        minHeight: 14,
+        padding: "0 4px",
+      })
+      this._seriesNoteEditor = editor
+
+      const wrapper = el.querySelector(".overtype-wrapper")
+      const ta = el.querySelector("textarea")
+      const preview = el.querySelector(".overtype-preview")
+      if (wrapper && ta) {
+        wrapper.style.setProperty("min-height", "0", "important")
+        ta.style.setProperty("padding", "0 4px", "important")
+        ta.style.setProperty("border", "none", "important")
+        // Store resize so startEditing/stopEditing can re-measure after
+        // toggling pointer-events and readOnly, which can shift the textarea.
+        this._resizeSeriesNote = () => {
+          ta.style.setProperty("height", "0", "important")
+          const h = ta.scrollHeight + "px"
+          ta.style.setProperty("height", h, "important")
+          wrapper.style.setProperty("height", h, "important")
+          if (preview) preview.style.setProperty("height", h, "important")
+        }
+        requestAnimationFrame(this._resizeSeriesNote)
+        ta.addEventListener("input", () => requestAnimationFrame(this._resizeSeriesNote))
+
+        ta.style.pointerEvents = "none"
+        ta.readOnly = true
+        ta.addEventListener("blur", () => this._saveSeriesNote())
+      }
+    },
+
+    _saveSeriesNote() {
+      if (!this._seriesNoteEditor) return
+      const note = this._seriesNoteEditor.getValue().trim()
+      if (note === (this.taskNote || "").trim()) return
+
+      this.taskNote = note
+      this.saveSeriesField("note", note)
+      const card = document.querySelector(`[data-series-id="${this.seriesId}"]`)
+      if (card) {
+        card.dataset.taskNote = note
+        card.dataset.taskName = note.split("\n")[0]?.trim() || note
+        const nameEl = card.querySelector(".task-name")
+        if (nameEl) nameEl.textContent = card.dataset.taskName
+      }
+    },
+
+    saveSeriesField(field, value) {
+      if (!this.seriesId) return
+      fetch(`/series/${this.seriesId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `${encodeURIComponent(field)}=${encodeURIComponent(value)}`,
+      })
+    },
+
+    saveInterval() {
+      if (!this.seriesId) return
+      const count = this.intervalCount
+      const unit = this.intervalUnit
+      fetch(`/series/${this.seriesId}`, {
+        method: "PATCH",
+        headers: { "Content-Type": "application/x-www-form-urlencoded" },
+        body: `interval_count=${encodeURIComponent(count)}&interval_unit=${encodeURIComponent(unit)}`,
+      }).then(() => {
+        const label = `Every ${count} ${count === 1 ? unit : unit + "s"}`
+        this.taskInterval = label
+        const card = document.querySelector(`[data-series-id="${this.seriesId}"]`)
+        if (card) {
+          card.dataset.taskInterval = label
+          card.dataset.intervalCount = count
+          card.dataset.intervalUnit = unit
+        }
+      })
+    },
+
     completeTask(taskId, seriesId) {
       fetch(`/tasks/${taskId}/complete`, { method: "POST" })
         .then((r) => {
diff --git a/test/test_web.rb b/test/test_web.rb
index bc3fb54..deda2d8 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -282,6 +282,108 @@ class TestWeb < Minitest::Test
     assert_equal 404, last_response.status
   end
 
+  def test_patch_series_updates_note
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
+    assert last_response.ok?
+
+    body = JSON.parse(last_response.body)
+    assert_equal "Call Dad", body["note"]
+    assert_equal "Call Dad", DB[:series].first(id: series[:id])[:note]
+  end
+
+  def test_patch_series_updates_interval
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    patch "/series/#{series[:id]}", { interval_count: "3", interval_unit: "month" }, tailscale_headers
+    assert last_response.ok?
+
+    body = JSON.parse(last_response.body)
+    assert_equal 3, body["interval_count"]
+    assert_equal "month", body["interval_unit"]
+
+    updated = DB[:series].first(id: series[:id])
+    assert_equal 3, updated[:interval_count]
+    assert_equal "month", updated[:interval_unit]
+  end
+
+  def test_patch_series_updates_due_date
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    task = DB[:tasks].first(series_id: series[:id])
+    patch "/series/#{series[:id]}", { due_date: "2026-04-15" }, tailscale_headers
+    assert last_response.ok?
+
+    body = JSON.parse(last_response.body)
+    assert_equal "2026-04-15", body["due_date"]
+    assert_equal Date.new(2026, 4, 15), DB[:tasks].first(id: task[:id])[:due_date]
+  end
+
+  def test_patch_series_rejects_invalid_interval_unit
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    patch "/series/#{series[:id]}", { interval_unit: "fortnight" }, tailscale_headers
+    assert_equal 422, last_response.status
+  end
+
+  def test_patch_series_rejects_zero_interval_count
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    patch "/series/#{series[:id]}", { interval_count: "0" }, tailscale_headers
+    assert_equal 422, last_response.status
+  end
+
+  def test_patch_series_requires_own_series
+    post "/series", {
+      note: "Alice task", interval_unit: "day", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers(login: "alice@example.com", name: "Alice")
+
+    series = DB[:series].first
+    patch "/series/#{series[:id]}", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
+    assert_equal 404, last_response.status
+  end
+
+  def test_patch_series_ignores_fields_not_provided
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    series = DB[:series].first
+    patch "/series/#{series[:id]}", { note: "Call Dad" }, tailscale_headers
+    assert last_response.ok?
+
+    updated = DB[:series].first(id: series[:id])
+    assert_equal "Call Dad", updated[:note]
+    assert_equal "week", updated[:interval_unit]
+    assert_equal 2, updated[:interval_count]
+
+    task = DB[:tasks].first(series_id: series[:id])
+    assert_equal Date.new(2026, 3, 1), task[:due_date]
+  end
+
   def test_completed_includes_notes
     post "/series", {
       note: "Call Mom", interval_unit: "week", interval_count: "1",