Files
sure/app/javascript/controllers/goal_pledge_preview_controller.js
Guillem Arias 9f29185160 fix(goals): address AI review on PR #1798 (CodeRabbit + Codex)
Correctness:
- GoalPledge#matches? rejects outflows on transfer pledges so a +$200
  purchase no longer satisfies a $200 deposit pledge after .abs
- GoalsController#sync_linked_accounts! saves through the goal so
  currency/depository/family validations actually run on update
- AlreadyClaimedError replaces empty RecordInvalid in resolve_with! and
  reconciler rescues the dedicated class
- SweepExpiredGoalPledgesJob wraps each expire! in a per-record rescue
- Assistant::Function::CreateGoal disambiguates duplicate account names
  and returns an absolute URL via mailer host config
- Family#savings_inflow_velocity defensively scopes from the family's
  accounts (was Account.joins(:goal_accounts).where(goal_id: ...))
- GoalPledgesController#set_goal preloads linked_accounts + providers
  to drop the N+1 on any_connected_account?
- Stepper subtitle update walks to the enclosing dialog before
  querySelector so two stepper instances don't fight over one header
- categories/_form.html.erb data-action targets color-icon-picker, not
  the non-existent "category" controller

UX / visual:
- Projection chart drops preserveAspectRatio="none" and pins endDate at
  today for past-due goals so the today marker stays in-domain
- _color_picker / categories form swap non-standard border-1 for border
- Goals index search input uses ring-alpha-black-100 (was raw gray-500)

Refactors:
- Goal#header_summary extracts the multi-line ERB header block
- Goal#catch_up_delta_money sums open_pledges in SQL
- Goal#projection_summary uses I18n.l for the on-track month label
- Account#default_pledge_kind moves the manual/transfer decision out of
  GoalPledgesController
- GoalPledge::Reconciler iterates ordered (created_at, id) so first-claim
  wins is deterministic under non-sequential PKs
- Goals::FundingAccountsBreakdownComponent + Goals::AccountStackComponent
  use clamp(0..) instead of Float::INFINITY / [x, 0].max
- Goals::StatusPillComponent#label provides a titleize fallback
- Goal projection chart skips the redundant initial _draw and reuses
  the snapped point in the past branch (no double-bisect)
- Goal pledge preview drops maximumFractionDigits: 0 so USD/EUR show
  cents while JPY/KRW stay whole-unit
- Demo generator captures the Wedding fund goal in the seed loop
  instead of looking it up by hardcoded name

Tests:
- GoalPledgeTest: outflow rejection
- GoalsControllerTest: cross-currency attachment rejected on update
- SweepExpiredGoalPledgesJobTest: cancelled coverage + per-record rescue
- GoalTest: pledge_action_label_key flips to manual_save without an
  unconditional guard
2026-05-15 00:01:13 +02:00

91 lines
3.1 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
// Live impact preview for the record-pledge 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",
"accountSelect",
"helperConnected",
"helperManual",
];
static values = {
currentBalance: Number,
targetAmount: Number,
currency: String,
templateZero: String,
templateNonzero: String,
templateReached: String,
};
connect() {
this.update();
this.accountChanged();
}
// Helper text reacts to the currently-selected account, not the goal as a
// whole. A mixed-funding goal (one connected account + one manual) used to
// paint the "connected" helper even if the user then picked the manual
// account from the dropdown; the saved pledge would be `kind: manual_save`
// (correct, per `kind_for_account` in the controller) but the helper read
// "transfer-style" copy until submission.
accountChanged() {
if (!this.hasAccountSelectTarget) return;
if (!this.hasHelperConnectedTarget || !this.hasHelperManualTarget) return;
const opt = this.accountSelectTarget.selectedOptions[0];
const isManual = opt?.dataset.manual === "true";
this.helperConnectedTarget.hidden = isManual;
this.helperManualTarget.hidden = !isManual;
}
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 {
// Let Intl pick the currency-specific default fraction digits so
// USD/EUR previews show cents while JPY/KRW stay whole-unit. The
// server saves the user-entered amount verbatim; the preview must
// not silently round it.
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: this.currencyValue || "USD",
}).format(value);
} catch {
return `${this.currencyValue || "$"}${value.toLocaleString()}`;
}
}
}