fix(goals): catch-up subtracts pending pledges from the demand

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.
This commit is contained in:
Guillem Arias
2026-05-14 21:54:41 +02:00
parent 172c8603b6
commit 71ca400f42
2 changed files with 13 additions and 7 deletions

View File

@@ -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

View File

@@ -139,11 +139,13 @@
</div>
<% 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 %>
<p class="text-secondary">
<%= 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)) %>