Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/controllers/goals_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def new
)
@linkable_accounts = linkable_accounts_for_new
@currently_linked_account_ids = []
@pooled_allocations = Goal.pooled_allocations_for(Current.family)
@breadcrumbs = plan_breadcrumb_prefix + [
[ t("goals.index.title"), goals_path ],
[ t("goals.new.heading"), nil ]
Expand Down Expand Up @@ -79,11 +80,13 @@ def create
# the same built records — so the user was left staring at an error telling
# them to enter an amount, on a form whose accounts had silently cleared.
@currently_linked_account_ids = @goal.goal_accounts.map { |ga| ga.account_id.to_s }
@pooled_allocations = Goal.pooled_allocations_for(Current.family)
render :new, status: :unprocessable_entity
end

def edit
@linkable_accounts = linkable_accounts_for_new
@pooled_allocations = Goal.pooled_allocations_for(Current.family)
@currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s)
end

Expand All @@ -95,6 +98,7 @@ def update
if accounts_supplied && accounts.empty?
@goal.errors.add(:base, :at_least_one_linked_account_required)
@linkable_accounts = linkable_accounts_for_new
@pooled_allocations = Goal.pooled_allocations_for(Current.family)
@currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s)
render :edit, status: :unprocessable_entity
return
Expand Down Expand Up @@ -125,6 +129,7 @@ def update
end
rescue ActiveRecord::RecordInvalid
@linkable_accounts = linkable_accounts_for_new
@pooled_allocations = Goal.pooled_allocations_for(Current.family)
@currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s)
render :edit, status: :unprocessable_entity
end
Expand Down
20 changes: 20 additions & 0 deletions app/helpers/goals_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,24 @@ def goal_complete_confirm(goal)
btn_text: t("goals.show.confirm_complete_cta")
)
end

# Fixed earmarks on `account` held by goals OTHER than the one being
# edited, read from a pooled map loaded once per render
# (Goal.pooled_allocations_for) rather than per account — the form lists
# every fundable account, so a per-account query is a guaranteed N+1.
#
# Excluding the current goal is what makes the warning trustworthy:
# `Account#goal_earmarked_total` counts every goal, so reopening a goal
# that earmarks 5,000 on a 6,000 account would leave 1,000 of apparent
# headroom, and re-entering the same 5,000 would trip a message although
# nothing changed.
#
# Whole-balance links carry a nil allocated_amount and contribute zero:
# 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 }
end
end
97 changes: 97 additions & 0 deletions app/javascript/controllers/goal_earmark_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Controller } from "@hotwired/stimulus"

