mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 15:59:02 +00:00
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
91 lines
3.1 KiB
JavaScript
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()}`;
|
|
}
|
|
}
|
|
}
|