Recalculate active due date when backdating the latest completion
Backdating a completion didn't update the next task's due date,
leaving it anchored to the original completion date. Extracted
Series#next_due_date from Task#complete! to reuse in both paths.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index fcfa46b..a627cad 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -62,6 +62,23 @@ class Series < Sequel::Model
{ streak: streak, on_time_pct: (on_time * 100.0 / completed.size).round, total: completed.size }
end
+ def next_due_date(completed_on)
+ case interval_unit
+ when "day"
+ completed_on + interval_count
+ when "week"
+ completed_on + (7 * interval_count)
+ when "month"
+ completed_on >> interval_count
+ when "quarter"
+ completed_on >> (3 * interval_count)
+ when "year"
+ completed_on >> (12 * interval_count)
+ else
+ fail
+ end
+ end
+
def self.create_with_first_task(user:, note:, interval_unit:, interval_count:, first_due_date:)
DB.transaction do
series = create(
@@ -105,21 +122,7 @@ class Task < Sequel::Model
def complete!(completed_on:)
DB.transaction do
update(completed_at: Time.new(completed_on.year, completed_on.month, completed_on.day))
- next_date = case series.interval_unit
- when "day"
- completed_on + series.interval_count
- when "week"
- completed_on + (7 * series.interval_count)
- when "month"
- completed_on >> series.interval_count
- when "quarter"
- completed_on >> (3 * series.interval_count)
- when "year"
- completed_on >> (12 * series.interval_count)
- else
- fail
- end
- Task.create(series_id: series.id, due_date: next_date)
+ Task.create(series_id: series.id, due_date: series.next_due_date(completed_on))
end
end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 182f396..252ac4a 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -194,7 +194,19 @@ class Web < Roda
result["completed_at"] = completed_date.to_s
end
- Task.where(id: task_id).update(updates) unless updates.empty?
+ unless updates.empty?
+ DB.transaction do
+ Task.where(id: task_id).update(updates)
+
+ if completed_date
+ latest = @series.completed_tasks.first
+ if latest && latest[:id] == @task[:id]
+ active = @series.active_task
+ active.update(due_date: @series.next_due_date(completed_date)) if active
+ end
+ end
+ end
+ end
response["content-type"] = "application/json"
result.to_json
diff --git a/test/test_web.rb b/test/test_web.rb
index a575fbb..ea3bedc 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -332,6 +332,25 @@ class TestWeb < Minitest::Test
assert_equal Date.new(2026, 1, 15), DB[:tasks].first(id: completed_task[:id])[:completed_at].to_date
end
+ def test_patch_latest_completed_task_updates_active_due_date
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
+ first_due_date: "2026-03-01")
+
+ series = DB[:series].first
+ task = DB[:tasks].first
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+ active_task = DB[:tasks].where(completed_at: nil).first
+ original_due = active_task[:due_date]
+
+ # Backdate completion by one week — active due date should shift accordingly
+ patch_task series[:id], task[:id], { completed_at: "2026-02-21" }
+ assert last_response.ok?
+
+ updated_active = DB[:tasks].first(id: active_task[:id])
+ assert_equal Date.new(2026, 3, 7), updated_active[:due_date]
+ end
+
def test_patch_task_saves_both_note_and_completed_at
create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
first_due_date: (Date.today - 3).to_s)