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);
}

View File

@@ -1,6 +1,13 @@
<%# locals: (goal:, linkable_accounts:) %>
<div data-controller="goal-stepper">
<div data-controller="goal-stepper"
data-goal-stepper-currency-value="<%= Current.family.primary_currency_code %>"
data-goal-stepper-summary-with-date-value="<%= t("goals.form_stepper.step2.review.summary_with_date") %>"
data-goal-stepper-summary-no-date-value="<%= t("goals.form_stepper.step2.review.summary_no_date") %>"
data-goal-stepper-account-count-one-value="<%= t("goals.form_stepper.step2.review.account_count.one") %>"
data-goal-stepper-account-count-other-value="<%= t("goals.form_stepper.step2.review.account_count.other") %>"
data-goal-stepper-suggested-with-date-value="<%= t("goals.form_stepper.step2.review.suggested_with_date") %>"
data-goal-stepper-suggested-no-date-value="<%= t("goals.form_stepper.step2.review.suggested_no_date") %>">
<% if goal.errors[:base].any? %>
<%= render "shared/form_errors", model: goal %>
<% end %>
@@ -106,7 +113,7 @@
<p class="text-sm text-secondary"><%= t("goals.form_stepper.step2.subheading") %></p>
</div>
<div class="border border-subdued rounded-lg p-5 space-y-4">
<div class="border border-subdued rounded-lg p-5 space-y-3">
<div class="flex items-center gap-3">
<%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "lg", rounded: false) %>
<div class="min-w-0 flex-1">
@@ -115,15 +122,7 @@
</div>
</div>
<div class="border-t border-subdued pt-3 flex items-center justify-between text-sm">
<span class="text-secondary"><%= t("goals.form_stepper.step2.funding_accounts") %></span>
<span class="text-primary tabular-nums" data-goal-stepper-target="reviewAccounts">—</span>
</div>
<div class="border-t border-subdued pt-3 flex items-center justify-between text-sm">
<span class="text-secondary"><%= t("goals.form_stepper.step2.suggested_monthly") %></span>
<span class="text-primary tabular-nums" data-goal-stepper-target="reviewSuggested">—</span>
</div>
<p class="text-sm text-primary tabular-nums border-t border-subdued pt-3" data-goal-stepper-target="reviewSuggested">—</p>
</div>
<details class="border border-subdued rounded-lg group" data-goal-stepper-target="initialContributionToggle">

View File

@@ -258,10 +258,16 @@ en:
label: Review & start
heading: Looks good?
subheading: Review your goal and add an optional starting contribution.
funding_accounts: Funding accounts
suggested_monthly: Suggested monthly
add_initial_contribution: Add an initial contribution
add_initial_contribution_sub: Optional · jumpstart this goal with funds you've already set aside.
initial_amount: Amount
initial_account: From account
select_account: Select an account
review:
summary_with_date: "{amount} by {date}"
summary_no_date: "{amount}"
account_count:
one: 1 account
other: "{count} accounts"
suggested_with_date: "Save {monthly}/mo across {accounts} to hit it on time."
suggested_no_date: Set a target date to project a finish line.