diff --git a/app/helpers/goals_helper.rb b/app/helpers/goals_helper.rb index 13eb4d4e3..2c1fb225e 100644 --- a/app/helpers/goals_helper.rb +++ b/app/helpers/goals_helper.rb @@ -35,8 +35,21 @@ module GoalsHelper # they reserve no fixed slice. That is the right reading here — what they # do claim is guarded separately, at write time, by GoalAccount. def earmarked_by_other_goals(account, pooled:, current_goal: nil) - (pooled[account.id] || []) - .reject { |row| current_goal&.persisted? && row[:goal_id] == current_goal.id } - .sum { |row| row[:allocated_amount].to_d } + other_goal_rows(account, pooled, current_goal).sum { |row| row[:allocated_amount].to_d } end + + # A whole-account link carries a nil allocation, so it contributes zero to + # the sum above — "nothing else claims this account" and "another goal + # claims all of it" look identical from that figure alone. They are not: + # the second refuses a blank allocation at the door + # (`GoalAccount#whole_account_link_must_be_exclusive`). + def whole_account_claimed_by_other_goals?(account, pooled:, current_goal: nil) + other_goal_rows(account, pooled, current_goal).any? { |row| row[:allocated_amount].nil? } + end + + private + def other_goal_rows(account, pooled, current_goal) + (pooled[account.id] || []) + .reject { |row| current_goal&.persisted? && row[:goal_id] == current_goal.id } + end end diff --git a/app/javascript/controllers/goal_earmark_controller.js b/app/javascript/controllers/goal_earmark_controller.js index 6e2aa855a..2295a27a8 100644 --- a/app/javascript/controllers/goal_earmark_controller.js +++ b/app/javascript/controllers/goal_earmark_controller.js @@ -19,6 +19,7 @@ export default class extends Controller { wholeBalance: String, prorata: String, headroom: String, + wholeBalanceAlone: String, } // A complete number, optionally with one decimal separator and digits after @@ -49,10 +50,19 @@ export default class extends Controller { const balance = Number.parseFloat(row.dataset.balance || "0") const others = Number.parseFloat(row.dataset.earmarkedByOthers || "0") + // A whole-account link elsewhere sums to zero above, so the amount alone + // cannot tell "nobody else claims this" from "somebody claims all of it". + const claimedWhole = row.dataset.wholeAccountClaimed === "true" const raw = input.value.trim() + // "whatever is left after the other earmarks" describes nothing when there + // are none — and this is where a first-time user meets the word, pointed + // at something absent. With the account to itself, say that instead. if (raw === "") { - return this.#show(warning, this.wholeBalanceValue) + return this.#show( + warning, + others > 0 || claimedWhole ? this.wholeBalanceValue : this.wholeBalanceAloneValue, + ) } if (!this.constructor.ALLOCATION_PATTERN.test(raw)) return this.#hide(warning) diff --git a/app/views/goals/_form.html.erb b/app/views/goals/_form.html.erb index 1cdd3949f..f6de2a666 100644 --- a/app/views/goals/_form.html.erb +++ b/app/views/goals/_form.html.erb @@ -125,6 +125,7 @@ the server decides the locale and the client applies it. %> data-goal-earmark-locale-value="<%= I18n.locale %>" data-goal-earmark-whole-balance-value="<%= t("goals.form.earmark.whole_balance") %>" + data-goal-earmark-whole-balance-alone-value="<%= t("goals.form.earmark.whole_balance_alone") %>" data-goal-earmark-prorata-value="<%= t("goals.form.earmark.prorata") %>" data-goal-earmark-headroom-value="<%= t("goals.form.earmark.headroom") %>"> <% linked_allocation_by_account = goal.goal_accounts.index_by(&:account_id) %> @@ -136,7 +137,8 @@ <% linked_ga = linked_allocation_by_account[account.id] %>
0 %>" data-balance="<%= account.balance.to_d %>" - data-earmarked-by-others="<%= earmarked_by_other_goals(account, pooled: pooled_allocations, current_goal: goal) %>"> + data-earmarked-by-others="<%= earmarked_by_other_goals(account, pooled: pooled_allocations, current_goal: goal) %>" + data-whole-account-claimed="<%= whole_account_claimed_by_other_goals?(account, pooled: pooled_allocations, current_goal: goal) %>">