feat(goals/stepper+chart): Step 2 derived projection + JS i18n + Intl.NumberFormat

B — Step 2 of the create stepper used to echo Step 1 fields back at
the user in three labelled rows (Funding accounts: 2 · $123,456 balance;
Suggested monthly: $1,003/mo over 12 months). Replaces those rows with
a single derived sentence:

  "Save $1,003/mo across 2 accounts to hit it on time."

If no target date is set: "Set a target date to project a finish line."
The previous "Suggested monthly" + "Funding accounts" rows are dropped;
review block shows only Name, "$12,000 by May 11 2027", and the
derived insight sentence.

L — All hard-coded English templates + currency symbols in the JS
controllers go through Stimulus values now:

- goal_stepper_controller: new {currency, summaryWithDate, summaryNoDate,
  accountCountOne, accountCountOther, suggestedWithDate, suggestedNoDate}
  values. Money formatted via Intl.NumberFormat(undefined, { style:
  "currency", currency: this.currencyValue, maximumFractionDigits: 0 }).
- goal_projection_chart_controller: _fmtMoney upgraded to Intl.NumberFormat
  (was $/€/£ ternary fallback that lost JPY/INR/CHF/...).

Locale: new goals.form_stepper.step2.review.{summary_*,account_count,
suggested_*}. Old funding_accounts / suggested_monthly keys retained
(unused by the new ERB) so any translator paths in flight don't break.

Verified live via Playwright: step-2 review reads "Save $1,003/mo
across 2 accounts to hit it on time." for a $12,000 / 12-month / 2-
account goal.
This commit is contained in:
Guillem Arias
2026-05-11 20:42:13 +02:00
parent e03204bb96
commit 3fa762289a
4 changed files with 63 additions and 36 deletions

View File

@@ -399,8 +399,16 @@ export default class extends Controller {
}
_fmtMoney(amount, currency) {
const symbol = currency === "EUR" ? "€" : currency === "GBP" ? "£" : "$";
return `${symbol}${Math.round(amount).toLocaleString()}`;
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: currency || "USD",
maximumFractionDigits: 0,
}).format(amount);
} catch {
const symbol = currency === "EUR" ? "€" : currency === "GBP" ? "£" : "$";
return `${symbol}${Math.round(amount).toLocaleString()}`;
}
}
_fmtMoneyShort(amount, currency) {

View File

@@ -26,7 +26,6 @@ export default class extends Controller {
"initialContributionAccountSelect",
"reviewName",
"reviewSummary",
"reviewAccounts",
"reviewSuggested",
"footerLeftButton",
"footerRightButton",
@@ -42,6 +41,13 @@ export default class extends Controller {
backLabel: { type: String, default: "Back" },
continueLabel: { type: String, default: "Continue" },
submitLabel: { type: String, default: "Create goal" },
currency: { type: String, default: "USD" },
summaryWithDate: { type: String, default: "{amount} by {date}" },
summaryNoDate: { type: String, default: "{amount}" },
accountCountOne: { type: String, default: "1 account" },
accountCountOther: { type: String, default: "{count} accounts" },
suggestedWithDate: { type: String, default: "Save {monthly}/mo across {accounts} to hit it on time." },
suggestedNoDate: { type: String, default: "Set a target date to project a finish line." },
};
connect() {
@@ -237,41 +243,49 @@ export default class extends Controller {
const amount = amountInput?.value ? Number.parseFloat(amountInput.value) : 0;
const dateInput = this.element.querySelector('input[type="date"][name="goal[target_date]"]');
const dateValue = dateInput?.value;
const checked = this.linkedAccountCheckboxTargets.filter((cb) => cb.checked);
this.reviewNameTarget.textContent = name;
if (this.hasReviewSummaryTarget) {
const currency = amountInput?.dataset?.currency || "$";
const formattedAmount = amountInput?.value ? `${currency}${amount.toLocaleString()}` : "—";
this.reviewSummaryTarget.textContent = dateValue
? `${formattedAmount} by ${this.#formatDate(dateValue)}`
: formattedAmount;
}
if (this.hasReviewAccountsTarget) {
const checked = this.linkedAccountCheckboxTargets.filter((cb) => cb.checked);
const total = checked.reduce(
(sum, cb) => sum + Number.parseFloat(cb.dataset.accountBalance || 0),
0,
);
this.reviewAccountsTarget.textContent = checked.length
? `${checked.length} ${checked.length === 1 ? "account" : "accounts"} · $${total.toLocaleString()} balance`
: "—";
const formattedAmount = amount > 0 ? this.#money(amount) : "";
const template = dateValue ? this.summaryWithDateValue : this.summaryNoDateValue;
this.reviewSummaryTarget.textContent = template
.replace("{amount}", formattedAmount)
.replace("{date}", dateValue ? this.#formatDate(dateValue) : "");
}
if (this.hasReviewSuggestedTarget) {
const months = dateValue ? this.#monthsBetween(new Date(), new Date(dateValue)) : 0;
if (amount > 0 && months > 0) {
const accountLabel = checked.length === 1
? this.accountCountOneValue
: this.accountCountOtherValue.replace("{count}", checked.length.toString());
if (amount > 0 && months > 0 && checked.length > 0) {
const perMonth = Math.ceil(amount / months);
this.reviewSuggestedTarget.textContent = `$${perMonth.toLocaleString()}/mo over ${Math.max(1, Math.round(months))} months`;
} else if (amount > 0) {
this.reviewSuggestedTarget.textContent = `$${amount.toLocaleString()} (no target date)`;
this.reviewSuggestedTarget.textContent = this.suggestedWithDateValue
.replace("{monthly}", this.#money(perMonth))
.replace("{accounts}", accountLabel);
} else if (amount > 0 && checked.length > 0) {
this.reviewSuggestedTarget.textContent = this.suggestedNoDateValue;
} else {
this.reviewSuggestedTarget.textContent = "—";
}
}
}
#money(value) {
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: this.currencyValue || "USD",
maximumFractionDigits: 0,
}).format(value);
} catch {
return `${this.currencyValue || "$"}${Math.round(value).toLocaleString()}`;
}
}
#monthsBetween(from, to) {
return (to - from) / (1000 * 60 * 60 * 24 * 30.44);
}