feat(goal_contributions/new): live impact preview below amount field

Add-contribution modal previously offered zero feedback on what the
typed amount would do to goal progress. Now renders "Currently X%
saved (Y of Z)." at rest and updates on each keystroke to
"Will bring you to X% saved (Y of Z)." or "Will reach your Z target."
when the contribution would close the gap.

- New goal_contribution_preview_controller.js consumes current balance
  + target + currency + three localized template strings as Stimulus
  values. Intl.NumberFormat for currency formatting (locale-correct
  out of the box; fallback to currencyValue prefix on environments
  that don't support it).
- ERB form-level data-controller wires the values; amount input uses
  amount_data: to thread the Stimulus target / action through the
  money_field helper.
- Locale: goal_contributions.new.preview_{zero,nonzero,reached} with
  {percent}, {current}, {newTotal}, {target} placeholders.
This commit is contained in:
Guillem Arias
2026-05-11 20:25:44 +02:00
parent 04422f36b3
commit e179abd0b3
3 changed files with 87 additions and 2 deletions

View File

@@ -0,0 +1,66 @@
import { Controller } from "@hotwired/stimulus";
// Live impact preview for the add-contribution modal. Reads current
// balance + target amount from values and updates a preview sentence
// each keystroke. Template strings come from ERB so the wording stays
// localized.
export default class extends Controller {
static targets = ["amountInput", "preview"];
static values = {
currentBalance: Number,
targetAmount: Number,
currency: String,
templateZero: String,
templateNonZero: String,
templateReached: String,
};
connect() {
this.update();
}
update() {
if (!this.hasPreviewTarget) return;
const amount = this.#amountValue();
const newTotal = this.currentBalanceValue + amount;
const target = this.targetAmountValue;
const reached = newTotal >= target && target > 0;
const percent = target > 0 ? Math.min(100, Math.round((newTotal / target) * 100)) : 0;
let text;
if (reached) {
text = this.templateReachedValue.replace("{target}", this.#money(target));
} else if (amount === 0) {
text = this.templateZeroValue
.replaceAll("{percent}", percent.toString())
.replaceAll("{current}", this.#money(this.currentBalanceValue))
.replaceAll("{target}", this.#money(target));
} else {
text = this.templateNonZeroValue
.replaceAll("{percent}", percent.toString())
.replaceAll("{newTotal}", this.#money(newTotal))
.replaceAll("{target}", this.#money(target));
}
this.previewTarget.textContent = text;
}
#amountValue() {
if (!this.hasAmountInputTarget) return 0;
const parsed = Number.parseFloat(this.amountInputTarget.value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
#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()}`;
}
}
}

View File

@@ -7,11 +7,27 @@
<%= styled_form_with model: @contribution,
url: goal_contributions_path(@goal),
class: "space-y-3" do |f| %>
class: "space-y-3",
data: {
controller: "goal-contribution-preview",
goal_contribution_preview_current_balance_value: @goal.current_balance.to_f,
goal_contribution_preview_target_amount_value: @goal.target_amount.to_f,
goal_contribution_preview_currency_value: @goal.currency,
goal_contribution_preview_template_zero_value: t(".preview_zero"),
goal_contribution_preview_template_nonzero_value: t(".preview_nonzero"),
goal_contribution_preview_template_reached_value: t(".preview_reached")
} do |f| %>
<%= f.money_field :amount,
label: t(".amount"),
hide_currency: true,
autofocus: true %>
autofocus: true,
amount_data: {
goal_contribution_preview_target: "amountInput",
action: "input->goal-contribution-preview#update"
} %>
<p class="text-xs text-secondary tabular-nums -mt-1"
data-goal-contribution-preview-target="preview"></p>
<%= f.select :account_id,
options_from_collection_for_select(@goal.linked_accounts, :id, :name, @contribution.account_id),

View File

@@ -9,6 +9,9 @@ en:
contributed_at: Date
notes: Notes (optional)
submit: Save contribution
preview_zero: "Currently {percent}% saved ({current} of {target})."
preview_nonzero: "Will bring you to {percent}% saved ({newTotal} of {target})."
preview_reached: "Will reach your {target} target."
create:
success: Contribution saved.
destroy: