1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-18 20:59:39 +02:00

Balance sheet cache layer, non-blocking sync UI (#2356)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* 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:
Zach Gollwitzer 2025-06-10 18:20:06 -04:00 committed by GitHub
parent dab693d74f
commit 10ce2c8e23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 529 additions and 466 deletions

View file

@ -2,25 +2,24 @@ class AccountableSparklinesController < ApplicationController
def show
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
# Pre-load the series to catch any errors before rendering
@series = Rails.cache.fetch(cache_key) do
account_ids = family.accounts.active.where(accountable_type: @accountable.name).pluck(:id)
etag_key = cache_key
builder = Balance::ChartSeriesBuilder.new(
account_ids: account_ids,
currency: family.currency,
period: Period.last_30_days,
favorable_direction: @accountable.favorable_direction,
interval: "1 day"
)
# Use HTTP conditional GET so the client receives 304 Not Modified when possible.
if stale?(etag: etag_key, last_modified: family.latest_sync_completed_at)
@series = Rails.cache.fetch(etag_key, expires_in: 24.hours) do
builder = Balance::ChartSeriesBuilder.new(
account_ids: account_ids,
currency: family.currency,
period: Period.last_30_days,
favorable_direction: @accountable.favorable_direction,
interval: "1 day"
)
builder.balance_series
builder.balance_series
end
render layout: false
end
render layout: false
rescue => e
Rails.logger.error "Accountable sparkline error for #{@accountable&.name}: #{e.message}"
render partial: "accountable_sparklines/error", layout: false
end
private
@ -28,7 +27,15 @@ class AccountableSparklinesController < ApplicationController
Current.family
end
def accountable
Accountable.from_type(params[:accountable_type]&.classify)
end
def account_ids
family.accounts.active.where(accountable_type: accountable.name).pluck(:id)
end
def cache_key
family.build_cache_key("#{@accountable.name}_sparkline")
family.build_cache_key("#{@accountable.name}_sparkline", invalidate_on_data_updates: true)
end
end

View file

@ -23,12 +23,14 @@ class AccountsController < ApplicationController
end
def sparkline
# Pre-load the sparkline series to catch any errors before rendering
@sparkline_series = @account.sparkline_series
render layout: false
rescue => e
Rails.logger.error "Sparkline error for account #{@account.id}: #{e.message}"
render partial: "accounts/sparkline_error", layout: false
etag_key = @account.family.build_cache_key("#{@account.id}_sparkline", invalidate_on_data_updates: true)
# Short-circuit with 304 Not Modified when the client already has the latest version.
# We defer the expensive series computation until we know the content is stale.
if stale?(etag: etag_key, last_modified: @account.family.latest_sync_completed_at)
@sparkline_series = @account.sparkline_series
render layout: false
end
end
private

View file

@ -61,18 +61,6 @@ class Account < ApplicationRecord
end
end
def syncing?
self_syncing = syncs.visible.any?
# Since Plaid Items sync as a "group", if the item is syncing, even if the account
# sync hasn't yet started (i.e. we're still fetching the Plaid data), show it as syncing in UI.
if linked?
plaid_account&.plaid_item&.syncing? || self_syncing
else
self_syncing
end
end
def institution_domain
url_string = plaid_account&.plaid_item&.institution_url
return nil unless url_string.present?

View file

@ -24,20 +24,10 @@ module Account::Chartable
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
cache_key = family.build_cache_key("#{id}_sparkline", invalidate_on_data_updates: true)
Rails.cache.fetch(cache_key) do
Rails.cache.fetch(cache_key, expires_in: 24.hours) do
balance_series
end
rescue => e
Rails.logger.error "Sparkline series error for account #{id}: #{e.message}"
# Return empty series as fallback
Series.new(
start_date: 30.days.ago.to_date,
end_date: Date.current,
interval: "1 day",
values: [],
favorable_direction: favorable_direction
)
end
end

View file

@ -16,13 +16,13 @@ class Account::SyncCompleteEvent
locals: { account: account }
)
# Replace the groups this account belongs to in the sidebar
account_group_ids.each do |id|
# Replace the groups this account belongs to in both desktop and mobile sidebars
sidebar_targets.each do |(tab, mobile_flag)|
account.broadcast_replace_to(
account.family,
target: id,
target: account_group.dom_id(tab: tab, mobile: mobile_flag),
partial: "accounts/accountable_group",
locals: { account_group: account_group, open: true }
locals: { account_group: account_group, open: true, all_tab: tab == :all, mobile: mobile_flag }
)
end
@ -37,18 +37,18 @@ class Account::SyncCompleteEvent
end
private
# The sidebar will show the account in both its classification tab and the "all" tab,
# so we need to broadcast to both.
def account_group_ids
unless account_group.present?
error = Error.new("Account #{account.id} is not part of an account group")
Rails.logger.warn(error.message)
Sentry.capture_exception(error, level: :warning)
return []
end
# Returns an array of [tab, mobile?] tuples that should receive an update.
# We broadcast to both the classification-specific tab and the "all" tab,
# for desktop (mobile: false) and mobile (mobile: true) variants.
def sidebar_targets
return [] unless account_group.present?
id = account_group.id
[ id, "#{account_group.classification}_#{id}" ]
[
[ account_group.classification.to_sym, false ],
[ :all, false ],
[ account_group.classification.to_sym, true ],
[ :all, true ]
]
end
def account_group

