diff --git a/app/components/goals/account_stack_component.rb b/app/components/goals/account_stack_component.rb index f0fddc00d..c1892e100 100644 --- a/app/components/goals/account_stack_component.rb +++ b/app/components/goals/account_stack_component.rb @@ -10,7 +10,7 @@ class Goals::AccountStackComponent < ApplicationComponent end def extra_count - [ @accounts.size - @max, 0 ].max + (@accounts.size - @max).clamp(0..) end def initial_for(account) diff --git a/app/components/goals/funding_accounts_breakdown_component.rb b/app/components/goals/funding_accounts_breakdown_component.rb index 88995a00e..e63d7695c 100644 --- a/app/components/goals/funding_accounts_breakdown_component.rb +++ b/app/components/goals/funding_accounts_breakdown_component.rb @@ -75,7 +75,7 @@ class Goals::FundingAccountsBreakdownComponent < ApplicationComponent result = Hash.new { |h, k| h[k] = { last_30: 0.to_d, last_90: 0.to_d } } rows.each do |aid, date, amount| - inflow = (-amount.to_d).clamp(0, Float::INFINITY) + inflow = (-amount.to_d).clamp(0..) result[aid][:last_90] += inflow result[aid][:last_30] += inflow if date >= cutoff_30 end diff --git a/app/components/goals/status_pill_component.rb b/app/components/goals/status_pill_component.rb index 03822722c..d1ec2179f 100644 --- a/app/components/goals/status_pill_component.rb +++ b/app/components/goals/status_pill_component.rb @@ -29,7 +29,7 @@ class Goals::StatusPillComponent < ApplicationComponent end def label - I18n.t("goals.status.#{status_key}") + I18n.t("goals.status.#{status_key}", default: status_key.to_s.titleize) end def classes diff --git a/app/controllers/goal_pledges_controller.rb b/app/controllers/goal_pledges_controller.rb index e7e0bf398..c3c9c438d 100644 --- a/app/controllers/goal_pledges_controller.rb +++ b/app/controllers/goal_pledges_controller.rb @@ -16,7 +16,7 @@ class GoalPledgesController < ApplicationController @pledge = @goal.goal_pledges.new( currency: @goal.currency, account: account, - kind: kind_for_account(account), + kind: account&.default_pledge_kind || "transfer", amount: params[:amount].presence ) end @@ -24,7 +24,7 @@ class GoalPledgesController < ApplicationController def create @pledge = @goal.goal_pledges.new(pledge_params) @pledge.account = lookup_account(params.dig(:goal_pledge, :account_id)) - @pledge.kind = kind_for_account(@pledge.account) + @pledge.kind = @pledge.account&.default_pledge_kind || "transfer" @pledge.currency = @goal.currency if @pledge.save @@ -56,7 +56,12 @@ class GoalPledgesController < ApplicationController private def set_goal - @goal = Current.family.goals.find(params[:goal_id]) + # Preload linked accounts + their providers so any_connected_account? + # and the new-pledge form's per-account helpers don't trigger N+1 + # queries on account_providers. + @goal = Current.family.goals + .includes(:open_pledges, linked_accounts: :account_providers) + .find(params[:goal_id]) end def set_pledge @@ -77,17 +82,6 @@ class GoalPledgesController < ApplicationController requested || @goal.linked_accounts.first end - # Per-account: manual accounts get a `manual_save` pledge (resolves on the - # user's next valuation), connected accounts get a `transfer` pledge - # (resolves when the synced deposit posts). Account-level avoids the - # mixed-funding goal bug where the goal-level toggle picked one kind for - # all pledges regardless of which account the user actually moved money - # into. - def kind_for_account(account) - return "transfer" if account.nil? - account.manual? ? "manual_save" : "transfer" - end - def record_not_found redirect_to goals_path, alert: t("goals.errors.not_found") end diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index efe19012f..afba329b6 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -175,15 +175,18 @@ class GoalsController < ApplicationController end def sync_linked_accounts!(goal, accounts) - desired = accounts.map(&:id).to_set - current = goal.goal_accounts.pluck(:account_id).to_set + desired_ids = accounts.map(&:id).to_set + current_ids = goal.goal_accounts.pluck(:account_id).to_set - (current - desired).each do |id| + (current_ids - desired_ids).each do |id| goal.goal_accounts.where(account_id: id).destroy_all end - (desired - current).each do |id| - goal.goal_accounts.create!(account_id: id) - end + additions = accounts.reject { |a| current_ids.include?(a.id) } + additions.each { |a| goal.goal_accounts.build(account: a) } + # Save through the goal so currency / depository / family + # validations fire. `create!` on goal_accounts directly bypasses them + # and let cross-currency / non-depository attachments through. + goal.save! end def kpi_payload(active_goals) diff --git a/app/javascript/controllers/goal_pledge_preview_controller.js b/app/javascript/controllers/goal_pledge_preview_controller.js index 9246b6503..625ee4af7 100644 --- a/app/javascript/controllers/goal_pledge_preview_controller.js +++ b/app/javascript/controllers/goal_pledge_preview_controller.js @@ -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()}`; } } } diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index a52d0cc1e..bdb791ac8 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -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