1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-09 07:25:19 +02:00

Merge branch 'main' into fix-chat-title-edit

This commit is contained in:
Alex Hatzenbuhler 2025-05-23 14:11:59 -05:00 committed by GitHub
commit 7b5661c948
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 141 additions and 79 deletions

View file

@ -26,7 +26,24 @@ class TransactionsController < ApplicationController
params: ->(params) { params.except(:focused_record_id) } 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 end
def clear_filter def clear_filter
@ -140,16 +157,47 @@ class TransactionsController < ApplicationController
def search_params def search_params
cleaned_params = params.fetch(:q, {}) cleaned_params = params.fetch(:q, {})
.permit( .permit(
:start_date, :end_date, :search, :amount, :start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [], :amount_operator, accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: [] categories: [], merchants: [], types: [], tags: []
) )
.to_h .to_h
.compact_blank .compact_blank
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present? 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 cleaned_params
end end

View file

@ -22,65 +22,68 @@ class BalanceSheet
end end
def classification_groups def classification_groups
asset_groups = account_groups("asset") Rails.cache.fetch(family.build_cache_key("bs_classification_groups")) do
liability_groups = account_groups("liability") asset_groups = account_groups("asset")
liability_groups = account_groups("liability")
[ [
ClassificationGroup.new( ClassificationGroup.new(
key: "asset", key: "asset",
display_name: "Assets", display_name: "Assets",
icon: "plus", icon: "plus",
total_money: total_assets_money, total_money: total_assets_money,
account_groups: asset_groups, account_groups: asset_groups,
syncing?: asset_groups.any?(&:syncing?) syncing?: asset_groups.any?(&:syncing?)
), ),
ClassificationGroup.new( ClassificationGroup.new(
key: "liability", key: "liability",
display_name: "Debts", display_name: "Debts",
icon: "minus", icon: "minus",
total_money: total_liabilities_money, total_money: total_liabilities_money,
account_groups: liability_groups, account_groups: liability_groups,
syncing?: liability_groups.any?(&:syncing?) syncing?: liability_groups.any?(&:syncing?)
) )
] ]
end
end end
def account_groups(classification = nil) def account_groups(classification = nil)
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query Rails.cache.fetch(family.build_cache_key("bs_account_groups_#{classification || 'all'}")) do
classification_total = classification_accounts.sum(&:converted_balance) classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
account_groups = classification_accounts.group_by(&:accountable_type) classification_total = classification_accounts.sum(&:converted_balance)
.transform_keys { |k| Accountable.from_type(k) }
groups = account_groups.map do |accountable, accounts| account_groups = classification_accounts.group_by(&:accountable_type)
group_total = accounts.sum(&:converted_balance) .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( key = accountable.model_name.param_key
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
account group = AccountGroup.new(
end.sort_by(&:weight).reverse id: classification ? "#{classification}_#{key}_group" : "#{key}_group",
) key: key,
end 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| group
manual_order = Accountable::TYPES end
type_name = group.key.camelize
manual_order.index(type_name) || Float::INFINITY groups.sort_by do |group|
manual_order = Accountable::TYPES
type_name = group.key.camelize
manual_order.index(type_name) || Float::INFINITY
end
end end
end end

View file

@ -1,11 +1,13 @@
<%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %> <% cache Current.family.build_cache_key("#{@accountable.name}_sparkline_html") do %>
<div class="flex items-center justify-end gap-1"> <%= turbo_frame_tag "#{@accountable.model_name.param_key}_sparkline" do %>
<div class="w-8 h-3"> <div class="flex items-center justify-end gap-1">
<%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %> <div class="w-8 h-3">
</div> <%= render "shared/sparkline", id: dom_id(@accountable, :sparkline_chart), series: @series %>
</div>
<%= tag.p @series.trend.percent_formatted, <%= tag.p @series.trend.percent_formatted,
style: "color: #{@series.trend.color}", style: "color: #{@series.trend.color}",
class: "font-mono text-right text-xs font-medium text-primary" %> class: "font-mono text-right text-xs font-medium text-primary" %>
</div> </div>
<% end %>
<% end %> <% end %>

View file

@ -1,11 +1,13 @@
<%= turbo_frame_tag dom_id(@account, :sparkline) do %> <% cache Current.family.build_cache_key("account_#{@account.id}_sparkline_html") do %>
<div class="flex items-center justify-end gap-1"> <%= turbo_frame_tag dom_id(@account, :sparkline) do %>
<div class="w-8 h-5"> <div class="flex items-center justify-end gap-1">
<%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @account.sparkline_series %> <div class="w-8 h-5">
</div> <%= render "shared/sparkline", id: dom_id(@account, :sparkline_chart), series: @account.sparkline_series %>
</div>
<%= tag.p @account.sparkline_series.trend.percent_formatted, <%= tag.p @account.sparkline_series.trend.percent_formatted,
style: "color: #{@account.sparkline_series.trend.color}", style: "color: #{@account.sparkline_series.trend.color}",
class: "font-mono text-right text-xs font-medium text-primary" %> class: "font-mono text-right text-xs font-medium text-primary" %>
</div> </div>
<% end %>
<% end %> <% end %>

View file

@ -116,7 +116,12 @@
<% else %> <% else %>
<div class="ml-auto flex items-center text-right gap-6"> <div class="ml-auto flex items-center text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2"> <div class="w-28 shrink-0 flex items-center justify-end gap-2">
<%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %> <%
# Calculate weight as percentage of classification total
classification_total = classification_group.total_money.amount
account_weight = classification_total.zero? ? 0 : account.converted_balance / classification_total * 100
%>
<%= render "pages/dashboard/group_weight", weight: account_weight, color: account_group.color %>
</div> </div>
<div class="w-40 shrink-0"> <div class="w-40 shrink-0">

View file

@ -1,10 +1,12 @@
<%# locals: (weight:, color:) %> <%# locals: (weight:, color:) %>
<% effective_weight = weight.presence || 0 %>
<div class="w-full flex items-center justify-between gap-2"> <div class="w-full flex items-center justify-between gap-2">
<div class="flex gap-[3px]"> <div class="flex gap-[3px]">
<% 10.times do |i| %> <% 10.times do |i| %>
<div class="w-0.5 h-2.5 rounded-lg <%= i < (weight / 10.0).ceil ? "" : "opacity-20" %>" style="background-color: <%= color %>;"></div> <div class="w-0.5 h-2.5 rounded-lg <%= i < (effective_weight / 10.0).ceil ? "" : "opacity-20" %>" style="background-color: <%= color %>;"></div>
<% end %> <% end %>
</div> </div>
<p class="text-sm"><%= number_to_percentage(weight, precision: 2) %></p> <p class="text-sm"><%= number_to_percentage(effective_weight, precision: 2) %></p>
</div> </div>