fix(goals): make the figure beside the ring agree with the ring (#3213)

* fix(goals): make the figure beside the ring agree with the ring

Recording a spend left the goal page contradicting itself. The ring is
drawn from `progress_percent`, which counts money still held plus money
already spent on the goal; the figure beside it showed only the first half.
A goal that had saved 5,000 and spent 2,000 of it rendered a 100% ring next
to "3,000 of 5,000" — two answers on one card, with nothing to say which to
believe.

The model was never wrong: `progress_percent` and `remaining_amount` have
both counted the two halves since the spend feature landed. Only the display
took one of them. `progress_amount` names what progress actually counts, and
both surfaces now read from it.

The amount already used is reported as part of that total rather than
beside it — "Including 2,000 already used" — so a reader has nothing to add
up and no reason to read a completed goal as a shortfall. "Used" rather
than "spent", matching the menu entry the user came through: it is the same
gesture, and spending on the thing you saved for is the goal working, not
failing.

Shown only where there is something to show. The overwhelming majority of
goals never record a spend, and a permanent "0 used" line would be noise on
every card. A reserve refuses consumption outright, so this never appears
on one.

What is still sitting in each account keeps its place in the funding
breakdown, which is where that question belongs — the view test asserts the
headline specifically rather than the whole page, for exactly that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* fix(goals): make the ring announce what it shows

Review on #3213. The headline moved to the progress total; the ring's
`aria-label` still read `current_balance_money`. A screen reader announced
"$3,000 of $5,000 saved" while the line beside it said "$5,000, including
$2,000 already used" — the same ring, two different numbers depending on
whether you could see it.

The wording moves with the figure. "Saved" stops being the whole truth once
part of the total has been spent on the goal, so a goal that has recorded
one gets the sentence that says so, and every other goal keeps the wording
it had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

* style(goals): use the component's t() for the ring's labels

Review on #3213. `I18n.t` works, but the component helper is what the rest
of the codebase reaches for and it carries the view's locale context.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
buzzromain
2026-08-28 02:06:13 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 8c10c1e410
commit e7619cac89
9 changed files with 195 additions and 4 deletions
+74
View File
@@ -680,6 +680,18 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
end
private
def spent_goal_for_display
account = Account.create!(
family: @user.family, accountable: Depository.new,
name: "Spent Pot", currency: "USD", balance: 5_000
)
goal = @user.family.goals.create!(name: "Trip", target_amount: 5_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 5_000)
end
goal.consume!(2_000)
goal
end
# SQL the pooled-allocation read issues, and nothing else: goal_accounts
# joined to goals.
def count_pool_queries
@@ -694,6 +706,68 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
ActiveSupport::Notifications.unsubscribe(sub)
end
# The surface the user actually reads: the figure beside the ring, and the
# line saying part of it has been used. Asserted on the rendered page rather
# than the model, because the contradiction was a display bug — the model
# has always counted both halves.
test "the goal page reports the used portion as part of the total" do
goal = spent_goal_for_display
get goal_url(goal)
assert_response :success
assert_includes response.body, I18n.t("goals.show.ring.including_used",
amount: goal.consumed_amount_money.format(precision: 0))
# The headline figure specifically, not "somewhere on the page": the
# account balance still appears in the funding breakdown below, which is
# where that question belongs.
headline = css_select("p.text-xl.font-medium.text-primary").map(&:text).map(&:strip)
assert_includes headline, goal.progress_amount_money.format(precision: 0)
assert_not_includes headline, goal.current_balance_money.format(precision: 0)
end
test "a goal that has spent nothing says nothing about it" do
account = unclaimed_account("Quiet Pot")
goal = @user.family.goals.create!(name: "Quiet", target_amount: 1_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 1_000)
end
get goal_url(goal)
assert_response :success
assert_no_match(/already used|déjà utilisés/, response.body)
end
# The ring announced the account balance while the visible headline beside it
# reported the progress total: same ring, two different numbers depending on
# whether you could see it.
test "the ring announces the same total the headline shows" do
goal = spent_goal_for_display
get goal_url(goal)
assert_response :success
label = css_select("[role=progressbar]").first["aria-label"]
assert_includes label, goal.progress_amount_money.format
assert_not_includes label, goal.current_balance_money.format
assert_includes label, goal.consumed_amount_money.format
end
test "a goal with nothing used keeps the plain wording" do
account = unclaimed_account("Plain Pot")
goal = @user.family.goals.create!(name: "Plain", target_amount: 1_000, currency: "USD") do |g|
g.goal_accounts.build(account: account, allocated_amount: 1_000)
end
get goal_url(goal)
label = css_select("[role=progressbar]").first["aria-label"]
assert_no_match(/already used|déjà utilisés/, label)
end
private
# A private account of another member, linked to the goal under test.