Add completion stats (streak, on-time rate) to series panel
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/models.rb b/lib/ketchup/models.rb
index f731e69..bc66adb 100644
--- a/lib/ketchup/models.rb
+++ b/lib/ketchup/models.rb
@@ -48,6 +48,20 @@ class Series < Sequel::Model
.all
end
+ def completion_stats
+ completed = completed_tasks
+ return { streak: 0, on_time_pct: 100, total: 0 } if completed.empty?
+
+ streak = 0
+ on_time = 0
+ completed.each_with_index do |t, i|
+ on_time += 1 if t[:completed_at].to_date <= t[:due_date]
+ streak += 1 if i == streak && t[:completed_at].to_date <= t[:due_date]
+ end
+
+ { streak: streak, on_time_pct: (on_time * 100.0 / completed.size).round, total: completed.size }
+ end
+
def self.create_with_first_task(user:, note:, interval_unit:, interval_count:, first_due_date:)
DB.transaction do
series = create(
diff --git a/lib/ketchup/views/series_detail.rb b/lib/ketchup/views/series_detail.rb
index cbc8bd2..648d1c2 100644
--- a/lib/ketchup/views/series_detail.rb
+++ b/lib/ketchup/views/series_detail.rb
@@ -90,6 +90,16 @@ module Views
end
unless @series.completed_tasks.empty?
+ stats = @series.completion_stats
+ div(class: "panel-stats") do
+ dl(class: "detail-fields") do
+ dt { "Streak" }
+ dd { stats[:streak].to_s }
+ dt { "On-time" }
+ dd { "#{stats[:on_time_pct]}%" }
+ end
+ end
+
div(class: "task-history") do
h3 { "History" }
ul do
diff --git a/public/css/app.css b/public/css/app.css
index 476118a..df0d2a0 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -550,6 +550,12 @@ h2 { font-size: var(--step-0); font-weight: 600; }
width: 100%;
}
+.panel-stats {
+ margin-block-start: var(--space-m);
+ padding-block-start: var(--space-s);
+ border-block-start: 1px solid #f0f0f0;
+}
+
/* -------------------- */
/* Task history */
/* -------------------- */
diff --git a/test/test_web.rb b/test/test_web.rb
index 1b73b3f..0429233 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -637,6 +637,18 @@ class TestWeb < Minitest::Test
assert_includes last_response.body, "Haircut"
end
+ def test_series_panel_shows_stats
+ create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
+ first_due_date: (Date.today - 7).to_s)
+
+ series = DB[:series].first
+ task = DB[:tasks].first
+ csrf_post "/series/#{series[:id]}/tasks/#{task[:id]}/complete", {}, auth_headers
+
+ get "/series/#{series[:id]}/panel", {}, auth_headers
+ assert_includes last_response.body, "panel-stats"
+ end
+
private
def csrf_post(path, params = {}, headers = auth_headers)