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
This commit is contained in:
Guillem Arias
2026-05-15 00:01:13 +02:00
parent 95262c1b6a
commit 9f29185160
24 changed files with 230 additions and 100 deletions

View File

@@ -75,13 +75,16 @@ export default class extends Controller {
#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",
maximumFractionDigits: 0,
}).format(value);
} catch {
return `${this.currencyValue || "$"}${Math.round(value).toLocaleString()}`;
return `${this.currencyValue || "$"}${value.toLocaleString()}`;
}
}
}

View File

@@ -14,15 +14,16 @@ export default class extends Controller {
static values = { data: Object, ariaLabel: String, ariaDescription: String };
connect() {
this._draw();
this._resize = this._draw.bind(this);
window.addEventListener("resize", this._resize);
// Container may have 0 width on initial connect (Turbo restoration,
// hidden parent, etc). Re-draw whenever the box settles into a real
// size.
// size. The first observer callback also performs the initial paint.
if (typeof ResizeObserver !== "undefined") {
this._observer = new ResizeObserver(() => this._draw());
this._observer.observe(this.element);
} else {
this._draw();
}
// Repaint when the user toggles theme so SVG attributes (which bake
// light/dark hex values at draw time) follow data-theme. Lives here
@@ -87,7 +88,11 @@ export default class extends Controller {
const currentAmount = data.current_amount || 0;
const avgMonthly = data.avg_monthly || 0;
const endDate = target || new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
// Past-due goals: pin endDate at today so the "today" marker stays inside
// the x-domain instead of clipping right at the edge.
const endDate = target
? new Date(Math.max(target.getTime(), today.getTime()))
: new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
// Drop any same-day-or-later points from the balance series: we own the
// endpoint with `currentAmount` (live `linked_accounts.sum(:balance)`)
@@ -144,8 +149,7 @@ export default class extends Controller {
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio", "none");
.attr("viewBox", `0 0 ${width} ${height}`);
// Drop the <title> child; browsers render it as a native hover tooltip
// that fights with our own crosshair tooltip. aria-label gives the same
@@ -502,12 +506,10 @@ export default class extends Controller {
hoverSavedDot.style("display", "none");
lines.push(`Projected: ${this._fmtMoney(projValue, data.currency)}`);
} else {
// Saved segment: snap saved dot to the nearest contribution; no
// projection dot in the past.
const i = bisectDate(savedSeries, hoverDate);
const a = savedSeries[Math.max(0, i - 1)];
const b = savedSeries[Math.min(savedSeries.length - 1, i)];
const savedPoint = !a ? b : !b ? a : (hoverDate - a.date < b.date - hoverDate ? a : b);
// Saved segment: hoverDate is already snapped to nearest savedSeries
// entry above, so reuse that entry directly instead of running
// bisectDate a second time.
const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1];
hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null);
hoverProjDot.style("display", "none");
lines.push(`Saved: ${this._fmtMoney(savedPoint.value, data.currency)}`);

View File

@@ -204,8 +204,11 @@ export default class extends Controller {
this.stepperLineTarget.classList.toggle("border-subdued", this.currentStep === 1);
}
// Modal subtitle lives in the dialog header, outside this controller's
// DOM scope. Locate it by attribute and update directly.
const subtitle = document.querySelector('[data-goal-stepper-modal-subtitle]');
// DOM scope. Walk up to the enclosing dialog first so two stepper
// instances on the same page (eg. edit modal opened over the new modal)
// each update their own header rather than the first match in the DOM.
const dialog = this.element.closest("dialog, [role='dialog']");
const subtitle = (dialog || document).querySelector("[data-goal-stepper-modal-subtitle]");
if (subtitle) {
subtitle.textContent =
this.currentStep === 1 ? this.step1SubtitleValue : this.step2SubtitleValue;