View file

@ -31,11 +31,11 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
monthly_history: historical_data(period)
},
assets: {
current: family.balance_sheet.total_assets_money.format,
current: family.balance_sheet.assets.total_money.format,
monthly_history: historical_data(period, classification: "asset")
},
liabilities: {
current: family.balance_sheet.total_liabilities_money.format,
current: family.balance_sheet.liabilities.total_money.format,
monthly_history: historical_data(period, classification: "liability")
},
insights: insights_data
@ -65,8 +65,8 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
end
def insights_data
assets = family.balance_sheet.total_assets
liabilities = family.balance_sheet.total_liabilities
assets = family.balance_sheet.assets.total
liabilities = family.balance_sheet.liabilities.total
ratio = liabilities.zero? ? 0 : (liabilities / assets.to_f)
{

View file

@ -1,7 +1,7 @@
class BalanceSheet
include Monetizable
monetize :total_assets, :total_liabilities, :net_worth
monetize :net_worth
attr_reader :family
@ -9,99 +9,36 @@ class BalanceSheet
@family = family
end
def total_assets
totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance)
def assets
@assets ||= ClassificationGroup.new(
classification: "asset",
currency: family.currency,
accounts: account_totals.asset_accounts
)
end
def total_liabilities
totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance)
end
def net_worth
total_assets - total_liabilities
def liabilities
@liabilities ||= ClassificationGroup.new(
classification: "liability",
currency: family.currency,
accounts: account_totals.liability_accounts
)
end
def classification_groups
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?)
)
]
end
[ assets, liabilities ]
end
def account_groups(classification = nil)
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)
def account_groups
[ assets.account_groups, liabilities.account_groups ].flatten
end
account_groups = classification_accounts.group_by(&:accountable_type)
.transform_keys { |k| Accountable.from_type(k) }
groups = account_groups.map do |accountable, accounts|
group_total = accounts.sum(&:converted_balance)
key = accountable.model_name.param_key
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
)
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
def net_worth
assets.total - liabilities.total
end
def net_worth_series(period: Period.last_30_days)
memo_key = [ period.start_date, period.end_date ].compact.join("_")
@net_worth_series ||= {}
account_ids = active_accounts.pluck(:id)
builder = (@net_worth_series[memo_key] ||= Balance::ChartSeriesBuilder.new(
account_ids: account_ids,
currency: currency,
period: period,
favorable_direction: "up"
))
builder.balance_series
net_worth_series_builder.net_worth_series(period: period)
end
def currency
@ -109,32 +46,19 @@ class BalanceSheet
end
def syncing?
classification_groups.any? { |group| group.syncing? }
sync_status_monitor.syncing?
end
private
ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, :syncing?, keyword_init: true)
AccountGroup = Struct.new(:id, :key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, :syncing?, keyword_init: true)
def active_accounts
family.accounts.active.with_attached_logo
def sync_status_monitor
@sync_status_monitor ||= SyncStatusMonitor.new(family)
end
def totals_query
@totals_query ||= active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN syncs ON syncs.syncable_id = accounts.id AND syncs.syncable_type = 'Account' AND syncs.status IN (?) AND syncs.created_at > ?",
%w[pending syncing],
Sync::VISIBLE_FOR.ago
]))
.select(
"accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
"COUNT(syncs.id) > 0 as is_syncing",
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
)
.group(:classification, :accountable_type, :id)
.to_a
def account_totals
@account_totals ||= AccountTotals.new(family, sync_status_monitor: sync_status_monitor)
end
def net_worth_series_builder
@net_worth_series_builder ||= NetWorthSeriesBuilder.new(family)
end
end

