mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 06:41:08 +00:00
* 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>
98 lines
3.1 KiB
JavaScript
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()}`
|
|
}
|
|
}
|
|
}
|