Add task completion with circular check button
Complete a task via POST /tasks/:id/complete, which marks it done and
creates the next occurrence based on today's date. The button renders
as a small circle that reveals a checkmark on hover, using
display:contents to escape the global form grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
change mpptpznuqzqwnqrqlmpkpusxxktkpzzk
commit 02addae565b65e8af48d55d5188d7d9792fba0bd
author Alpha Chen <alpha@kejadlen.dev>
date
parent kypxqknq
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index daa3473..c65b512 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -41,6 +41,29 @@ end
 class Task < Sequel::Model
   many_to_one :series
 
+  def complete!
+    DB.transaction do
+      update(completed_at: Time.now)
+
+      today = Date.today
+      next_date = case series.interval_unit
+                  when "day"
+                    today + series.interval_count
+                  when "week"
+                    today + (7 * series.interval_count)
+                  when "month"
+                    today >> series.interval_count
+                  when "quarter"
+                    today >> (3 * series.interval_count)
+                  when "year"
+                    today >> (12 * series.interval_count)
+                  else
+                    fail
+                  end
+      Task.create(series_id: series.id, due_date: next_date)
+    end
+  end
+
   dataset_module do
     def active
       where(completed_at: nil)
diff --git a/lib/ketchup/views/home.rb b/lib/ketchup/views/home.rb
index 43729a7..69de8b3 100644
--- a/lib/ketchup/views/home.rb
+++ b/lib/ketchup/views/home.rb
@@ -80,14 +80,22 @@ module Views
       overdue = task[:due_date] < Date.today
 
       div(class: ["task-card", ("task-overdue" if overdue)]) do
-        span(class: "task-name") { name }
-        span(class: "task-due") do
-          plain overdue ? "Overdue — due #{task[:due_date]}" : "Due #{task[:due_date]}"
+        div(class: "task-body") do
+          span(class: "task-name") { name }
+          div(class: "task-meta") do
+            span(class: "task-due") do
+              plain overdue ? "Overdue — due #{task[:due_date]}" : "Due #{task[:due_date]}"
+            end
+            span(class: "task-meta-sep") { "\u00B7" }
+            span(class: "task-interval") do
+              count = task[:interval_count]
+              unit = task[:interval_unit]
+              plain "Every #{count} #{count == 1 ? unit : "#{unit}s"}"
+            end
+          end
         end
-        span(class: "task-interval") do
-          count = task[:interval_count]
-          unit = task[:interval_unit]
-          plain "Every #{count} #{count == 1 ? unit : "#{unit}s"}"
+        form(method: "post", action: "/tasks/#{task[:id]}/complete", class: "complete-form") do
+          button(type: "submit", title: "Complete", **{ "aria-label": "Complete #{name}" }) { "✓" }
         end
       end
     end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index d93a64c..0f350a0 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -25,6 +25,15 @@ class Web < Roda
       Views::Home.new(current_user:, tasks:).call
     end
 
+    r.on "tasks", Integer do |task_id|
+      r.post "complete" do
+        task = Task.active.for_user(current_user).where(Sequel[:tasks][:id] => task_id).first
+        r.halt 404 unless task
+        task.complete!
+        r.redirect "/"
+      end
+    end
+
     r.on "series" do
       r.post do
         note = r.params["note"].to_s.strip
diff --git a/public/css/app.css b/public/css/app.css
index b6b9963..c5e2fa3 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -85,13 +85,22 @@ h1 { font-size: var(--step-2); }
 
 .task-card {
   display: grid;
-  gap: var(--space-3xs);
+  grid-template-columns: 1fr auto;
+  align-items: center;
+  gap: var(--space-s);
   padding: var(--space-s);
   border: 1px solid #e0e0e0;
   border-radius: 6px;
   background: #fff;
 }
 
+.task-body {
+  display: flex;
+  flex-direction: column;
+  gap: var(--space-3xs);
+  min-width: 0;
+}
+
 .task-card.task-overdue {
   border-color: #c0392b;
   background: #fdf2f2;
@@ -101,8 +110,14 @@ h1 { font-size: var(--step-2); }
   font-weight: 600;
 }
 
-.task-due {
+.task-meta {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-3xs);
   font-size: var(--step--1);
+}
+
+.task-due {
   color: #666;
 }
 
@@ -111,8 +126,11 @@ h1 { font-size: var(--step-2); }
   font-weight: 600;
 }
 
+.task-meta-sep {
+  color: #ccc;
+}
+
 .task-interval {
-  font-size: var(--step--1);
   color: #888;
 }
 
@@ -224,3 +242,37 @@ button[type="submit"] {
 button[type="submit"]:hover {
   background: #333;
 }
+
+.complete-form {
+  display: contents;
+}
+
+.complete-form button {
+  width: 2rem;
+  height: 2rem;
+  padding: 0;
+  border: 1.5px solid #ccc;
+  border-radius: 50%;
+  background: transparent;
+  color: transparent;
+  font-size: 0.8rem;
+  line-height: 1;
+  cursor: pointer;
+  transition: border-color 0.15s, color 0.15s, background 0.15s;
+}
+
+.complete-form button:hover {
+  border-color: #1a1a1a;
+  color: #1a1a1a;
+  background: #f0f0f0;
+}
+
+.task-overdue .complete-form button {
+  border-color: #e0a9a5;
+}
+
+.task-overdue .complete-form button:hover {
+  border-color: #c0392b;
+  color: #c0392b;
+  background: #fce8e8;
+}
diff --git a/test/test_web.rb b/test/test_web.rb
index 7a11981..52a62d1 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -157,6 +157,58 @@ class TestWeb < Minitest::Test
     assert_equal 0, DB[:tasks].count
   end
 
+  def test_complete_task
+    post "/series", {
+      note: "Call Mom", interval_unit: "week", interval_count: "2",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    task = DB[:tasks].first
+    post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+    assert last_response.redirect?
+
+    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
+
+  def test_complete_task_advances_by_months
+    post "/series", {
+      note: "Dentist", interval_unit: "month", interval_count: "3",
+      first_due_date: "2026-01-31"
+    }, tailscale_headers
+
+    task = DB[:tasks].first
+    post "/tasks/#{task[:id]}/complete", {}, tailscale_headers
+
+    new_task = DB[:tasks].where(completed_at: nil).first
+    assert_equal Date.today >> 3, new_task[:due_date]
+  end
+
+  def test_complete_task_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: "bob@example.com", name: "Bob")
+    assert_equal 404, last_response.status
+  end
+
+  def test_complete_task_requires_tailscale_user
+    post "/series", {
+      note: "Call Mom", interval_unit: "day", interval_count: "1",
+      first_due_date: "2026-03-01"
+    }, tailscale_headers
+
+    task = DB[:tasks].first
+    post "/tasks/#{task[:id]}/complete"
+    assert_equal 403, last_response.status
+  end
+
   def test_create_series_requires_tailscale_user
     post "/series", {
       note: "Nope", interval_unit: "day", interval_count: "1",