View 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

View 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

View 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

View 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

View 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

View file

@ -6,7 +6,7 @@ module Syncable
end
def syncing?
raise NotImplementedError, "Subclasses must implement the syncing? method"
syncs.visible.any?
end
# Schedules a sync for syncable. If there is an existing sync pending/syncing for this syncable,

View file

@ -35,25 +35,6 @@ class Family < ApplicationRecord
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
# If any accounts or plaid items are syncing, the family is also syncing, even if a formal "Family Sync" is not running.
def syncing?
# Check for any in-progress syncs that belong directly to the family, to one of the
# family's accounts, or to one of the family's Plaid items. By moving the `visible`
# scope to the beginning we narrow down the candidate rows **before** performing the
# joins and by explicitly constraining the `syncable_type` for the direct Family
# match we allow Postgres to use the composite index on `(syncable_type, syncable_id)`.
Sync.visible
.joins("LEFT JOIN accounts ON accounts.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
.joins("LEFT JOIN plaid_items ON plaid_items.id = syncs.syncable_id AND syncs.syncable_type = 'PlaidItem'")
.where(
"(syncs.syncable_type = 'Family' AND syncs.syncable_id = :family_id) OR " \
"accounts.family_id = :family_id OR " \
"plaid_items.family_id = :family_id",
family_id: id
)
.exists?
end
def assigned_merchants
merchant_ids = transactions.where.not(merchant_id: nil).pluck(:merchant_id).uniq
Merchant.where(id: merchant_ids)
@ -110,13 +91,15 @@ class Family < ApplicationRecord
entries.order(:date).first&.date || Date.current
end
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
def build_cache_key(key)
def build_cache_key(key, invalidate_on_data_updates: false)
# Our data sync process updates this timestamp whenever any family account successfully completes a data update.
# By including it in the cache key, we can expire caches every time family account data changes.
data_invalidation_key = invalidate_on_data_updates ? latest_sync_completed_at : nil
[
"family",
id,
key,
entries.maximum(:updated_at)
data_invalidation_key
].compact.join("_")
end

View file

@ -46,14 +46,6 @@ class PlaidItem < ApplicationRecord
DestroyJob.perform_later(self)
end
def syncing?
Sync.joins("LEFT JOIN accounts a ON a.id = syncs.syncable_id AND syncs.syncable_type = 'Account'")
.joins("LEFT JOIN plaid_accounts pa ON pa.id = a.plaid_account_id")
.where("syncs.syncable_id = ? OR pa.plaid_item_id = ?", id, id)
.visible
.exists?
end
def import_latest_plaid_data
PlaidItem::Importer.new(self, plaid_provider: plaid_provider).import
end

View file

@ -29,13 +29,13 @@ class Sync < ApplicationRecord
state :failed
state :stale
after_all_transitions :log_status_change
after_all_transitions :handle_transition
event :start, after_commit: :report_warnings do
event :start, after_commit: :handle_start_transition do
transitions from: :pending, to: :syncing
end
event :complete do
event :complete, after_commit: :handle_completion_transition do
transitions from: :syncing, to: :completed
end
@ -163,9 +163,30 @@ class Sync < ApplicationRecord
end
end
def handle_start_transition
report_warnings
end
def handle_transition
log_status_change
family.touch(:latest_sync_activity_at)
end
def handle_completion_transition
family.touch(:latest_sync_completed_at)
end
def window_valid
if window_start_date && window_end_date && window_start_date > window_end_date
errors.add(:window_end_date, "must be greater than window_start_date")
end
end
def family
if syncable.is_a?(Family)
syncable
else
syncable.family
end
end
end

View file

@ -1,8 +0,0 @@
<%= turbo_frame_tag "#{params[:accountable_type]}_sparkline" do %>
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-3 flex items-center justify-center">
<%= icon("alert-triangle", size: "sm", class: "text-warning") %>
</div>
<p class="font-mono text-right text-xs text-warning">Error</p>
</div>
<% end %>

View file

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

View file

@ -41,7 +41,7 @@
) %>
<div>
<% family.balance_sheet.account_groups("asset").each do |group| %>
<% family.balance_sheet.assets.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<% end %>
</div>
@ -61,7 +61,7 @@
) %>
<div>
<% family.balance_sheet.account_groups("liability").each do |group| %>
<% family.balance_sheet.liabilities.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<% end %>
</div>
@ -82,7 +82,7 @@
<div>
<% family.balance_sheet.account_groups.each do |group| %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile %>
<%= render "accounts/accountable_group", account_group: group, mobile: mobile, all_tab: true %>
<% end %>
</div>
</div>

View file

@ -1,24 +1,21 @@
<%# locals: (account_group:, mobile: false, open: nil, **args) %>
<%# locals: (account_group:, mobile: false, all_tab: false, open: nil, **args) %>
<div id="<%= mobile ? "mobile_#{account_group.id}" : account_group.id %>">
<div id="<%= account_group.dom_id(tab: all_tab ? :all : nil, mobile: mobile) %>">
<% is_open = open.nil? ? account_group.accounts.any? { |account| page_active?(account_path(account)) } : open %>
<%= render DisclosureComponent.new(title: account_group.name, align: :left, open: is_open) do |disclosure| %>
<% disclosure.with_summary_content do %>
<% if account_group.syncing? %>
<div class="ml-2 group-open:hidden">
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
</div>
<% end %>
<div class="ml-auto text-right grow">
<% if account_group.syncing? %>
<div class="space-y-1">
<div class="h-5 w-24 rounded ml-auto bg-loader"></div>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% else %>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
<%= turbo_frame_tag "#{account_group.key}_sparkline", src: accountable_sparkline_path(account_group.key), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
<% end %>
</div>
<% end %>
@ -34,29 +31,23 @@
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
<div class="min-w-0 grow">
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
<div class="flex items-center gap-2 mb-0.5">
<%= tag.p account.name, class: "text-sm text-primary font-medium truncate" %>
<% if account.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "xs" } %>
<% end %>
</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div>
<% if account.syncing? %>
<div class="ml-auto text-right grow h-10">
<div class="space-y-1">
<div class="h-5 w-24 bg-loader rounded ml-auto"></div>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
</div>
<% else %>
<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
</div>

View file

@ -1,8 +0,0 @@
<%= turbo_frame_tag dom_id(@account, :sparkline) do %>
<div class="flex items-center justify-end gap-1">
<div class="w-8 h-5 flex items-center justify-center">
<%= icon("alert-triangle", size: "sm", class: "text-warning") %>
</div>
<p class="font-mono text-right text-xs text-warning">Error</p>
</div>
<% end %>

View file

@ -2,25 +2,21 @@
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<% if @account.syncing? %>
<%= render "accounts/chart_loader" %>
<% else %>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
</div>
<div class="px-4">
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: @period.comparison_label } %>
</div>
<div class="h-64 pb-4">
<% if series.any? %>
<div
<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
</div>
<% end %>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
</div>
<% end %>

View file

@ -9,18 +9,14 @@
<div class="flex items-center gap-1">
<%= tag.p account.investment? ? "Total value" : default_value_title, class: "text-sm font-medium text-secondary" %>
<% if !account.syncing? && account.investment? %>
<% if account.investment? %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: account.balance_money - account.cash_balance_money, cash: account.cash_balance_money %>
<% end %>
</div>
<div class="flex flex-row gap-2 items-baseline">
<% if account.syncing? %>
<div class="bg-loader rounded-md h-7 w-20"></div>
<% else %>
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% if account.currency != Current.family.currency %>
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
<% end %>
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
<% if account.currency != Current.family.currency %>
<%= tag.p format_money(account.balance_money.exchange_to(Current.family.currency, fallback_rate: 1)), class: "text-sm font-medium text-secondary" %>
<% end %>
</div>
</div>

View file

@ -10,10 +10,16 @@
<div class="flex items-center gap-3 overflow-hidden">
<%= render "accounts/logo", account: account %>
<div class="truncate">
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
<% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<div class="flex items-center gap-2">
<div class="truncate">
<h2 class="font-medium text-xl truncate"><%= title || account.name %></h2>
<% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<% end %>
</div>
<% if account.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</div>
</div>

View file

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

View file

@ -20,12 +20,8 @@
<div class="col-span-2 flex justify-end items-center gap-2">
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
<% if account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full"></div>
<% else %>
<%= render "shared/progress_circle", progress: cash_weight %>
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
<% end %>
<%= render "shared/progress_circle", progress: cash_weight %>
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
</div>
<div class="col-span-2 text-right">
@ -33,13 +29,7 @@
</div>
<div class="col-span-2 text-right">
<% if account.syncing? %>
<div class="flex justify-end">
<div class="w-16 h-6 bg-loader rounded-full"></div>
</div>
<% else %>
<%= tag.p format_money account.cash_balance_money %>
<% end %>
<%= tag.p format_money account.cash_balance_money %>
</div>
<div class="col-span-2 text-right">

View file

@ -17,9 +17,7 @@
</div>
<div class="col-span-2 flex justify-end items-center gap-2">
<% if holding.account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full"></div>
<% elsif holding.weight %>
<% if holding.weight %>
<%= render "shared/progress_circle", progress: holding.weight %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
@ -28,39 +26,21 @@
</div>
<div class="col-span-2 text-right">
<% if holding.account.syncing? %>
<div class="flex justify-end">
<div class="w-16 h-6 bg-loader rounded-full"></div>
</div>
<% else %>
<%= tag.p format_money holding.avg_cost %>
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
<% end %>
<%= tag.p format_money holding.avg_cost %>
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
</div>
<div class="col-span-2 text-right">
<% if holding.account.syncing? %>
<div class="flex flex-col gap-2 items-end">
<div class="w-16 h-4 bg-loader rounded-full"></div>
<div class="w-16 h-2 bg-loader rounded-full"></div>
</div>
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "--", class: "text-secondary" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
<%= tag.p "--", class: "text-secondary" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-secondary" %>
</div>
<div class="col-span-2 text-right">
<% if holding.account.syncing? %>
<div class="flex flex-col gap-2 items-end">
<div class="w-16 h-4 bg-loader rounded-full"></div>
<div class="w-16 h-2 bg-loader rounded-full"></div>
</div>
<% elsif holding.trend %>
<% if holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>

View file

