From 6be5f813a45f963b8f87e6b310ae6e16a0e329fb Mon Sep 17 00:00:00 2001 From: Guillem Arias Date: Mon, 11 May 2026 19:25:08 +0200 Subject: [PATCH] perf(savings_goals/show): preload + memoize cut show from 17 to 5 queries - set_savings_goal: with_current_balance + includes(savings_contributions: :account, linked_accounts: []) so contributions / accounts / current balance don't re-query inside helpers and view partials - SavingsGoal#status + #average_monthly_contribution: defined?(@ivar) memoization so the 5+ callsites per show (header banner, projection_summary, donut, goal-card pace, stats_for) don't recompute the exists?/MIN/SUM triplet each time - SavingsGoal#projection_payload: sort loaded contributions in Ruby instead of running a fresh ORDER BY - SavingsGoalsController#show: replace .chronological re-query with in-memory sort over the preloaded association - funding_breakdown_for: group_by + transform_values off the loaded collection instead of an extra GROUP BY SQL - stats_for: contributions_count uses .size to read the loaded cache instead of issuing COUNT(*) --- app/controllers/savings_goals_controller.rb | 15 +++++--- app/models/savings_goal.rb | 40 +++++++++++++-------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/app/controllers/savings_goals_controller.rb b/app/controllers/savings_goals_controller.rb index 9a1b443c9..bf7a4963c 100644 --- a/app/controllers/savings_goals_controller.rb +++ b/app/controllers/savings_goals_controller.rb @@ -24,7 +24,9 @@ class SavingsGoalsController < ApplicationController end def show - @contributions = @savings_goal.savings_contributions.includes(:account).chronological + @contributions = @savings_goal.savings_contributions + .sort_by { |c| [ c.contributed_at, c.created_at ] } + .reverse @funding_breakdown = funding_breakdown_for(@savings_goal) @stats = stats_for(@savings_goal) @breadcrumbs = [ @@ -114,7 +116,10 @@ class SavingsGoalsController < ApplicationController private def set_savings_goal - @savings_goal = Current.family.savings_goals.find(params[:id]) + @savings_goal = Current.family.savings_goals + .with_current_balance + .includes(savings_contributions: :account, linked_accounts: []) + .find(params[:id]) end def savings_goal_params @@ -156,8 +161,8 @@ class SavingsGoalsController < ApplicationController def funding_breakdown_for(goal) totals = goal.savings_contributions - .group(:account_id) - .sum(:amount) + .group_by(&:account_id) + .transform_values { |arr| arr.sum(&:amount) } goal.linked_accounts.map do |account| amount = totals[account.id] || 0 { account: account, amount: amount, money: Money.new(amount, goal.currency) } @@ -219,7 +224,7 @@ class SavingsGoalsController < ApplicationController { avg_monthly: avg, avg_monthly_sub: sub_avg, - contributions_count: goal.savings_contributions.count, + contributions_count: goal.savings_contributions.size, monthly_target_sub: sub_target, projection_summary: summary } diff --git a/app/models/savings_goal.rb b/app/models/savings_goal.rb index 53141e5e0..ea5280548 100644 --- a/app/models/savings_goal.rb +++ b/app/models/savings_goal.rb @@ -139,7 +139,7 @@ class SavingsGoal < ApplicationRecord # date ascending. Consumed by the # `savings-goal-projection-chart` Stimulus controller. def projection_payload - sorted = savings_contributions.order(contributed_at: :asc).to_a + sorted = savings_contributions.sort_by(&:contributed_at) running = 0 saved_series = sorted.map do |c| running += c.amount.to_d @@ -167,26 +167,38 @@ class SavingsGoal < ApplicationRecord # :behind → has target_date and current pace < required monthly pace # :no_target_date → progress < 100 and target_date is nil def status - return :reached if progress_percent >= 100 - return :no_target_date if target_date.nil? - return :on_track if monthly_target_amount.to_d <= average_monthly_contribution.to_d + return @status if defined?(@status) - :behind + @status = if progress_percent >= 100 + :reached + elsif target_date.nil? + :no_target_date + elsif monthly_target_amount.to_d <= average_monthly_contribution.to_d + :on_track + else + :behind + end end def average_monthly_contribution - return 0 if savings_contributions.empty? + return @average_monthly_contribution if defined?(@average_monthly_contribution) - first_at = if savings_contributions.loaded? - savings_contributions.map(&:contributed_at).compact.min + @average_monthly_contribution = if savings_contributions.empty? + 0 else - savings_contributions.minimum(:contributed_at) + first_at = if savings_contributions.loaded? + savings_contributions.map(&:contributed_at).compact.min + else + savings_contributions.minimum(:contributed_at) + end + if first_at.blank? + current_balance + else + months = ((Date.current.year - first_at.year) * 12 + (Date.current.month - first_at.month)) + 1 + months = 1 if months < 1 + (current_balance.to_d / months).round(2) + end end - return current_balance if first_at.blank? - - months = ((Date.current.year - first_at.year) * 12 + (Date.current.month - first_at.month)) + 1 - months = 1 if months < 1 - (current_balance.to_d / months).round(2) end def last_contribution_at