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
change yxsmwzwuntlnmpyrnwsmxtnpzpztoypv
commit 33987608950ea90d9bbc69387b6516eb1695fd87
author Alpha Chen <alpha@kejadlen.dev>
date
parent vmnzyyuu
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)