mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Dashboard View and Calculations (#521)
* Handle Turbo updates with tabs Fixes #491 * Add Filterable concern for controllers * Add trendline chart * Extract common UI to partials * Series refactor * Put placeholders for calculations in * Add classification generated column to account * Add basic net worth calculation * Add net worth tests * Get net worth graph working * Fix lint errors * Implement asset grouping query * Make trends and series more intuitive * Fully functional dashboard * Remove logging
This commit is contained in:
parent
680a91d807
commit
6f0e410684
37 changed files with 594 additions and 74 deletions
|
@ -11,27 +11,110 @@ class Account < ApplicationRecord
|
|||
|
||||
before_create :check_currency
|
||||
|
||||
def balance_series(period)
|
||||
MoneySeries.new(
|
||||
balances.in_period(period).order(:date),
|
||||
{ trend_type: classification }
|
||||
)
|
||||
def trend(period = Period.all)
|
||||
first = balances.in_period(period).order(:date).first
|
||||
last = balances.in_period(period).order(date: :desc).first
|
||||
Trend.new(current: last.balance, previous: first.balance, type: classification)
|
||||
end
|
||||
|
||||
def valuation_series
|
||||
MoneySeries.new(
|
||||
valuations.order(:date),
|
||||
{ trend_type: classification, amount_accessor: :value }
|
||||
)
|
||||
end
|
||||
# TODO: We will need a better way to encapsulate large queries & transformation logic, but leaving all in one spot until
|
||||
# we have a better understanding of the requirements
|
||||
def self.by_group(period = Period.all)
|
||||
ranked_balances_cte = joins(:balances)
|
||||
.select("
|
||||
account_balances.account_id,
|
||||
account_balances.balance,
|
||||
account_balances.date,
|
||||
ROW_NUMBER() OVER (PARTITION BY account_balances.account_id ORDER BY date ASC) AS rn_asc,
|
||||
ROW_NUMBER() OVER (PARTITION BY account_balances.account_id ORDER BY date DESC) AS rn_desc
|
||||
")
|
||||
|
||||
def check_currency
|
||||
if self.currency == self.family.currency
|
||||
self.converted_balance = self.balance
|
||||
self.converted_currency = self.currency
|
||||
else
|
||||
self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance)
|
||||
self.converted_currency = self.family.currency
|
||||
if period.date_range
|
||||
ranked_balances_cte = ranked_balances_cte.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end)
|
||||
end
|
||||
|
||||
accounts_with_period_balances = AccountBalance.with(
|
||||
ranked_balances: ranked_balances_cte
|
||||
)
|
||||
.from("ranked_balances AS rb")
|
||||
.joins("JOIN accounts a ON a.id = rb.account_id")
|
||||
.select("
|
||||
a.name,
|
||||
a.accountable_type,
|
||||
a.classification,
|
||||
SUM(CASE WHEN rb.rn_asc = 1 THEN rb.balance ELSE 0 END) AS start_balance,
|
||||
MAX(CASE WHEN rb.rn_asc = 1 THEN rb.date ELSE NULL END) as start_date,
|
||||
SUM(CASE WHEN rb.rn_desc = 1 THEN rb.balance ELSE 0 END) AS end_balance,
|
||||
MAX(CASE WHEN rb.rn_desc = 1 THEN rb.date ELSE NULL END) as end_date
|
||||
")
|
||||
.where("rb.rn_asc = 1 OR rb.rn_desc = 1")
|
||||
.group("a.id")
|
||||
.order("end_balance")
|
||||
.to_a
|
||||
|
||||
assets = accounts_with_period_balances.select { |row| row.classification == "asset" }
|
||||
liabilities = accounts_with_period_balances.select { |row| row.classification == "liability" }
|
||||
|
||||
total_assets = assets.sum(&:end_balance)
|
||||
total_liabilities = liabilities.sum(&:end_balance)
|
||||
|
||||
{
|
||||
asset: {
|
||||
total: total_assets,
|
||||
groups: assets.group_by(&:accountable_type).transform_values do |rows|
|
||||
end_balance = rows.sum(&:end_balance)
|
||||
start_balance = rows.sum(&:start_balance)
|
||||
{
|
||||
start_balance: start_balance,
|
||||
end_balance: end_balance,
|
||||
allocation: (end_balance / total_assets * 100).round(2),
|
||||
trend: Trend.new(current: end_balance, previous: start_balance, type: "asset"),
|
||||
accounts: rows.map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
start_balance: account.start_balance,
|
||||
end_balance: account.end_balance,
|
||||
allocation: (account.end_balance / total_assets * 100).round(2),
|
||||
trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: "asset")
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
},
|
||||
liability: {
|
||||
total: total_liabilities,
|
||||
groups: liabilities.group_by(&:accountable_type).transform_values do |rows|
|
||||
end_balance = rows.sum(&:end_balance)
|
||||
start_balance = rows.sum(&:start_balance)
|
||||
{
|
||||
start_balance: start_balance,
|
||||
end_balance: end_balance,
|
||||
allocation: (end_balance / total_liabilities * 100).round(2),
|
||||
trend: Trend.new(current: end_balance, previous: start_balance, type: "liability"),
|
||||
accounts: rows.map do |account|
|
||||
{
|
||||
name: account.name,
|
||||
start_balance: account.start_balance,
|
||||
end_balance: account.end_balance,
|
||||
allocation: (account.end_balance / total_liabilities * 100).round(2),
|
||||
trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: "liability")
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_currency
|
||||
if self.currency == self.family.currency
|
||||
self.converted_balance = self.balance
|
||||
self.converted_currency = self.currency
|
||||
else
|
||||
self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance)
|
||||
self.converted_currency = self.family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,7 +3,10 @@ class AccountBalance < ApplicationRecord
|
|||
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
|
||||
def trend(previous)
|
||||
Trend.new(balance, previous&.balance)
|
||||
def self.to_series(account, period = Period.all)
|
||||
MoneySeries.new(
|
||||
in_period(period).order(:date),
|
||||
{ trend_type: account.classification }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,4 +22,10 @@ class Period
|
|||
]
|
||||
|
||||
INDEX = BUILTIN.index_by(&:name)
|
||||
|
||||
BUILTIN.each do |period|
|
||||
define_singleton_method(period.name) do
|
||||
period
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,17 +1,16 @@
|
|||
class Trend
|
||||
attr_reader :current, :previous, :type
|
||||
|
||||
def initialize(current:, previous: nil, type: "asset")
|
||||
def initialize(current: nil, previous: nil, type: "asset")
|
||||
@current = current
|
||||
@previous = previous
|
||||
@type = type # asset means positive trend is good, liability means negative trend is good
|
||||
end
|
||||
|
||||
def direction
|
||||
return "flat" unless @previous
|
||||
return "up" if @current > @previous
|
||||
return "down" if @current < @previous
|
||||
"flat"
|
||||
return "flat" if @current == @previous
|
||||
return "up" if @previous.nil? || (@current && @current > @previous)
|
||||
"down"
|
||||
end
|
||||
|
||||
def amount
|
||||
|
@ -22,6 +21,6 @@ class Trend
|
|||
def percent
|
||||
return 0 if @previous.nil?
|
||||
return Float::INFINITY if @previous == 0
|
||||
((@current - @previous).abs / @previous.to_f * 100).round(1)
|
||||
((@current - @previous).abs / @previous.abs.to_f * 100).round(1)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,8 +3,13 @@ class Valuation < ApplicationRecord
|
|||
|
||||
after_commit :sync_account
|
||||
|
||||
def trend(previous)
|
||||
Trend.new(current: value, previous: previous&.value, type: account.classification)
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
|
||||
def self.to_series(account, period = Period.all)
|
||||
MoneySeries.new(
|
||||
in_period(period).order(:date),
|
||||
{ trend_type: account.classification, amount_accessor: :value }
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue