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] %>