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>
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",