// Tells the user what each funding account still has room for, as they type.
//
// Nothing here is an error, and the wording matters more than the maths: a
// goal in progress legitimately claims more than its account holds. An
// account of 6,000 backing two goals of 5,000 is a correct setup, not an
// over-allocation, so a warning phrased as one would fire permanently. The
// pro-rata message states the consequence instead of scolding.
//
// Deliberately separate from goal-form, which is already at 10 targets
// against the 7 the project guidelines suggest. Three here, and no shared
// state between them.
export default class extends Controller {
static targets = ["allocationInput", "warning", "checkbox"]
static values = {
currency: String,
locale: String,
wholeBalance: String,
prorata: String,
headroom: String,
}

// A complete number, optionally with one decimal separator and digits after
// it. `Number.parseFloat` alone accepts prefixes — "500abc" becomes 500 —
// and a bare comma-to-dot swap turns the thousands-separated "1,500" into
// 1.5, so a typo or a habit from another locale silently changed the amount
// the preview was based on.
static ALLOCATION_PATTERN = /^\d+(?:[.,]\d+)?$/

connect() {
this.refresh()
}

refresh() {
this.allocationInputTargets.forEach((input) => this.#refreshRow(input))
}

#refreshRow(input) {
const row = input.closest("[data-balance]")
if (!row) return

const warning = row.querySelector('[data-goal-earmark-target="warning"]')
const checkbox = row.querySelector('input[type="checkbox"]')
if (!warning) return

// An unchecked account funds nothing, so it has nothing to say.
if (checkbox && !checkbox.checked) return this.#hide(warning)

const balance = Number.parseFloat(row.dataset.balance || "0")
const others = Number.parseFloat(row.dataset.earmarkedByOthers || "0")
const raw = input.value.trim()

if (raw === "") {
return this.#show(warning, this.wholeBalanceValue)
}

if (!this.constructor.ALLOCATION_PATTERN.test(raw)) return this.#hide(warning)

const entered = Number.parseFloat(raw.replace(",", "."))
if (Number.isNaN(entered)) return this.#hide(warning)

const total = others + entered

if (total > balance) {
this.#show(
warning,
this.prorataValue
.replace("{total}", this.#money(total))
.replace("{balance}", this.#money(balance)),
)
} else {
this.#show(warning, this.headroomValue.replace("{left}", this.#money(balance - total)))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

#show(element, text) {
element.textContent = text
element.classList.remove("hidden")
}

#hide(element) {
element.classList.add("hidden")
}

#money(value) {
try {
return new Intl.NumberFormat(this.localeValue || undefined, {
style: "currency",
currency: this.currencyValue || "USD",
maximumFractionDigits: 0,
}).format(value)
} catch {
return `${this.currencyValue || "$"}${Math.round(value).toLocaleString()}`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
60 changes: 58 additions & 2 deletions app/models/assistant/function/create_goal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def params_schema
items: { type: "string" },
description: "Names of the user's Depository accounts to link. Must contain at least one. Use names exactly as they appear in the available accounts list. The goal's balance is the balance of these accounts."
},
earmarks: {
type: "object",
description: "Optional map of account name to the amount to reserve from that account, e.g. {\"Livret A\": 2000}. Required for an account already claimed in full by another goal — the available accounts list says which, and how much room is left. Accounts left out of this map reserve whatever the account has spare.",
additionalProperties: { type: "number" }
},
notes: {
type: "string",
description: "Optional freeform notes."
Expand All @@ -69,6 +74,7 @@ def call(params = {})
target_date = parse_date(params["target_date"])
linked_account_names = Array(params["linked_account_names"]).map { |n| n.to_s.strip }.reject(&:blank?)
notes = params["notes"].to_s.strip
earmarks = parse_earmarks(params["earmarks"])

return error("name_required", "Please provide a name for the goal.") if name.blank?

Expand Down Expand Up @@ -117,6 +123,21 @@ def call(params = {})
)
end

# Named before the save, so the assistant gets a reason it can act on
# rather than a generic validation failure it can only relay. Claiming an
# account in full is exclusive; joining one that is already claimed needs
# an explicit earmark, and the assistant can ask for one.
over_claimed = matched.select { |a| whole_account_claimed_ids.include?(a.id) && earmarks[a.name].nil? }
if over_claimed.any?
return error(
"account_claimed_in_full",
"Another goal already claims #{over_claimed.map(&:name).to_sentence} in full. " \
"Ask the user how much to reserve from #{'it'.pluralize(over_claimed.size)}, then pass it in `earmarks`.",
claimed_account_names: over_claimed.map(&:name),
available_accounts: depository_account_payload
)
end

goal = nil
Goal.transaction do
goal = family.goals.new(
Expand All @@ -127,7 +148,7 @@ def call(params = {})
notes: notes.presence,
color: Goal::COLORS.sample
)
matched.each { |a| goal.goal_accounts.build(account: a) }
matched.each { |a| goal.goal_accounts.build(account: a, allocated_amount: earmarks[a.name]) }
goal.save!
end

Expand Down Expand Up @@ -174,8 +195,43 @@ def parse_date(value)
nil
end

# Says what is left, not just what exists. A goal that claims an account in
# full is exclusive, so an account already claimed can only be joined with
# an explicit earmark — and the assistant has no way to know that unless
# the list says so.
def depository_account_payload
family.accounts.where(accountable_type: "Depository").visible.pluck(:name, :currency).map { |n, c| { name: n, currency: c } }
claimed = whole_account_claimed_ids

family.accounts.where(accountable_type: "Depository").visible.map do |account|
{
name: account.name,
currency: account.currency,
free_to_earmark: Money.new(account.free_to_earmark, account.currency).format,
claimed_in_full: claimed.include?(account.id)
}
end
end

def whole_account_claimed_ids
@whole_account_claimed_ids ||= GoalAccount.joins(:goal)
.where(allocated_amount: nil)
.where(goals: { family_id: family.id })
.where.not(goals: { state: Goal::RELEASED_STATES })
.pluck(:account_id)
.to_set
end

# Names are the assistant's handle on an account, so the map is keyed by
# them. Non-positive amounts are dropped rather than refused: a zero
# earmark and no earmark mean different things to the model, and neither
# is what the user asked for.
def parse_earmarks(raw)
return {} unless raw.is_a?(Hash)

raw.each_with_object({}) do |(account_name, amount), acc|
value = parse_decimal(amount)
acc[account_name.to_s.strip] = value if value && value.positive?
end
end

def error(key, message, extras = {})
Expand Down
33 changes: 29 additions & 4 deletions app/views/goals/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%# locals: (goal:, linkable_accounts:, currently_linked_account_ids: []) %>
<%# locals: (goal:, linkable_accounts:, currently_linked_account_ids: [], pooled_allocations: {}) %>

<%# goal-kind lives on this wrapper, not on the kind selector: its
dateField target is a sibling of the radios, and a controller only sees
Expand Down Expand Up @@ -70,15 +70,33 @@
<p class="text-xs text-secondary mt-0.5"><%= t("goals.form.fields.funding_accounts_hint") %></p>
<p class="text-xs text-subdued mt-0.5"><%= t("goals.form.fields.earmark_hint") %></p>
</div>
<div class="bg-container-inset rounded-lg p-1">
<%# Its own controller, not goal-form: that one is already at 10 targets
against the 7 the project guidelines suggest, and this needs none of
its state. Three targets here. %>
<div class="bg-container-inset rounded-lg p-1"
data-controller="goal-earmark"
data-goal-earmark-currency-value="<%= goal.currency.presence || Current.family.primary_currency_code %>"
<%# The APPLICATION locale, not the browser's. `Intl.NumberFormat`
with `undefined` lets the browser pick, so a French user on an
English-locale browser read separators and symbol placement that
matched nothing else on the page. The amounts themselves cannot
be formatted server-side — they change with every keystroke — so
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-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) %>
<% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %>
<% grouped.each_with_index do |(subtype, accts), group_idx| %>
<div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("goals.form.subtypes.#{subtype}", default: subtype.titleize) %></div>
<div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>">
<% accts.each_with_index do |account, idx| %>
<% linked_ga = linked_allocation_by_account[account.id] %>
<div class="flex items-center gap-3 px-3 py-2.5 hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>">
<div class="px-3 py-2.5 hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>"
data-balance="<%= account.balance.to_d %>"
data-earmarked-by-others="<%= earmarked_by_other_goals(account, pooled: pooled_allocations, current_goal: goal) %>">
<div class="flex items-center gap-3">
<label class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
<%= check_box_tag "goal[account_ids][]",
account.id,
Expand All @@ -87,7 +105,8 @@
class: "checkbox checkbox--light shrink-0",
data: {
goal_form_target: "linkedAccountCheckbox",
action: "change->goal-form#linkedAccountChanged"
goal_earmark_target: "checkbox",
action: "change->goal-form#linkedAccountChanged change->goal-earmark#refresh"
} %>
<%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %>
<div class="min-w-0">
Expand All @@ -102,7 +121,13 @@
inputmode: "decimal",
autocomplete: "off",
aria: { label: t("goals.form.fields.earmark_for", account: account.name) },
data: { goal_earmark_target: "allocationInput", action: "input->goal-earmark#refresh" },
class: "shrink-0 w-36 rounded-md border border-primary bg-container px-2.5 py-1.5 text-sm text-right tabular-nums text-primary placeholder:text-subdued focus-ring privacy-sensitive" %>
</div>
<%# Never text-destructive: none of this is an error. A goal
in progress legitimately claims more than its account
holds — that is what saving toward something looks like. %>
<p class="mt-1 text-xs text-secondary privacy-sensitive hidden" data-goal-earmark-target="warning"></p>
</div>
<% end %>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/goals/edit.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".heading")) %>
<% dialog.with_body do %>
<%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids %>
<%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids, pooled_allocations: @pooled_allocations %>
<% end %>
<% end %>
4 changes: 2 additions & 2 deletions app/views/goals/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
</div>
<% end %>
<% dialog.with_body do %>
<%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids %>
<%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids, pooled_allocations: @pooled_allocations %>
<% end %>
<% end %>
<% else %>
Expand All @@ -25,6 +25,6 @@
<p class="text-sm text-secondary mt-0.5"><%= t(".subtitle") %></p>
</div>
</header>
<%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids %>
<%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids, pooled_allocations: @pooled_allocations %>
</div>
<% end %>
4 changes: 4 additions & 0 deletions config/locales/views/goals/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ en:
name_required: Give your goal a name.
amount_required: Set a target above zero.
accounts_required: Pick at least one funding account.
earmark:
headroom: "{left} left to earmark on this account."
prorata: "Your goals on this account come to {total} for a balance of {balance} — they will progress pro rata."
whole_balance: This account will fund whatever is left after the other earmarks.
fields:
name: Name
name_placeholder: Emergency fund, House down payment…
Expand Down
4 changes: 4 additions & 0 deletions config/locales/views/goals/fr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ fr:
accounts_required: Sélectionnez au moins un compte de financement.
amount_required: Définissez une cible supérieure à zéro.
name_required: Donnez un nom à votre objectif.
earmark:
headroom: "Il reste {left} affectables sur ce compte."
prorata: "Vos objectifs sur ce compte totalisent {total} pour un solde de {balance} : ils progresseront au prorata."
whole_balance: Ce compte financera le solde restant après les autres affectations.
fields:
color: Couleur
earmark_for: Affecter un montant pour %{account}
Expand Down
Loading
Loading