diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index c307dd2dd..0605bd3f0 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -221,13 +221,18 @@ class GoalsController < ApplicationController no_date = active_goals.count { |g| g.status == :no_target_date } paused = active_goals.count(&:paused?) - # Denominator of the "Goals on track" tile. Goals that hit their - # target are no longer being tracked toward pace, so they don't - # belong in the fraction; paused goals stop the pace clock on - # purpose, so they don't either. When this hits zero the tile - # swaps to a celebration / empty state in the view. + # Denominator of the "Goals on track" tile. A goal only belongs in + # the fraction if there is a benchmark to compare against: + # - reached → target already hit, no longer tracked toward pace + # - paused → user stopped the pace clock on purpose + # - no_target_date → open-ended saving (emergency fund, sabbatical + # fund, etc.) has no required monthly pace, so "on track" is + # undefined. Counting it would penalise the user for having + # open-ended goals — they'd never improve the ratio. + # When this hits zero the tile swaps to a celebration / empty + # state in the view. tracked_total = active_goals.count do |g| - !g.paused? && g.status != :reached + !g.paused? && g.status != :reached && g.status != :no_target_date end { diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index 6b28eec84..e1961daf6 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -176,6 +176,23 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_match(/1\s*reached/i, response.body) end + test "index KPI 'on track' denominator excludes no-target-date goals" do + family = users(:family_admin).family + family.goals.destroy_all + # One trackable goal (has target_date) + one open-ended (no target_date). + # The trackable one should be the only thing in the denominator; + # open-ended goals can't be off pace because they have no required pace. + build_goal(family, "House", target_amount: 1_000_000, target_date: 1.year.from_now) + build_goal(family, "Emergency", target_amount: 1_000_000, target_date: nil) + + get goals_url + assert_response :success + # Expect "0 of 1" — the open-ended goal stays out of the fraction + # even though it's active. + assert_match(/0\s*of\s*1/i, response.body) + assert_match(/without a deadline/i, response.body) + end + private def build_goal(family, name, target_amount: 1_000_000, target_date: nil) g = family.goals.new(name: name, target_amount: target_amount, target_date: target_date, currency: "USD")