From adbef877a3dadc95cb8d4676954d1ce0b55179b6 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 18 May 2026 22:04:00 +0200 Subject: [PATCH] fix(goals): drop no-target-date goals from the on-track denominator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The KPI tile reads 'X of Y on track'. Y was every active goal minus reached + paused, which included open-ended goals (no target_date). But an open-ended goal has no required monthly pace to compare against — by definition it can be neither on track nor behind. Counting it in the denominator dragged the ratio down and never improved as the user kept saving (the fraction stays stuck because the open-ended goal is never a hit). Exclude :no_target_date from tracked_total. Numerator unchanged. The subline still surfaces 'N without a deadline' as informational so the user knows those goals exist. --- app/controllers/goals_controller.rb | 17 +++++++++++------ test/controllers/goals_controller_test.rb | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) 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")