mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user