Unify history note into a single Alpine component
Two conditional divs and a separate adding scope replaced by
one historyNote component with reactive hasNote/editing state.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index fe79274..0c22622 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -145,35 +145,25 @@ module Views
h3 { "History" }
ul do
@series.completed_tasks.each do |ct|
- li(class: "task-history-item") do
+ li(
+ class: "task-history-item",
+ "x-data": "historyNote(#{ct[:id]}, #{ct[:note] ? "true" : "false"})"
+ ) do
div(class: "task-history-row") do
span(class: "task-history-check") { "✓" }
span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
- if ct[:note].nil?
- span(
- class: "task-history-add-note",
- "x-data": "{ adding: false }",
- "x-show": "!adding",
- "x-on:click": "adding = true; $dispatch('add-note-#{ct[:id]}')",
- "x-on:reset-note-#{ct[:id]}.window": "adding = false"
- ) { "add a note..." }
- end
+ span(
+ class: "task-history-add-note",
+ "x-show": "!hasNote && !editing",
+ "x-on:click": "edit()"
+ ) { "add a note..." }
end
div(
class: "task-history-note-editor",
- "data-task-id": ct[:id].to_s,
"data-value": ct[:note] || "",
- "x-data": "historyNoteEditor",
- "x-init": "activate()"
- ) if ct[:note]
- div(
- class: "task-history-note-editor",
- "data-task-id": ct[:id].to_s,
- "data-value": "",
- "x-data": "historyNoteEditor",
- style: "display: none",
- "x-on:add-note-#{ct[:id]}.window": "show($el); activate()"
- ) if ct[:note].nil?
+ "x-show": "hasNote || editing",
+ "x-ref": "editor"
+ )
end
end
end
diff --git a/public/js/app.js b/public/js/app.js
index eb660f8..bc68567 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -105,20 +105,25 @@ document.addEventListener("alpine:init", () => {
},
}))
- Alpine.data("historyNoteEditor", () => ({
+ Alpine.data("historyNote", (taskId, hasNote) => ({
+ hasNote,
+ editing: false,
_editor: null,
- show(el) {
- el.style.display = ""
+ init() {
+ if (this.hasNote) this._initEditor()
},
- activate() {
- const el = this.$el
- const taskId = el.dataset.taskId
- const initialNote = el.dataset.value || ""
+ edit() {
+ this.editing = true
+ this.$nextTick(() => this._initEditor())
+ },
- if (this._editor) return
+ _initEditor() {
+ const el = this.$refs.editor
+ if (!el || this._editor) return
+ const initialNote = el.dataset.value || ""
const [editor] = new OverType(el, {
value: initialNote,
placeholder: "Add a note...",
@@ -136,28 +141,19 @@ document.addEventListener("alpine:init", () => {
if (!initialNote) textarea.focus()
textarea.addEventListener("blur", () => {
const note = editor.getValue().trim()
-
- if (!note) {
- if (initialNote) {
- fetch(`/tasks/${taskId}/note`, {
- method: "PATCH",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `note=`,
- })
- }
- el.style.display = "none"
- window.dispatchEvent(new Event(`reset-note-${taskId}`))
+ if (note === initialNote.trim()) {
+ if (!note) this.editing = false
return
}
- if (note === (initialNote || "").trim()) return
-
- el.dataset.value = note
fetch(`/tasks/${taskId}/note`, {
method: "PATCH",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `note=${encodeURIComponent(note)}`,
})
+ el.dataset.value = note
+ this.hasNote = !!note
+ this.editing = false
})
}
},