mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* fix(goals): stop the first goal being told about earmarks it has none of Linking an account and leaving the amount blank shows "This account will fund whatever is left after the other earmarks". On a first goal there are no other earmarks — so the sentence points at something absent, and it is where a user meets the word for the first time. The row already carries `data-earmarked-by-others`, and it is zero in that case. With the account to itself the hint now says so plainly, and "earmark" appears only where earmarks actually exist — which lets the context do the explaining instead of the vocabulary needing it. Pinned server-side rather than in JS. The branch is a ternary; the failure that matters is the form not handing over the second string, which does not raise — the value reads as undefined and the line renders empty. There is also a test that the two hints stay different, since identical copy would leave the branch doing nothing and the first-time reader back where they started. Nothing runs `test/javascript` — no npm script, no CI step — so a test there would have guarded nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): tell an account claimed in full from one claimed by nobody Review on #3216. The new "this account is yours alone" hint keyed off `earmarked_by_other_goals`, which sums `allocated_amount` — and a whole-account link carries nil, so it contributes zero. An account another goal already claims in full therefore looked exactly like an unclaimed one. The form promised the whole balance, and `whole_account_link_must_be_exclusive` refused the blank allocation on submit. That is worse than the sentence this PR set out to fix: it does not merely describe something absent, it describes something the save then contradicts. The row carries both readings now, because neither can be derived from the other. `whole_account_claimed_by_other_goals?` asks the question the sum cannot answer, and the two share the same row filter so the goal being edited is excluded from both. The two tests added here also move above the first `private`, same as on `define_method`, which is public regardless — but they read as a mistake sitting among the helpers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
108 lines
3.6 KiB
JavaScript
108 lines
3.6 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,
|
|
wholeBalanceAlone: 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")
|
|
// 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,
|
|
others > 0 || claimedWhole ? this.wholeBalanceValue : this.wholeBalanceAloneValue,
|
|
)
|
|
}
|
|
|
|
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()}`
|
|
}
|
|
}
|
|
}
|