Add notes to completed tasks
Completed history entries lacked context about what happened.
Notes let you annotate each completion from the sidebar.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/Rakefile b/Rakefile
index 0f6851e..9f5c1b6 100644
--- a/Rakefile
+++ b/Rakefile
@@ -75,10 +75,22 @@ task :seed do
end
# 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",
+ ]
+
all_series.sample(5).each do |s|
rand(1..4).times do |i|
oldest_task = s.active_task
- oldest_task.update(completed_at: oldest_task.due_date.to_time + rand(0..3) * 86400)
+ note = rand < 0.5 ? completion_notes.sample : nil
+ oldest_task.update(
+ completed_at: oldest_task.due_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
diff --git a/db/migrate/004_add_note_to_tasks.rb b/db/migrate/004_add_note_to_tasks.rb
new file mode 100644
index 0000000..b406de8
--- /dev/null
+++ b/db/migrate/004_add_note_to_tasks.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+ change do
+ alter_table(:tasks) do
+ add_column :note, String
+ end
+ end
+end
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 554822e..6f7b922 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -78,9 +78,32 @@ module Views
h3 { "History" }
ul do
template("x-for": "ct in $store.sidebar.completedTasks") do
- li do
- span(class: "task-history-check") { "✓" }
- span(class: "task-history-date", "x-text": "ct.completed_at")
+ li(class: "task-history-item", "x-on:click": "$store.sidebar.editNote(ct.id, ct.note || '')") 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
+ 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
+ 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-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
+ end
end
end
end
@@ -218,13 +241,12 @@ module Views
"data-task-urgency": task.urgency > 0 ? "#{format("%.1f", task.urgency)}x" : "",
"data-task-overdue": overdue.to_s
) do
- form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
- button(
- type: "submit", title: "Complete",
- "x-on:click.stop": "",
- **{ "aria-label": "Complete #{name}" }
- ) { "✓" }
- end
+ button(
+ type: "button", title: "Complete",
+ class: "complete-btn",
+ "x-on:click.stop": "$store.sidebar.completeTask(#{task[:id]}, #{task[:series_id]})",
+ **{ "aria-label": "Complete #{name}" }
+ ) { "✓" }
span(class: "task-name") { name }
if sortable && overdue
span(class: "task-secondary task-urgency", "x-show": "sort === 'urgency'") { "#{format("%.1f", task.urgency)}x" } if task.urgency > 0
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 04e19d4..8f32aee 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -9,6 +9,7 @@ require_relative "views/home"
class Web < Roda
plugin :halt
plugin :static, %w[ /css /js ]
+ plugin :all_verbs
def current_user
login = env["HTTP_TAILSCALE_USER_LOGIN"]
@@ -36,7 +37,26 @@ class Web < Roda
task = Task.active.for_user(current_user).where(Sequel[:tasks][:id] => task_id).first
r.halt 404 unless task
task.complete!
- r.redirect "/"
+
+ new_task = task.series.active_task
+ response["content-type"] = "application/json"
+ { series_id: task[:series_id], task: { id: new_task.id, due_date: new_task.due_date.to_s } }.to_json
+ end
+
+ r.patch "note" do
+ task = Task.join(:series, id: :series_id)
+ .where(Sequel[:series][:user_id] => current_user.id)
+ .where(Sequel[:tasks][:id] => task_id)
+ .select_all(:tasks)
+ .first
+ r.halt 404 unless task
+ r.halt 422 if task[:completed_at].nil?
+
+ note = r.params["note"].to_s.strip
+ Task.where(id: task_id).update(note: note.empty? ? nil : note)
+
+ response["content-type"] = "application/json"
+ { note: note }.to_json
end
end
@@ -48,9 +68,9 @@ class Web < Roda
completed = series.tasks_dataset
.exclude(completed_at: nil)
.order(Sequel.desc(:completed_at))
- .select(:due_date, :completed_at)
+ .select(:id, :due_date, :completed_at, :note)
.all
- .map { |t| { due_date: t[:due_date].to_s, completed_at: t[:completed_at].strftime("%Y-%m-%d") } }
+ .map { |t| { id: t[:id], due_date: t[:due_date].to_s, completed_at: t[:completed_at].strftime("%Y-%m-%d"), note: t[:note] } }
response["content-type"] = "application/json"
completed.to_json
diff --git a/public/css/app.css b/public/css/app.css
index 2fe75d3..cc4c99b 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -246,19 +246,84 @@ h2 { font-size: var(--step-0); font-weight: 600; }
padding: 0;
}
-.task-history li {
+.task-history-item {
+ padding-block: var(--space-3xs);
+ cursor: pointer;
+}
+
+.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-row {
display: flex;
align-items: center;
gap: var(--space-3xs);
font-size: var(--step--2);
color: #444;
- padding-block: 2px;
}
.task-history-check {
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 {
+ font-size: var(--step--2);
+ color: #ccc;
+ margin-left: auto;
+ display: none;
+}
+
+.task-history-item:hover .task-history-placeholder {
+ display: inline;
+}
+
+.task-history-edit {
+ margin-top: var(--space-3xs);
+}
+
+.task-history-edit textarea {
+ width: 100%;
+ font-size: var(--step--2);
+ padding: var(--space-3xs);
+ box-sizing: border-box;
+}
+
+.task-history-edit-actions {
+ display: flex;
+ gap: var(--space-3xs);
+ margin-top: var(--space-3xs);
+}
+
+.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);
border-block-end: 1px solid #ccc;
@@ -383,11 +448,7 @@ button[type="submit"]:hover {
background: #333;
}
-.complete-form {
- display: contents;
-}
-
-.complete-form button {
+.complete-btn {
width: 14px;
height: 14px;
padding: 0;
@@ -401,17 +462,17 @@ button[type="submit"]:hover {
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
-.complete-form button:hover {
+.complete-btn:hover {
border-color: #1a1a1a;
color: #1a1a1a;
background: #f0f0f0;
}
-.task-overdue .complete-form button {
+.task-overdue .complete-btn {
border-color: #e0a9a5;
}
-.task-overdue .complete-form button:hover {
+.task-overdue .complete-btn:hover {
border-color: #c0392b;
color: #c0392b;
background: #fce8e8;
diff --git a/public/js/app.js b/public/js/app.js
index 24d35c5..6c1066a 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -36,6 +36,17 @@ document.addEventListener("alpine:init", () => {
taskUrgency: "",
taskOverdue: false,
completedTasks: [],
+ editingNoteId: null,
+ editingNoteText: "",
+
+ init() {
+ const seriesId = sessionStorage.getItem("showSeries")
+ if (seriesId) {
+ sessionStorage.removeItem("showSeries")
+ const el = document.querySelector(`[data-series-id="${seriesId}"]`)
+ if (el) this.showTask(el)
+ }
+ },
showTask(el) {
this.taskId = el.dataset.taskId
@@ -46,6 +57,8 @@ document.addEventListener("alpine:init", () => {
this.taskUrgency = el.dataset.taskUrgency
this.taskOverdue = el.dataset.taskOverdue === "true"
this.completedTasks = []
+ this.editingNoteId = null
+ this.editingNoteText = ""
this.mode = "task"
fetch(`/series/${el.dataset.seriesId}/completed`)
@@ -66,6 +79,46 @@ document.addEventListener("alpine:init", () => {
this.showForm()
}
},
+
+ editNote(taskId, currentNote) {
+ if (this.editingNoteId === taskId) {
+ this.editingNoteId = null
+ this.editingNoteText = ""
+ return
+ }
+ this.editingNoteId = taskId
+ this.editingNoteText = currentNote
+ },
+
+ saveNote(taskId) {
+ 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.editingNoteId = null
+ this.editingNoteText = ""
+ })
+ },
+
+ cancelNote() {
+ this.editingNoteId = null
+ this.editingNoteText = ""
+ },
+
+ completeTask(taskId, seriesId) {
+ fetch(`/tasks/${taskId}/complete`, { method: "POST" })
+ .then((r) => {
+ if (!r.ok) throw new Error()
+ sessionStorage.setItem("showSeries", seriesId)
+ window.location.reload()
+ })
+ },
})
Alpine.data("upcoming", () => ({
diff --git a/test/test_web.rb b/test/test_web.rb
index 111d41d..dc34ffe 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -166,12 +166,15 @@ class TestWeb < Minitest::Test
task = DB[:tasks].first
post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
- assert last_response.redirect?
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ new_task = DB[:tasks].where(completed_at: nil).first
+ assert_equal new_task[:id], body["task"]["id"]
+ assert_equal (Date.today + 14).to_s, body["task"]["due_date"]
old_task = DB[:tasks].first(id: task[:id])
refute_nil old_task[:completed_at]
-
- new_task = DB[:tasks].where(completed_at: nil).first
assert_equal Date.today + 14, new_task[:due_date]
end
@@ -236,6 +239,71 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, "+ New"
end
+ def test_patch_note_saves_on_completed_task
+ post "/series", {
+ note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: "2026-03-01"
+ }, tailscale_headers
+
+ task = DB[:tasks].first
+ post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch "/tasks/#{completed_task[:id]}/note", { note: "Called, all good" }, tailscale_headers
+ assert last_response.ok?
+
+ body = JSON.parse(last_response.body)
+ assert_equal "Called, all good", body["note"]
+ assert_equal "Called, all good", DB[:tasks].first(id: completed_task[:id])[:note]
+ end
+
+ def test_patch_note_rejects_active_task
+ post "/series", {
+ note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: "2026-03-01"
+ }, tailscale_headers
+
+ task = DB[:tasks].first
+ patch "/tasks/#{task[:id]}/note", { note: "nope" }, tailscale_headers
+ assert_equal 422, last_response.status
+ end
+
+ def test_patch_note_requires_own_task
+ 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")
+
+ task = DB[:tasks].first
+ post "/tasks/#{task[:id]}/complete", {}, tailscale_headers(login: "alice@example.com", name: "Alice")
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch "/tasks/#{completed_task[:id]}/note", { note: "hacked" }, tailscale_headers(login: "bob@example.com", name: "Bob")
+ assert_equal 404, last_response.status
+ end
+
+ def test_completed_includes_notes
+ post "/series", {
+ note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: "2026-03-01"
+ }, tailscale_headers
+
+ task = DB[:tasks].first
+ post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+
+ completed_task = DB[:tasks].first(id: task[:id])
+ patch "/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, tailscale_headers
+
+ series = DB[:series].first
+ get "/series/#{series[:id]}/completed", {}, tailscale_headers
+ assert last_response.ok?
+
+ data = JSON.parse(last_response.body)
+ assert_equal 1, data.length
+ assert_equal "Left a message", data[0]["note"]
+ assert data[0].key?("id")
+ end
+
private
def tailscale_headers(