Add backdated completion to series detail page
Completions only happened from the dashboard with today's date.
The new Current task section on the series page lets users pick
the actual completion date before marking a task done.

Assisted-by: Claude Opus 4.6 via Claude Code
change purnnoppqzvxtpooxotlkzzysquwurtv
commit e0f9518b74f8851f58f4c267fa8a3fe3e937653a
author Alpha Chen <alpha@kejadlen.dev>
date
parent qlrooyzx
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index 910e3d2..fcfa46b 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -102,20 +102,20 @@ class Task < Sequel::Model
     days_overdue.to_f / interval
   end
 
-  def complete!(today:)
+  def complete!(completed_on:)
     DB.transaction do
-      update(completed_at: Time.now)
+      update(completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day))
       next_date = case series.interval_unit
                   when "day"
-                    today + series.interval_count
+                    completed_on + series.interval_count
                   when "week"
-                    today + (7 * series.interval_count)
+                    completed_on + (7 * series.interval_count)
                   when "month"
-                    today >> series.interval_count
+                    completed_on >> series.interval_count
                   when "quarter"
-                    today >> (3 * series.interval_count)
+                    completed_on >> (3 * series.interval_count)
                   when "year"
-                    today >> (12 * series.interval_count)
+                    completed_on >> (12 * series.interval_count)
                   else
                     fail
                   end
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index e1d6794..87c7f9c 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -81,28 +81,53 @@ module Views
                     end
                   end
 
-                  if active_task
-                    dt { "Next due date" }
-                    dd(
-                      "x-show": "!editing",
-                      "x-text": "new Date('#{active_task[:due_date]}T00:00').toLocaleDateString()"
-                    ) { active_task[:due_date].to_s }
-                    dd(
-                      "x-show": "editing",
-                      "x-cloak": true,
-                      "x-data": "dueDateEditor(#{@series.id}, '#{active_task[:due_date]}')"
-                    ) do
-                      input(
-                        type: "date",
-                        class: "detail-input detail-input-date",
-                        "x-model": "dueDate",
-                        "x-on:change": "save()"
-                      )
+                end
+
+                if active_task
+                  complete_path = "/series/#{@series.id}/tasks/#{active_task.id}/complete"
+                  form(method: "post", action: complete_path, class: "current-task") do
+                    input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
+                    input(type: "hidden", name: "return_to", value: "/series/#{@series.id}")
+                    div(class: "section-header") do
+                      h2(class: "section-title") do
+                        span(class: "section-title-text") { "Current task" }
+                      end
+                      button(type: "submit", class: "section-edit-btn") { "Complete" }
                     end
+                    dl(class: "detail-fields") do
+                      dt { "Due date" }
+                      dd(
+                        "x-show": "!editing",
+                        "x-text": "new Date('#{active_task[:due_date]}T00:00').toLocaleDateString()"
+                      ) { active_task[:due_date].to_s }
+                      dd(
+                        "x-show": "editing",
+                        "x-cloak": true,
+                        "x-data": "dueDateEditor(#{@series.id}, '#{active_task[:due_date]}')"
+                      ) do
+                        input(
+                          type: "date",
+                          class: "detail-input detail-input-date",
+                          "x-model": "dueDate",
+                          "x-on:change": "save()"
+                        )
+                      end
 
-                    if active_task.urgency > 0
-                      dt(class: "detail-overdue") { "Urgency" }
-                      dd(class: "detail-overdue") { "#{format("%.1f", active_task.urgency)}x" }
+                      if active_task.urgency > 0
+                        dt(class: "detail-overdue") { "Urgency" }
+                        dd(class: "detail-overdue") { "#{format("%.1f", active_task.urgency)}x" }
+                      end
+
+                      dt { "Completed on" }
+                      dd do
+                        input(
+                          type: "date",
+                          name: "completed_date",
+                          class: "detail-input detail-input-date",
+                          "x-data": true,
+                          "x-init": "$el.value = new Date().toISOString().slice(0, 10)"
+                        )
+                      end
                     end
                   end
                 end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 1c7293d..b7b13d3 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -147,7 +147,14 @@ class Web < Roda
           r.on "complete" do
             r.post do
               r.halt 422 unless @task[:completed_at].nil?
-              @task.complete!(today: Date.today)
+
+              completed_date = if (d = r.params["completed_date"]) && !d.empty?
+                Date.parse(d)
+              else
+                Date.today
+              end
+
+              @task.complete!(completed_on: completed_date)
 
               note_title = @series.note.lines.first&.strip || @series.note
               complete_path = "/series/#{series_id}/tasks/#{@task.id}/complete"
diff --git a/public/css/app.css b/public/css/app.css
index 9735dbd..65ad910 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -589,6 +589,14 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   width: 100%;
 }
 
+/* -------------------- */
+/* Current task section */
+/* -------------------- */
+
+.current-task {
+  margin-block-start: var(--space-l);
+}
+
 /* -------------------- */
 /* Task history */
 /* -------------------- */
@@ -735,7 +743,7 @@ textarea {
   gap: var(--space-3xs);
 }
 
-button[type="submit"]:not(.complete-btn) {
+button[type="submit"]:not(.complete-btn):not(.section-edit-btn) {
   padding: var(--space-3xs) var(--space-s);
   border: 1px solid #1a1a1a;
   border-radius: 4px;
@@ -746,7 +754,7 @@ button[type="submit"]:not(.complete-btn) {
   cursor: pointer;
 }
 
-button[type="submit"]:not(.complete-btn):hover {
+button[type="submit"]:not(.complete-btn):not(.section-edit-btn):hover {
   background: #333;
 }
 
diff --git a/test/test_web.rb b/test/test_web.rb
index 02cbf7e..ef63dc9 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -207,7 +207,7 @@ class TestWeb < Minitest::Test
                   first_due_date: "2026-03-01")
 
     task = Task.first
-    task.complete!(today: Date.new(2026, 4, 1))
+    task.complete!(completed_on: Date.new(2026, 4, 1))
 
     new_task = Task.where(completed_at: nil).first
     assert_equal Date.new(2026, 4, 15), new_task.due_date