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(*)
This commit is contained in:
Guillem Arias
2026-05-11 19:25:08 +02:00
parent 8d8049434e
commit 6be5f813a4
2 changed files with 36 additions and 19 deletions

View File

@@ -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
}

View File

@@ -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