mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-06 22:15:20 +02:00
Balance sheet cache layer, non-blocking sync UI (#2356)
* Balance sheet cache layer with cache-busting * Update family cache timestamps during Sync * Less blocking sync loaders * Consolidate family data caching key logic * Fix turbo stream broadcasts * Remove dev delay * Add back account group sorting
This commit is contained in:
parent
dab693d74f
commit
10ce2c8e23
35 changed files with 529 additions and 466 deletions
61
app/models/balance_sheet/account_group.rb
Normal file
61
app/models/balance_sheet/account_group.rb
Normal file
|
@ -0,0 +1,61 @@
|
|||
class BalanceSheet::AccountGroup
|
||||
include Monetizable
|
||||
|
||||
monetize :total, as: :total_money
|
||||
|
||||
attr_reader :name, :color, :accountable_type, :accounts
|
||||
|
||||
def initialize(name:, color:, accountable_type:, accounts:, classification_group:)
|
||||
@name = name
|
||||
@color = color
|
||||
@accountable_type = accountable_type
|
||||
@accounts = accounts
|
||||
@classification_group = classification_group
|
||||
end
|
||||
|
||||
# A stable DOM id for this group.
|
||||
# Example outputs:
|
||||
# dom_id(tab: :asset) # => "asset_depository"
|
||||
# dom_id(tab: :all, mobile: true) # => "mobile_all_depository"
|
||||
#
|
||||
# Keeping all of the logic here means the view layer and broadcaster only
|
||||
# need to ask the object for its DOM id instead of rebuilding string
|
||||
# fragments in multiple places.
|
||||
def dom_id(tab: nil, mobile: false)
|
||||
parts = []
|
||||
parts << "mobile" if mobile
|
||||
parts << (tab ? tab.to_s : classification.to_s)
|
||||
parts << key
|
||||
parts.compact.join("_")
|
||||
end
|
||||
|
||||
def key
|
||||
accountable_type.to_s.underscore
|
||||
end
|
||||
|
||||
def total
|
||||
accounts.sum(&:converted_balance)
|
||||
end
|
||||
|
||||
def weight
|
||||
return 0 if classification_group.total.zero?
|
||||
|
||||
total / classification_group.total.to_d * 100
|
||||
end
|
||||
|
||||
def syncing?
|
||||
accounts.any?(&:syncing?)
|
||||
end
|
||||
|
||||
# "asset" or "liability"
|
||||
def classification
|
||||
classification_group.classification
|
||||
end
|
||||
|
||||
def currency
|
||||
classification_group.currency
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :classification_group
|
||||
end
|
63
app/models/balance_sheet/account_totals.rb
Normal file
63
app/models/balance_sheet/account_totals.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
class BalanceSheet::AccountTotals
|
||||
def initialize(family, sync_status_monitor:)
|
||||
@family = family
|
||||
@sync_status_monitor = sync_status_monitor
|
||||
end
|
||||
|
||||
def asset_accounts
|
||||
@asset_accounts ||= account_rows.filter { |t| t.classification == "asset" }
|
||||
end
|
||||
|
||||
def liability_accounts
|
||||
@liability_accounts ||= account_rows.filter { |t| t.classification == "liability" }
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :family, :sync_status_monitor
|
||||
|
||||
AccountRow = Data.define(:account, :converted_balance, :is_syncing) do
|
||||
def syncing? = is_syncing
|
||||
|
||||
# Allows Rails path helpers to generate URLs from the wrapper
|
||||
def to_param = account.to_param
|
||||
delegate_missing_to :account
|
||||
end
|
||||
|
||||
def active_accounts
|
||||
@active_accounts ||= family.accounts.active.with_attached_logo
|
||||
end
|
||||
|
||||
def account_rows
|
||||
@account_rows ||= query.map do |account_row|
|
||||
AccountRow.new(
|
||||
account: account_row,
|
||||
converted_balance: account_row.converted_balance,
|
||||
is_syncing: sync_status_monitor.account_syncing?(account_row)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def cache_key
|
||||
family.build_cache_key(
|
||||
"balance_sheet_account_rows",
|
||||
invalidate_on_data_updates: true
|
||||
)
|
||||
end
|
||||
|
||||
def query
|
||||
@query ||= Rails.cache.fetch(cache_key) do
|
||||
active_accounts
|
||||
.joins(ActiveRecord::Base.sanitize_sql_array([
|
||||
"LEFT JOIN exchange_rates ON exchange_rates.date = ? AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?",
|
||||
Date.current,
|
||||
family.currency
|
||||
]))
|
||||
.select(
|
||||
"accounts.*",
|
||||
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance"
|
||||
)
|
||||
.group(:classification, :accountable_type, :id)
|
||||
.to_a
|
||||
end
|
||||
end
|
||||
end
|
61
app/models/balance_sheet/classification_group.rb
Normal file
61
app/models/balance_sheet/classification_group.rb
Normal file
|
@ -0,0 +1,61 @@
|
|||
class BalanceSheet::ClassificationGroup
|
||||
include Monetizable
|
||||
|
||||
monetize :total, as: :total_money
|
||||
|
||||
attr_reader :classification, :currency
|
||||
|
||||
def initialize(classification:, currency:, accounts:)
|
||||
@classification = normalize_classification!(classification)
|
||||
@name = name
|
||||
@currency = currency
|
||||
@accounts = accounts
|
||||
end
|
||||
|
||||
def name
|
||||
classification.titleize.pluralize
|
||||
end
|
||||
|
||||
def icon
|
||||
classification == "asset" ? "plus" : "minus"
|
||||
end
|
||||
|
||||
def total
|
||||
accounts.sum(&:converted_balance)
|
||||
end
|
||||
|
||||
def syncing?
|
||||
accounts.any?(&:syncing?)
|
||||
end
|
||||
|
||||
# For now, we group by accountable type. This can be extended in the future to support arbitrary user groupings.
|
||||
def account_groups
|
||||
groups = accounts.group_by(&:accountable_type)
|
||||
.transform_keys { |at| Accountable.from_type(at) }
|
||||
.map do |accountable, account_rows|
|
||||
BalanceSheet::AccountGroup.new(
|
||||
name: accountable.display_name,
|
||||
color: accountable.color,
|
||||
accountable_type: accountable,
|
||||
accounts: account_rows,
|
||||
classification_group: self
|
||||
)
|
||||
end
|
||||
|
||||
# Sort the groups using the manual order defined by Accountable::TYPES so that
|
||||
# the UI displays account groups in a predictable, domain-specific sequence.
|
||||
groups.sort_by do |group|
|
||||
manual_order = Accountable::TYPES
|
||||
type_name = group.key.camelize
|
||||
manual_order.index(type_name) || Float::INFINITY
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :accounts
|
||||
|
||||
def normalize_classification!(classification)
|
||||
raise ArgumentError, "Invalid classification: #{classification}" unless %w[asset liability].include?(classification)
|
||||
classification
|
||||
end
|
||||
end
|
38
app/models/balance_sheet/net_worth_series_builder.rb
Normal file
38
app/models/balance_sheet/net_worth_series_builder.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
class BalanceSheet::NetWorthSeriesBuilder
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def net_worth_series(period: Period.last_30_days)
|
||||
Rails.cache.fetch(cache_key(period)) do
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: active_account_ids,
|
||||
currency: family.currency,
|
||||
period: period,
|
||||
favorable_direction: "up"
|
||||
)
|
||||
|
||||
builder.balance_series
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :family
|
||||
|
||||
def active_account_ids
|
||||
@active_account_ids ||= family.accounts.active.with_attached_logo.pluck(:id)
|
||||
end
|
||||
|
||||
def cache_key(period)
|
||||
key = [
|
||||
"balance_sheet_net_worth_series",
|
||||
period.start_date,
|
||||
period.end_date
|
||||
].compact.join("_")
|
||||
|
||||
family.build_cache_key(
|
||||
key,
|
||||
invalidate_on_data_updates: true
|
||||
)
|
||||
end
|
||||
end
|
35
app/models/balance_sheet/sync_status_monitor.rb
Normal file
35
app/models/balance_sheet/sync_status_monitor.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
class BalanceSheet::SyncStatusMonitor
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def syncing?
|
||||
syncing_account_ids.any?
|
||||
end
|
||||
|
||||
def account_syncing?(account)
|
||||
syncing_account_ids.include?(account.id)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :family
|
||||
|
||||
def syncing_account_ids
|
||||
Rails.cache.fetch(cache_key) do
|
||||
Sync.visible
|
||||
.where(syncable_type: "Account", syncable_id: family.accounts.active.pluck(:id))
|
||||
.pluck(:syncable_id)
|
||||
.to_set
|
||||
end
|
||||
end
|
||||
|
||||
# We re-fetch the set of syncing IDs any time a sync that belongs to the family is started or completed.
|
||||
# This ensures we're always fetching the latest sync statuses without re-querying on every page load in idle times (no syncs happening).
|
||||
def cache_key
|
||||
[
|
||||
"balance_sheet_sync_status",
|
||||
family.id,
|
||||
family.latest_sync_activity_at
|
||||
].join("_")
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue