Live-edit history notes with compact OverType editors
OverType's 60px min-height and the app's global textarea padding
inflated scrollHeight, requiring post-init inline style overrides
and per-input re-measurement to keep single-line notes compact.

Assisted-by: Claude Opus 4.6 via Claude Code
change tsqkkvnwpvtswluwuknvsnukktnxovls
commit f132bc0884e3d830d664a9326f5221309d754acc
author Alpha Chen <alpha@kejadlen.dev>
date
parent vxtymrry
diff --git a/Rakefile b/Rakefile
index 0ab2a5d..5575125 100644
--- a/Rakefile
+++ b/Rakefile
@@ -83,25 +83,26 @@ task :seed do
     "All good\n\n- Changed filter\n- Reset thermostat",
   ]
 
-  overdue, upcoming = all_series.partition { |s| s.active_task.due_date < Date.today }
-  with_history = overdue.sample([3, overdue.length].min) + upcoming.sample([3, upcoming.length].min)
-
-  with_history.each do |s|
-    rand(1..4).times do |i|
-      oldest_task = s.active_task
+  all_series.sample(6).each do |s|
+    interval_days = case s.interval_unit
+                    when "day" then s.interval_count
+                    when "week" then 7 * s.interval_count
+                    when "month" then 30 * s.interval_count
+                    when "quarter" then 91 * s.interval_count
+                    when "year" then 365 * s.interval_count
+                    end
+
+    active = s.active_task
+    prev_date = active.due_date
+    rand(1..4).times do
+      prev_date -= interval_days
       note = rand < 0.5 ? completion_notes.sample : nil
-      oldest_task.update(
-        completed_at: oldest_task.due_date.to_time + rand(0..3) * 86400,
+      Task.create(
+        series_id: s.id,
+        due_date: prev_date,
+        completed_at: prev_date.to_time + rand(0..3) * 86400,
         note: note
       )
-      next_date = oldest_task.due_date + case s.interval_unit
-                                         when "day" then s.interval_count
-                                         when "week" then 7 * s.interval_count
-                                         when "month" then 30 * s.interval_count
-                                         when "quarter" then 91 * s.interval_count
-                                         when "year" then 365 * s.interval_count
-                                         end
-      Task.create(series_id: s.id, due_date: next_date)
     end
   end
 
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 5523dcc..5a641a1 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -78,31 +78,22 @@ module Views
                   h3 { "History" }
                   ul do
                     template("x-for": "ct in $store.sidebar.completedTasks") do
-                      li(class: "task-history-item", "x-on:click": "$store.sidebar.editNote(ct.id, ct.note || '')") do
+                      li(class: "task-history-item") do
                         div(class: "task-history-row") do
                           span(class: "task-history-check") { "✓" }
                           span(class: "task-history-date", "x-text": "ct.completed_at")
-                          template("x-if": "$store.sidebar.editingNoteId !== ct.id") do
-                            span(class: "task-history-placeholder") do
-                              span("x-text": "ct.note ? 'edit' : 'add a note...'")
-                            end
+                          template("x-if": "!ct.note && $store.sidebar.addingNoteId !== ct.id") do
+                            span(
+                              class: "task-history-add-note",
+                              "x-on:click": "$store.sidebar.addingNoteId = ct.id"
+                            ) { "add a note..." }
                           end
                         end
-                        template("x-if": "ct.note && $store.sidebar.editingNoteId !== ct.id") do
-                          p(class: "task-history-note", "x-text": "ct.note")
-                        end
-                        template("x-if": "$store.sidebar.editingNoteId === ct.id") do
+                        template("x-if": "ct.note || $store.sidebar.addingNoteId === ct.id") do
                           div(
-                            class: "task-history-edit",
-                            "x-on:click.stop": "",
-                            "x-init": "$store.sidebar.initNoteEditor($el.querySelector('.note-editor'))"
-                          ) do
-                            div(class: "note-editor overtype-wrap")
-                            div(class: "task-history-edit-actions") do
-                              button(type: "button", "x-on:click.stop": "$store.sidebar.saveNote(ct.id)") { "Save" }
-                              button(type: "button", class: "btn-cancel", "x-on:click.stop": "$store.sidebar.cancelNote()") { "Cancel" }
-                            end
-                          end
+                            class: "task-history-note-editor",
+                            "x-init": "$store.sidebar.initNoteEditor($el, ct.id, ct.note || '')"
+                          )
                         end
                       end
                     end
