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