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
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", () => ({