@ -3,26 +3,24 @@
<div class="space-y-4" id="balance-sheet">
<% balance_sheet.classification_groups.each do |classification_group| %>
<div class="bg-container shadow-border-xs rounded-xl space-y-4 p-4">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
<span>
<%= classification_group.display_name %>
</span>
<div class="flex items-center gap-2">
<h2 class="text-lg font-medium inline-flex items-center gap-1.5">
<span>
<%= classification_group.name %>
</span>
<% if classification_group.account_groups.any? %>
<span class="text-secondary">&middot;</span>
<% if classification_group.syncing? %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="bg-loader w-full h-full rounded-md"></div>
</div>
<% else %>
<% if classification_group.account_groups.any? %>
<span class="text-secondary">&middot;</span>
<span class="text-secondary font-medium text-lg"><%= classification_group.total_money.format(precision: 0) %></span>
<% end %>
</h2>
<% if classification_group.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</h2>
</div>
<% if classification_group.account_groups.any? %>
<div class="space-y-4">
<div class="flex gap-1">
<% classification_group.account_groups.each do |account_group| %>
@ -30,19 +28,15 @@
<% end %>
</div>
<% if classification_group.syncing? %>
<p class="text-xs text-subdued animate-pulse">Calculating latest balance data...</p>
<% else %>
<div class="flex flex-wrap gap-4">
<% classification_group.account_groups.each do |account_group| %>
<div class="flex items-center gap-2 text-sm">
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= account_group.color %>;"></div>
<p class="text-secondary"><%= account_group.name %></p>
<p class="text-primary font-mono"><%= number_to_percentage(account_group.weight, precision: 0) %></p>
</div>
<% end %>
</div>
<% end %>
<div class="flex flex-wrap gap-4">
<% classification_group.account_groups.each do |account_group| %>
<div class="flex items-center gap-2 text-sm">
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= account_group.color %>;"></div>
<p class="text-secondary"><%= account_group.name %></p>
<p class="text-primary font-mono"><%= number_to_percentage(account_group.weight, precision: 0) %></p>
</div>
<% end %>
</div>
</div>
<div class="bg-surface rounded-xl p-1 space-y-1 overflow-x-auto">
@ -71,27 +65,15 @@
<p><%= account_group.name %></p>
</div>
<% if account_group.syncing? %>
<div class="flex items-center justify-between text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
<div class="w-40 shrink-0 flex justify-end">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
<div class="flex items-center justify-between text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
</div>
<% else %>
<div class="flex items-center justify-between text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
<%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
</div>
<div class="w-40 shrink-0">
<p><%= format_money(account_group.total_money) %></p>
</div>
<div class="w-40 shrink-0">
<p><%= format_money(account_group.total_money) %></p>
</div>
<% end %>
</div>
</summary>
<div>
@ -103,32 +85,20 @@
<%= link_to account.name, account_path(account) %>
</div>
<% if account.syncing? %>
<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="bg-loader rounded-md h-4 w-12"></div>
</div>
<div class="w-40 shrink-0 flex justify-end">
<div class="bg-loader rounded-md h-4 w-12"></div>
</div>
</div>
<% else %>
<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="ml-auto flex items-center text-right gap-6">
<div class="w-28 shrink-0 flex items-center justify-end gap-2">
<%
# 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 class="w-40 shrink-0">
<p><%= format_money(account.balance_money) %></p>
</div>
<%= render "pages/dashboard/group_weight", weight: account_weight, color: account_group.color %>
</div>
<% end %>
<div class="w-40 shrink-0">
<p><%= format_money(account.balance_money) %></p>
</div>
</div>
</div>
<% if idx < account_group.accounts.size - 1 %>

View file

@ -5,23 +5,22 @@
<div class="flex justify-between gap-4 px-4">
<div class="space-y-2">
<div class="space-y-2">
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
<div class="flex items-center gap-2">
<p class="text-sm text-secondary font-medium"><%= t(".title") %></p>
<% if balance_sheet.syncing? %>
<div class="flex flex-col gap-2">
<div class="bg-loader rounded-md h-7 w-20"></div>
<div class="bg-loader rounded-md h-5 w-32"></div>
</div>
<% else %>
<p class="text-primary -space-x-0.5 text-3xl font-medium">
<%= series.trend.current.format %>
</p>
<% if series.trend.nil? %>
<p class="text-sm text-secondary"><%= t(".data_not_available") %></p>
<% else %>
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
<% if balance_sheet.syncing? %>
<%= render partial: "shared/sync_indicator", locals: { size: "sm" } %>
<% end %>
</div>
<p class="text-primary -space-x-0.5 text-3xl font-medium">
<%= series.trend.current.format %>
</p>
<% if series.trend.nil? %>
<p class="text-sm text-secondary"><%= t(".data_not_available") %></p>
<% else %>
<%= render partial: "shared/trend_change", locals: { trend: series.trend, comparison_label: period.comparison_label } %>
<% end %>
</div>
</div>
@ -35,21 +34,16 @@
<% end %>
</div>
<% if balance_sheet.syncing? %>
<div class="w-full flex items-center justify-center p-4 h-52">
<div class="bg-loader rounded-md h-full w-full"></div>
</div>
<% else %>
<% if series.any? %>
<div
<% if series.any? %>
<div
id="netWorthChart"
class="w-full flex-1 min-h-52"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm"><%= t(".data_not_available") %></p>
</div>
<% end %>
</div>

View file

@ -0,0 +1,5 @@
<%# locals: (size: "md") %>
<div class="animate-spin text-gray-500">
<%= icon "loader-circle", color: "current", size: size %>
</div>

View file

