From 71ca400f42b3bd24d49190c2f87f6704553dd284 Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Thu, 14 May 2026 21:54:41 +0200 Subject: [PATCH] fix(goals): catch-up subtracts pending pledges from the demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX audit finding. The catch-up alert demanded $X/mo without accounting for pledges the user had already recorded. The user recorded a $20k pledge → catch-up still demanded a fresh $20k → double-counting → stacked yellow CTAs telling them to do the thing they'd just done. Goal#catch_up_delta_money now subtracts `open_pledges.sum(amount)` from the demand: delta = max(monthly_target − pace − sum(open_pledges), 0) Uses the in-memory preloaded `open_pledges` collection (controllers already eager-load it), so no extra query. The clamp at zero keeps "$0/mo more" from rendering when pending pledges fully cover the gap. Alert branch in show.html.erb now also gates on `@goal.catch_up_delta_money.amount.positive?` — when the demand zeroes out via pending pledges, suppress the alert entirely. Status pill stays `:behind` (because `pace < required`), but the action surface goes quiet because the user already took it. --- app/models/goal.rb | 12 ++++++++---- app/views/goals/show.html.erb | 8 +++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/models/goal.rb b/app/models/goal.rb index daea1e73f..72b470cd9 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -281,13 +281,17 @@ class Goal < ApplicationRecord end end - # Monthly extra needed beyond the current pace to hit the target on time. - # Clamps at zero — never asks the user to "make up" a deficit they're - # already ahead of. + # Monthly extra needed beyond the current pace + currently-open pledges + # to hit the target on time. Pending pledges are approximate (one-off + # amounts treated as this-month inflow) but excluding them produced the + # bad case where the alert demanded $X/mo while the user had already + # pledged $X — telling them to act on top of the action they just took. + # Clamps at zero so a fully-covered goal doesn't surface a $0 demand. def catch_up_delta_money return Money.new(0, currency) if monthly_target_amount.nil? - delta = [ monthly_target_amount.to_d - pace.to_d, 0 ].max + pending = open_pledges.to_a.sum { |p| p.amount.to_d } + delta = [ monthly_target_amount.to_d - pace.to_d - pending, 0 ].max Money.new(delta, currency) end diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb index 0e3e80cc8..602f546d8 100644 --- a/app/views/goals/show.html.erb +++ b/app/views/goals/show.html.erb @@ -139,11 +139,13 @@ <% end %> <% end %> - <% elsif @goal.status == :behind && @goal.monthly_target_amount %> + <% elsif @goal.status == :behind && @goal.monthly_target_amount && @goal.catch_up_delta_money.amount.positive? %> <%# Title uses the *delta* — the amount the user must add to current pace - to make the date. The body carries the absolute numbers. Pre-fill the + to make the date, *after* subtracting open pledges. When pending + pledges already cover the gap, `catch_up_delta_money` returns zero + and this branch suppresses — no "Save $0/mo" demand. Pre-fill the pledge CTA with the delta too, so submitting once funds the gap - instead of double-counting on top of the existing pace. %> + instead of double-counting on top of pace and pending. %> <%= render DS::Alert.new(variant: "warning", title: t("goals.show.catch_up.title", amount: @goal.catch_up_delta_money.format(precision: 0))) do %>

<%= t("goals.show.catch_up.body", avg: @goal.pace_money.format(precision: 0), required: Money.new(@goal.monthly_target_amount, @goal.currency).format(precision: 0)) %>