@@ -114,7 +105,7 @@ module Views
             form(method: "post", action: "/series", "x-show": "$store.sidebar.mode === 'form'") do
               div(class: "field") do
                 label(for: "note") { "Note" }
-                div(id: "series-note-editor", class: "overtype-wrap")
+                div(id: "series-note-editor")
               end
 
               div(class: "field") do
diff --git a/public/css/app.css b/public/css/app.css
index dbaa6cd..9352bf4 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -247,15 +247,11 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-history-item {
-  padding-block: var(--space-3xs);
-  cursor: pointer;
+  padding-block: var(--space-2xs);
 }
 
-.task-history-item:hover,
-.task-history-item:has(.task-history-edit) {
-  background: #f8f8f8;
-  margin-inline: calc(-1 * var(--space-3xs));
-  padding-inline: var(--space-3xs);
+.task-history-item + .task-history-item {
+  border-top: 1px dotted #ccc;
 }
 
 .task-history-row {
@@ -270,56 +266,39 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   color: #999;
 }
 
-.task-history-note {
-  font-size: var(--step--2);
-  color: #999;
-  margin: 2px 0 0 calc(14px + var(--space-3xs));
-  white-space: pre-line;
-}
-
-.task-history-placeholder {
+.task-history-add-note {
   font-size: var(--step--2);
   color: #ccc;
   margin-left: auto;
+  cursor: pointer;
   display: none;
 }
 
-.task-history-item:hover .task-history-placeholder {
+.task-history-item:hover .task-history-add-note {
   display: inline;
 }
 
-.task-history-edit {
-  margin-top: var(--space-3xs);
+.task-history-note-editor {
+  margin-top: 1px;
+  margin-left: 14px;
+  border: 1px solid transparent;
+  border-radius: 3px;
+  line-height: 1.3;
 }
 
-.overtype-wrap {
-  font-size: var(--step--2);
+.task-history-note-editor:hover {
+  border-color: #ddd;
 }
 
-.task-history-edit-actions {
-  display: flex;
-  gap: var(--space-3xs);
-  margin-top: var(--space-3xs);
+.task-history-note-editor:focus-within {
+  border-color: #999;
 }
 
-.task-history-edit-actions button {
-  font-size: var(--step--2);
-  padding: 2px var(--space-2xs);
-  border: 1px solid #ccc;
-  border-radius: 3px;
-  background: #fff;
-  cursor: pointer;
-}
 
-.task-history-edit-actions button:first-child {
-  background: #1a1a1a;
-  border-color: #1a1a1a;
-  color: #fff;
-}
 
-.btn-cancel {
-  color: #777;
-}
+
+
+
 
 .calendar-horizon {
   padding-block: var(--space-s) var(--space-2xs);
diff --git a/public/js/app.js b/public/js/app.js
index 609a79d..5dc586e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -36,9 +36,7 @@ document.addEventListener("alpine:init", () => {
     taskUrgency: "",
     taskOverdue: false,
     completedTasks: [],
-    editingNoteId: null,
-    editingNoteText: "",
-    _noteEditor: null,
+    addingNoteId: null,
 
     init() {
       const seriesId = sessionStorage.getItem("showSeries")
@@ -58,8 +56,7 @@ document.addEventListener("alpine:init", () => {
       this.taskUrgency = el.dataset.taskUrgency
       this.taskOverdue = el.dataset.taskOverdue === "true"
       this.completedTasks = []
-      this.editingNoteId = null
-      this.editingNoteText = ""
+      this.addingNoteId = null
       this.mode = "task"
 
       fetch(`/series/${el.dataset.seriesId}/completed`)
@@ -81,49 +78,54 @@ document.addEventListener("alpine:init", () => {
       }
     },
 
-    editNote(taskId, currentNote) {
-      if (this.editingNoteId === taskId) {
-        this.editingNoteId = null
-        this.editingNoteText = ""
-        return
-      }
-      this.editingNoteId = taskId
-      this.editingNoteText = currentNote
-    },
-
-    saveNote(taskId) {
-      if (this._noteEditor) {
-        this.editingNoteText = this._noteEditor.getValue()
-      }
-      const note = this.editingNoteText.trim()
-      fetch(`/tasks/${taskId}/note`, {
-        method: "PATCH",
-        headers: { "Content-Type": "application/x-www-form-urlencoded" },
-        body: `note=${encodeURIComponent(note)}`,
-      })
-        .then((r) => r.json())
-        .then(() => {
-          const ct = this.completedTasks.find((t) => t.id === taskId)
-          if (ct) ct.note = note || null
-          this._noteEditor = null
-          this.editingNoteId = null
-          this.editingNoteText = ""
-        })
-    },
-
-    initNoteEditor(el) {
+    initNoteEditor(el, taskId, initialNote) {
       if (!el) return
       const [editor] = new OverType(el, {
-        value: this.editingNoteText,
+        value: initialNote,
         placeholder: "Add a note...",
+        autoResize: true,
+        minHeight: 14,
+        fontSize: "11px",
+        padding: "0 4px",
       })
-      this._noteEditor = editor
-    },
 
-    cancelNote() {
-      this._noteEditor = null
-      this.editingNoteId = null
-      this.editingNoteText = ""
+      // OverType's wrapper has min-height: 60px !important and its auto-resize
+      // runs during init — before we can intervene — locking in an inflated
+      // height. The global textarea styles (padding, border) also inflate
+      // scrollHeight. Fix: override those styles, then re-measure in the next
+      // frame once the overrides have taken effect.
+      const wrapper = el.querySelector(".overtype-wrapper")
+      const textarea = el.querySelector("textarea")
+      const preview = el.querySelector(".overtype-preview")
+      if (wrapper && textarea) {
+        wrapper.style.setProperty("min-height", "0", "important")
+        textarea.style.setProperty("padding", "0 4px", "important")
+        textarea.style.setProperty("border", "none", "important")
+        const resize = () => {
+          textarea.style.setProperty("height", "0", "important")
+          const h = textarea.scrollHeight + "px"
+          textarea.style.setProperty("height", h, "important")
+          wrapper.style.setProperty("height", h, "important")
+          if (preview) preview.style.setProperty("height", h, "important")
+        }
+        requestAnimationFrame(resize)
+        // OverType's auto-resize also fires on input, re-inflating the height.
+        textarea.addEventListener("input", () => requestAnimationFrame(resize))
+        if (this.addingNoteId === taskId) textarea.focus()
+        textarea.addEventListener("blur", () => {
+          const note = editor.getValue().trim()
+          const ct = this.completedTasks.find((t) => t.id === taskId)
+          if (this.addingNoteId === taskId) this.addingNoteId = null
+          if (!ct || (note || null) === (ct.note || null)) return
+
+          ct.note = note || null
+          fetch(`/tasks/${taskId}/note`, {
+            method: "PATCH",
+            headers: { "Content-Type": "application/x-www-form-urlencoded" },
+            body: `note=${encodeURIComponent(note)}`,
+          })
+        })
+      }
     },
 
     completeTask(taskId, seriesId) {
@@ -139,6 +141,7 @@ document.addEventListener("alpine:init", () => {
   new OverType("#series-note-editor", {
     placeholder: "What needs doing...",
     textareaProps: { name: "note", required: true },
+    autoResize: true,
   })
 
   Alpine.data("upcoming", () => ({