@ -0,0 +1,6 @@
class AddSyncTimestampsToFamily < ActiveRecord::Migration[7.2]
def change
add_column :families, :latest_sync_activity_at, :datetime, default: -> { "CURRENT_TIMESTAMP" }
add_column :families, :latest_sync_completed_at, :datetime, default: -> { "CURRENT_TIMESTAMP" }
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do
ActiveRecord::Schema[7.2].define(version: 2025_06_10_181219) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -228,6 +228,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_06_05_031616) do
t.boolean "data_enrichment_enabled", default: false
t.boolean "early_access", default: false
t.boolean "auto_sync_on_login", default: true, null: false
t.datetime "latest_sync_activity_at", default: -> { "CURRENT_TIMESTAMP" }
t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" }
end
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View file

@ -9,13 +9,4 @@ class AccountableSparklinesControllerTest < ActionDispatch::IntegrationTest
get accountable_sparkline_url("depository")
assert_response :success
end
test "should handle sparkline errors gracefully" do
# Mock an error in the balance_series method
Balance::ChartSeriesBuilder.any_instance.stubs(:balance_series).raises(StandardError.new("Test error"))
get accountable_sparkline_url("depository")
assert_response :success
assert_match /Error/, response.body
end
end

View file

@ -25,13 +25,4 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
get sparkline_account_url(@account)
assert_response :success
end
test "should handle sparkline errors gracefully" do
# Mock an error in the balance_series method to bypass the rescue in sparkline_series
Balance::ChartSeriesBuilder.any_instance.stubs(:balance_series).raises(StandardError.new("Test error"))
get sparkline_account_url(@account)
assert_response :success
assert_match /Error/, response.body
end
end

View file

@ -6,23 +6,23 @@ class BalanceSheetTest < ActiveSupport::TestCase
end
test "calculates total assets" do
assert_equal 0, BalanceSheet.new(@family).total_assets
assert_equal 0, BalanceSheet.new(@family).assets.total
create_account(balance: 1000, accountable: Depository.new)
create_account(balance: 5000, accountable: OtherAsset.new)
create_account(balance: 10000, accountable: CreditCard.new) # ignored
assert_equal 1000 + 5000, BalanceSheet.new(@family).total_assets
assert_equal 1000 + 5000, BalanceSheet.new(@family).assets.total
end
test "calculates total liabilities" do
assert_equal 0, BalanceSheet.new(@family).total_liabilities
assert_equal 0, BalanceSheet.new(@family).liabilities.total
create_account(balance: 1000, accountable: CreditCard.new)
create_account(balance: 5000, accountable: OtherLiability.new)
create_account(balance: 10000, accountable: Depository.new) # ignored
assert_equal 1000 + 5000, BalanceSheet.new(@family).total_liabilities
assert_equal 1000 + 5000, BalanceSheet.new(@family).liabilities.total
end
test "calculates net worth" do
@ -42,8 +42,8 @@ class BalanceSheetTest < ActiveSupport::TestCase
other_liability.update!(is_active: false)
assert_equal 10000 - 1000, BalanceSheet.new(@family).net_worth
assert_equal 10000, BalanceSheet.new(@family).total_assets
assert_equal 1000, BalanceSheet.new(@family).total_liabilities
assert_equal 10000, BalanceSheet.new(@family).assets.total
assert_equal 1000, BalanceSheet.new(@family).liabilities.total
end
test "calculates asset group totals" do
@ -53,7 +53,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
create_account(balance: 5000, accountable: OtherAsset.new)
create_account(balance: 10000, accountable: CreditCard.new) # ignored
asset_groups = BalanceSheet.new(@family).account_groups("asset")
asset_groups = BalanceSheet.new(@family).assets.account_groups
assert_equal 3, asset_groups.size
assert_equal 1000 + 2000, asset_groups.find { |ag| ag.name == "Cash" }.total
@ -68,7 +68,7 @@ class BalanceSheetTest < ActiveSupport::TestCase
create_account(balance: 5000, accountable: OtherLiability.new)
create_account(balance: 10000, accountable: Depository.new) # ignored
liability_groups = BalanceSheet.new(@family).account_groups("liability")
liability_groups = BalanceSheet.new(@family).liabilities.account_groups
assert_equal 2, liability_groups.size
assert_equal 1000 + 2000, liability_groups.find { |ag| ag.name == "Credit Cards" }.total