diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index e5382e73..e0e85f89 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -26,7 +26,24 @@ class TransactionsController < ApplicationController params: ->(params) { params.except(:focused_record_id) } ) - @totals = Current.family.income_statement.totals(transactions_scope: transactions_query) + # ------------------------------------------------------------------- + # Cache totals + # ------------------------------------------------------------------- + # Totals calculation is expensive (heavy SQL with grouping). We cache the + # result keyed by: + # • Family id + # • The family-level cache key that already embeds entries.maximum(:updated_at) + # • A digest of the current search params so each distinct filter set gets + # its own cache entry. + # When any entry is created/updated/deleted, the family cache key changes, + # automatically invalidating all related totals. + + params_digest = Digest::MD5.hexdigest(@q.to_json) + cache_key = Current.family.build_cache_key("transactions_totals_#{params_digest}") + + @totals = Rails.cache.fetch(cache_key) do + Current.family.income_statement.totals(transactions_scope: transactions_query) + end end def clear_filter @@ -140,16 +157,47 @@ class TransactionsController < ApplicationController def search_params cleaned_params = params.fetch(:q, {}) - .permit( - :start_date, :end_date, :search, :amount, - :amount_operator, accounts: [], account_ids: [], - categories: [], merchants: [], types: [], tags: [] - ) - .to_h - .compact_blank + .permit( + :start_date, :end_date, :search, :amount, + :amount_operator, accounts: [], account_ids: [], + categories: [], merchants: [], types: [], tags: [] + ) + .to_h + .compact_blank cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present? + # ------------------------------------------------------------------- + # Performance optimisation + # ------------------------------------------------------------------- + # When a user lands on the Transactions page without an explicit date + # filter, the previous behaviour queried *all* historical transactions + # for the family. For large datasets this results in very expensive + # SQL (as shown in Skylight) – particularly the aggregation queries + # used for @totals. To keep the UI responsive while still showing a + # sensible period of activity, we fall back to the user's preferred + # default period (stored on User#default_period, defaulting to + # "last_30_days") when **no** date filters have been supplied. + # + # This effectively changes the default view from "all-time" to a + # rolling window, dramatically reducing the rows scanned / grouped in + # Postgres without impacting the UX (the user can always clear the + # filter). + # ------------------------------------------------------------------- + if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank? + period_key = Current.user&.default_period.presence || "last_30_days" + + begin + period = Period.from_key(period_key) + cleaned_params[:start_date] = period.start_date + cleaned_params[:end_date] = period.end_date + rescue Period::InvalidKeyError + # Fallback – should never happen but keeps things safe. + cleaned_params[:start_date] = 30.days.ago.to_date + cleaned_params[:end_date] = Date.current + end + end + cleaned_params end diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index a89a9859..8c2af0f3 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -22,65 +22,68 @@ class BalanceSheet end def classification_groups - asset_groups = account_groups("asset") - liability_groups = account_groups("liability") + Rails.cache.fetch(family.build_cache_key("bs_classification_groups")) do + asset_groups = account_groups("asset") + liability_groups = account_groups("liability") - [ - ClassificationGroup.new( - key: "asset", - display_name: "Assets", - icon: "plus", - total_money: total_assets_money, - account_groups: asset_groups, - syncing?: asset_groups.any?(&:syncing?) - ), - ClassificationGroup.new( - key: "liability", - display_name: "Debts", - icon: "minus", - total_money: total_liabilities_money, - account_groups: liability_groups, - syncing?: liability_groups.any?(&:syncing?) - ) - ] + [ + ClassificationGroup.new( + key: "asset", + display_name: "Assets", + icon: "plus", + total_money: total_assets_money, + account_groups: asset_groups, + syncing?: asset_groups.any?(&:syncing?) + ), + ClassificationGroup.new( + key: "liability", + display_name: "Debts", + icon: "minus", + total_money: total_liabilities_money, + account_groups: liability_groups, + syncing?: liability_groups.any?(&:syncing?) + ) + ] + end end def account_groups(classification = nil) - classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query - classification_total = classification_accounts.sum(&:converted_balance) - account_groups = classification_accounts.group_by(&:accountable_type) - .transform_keys { |k| Accountable.from_type(k) } + Rails.cache.fetch(family.build_cache_key("bs_account_groups_#{classification || 'all'}")) do + classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query + classification_total = classification_accounts.sum(&:converted_balance) - groups = account_groups.map do |accountable, accounts| - group_total = accounts.sum(&:converted_balance) + account_groups = classification_accounts.group_by(&:accountable_type) + .transform_keys { |k| Accountable.from_type(k) } - key = accountable.model_name.param_key + groups = account_groups.map do |accountable, accounts| + group_total = accounts.sum(&:converted_balance) - AccountGroup.new( - id: classification ? "#{classification}_#{key}_group" : "#{key}_group", - key: key, - name: accountable.display_name, - classification: accountable.classification, - total: group_total, - total_money: Money.new(group_total, currency), - weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100, - missing_rates?: accounts.any? { |a| a.missing_rates? }, - color: accountable.color, - syncing?: accounts.any?(&:is_syncing), - accounts: accounts.map do |account| - account.define_singleton_method(:weight) do - classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100 - end + key = accountable.model_name.param_key - account - end.sort_by(&:weight).reverse - ) - end + group = AccountGroup.new( + id: classification ? "#{classification}_#{key}_group" : "#{key}_group", + key: key, + name: accountable.display_name, + classification: accountable.classification, + total: group_total, + total_money: Money.new(group_total, currency), + weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100, + missing_rates?: accounts.any? { |a| a.missing_rates? }, + color: accountable.color, + syncing?: accounts.any?(&:is_syncing), + accounts: accounts.map do |account| + account + end.sort_by(&:converted_balance).reverse + ) - groups.sort_by do |group| - manual_order = Accountable::TYPES - type_name = group.key.camelize - manual_order.index(type_name) || Float::INFINITY + group + end + + groups.sort_by do |group| + manual_order = Accountable::TYPES + type_name = group.key.camelize + manual_order.index(type_name) || Float::INFINITY + end end end diff --git a/app/views/accountable_sparklines/show.html.erb b/app/views/accountable_sparklines/show.html.erb index ba915d57..236c3616 100644 --- a/app/views/accountable_sparklines/show.html.erb +++ b/app/views/accountable_sparklines/show.html.erb @@ -1,11 +1,13 @@ -<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %> -
<%= number_to_percentage(weight, precision: 2) %>
+<%= number_to_percentage(effective_weight, precision: 2) %>