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
change wlowqnsyqoruxyqvrynvxkmtrsryposw
commit 4502955e16351a35cae5bfb5ef9d8bdc6cb7ff19
author Alpha Chen <alpha@kejadlen.dev>
date
parent nmolxxkl
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(