Files
sure/app/javascript/controllers/goal_earmark_controller.js
T
b6029c1e28 feat(goals): show what each account still has room to earmark (#3166)
* feat(goals): show what each account still has room to earmark

`Account#free_to_earmark` has existed, unused, since earmarks shipped —
its own comment said the UI was a follow-up. This is that follow-up, and
the wording is the substance of it.

It does not say "over-allocated". `free_to_earmark` is negative for as
long as the saving is unfinished, which is the normal condition of anyone
with goals in progress: a 6,000 account backing two goals of 5,000 gives
−4,000 and is a perfectly correct setup. A warning phrased as a fault
would fire permanently and teach people to ignore it. The message states
the consequence instead — the goals come to X for a balance of Y, so they
progress pro rata — and is never styled as an error.

The trap is the goal being edited. `goal_earmarked_total` counts every
goal including that one, so reopening a goal that earmarks 5,000 on a
6,000 account shows 1,000 of headroom, and re-entering the same 5,000
trips a message about a setup the user has not touched.
`earmarked_by_other_goals` excludes it, and only when it is persisted —
a goal being created has nothing to exclude.

The pool is read once per render and passed down, never per account: the
form lists every fundable account the user can see. A test counts the
query and fails at two.

The Stimulus controller is its own, with 3 targets. goal_form_controller
is at 10 against the 7 the project guidelines suggest, needs none of this
state, and is untouched.

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

* fix(goals): read the typed amount strictly, and format it in the app's locale

Addresses review feedback on #3166.

`Number.parseFloat` accepts prefixes, so "500abc" became 500, and the bare
comma-to-dot swap turned a thousands-separated "1,500" into 1.5. Either way the
preview described an amount the user had not typed — and the second case is a
habit from another locale, not a typo, so it would have gone unnoticed. The
value now has to match a complete number before anything is computed.

`Intl.NumberFormat(undefined, ...)` let the BROWSER pick the locale, so a
French user on an English-locale browser read separators and symbol placement
matching nothing else on the page. The amounts cannot be formatted server-side
— they change with every keystroke — so the server passes `I18n.locale` and the
client applies it. That puts the decision where the rest of the app's
formatting already lives.

bin/rails test: 6954 runs, 0 failures. RuboCop, erb_lint and biome clean.

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

* fix(goals): let the assistant create a second goal on a claimed account

Review on #3166. The function always built whole-account links and had no
way to express an earmark, so once exclusivity landed, asking for a second
goal on an account another goal already claimed came back as a bare
`validation_failed` — while the account list still advertised the account
as available. A common request became an unexplained refusal.

Three changes, and the list is the important one: it now says what is left
on each account and which are claimed in full, because the assistant
reasons from that list and had no way to know otherwise.

`earmarks` is an optional map of account name to amount, so the assistant
can reserve a slice rather than the whole balance. Accounts left out keep
the previous behaviour and take whatever is spare.

The refusal is named before the save — `account_claimed_in_full`, with the
account names — so the assistant gets a reason it can act on and ask about,
rather than a validation message it can only relay. Checked after the
currency check, which is the more fundamental of the two.

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

* test(goals): move the spend tests back out of the private section

The merge of `main` into this branch landed #3176's tests between
`count_pool_queries` and the helpers below it, inside the `private`
section and at the wrong indentation. `ci / lint` has been failing on
`Layout/IndentationConsistency` since.

They still ran — `test` is a class method, so `private` does not hide them
— which is why the unit job stayed green while lint went red.

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

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-08-26 21:12:06 +02:00

98 lines
3.1 KiB
JavaScript

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)))
}
}
#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()}`
}
}
}