Use OverType for markdown note editing
Plain textareas gave no formatting feedback while typing.
OverType overlays rendered markdown on the textarea so bold,
italic, and lists are visible as you write.
Seed data now includes markdown in completion notes and ensures
both overdue and upcoming series get completed task history.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/Rakefile b/Rakefile
index 9f5c1b6..0ab2a5d 100644
--- a/Rakefile
+++ b/Rakefile
@@ -77,13 +77,16 @@ task :seed do
# Add completed task history to some series
completion_notes = [
"Done, no issues",
- "Rescheduled from last week",
- "Took longer than expected",
- "Had to call back twice",
- "All good",
+ "Rescheduled from **last week**",
+ "Took longer than expected — **2 hours** instead of 1",
+ "Had to call back *twice*",
+ "All good\n\n- Changed filter\n- Reset thermostat",
]
- all_series.sample(5).each do |s|
+ 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
note = rand < 0.5 ? completion_notes.sample : nil
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 6f7b922..5523dcc 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -92,12 +92,12 @@ module Views
p(class: "task-history-note", "x-text": "ct.note")
end
template("x-if": "$store.sidebar.editingNoteId === ct.id") do
- div(class: "task-history-edit", "x-on:click.stop": "") do
- textarea(
- rows: 2,
- "x-model": "$store.sidebar.editingNoteText",
- "x-ref": "noteInput"
- )
+ 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" }
@@ -114,7 +114,7 @@ module Views
form(method: "post", action: "/series", "x-show": "$store.sidebar.mode === 'form'") do
div(class: "field") do
label(for: "note") { "Note" }
- textarea(id: "note", name: "note", rows: 2, required: true)
+ div(id: "series-note-editor", class: "overtype-wrap")
end
div(class: "field") do
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index c8342f5..9fc3d20 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -19,6 +19,7 @@ module Views
link(rel: "stylesheet", href: "/css/reset.css")
link(rel: "stylesheet", href: "/css/utopia.css")
link(rel: "stylesheet", href: "/css/app.css")
+ script(src: "https://unpkg.com/overtype/dist/overtype.min.js")
script(src: "/js/app.js", defer: true)
script(src: "https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js", defer: true)
end
diff --git a/public/css/app.css b/public/css/app.css
index cc4c99b..dbaa6cd 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -292,11 +292,8 @@ h2 { font-size: var(--step-0); font-weight: 600; }
margin-top: var(--space-3xs);
}
-.task-history-edit textarea {
- width: 100%;
+.overtype-wrap {
font-size: var(--step--2);
- padding: var(--space-3xs);
- box-sizing: border-box;
}
.task-history-edit-actions {
diff --git a/public/js/app.js b/public/js/app.js
index 6c1066a..609a79d 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -38,6 +38,7 @@ document.addEventListener("alpine:init", () => {
completedTasks: [],
editingNoteId: null,
editingNoteText: "",
+ _noteEditor: null,
init() {
const seriesId = sessionStorage.getItem("showSeries")
@@ -91,6 +92,9 @@ document.addEventListener("alpine:init", () => {
},
saveNote(taskId) {
+ if (this._noteEditor) {
+ this.editingNoteText = this._noteEditor.getValue()
+ }
const note = this.editingNoteText.trim()
fetch(`/tasks/${taskId}/note`, {
method: "PATCH",
@@ -101,12 +105,23 @@ document.addEventListener("alpine:init", () => {
.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) {
+ if (!el) return
+ const [editor] = new OverType(el, {
+ value: this.editingNoteText,
+ placeholder: "Add a note...",
+ })
+ this._noteEditor = editor
+ },
+
cancelNote() {
+ this._noteEditor = null
this.editingNoteId = null
this.editingNoteText = ""
},
@@ -121,6 +136,11 @@ document.addEventListener("alpine:init", () => {
},
})
+ new OverType("#series-note-editor", {
+ placeholder: "What needs doing...",
+ textareaProps: { name: "note", required: true },
+ })
+
Alpine.data("upcoming", () => ({
showEmpty: localStorage.getItem("upcoming-show-empty") !== "false",
diff --git a/test/test_web.rb b/test/test_web.rb
index dc34ffe..bc3fb54 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -22,7 +22,7 @@ class TestWeb < Minitest::Test
get "/", {}, tailscale_headers
assert last_response.ok?
assert_includes last_response.body, 'method="post" action="/series"'
- assert_includes last_response.body, 'name="note"'
+ assert_includes last_response.body, 'id="series-note-editor"'
assert_includes last_response.body, 'name="interval_count"'
assert_includes last_response.body, 'name="interval_unit"'
assert_includes last_response.body, 'name="first_due_date"'