mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 21:29:38 +02:00
Add the ability to "rollup" values in a time series (#554)
* Clean up time series models * Add value group rollup class for summarizing hierarchical data * Integrate new classes * Update UI to use new patterns * Update D3 charts to expect new data format * Clean up account model * More cleanup * Money improvements * Use new money fields * Remove invalid fixture data to avoid orphaned accountables * Update time series to work better with collections * Fix tests and UI bugs
This commit is contained in:
parent
0a8518506c
commit
f904d9d062
34 changed files with 687 additions and 391 deletions
|
@ -11,8 +11,8 @@ class AccountsController < ApplicationController
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@account = Current.family.accounts.find(params[:id])
|
@account = Current.family.accounts.find(params[:id])
|
||||||
@balance_series = @account.balances.to_series(@account, @period)
|
@balance_series = @account.series(@period)
|
||||||
@valuation_series = @account.valuations.to_series(@account)
|
@valuation_series = @account.valuations.to_series
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
|
|
@ -29,6 +29,7 @@ module ApplicationHelper
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def sidebar_modal(&block)
|
def sidebar_modal(&block)
|
||||||
content = capture &block
|
content = capture &block
|
||||||
render partial: "shared/sidebar_modal", locals: { content: content }
|
render partial: "shared/sidebar_modal", locals: { content: content }
|
||||||
|
|
|
@ -16,7 +16,8 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderChart = () => {
|
renderChart = () => {
|
||||||
this.drawChart(this.seriesValue);
|
const data = this.prepareData(this.seriesValue);
|
||||||
|
this.drawChart(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
trendStyles(trendDirection) {
|
trendStyles(trendDirection) {
|
||||||
|
@ -36,25 +37,27 @@ export default class extends Controller {
|
||||||
}[trendDirection];
|
}[trendDirection];
|
||||||
}
|
}
|
||||||
|
|
||||||
drawChart(series) {
|
prepareData(series) {
|
||||||
const data = series.data.map((b) => ({
|
return series.values.map((b) => ({
|
||||||
date: new Date(b.date + "T00:00:00"),
|
date: new Date(b.date + "T00:00:00"),
|
||||||
value: +b.amount,
|
value: +b.value.amount,
|
||||||
styles: this.trendStyles(b.trend.direction),
|
styles: this.trendStyles(b.trend.direction),
|
||||||
trend: b.trend,
|
trend: b.trend,
|
||||||
formatted: {
|
formatted: {
|
||||||
value: Intl.NumberFormat("en-US", {
|
value: Intl.NumberFormat(undefined, {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: b.currency.iso_code || "USD",
|
currency: b.value.currency || "USD",
|
||||||
}).format(b.amount),
|
}).format(b.value.amount),
|
||||||
change: Intl.NumberFormat("en-US", {
|
change: Intl.NumberFormat(undefined, {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: b.currency.iso_code || "USD",
|
currency: b.value.currency || "USD",
|
||||||
signDisplay: "always",
|
signDisplay: "always",
|
||||||
}).format(b.trend.amount),
|
}).format(b.trend.value.amount),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
drawChart(data) {
|
||||||
const chartContainer = d3.select(this.element);
|
const chartContainer = d3.select(this.element);
|
||||||
|
|
||||||
// Clear any existing chart
|
// Clear any existing chart
|
||||||
|
@ -77,6 +80,11 @@ export default class extends Controller {
|
||||||
])
|
])
|
||||||
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
|
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
|
||||||
|
|
||||||
|
if (data.length === 1) {
|
||||||
|
this.renderEmpty(svg, initialDimensions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const margin = { top: 20, right: 1, bottom: 30, left: 1 },
|
const margin = { top: 20, right: 1, bottom: 30, left: 1 },
|
||||||
width = +svg.attr("width") - margin.left - margin.right,
|
width = +svg.attr("width") - margin.left - margin.right,
|
||||||
height = +svg.attr("height") - margin.top - margin.bottom,
|
height = +svg.attr("height") - margin.top - margin.bottom,
|
||||||
|
@ -237,4 +245,26 @@ export default class extends Controller {
|
||||||
tooltip.style("opacity", 0);
|
tooltip.style("opacity", 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dot in middle of chart as placeholder for empty chart
|
||||||
|
renderEmpty(svg, { width, height }) {
|
||||||
|
svg
|
||||||
|
.append("line")
|
||||||
|
.attr("x1", width / 2)
|
||||||
|
.attr("y1", 0)
|
||||||
|
.attr("x2", width / 2)
|
||||||
|
.attr("y2", height)
|
||||||
|
.attr("stroke", tailwindColors.gray[300])
|
||||||
|
.attr("stroke-dasharray", "4, 4");
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append("circle")
|
||||||
|
.attr("cx", width / 2)
|
||||||
|
.attr("cy", height / 2)
|
||||||
|
.attr("r", 4)
|
||||||
|
.style("fill", tailwindColors.gray[400]);
|
||||||
|
|
||||||
|
svg.selectAll(".tick").remove();
|
||||||
|
svg.selectAll(".domain").remove();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@ import tailwindColors from "@maybe/tailwindcolors";
|
||||||
import * as d3 from "d3";
|
import * as d3 from "d3";
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static values = { series: Object, classification: String };
|
static values = { series: Object };
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.renderChart(this.seriesValue);
|
this.renderChart(this.seriesValue);
|
||||||
|
@ -15,15 +15,18 @@ export default class extends Controller {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderChart = () => {
|
renderChart = () => {
|
||||||
this.drawChart(this.seriesValue);
|
const data = this.prepareData(this.seriesValue);
|
||||||
|
this.drawChart(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
prepareData(series) {
|
||||||
|
return series.values.map((d) => ({
|
||||||
|
date: new Date(d.date + "T00:00:00"),
|
||||||
|
value: +d.value.amount,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
drawChart(series) {
|
drawChart(data) {
|
||||||
const data = series.data.map((d) => ({
|
|
||||||
...d,
|
|
||||||
date: new Date(d.date + "T00:00:00"),
|
|
||||||
amount: +d.amount,
|
|
||||||
}));
|
|
||||||
const chartContainer = d3.select(this.element);
|
const chartContainer = d3.select(this.element);
|
||||||
chartContainer.selectAll("*").remove();
|
chartContainer.selectAll("*").remove();
|
||||||
const initialDimensions = {
|
const initialDimensions = {
|
||||||
|
@ -48,7 +51,7 @@ export default class extends Controller {
|
||||||
const height = initialDimensions.height - margin.top - margin.bottom;
|
const height = initialDimensions.height - margin.top - margin.bottom;
|
||||||
|
|
||||||
const isLiability = this.classificationValue === "liability";
|
const isLiability = this.classificationValue === "liability";
|
||||||
const trendDirection = data[data.length - 1].amount - data[0].amount;
|
const trendDirection = data[data.length - 1].value - data[0].value;
|
||||||
let lineColor;
|
let lineColor;
|
||||||
|
|
||||||
if (trendDirection > 0) {
|
if (trendDirection > 0) {
|
||||||
|
@ -69,8 +72,8 @@ export default class extends Controller {
|
||||||
.domain(d3.extent(data, (d) => d.date));
|
.domain(d3.extent(data, (d) => d.date));
|
||||||
|
|
||||||
const PADDING = 0.05;
|
const PADDING = 0.05;
|
||||||
const dataMin = d3.min(data, (d) => d.amount);
|
const dataMin = d3.min(data, (d) => d.value);
|
||||||
const dataMax = d3.max(data, (d) => d.amount);
|
const dataMax = d3.max(data, (d) => d.value);
|
||||||
const padding = (dataMax - dataMin) * PADDING;
|
const padding = (dataMax - dataMin) * PADDING;
|
||||||
|
|
||||||
const yScale = d3
|
const yScale = d3
|
||||||
|
@ -81,7 +84,7 @@ export default class extends Controller {
|
||||||
const line = d3
|
const line = d3
|
||||||
.line()
|
.line()
|
||||||
.x((d) => xScale(d.date))
|
.x((d) => xScale(d.date))
|
||||||
.y((d) => yScale(d.amount));
|
.y((d) => yScale(d.value));
|
||||||
|
|
||||||
svg
|
svg
|
||||||
.append("path")
|
.append("path")
|
||||||
|
|
|
@ -26,10 +26,8 @@ class Account < ApplicationRecord
|
||||||
%w[name]
|
%w[name]
|
||||||
end
|
end
|
||||||
|
|
||||||
def trend(period = Period.all)
|
def balance_on(date)
|
||||||
first = balances.in_period(period).order(:date).first
|
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||||
last = balances.in_period(period).order(date: :desc).first
|
|
||||||
Trend.new(current: last.balance, previous: first.balance, type: classification)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.by_provider
|
def self.by_provider
|
||||||
|
@ -41,55 +39,28 @@ class Account < ApplicationRecord
|
||||||
exists?(status: "syncing")
|
exists?(status: "syncing")
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO: We will need a better way to encapsulate large queries & transformation logic, but leaving all in one spot until
|
def series(period = Period.all)
|
||||||
# we have a better understanding of the requirements
|
TimeSeries.from_collection(balances.in_period(period), :balance_money)
|
||||||
def self.by_group(period = Period.all)
|
|
||||||
ranked_balances_cte = active.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
|
|
||||||
")
|
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
accounts_with_period_balances = AccountBalance.with(
|
def self.by_group(period = Period.all)
|
||||||
ranked_balances: ranked_balances_cte
|
grouped_accounts = { assets: ValueGroup.new("Assets"), liabilities: ValueGroup.new("Liabilities") }
|
||||||
)
|
|
||||||
.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" }
|
Accountable.by_classification.each do |classification, types|
|
||||||
liabilities = accounts_with_period_balances.select { |row| row.classification == "liability" }
|
types.each do |type|
|
||||||
|
group = grouped_accounts[classification.to_sym].add_child_node(type)
|
||||||
|
Accountable.from_type(type).includes(:account).each do |accountable|
|
||||||
|
account = accountable.account
|
||||||
|
value_node = group.add_value_node(account)
|
||||||
|
value_node.attach_series(account.series(period))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
total_assets = assets.sum(&:end_balance)
|
grouped_accounts
|
||||||
total_liabilities = liabilities.sum(&:end_balance)
|
|
||||||
|
|
||||||
{
|
|
||||||
asset: build_group_summary(assets, "asset"),
|
|
||||||
liability: build_group_summary(liabilities, "liability")
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_currency
|
def check_currency
|
||||||
if self.currency == self.family.currency
|
if self.currency == self.family.currency
|
||||||
self.converted_balance = self.balance
|
self.converted_balance = self.balance
|
||||||
|
@ -99,34 +70,4 @@ class Account < ApplicationRecord
|
||||||
self.converted_currency = self.family.currency
|
self.converted_currency = self.family.currency
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.build_group_summary(accounts, classification)
|
|
||||||
total_balance = accounts.sum(&:end_balance)
|
|
||||||
{
|
|
||||||
total: total_balance,
|
|
||||||
groups: accounts.group_by(&:accountable_type).transform_values do |rows|
|
|
||||||
build_account_summary(rows, total_balance, classification)
|
|
||||||
end
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.build_account_summary(accounts, total_balance, classification)
|
|
||||||
end_balance = accounts.sum(&:end_balance)
|
|
||||||
start_balance = accounts.sum(&:start_balance)
|
|
||||||
{
|
|
||||||
start_balance: start_balance,
|
|
||||||
end_balance: end_balance,
|
|
||||||
allocation: (end_balance / total_balance * 100).round(2),
|
|
||||||
trend: Trend.new(current: end_balance, previous: start_balance, type: classification),
|
|
||||||
accounts: accounts.map do |account|
|
|
||||||
{
|
|
||||||
name: account.name,
|
|
||||||
start_balance: account.start_balance,
|
|
||||||
end_balance: account.end_balance,
|
|
||||||
allocation: (account.end_balance / total_balance * 100).round(2),
|
|
||||||
trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: classification)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,11 +6,4 @@ class AccountBalance < ApplicationRecord
|
||||||
monetize :balance
|
monetize :balance
|
||||||
|
|
||||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
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 }
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,15 +1,26 @@
|
||||||
module Accountable
|
module Accountable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
TYPES = %w[ Account::Credit Account::Depository Account::Investment Account::Loan Account::OtherAsset Account::OtherLiability Account::Property Account::Vehicle ]
|
ASSET_TYPES = %w[ Account::Depository Account::Investment Account::OtherAsset Account::Property Account::Vehicle ]
|
||||||
|
LIABILITY_TYPES = %w[ Account::Credit Account::Loan Account::OtherLiability ]
|
||||||
|
TYPES = ASSET_TYPES + LIABILITY_TYPES
|
||||||
|
|
||||||
def self.from_type(type)
|
def self.from_type(type)
|
||||||
return nil unless types.include?(type) || TYPES.include?(type)
|
return nil unless types.include?(type) || TYPES.include?(type)
|
||||||
"Account::#{type.demodulize}".constantize
|
"Account::#{type.demodulize}".constantize
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.types
|
def self.by_classification
|
||||||
TYPES.map { |type| type.demodulize }
|
{ assets: ASSET_TYPES, liabilities: LIABILITY_TYPES }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.types(classification = nil)
|
||||||
|
types = classification ? (classification.to_sym == :asset ? ASSET_TYPES : LIABILITY_TYPES) : TYPES
|
||||||
|
types.map { |type| type.demodulize }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.classification(type)
|
||||||
|
ASSET_TYPES.include?(type) ? :asset : :liability
|
||||||
end
|
end
|
||||||
|
|
||||||
included do
|
included do
|
||||||
|
|
|
@ -23,10 +23,12 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) if period.date_range
|
query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) if period.date_range
|
||||||
|
|
||||||
|
result = query.to_a
|
||||||
|
|
||||||
{
|
{
|
||||||
asset_series: MoneySeries.new(query, { trend_type: :asset, amount_accessor: "assets" }),
|
asset_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.assets, r.currency) } }),
|
||||||
liability_series: MoneySeries.new(query, { trend_type: :liability, amount_accessor: "liabilities" }),
|
liability_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.liabilities, r.currency) } }),
|
||||||
net_worth_series: MoneySeries.new(query, { trend_type: :asset, amount_accessor: "net_worth" })
|
net_worth_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.net_worth, r.currency) } })
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -35,7 +37,7 @@ class Family < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def net_worth
|
def net_worth
|
||||||
accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END")
|
Money.new(accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END"), currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
def assets
|
def assets
|
||||||
|
@ -43,6 +45,6 @@ class Family < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def liabilities
|
def liabilities
|
||||||
accounts.active.liabilities.sum(:balance)
|
Money.new(accounts.active.liabilities.sum(:balance), currency)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
class MoneySeries
|
|
||||||
def initialize(series, options = {})
|
|
||||||
@trend_type = options[:trend_type] || "asset" # Defines whether a positive trend is good or bad
|
|
||||||
@accessor = options[:amount_accessor] || :balance
|
|
||||||
@series = series
|
|
||||||
end
|
|
||||||
|
|
||||||
def valid?
|
|
||||||
@series.length > 1
|
|
||||||
end
|
|
||||||
|
|
||||||
def data
|
|
||||||
[ nil, *@series ].each_cons(2).map do |previous, current|
|
|
||||||
{
|
|
||||||
raw: current,
|
|
||||||
date: current.date,
|
|
||||||
value: Money.new(current.send(@accessor), current.currency),
|
|
||||||
trend: Trend.new(
|
|
||||||
current: current.send(@accessor),
|
|
||||||
previous: previous&.send(@accessor),
|
|
||||||
type: @trend_type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def trend
|
|
||||||
return Trend.new(current: 0, type: @trend_type) unless valid?
|
|
||||||
|
|
||||||
Trend.new(
|
|
||||||
current: @series.last.send(@accessor),
|
|
||||||
previous: @series.first&.send(@accessor),
|
|
||||||
type: @trend_type
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def serialize_for_d3_chart
|
|
||||||
{
|
|
||||||
data: data.map do |datum|
|
|
||||||
{
|
|
||||||
date: datum[:date],
|
|
||||||
amount: datum[:value].amount,
|
|
||||||
currency: datum[:value].currency,
|
|
||||||
trend: {
|
|
||||||
amount: datum[:trend].amount,
|
|
||||||
percent: datum[:trend].percent,
|
|
||||||
direction: datum[:trend].direction,
|
|
||||||
type: datum[:trend].type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end,
|
|
||||||
trend: {
|
|
||||||
amount: trend.amount,
|
|
||||||
percent: trend.percent,
|
|
||||||
direction: trend.direction,
|
|
||||||
type: trend.type
|
|
||||||
}
|
|
||||||
}.to_json
|
|
||||||
end
|
|
||||||
end
|
|
83
app/models/time_series.rb
Normal file
83
app/models/time_series.rb
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
|
||||||
|
class TimeSeries
|
||||||
|
attr_reader :type
|
||||||
|
|
||||||
|
def self.from_collection(collection, value_method, options = {})
|
||||||
|
data = collection.map do |obj|
|
||||||
|
{ date: obj.date, value: obj.public_send(value_method), original: obj }
|
||||||
|
end
|
||||||
|
new(data, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(data, options = {})
|
||||||
|
@type = options[:type] || :normal
|
||||||
|
initialize_series_data(data)
|
||||||
|
end
|
||||||
|
|
||||||
|
def values
|
||||||
|
@values ||= add_trends_to_series
|
||||||
|
end
|
||||||
|
|
||||||
|
def first
|
||||||
|
values.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def last
|
||||||
|
values.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def on(date)
|
||||||
|
values.find { |v| v.date == date }
|
||||||
|
end
|
||||||
|
|
||||||
|
def trend
|
||||||
|
TimeSeries::Trend.new(
|
||||||
|
current: last&.value,
|
||||||
|
previous: first&.value,
|
||||||
|
type: @type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Data shape that frontend expects for D3 charts
|
||||||
|
def to_json(*_args)
|
||||||
|
{
|
||||||
|
values: values.map do |v|
|
||||||
|
{
|
||||||
|
date: v.date,
|
||||||
|
value: JSON.parse(v.value.to_json),
|
||||||
|
trend: {
|
||||||
|
type: v.trend.type,
|
||||||
|
direction: v.trend.direction,
|
||||||
|
value: JSON.parse(v.trend.value.to_json),
|
||||||
|
percent: v.trend.percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
trend: {
|
||||||
|
type: @type,
|
||||||
|
direction: trend.direction,
|
||||||
|
value: JSON.parse(trend.value.to_json),
|
||||||
|
percent: trend.percent
|
||||||
|
},
|
||||||
|
type: @type
|
||||||
|
}.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def initialize_series_data(data)
|
||||||
|
@series_data = data.nil? || data.empty? ? [] : data.map { |d| TimeSeries::Value.new(d) }.sort_by(&:date)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_trends_to_series
|
||||||
|
[ nil, *@series_data ].each_cons(2).map do |previous, current|
|
||||||
|
unless current.trend
|
||||||
|
current.trend = TimeSeries::Trend.new(
|
||||||
|
current: current.value,
|
||||||
|
previous: previous&.value,
|
||||||
|
type: @type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
48
app/models/time_series/trend.rb
Normal file
48
app/models/time_series/trend.rb
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
class TimeSeries::Trend
|
||||||
|
attr_reader :type
|
||||||
|
|
||||||
|
# Tells us whether an increasing/decreasing trend is good or bad (i.e. a liability decreasing is good)
|
||||||
|
TYPES = %i[normal inverse].freeze
|
||||||
|
|
||||||
|
def initialize(current: nil, previous: nil, type: :normal)
|
||||||
|
validate_data_types(current, previous)
|
||||||
|
validate_type(type)
|
||||||
|
@current = current
|
||||||
|
@previous = previous
|
||||||
|
@type = type
|
||||||
|
end
|
||||||
|
|
||||||
|
def direction
|
||||||
|
return "flat" if @previous.nil? || @current == @previous
|
||||||
|
return "up" if @current && @current > @previous
|
||||||
|
"down"
|
||||||
|
end
|
||||||
|
|
||||||
|
def value
|
||||||
|
return Money.new(0) if @previous.nil? && @current.is_a?(Money)
|
||||||
|
return 0 if @previous.nil?
|
||||||
|
@current - @previous
|
||||||
|
end
|
||||||
|
|
||||||
|
def percent
|
||||||
|
return 0.0 if @previous.nil?
|
||||||
|
return Float::INFINITY if @previous == 0
|
||||||
|
((extract_numeric(@current) - extract_numeric(@previous)).abs / extract_numeric(@previous).abs.to_f * 100).round(1).to_f
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def validate_type(type)
|
||||||
|
raise ArgumentError, "Invalid type" unless TYPES.include?(type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_data_types(current, previous)
|
||||||
|
return if previous.nil? || current.nil?
|
||||||
|
raise ArgumentError, "Current and previous values must be of the same type" unless current.class == previous.class
|
||||||
|
raise ArgumentError, "Current and previous values must be of type Money or Numeric" unless current.is_a?(Money) || current.is_a?(Numeric)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_numeric(obj)
|
||||||
|
return obj.amount if obj.is_a? Money
|
||||||
|
obj
|
||||||
|
end
|
||||||
|
end
|
32
app/models/time_series/value.rb
Normal file
32
app/models/time_series/value.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
class TimeSeries::Value
|
||||||
|
include Comparable
|
||||||
|
|
||||||
|
attr_accessor :trend
|
||||||
|
attr_reader :value, :date, :original
|
||||||
|
|
||||||
|
def initialize(obj)
|
||||||
|
@original = obj[:original] || obj
|
||||||
|
|
||||||
|
if obj.is_a?(Hash)
|
||||||
|
@date = obj[:date]
|
||||||
|
@value = obj[:value]
|
||||||
|
else
|
||||||
|
@date = obj.date
|
||||||
|
@value = obj.value
|
||||||
|
end
|
||||||
|
|
||||||
|
validate_input
|
||||||
|
end
|
||||||
|
|
||||||
|
def <=>(other)
|
||||||
|
result = date <=> other.date
|
||||||
|
result = value <=> other.value if result == 0
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def validate_input
|
||||||
|
raise ArgumentError, "Date is required" unless @date.is_a?(Date)
|
||||||
|
raise ArgumentError, "Money or Numeric value is required" unless @value.is_a?(Money) || @value.is_a?(Numeric)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,26 +0,0 @@
|
||||||
class Trend
|
|
||||||
attr_reader :current, :previous, :type
|
|
||||||
|
|
||||||
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" if @current == @previous
|
|
||||||
return "up" if @previous.nil? || (@current && @current > @previous)
|
|
||||||
"down"
|
|
||||||
end
|
|
||||||
|
|
||||||
def amount
|
|
||||||
return 0 if @previous.nil?
|
|
||||||
@current - @previous
|
|
||||||
end
|
|
||||||
|
|
||||||
def percent
|
|
||||||
return 0 if @previous.nil?
|
|
||||||
return Float::INFINITY if @previous == 0
|
|
||||||
((@current - @previous).abs / @previous.abs.to_f * 100).round(1)
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -8,11 +8,8 @@ class Valuation < ApplicationRecord
|
||||||
|
|
||||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||||
|
|
||||||
def self.to_series(account, period = Period.all)
|
def self.to_series
|
||||||
MoneySeries.new(
|
TimeSeries.from_collection all, :value_money
|
||||||
in_period(period).order(:date),
|
|
||||||
{ trend_type: account.classification, amount_accessor: :value }
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
100
app/models/value_group.rb
Normal file
100
app/models/value_group.rb
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
class ValueGroup
|
||||||
|
attr_accessor :parent
|
||||||
|
attr_reader :name, :children, :value, :original
|
||||||
|
|
||||||
|
def initialize(name = "Root", value: nil, original: nil)
|
||||||
|
@name = name
|
||||||
|
@value = value
|
||||||
|
@children = []
|
||||||
|
@original = original
|
||||||
|
end
|
||||||
|
|
||||||
|
def sum
|
||||||
|
return value if is_value_node?
|
||||||
|
return 0 if children.empty? && value.nil?
|
||||||
|
children.sum(&:sum)
|
||||||
|
end
|
||||||
|
|
||||||
|
def avg
|
||||||
|
return value if is_value_node?
|
||||||
|
return 0 if children.empty? && value.nil?
|
||||||
|
leaf_values = value_nodes.map(&:value)
|
||||||
|
leaf_values.compact.sum.to_f / leaf_values.compact.size
|
||||||
|
end
|
||||||
|
|
||||||
|
def series
|
||||||
|
return @raw_series || TimeSeries.new([]) if is_value_node?
|
||||||
|
summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
|
||||||
|
child.series.values.each do |series_value|
|
||||||
|
acc[series_value.date] += series_value.value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
|
||||||
|
TimeSeries.new(summed_series)
|
||||||
|
end
|
||||||
|
|
||||||
|
def value_nodes
|
||||||
|
return [ self ] unless value.nil?
|
||||||
|
children.flat_map { |child| child.value_nodes }
|
||||||
|
end
|
||||||
|
|
||||||
|
def percent_of_total
|
||||||
|
return 100 if parent.nil?
|
||||||
|
((sum / parent.sum) * 100).round(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
def leaf?
|
||||||
|
children.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_child_node(name)
|
||||||
|
raise "Cannot add subgroup to node with a value" if is_value_node?
|
||||||
|
child = self.class.new(name)
|
||||||
|
child.parent = self
|
||||||
|
@children << child
|
||||||
|
child
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_value_node(obj)
|
||||||
|
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
|
||||||
|
child = create_value_node(obj)
|
||||||
|
child.parent = self
|
||||||
|
@children << child
|
||||||
|
child
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_series(raw_series)
|
||||||
|
validate_attached_series(raw_series)
|
||||||
|
@raw_series = raw_series
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_value_node?
|
||||||
|
value.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def can_add_value_node?
|
||||||
|
return false if is_value_node?
|
||||||
|
children.empty? || children.all?(&:is_value_node?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_value_node(obj)
|
||||||
|
value = if obj.respond_to?(:value)
|
||||||
|
obj.value
|
||||||
|
elsif obj.respond_to?(:balance)
|
||||||
|
obj.balance
|
||||||
|
elsif obj.respond_to?(:amount)
|
||||||
|
obj.amount
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Object must have a value, balance, or amount"
|
||||||
|
end
|
||||||
|
|
||||||
|
self.class.new(obj.name, value: value, original: obj)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_attached_series(series)
|
||||||
|
raise "Cannot add series to a node without a value" unless is_value_node?
|
||||||
|
raise "Attached series must be a TimeSeries" unless series.is_a?(TimeSeries)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,8 +1,7 @@
|
||||||
<%# locals: (valuation_series:) %>
|
<%# locals: (valuation_series:) %>
|
||||||
<% valuation_series.data.reverse_each.with_index do |valuation_item, index| %>
|
<% valuation_series.values.reverse_each.with_index do |valuation, index| %>
|
||||||
<% valuation, trend = valuation_item.values_at(:raw, :trend) %>
|
<% valuation_styles = trend_styles(valuation.trend) %>
|
||||||
<% valuation_styles = trend_styles(trend) %>
|
<%= turbo_frame_tag dom_id(valuation.original) do %>
|
||||||
<%= turbo_frame_tag dom_id(valuation) do %>
|
|
||||||
<div class="p-4 flex items-center">
|
<div class="p-4 flex items-center">
|
||||||
<div class="w-16">
|
<div class="w-16">
|
||||||
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center <%= valuation_styles[:bg_class] %>">
|
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center <%= valuation_styles[:bg_class] %>">
|
||||||
|
@ -15,14 +14,14 @@
|
||||||
<%# TODO: Add descriptive name of valuation %>
|
<%# TODO: Add descriptive name of valuation %>
|
||||||
<p class="text-gray-500">Manually entered</p>
|
<p class="text-gray-500">Manually entered</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex text-sm font-medium text-right"><%= format_money valuation.value_money %></div>
|
<div class="flex text-sm font-medium text-right"><%= format_money valuation.value %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-56 justify-end text-right text-sm font-medium">
|
<div class="flex w-56 justify-end text-right text-sm font-medium">
|
||||||
<% if trend.amount == 0 %>
|
<% if valuation.trend.value == 0 %>
|
||||||
<span class="text-gray-500">No change</span>
|
<span class="text-gray-500">No change</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="<%= valuation_styles[:text_class] %>"><%= valuation_styles[:symbol] %><%= format_money trend.amount.abs %></span>
|
<span class="<%= valuation_styles[:text_class] %>"><%= valuation_styles[:symbol] %><%= format_money valuation.trend.value.abs %></span>
|
||||||
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= trend.percent %>%)</span>
|
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation.trend.percent %>%)</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative w-[72px]" data-controller="dropdown">
|
<div class="relative w-[72px]" data-controller="dropdown">
|
||||||
|
@ -30,18 +29,18 @@
|
||||||
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500" ) %>
|
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500" ) %>
|
||||||
</button>
|
</button>
|
||||||
<div class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit" data-dropdown-target="menu">
|
<div class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit" data-dropdown-target="menu">
|
||||||
<%= link_to edit_valuation_path(valuation), class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
<%= link_to edit_valuation_path(valuation.original), class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
||||||
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
|
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
|
||||||
<span class="text-gray-900 text-sm">Edit entry</span>
|
<span class="text-gray-900 text-sm">Edit entry</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= link_to valuation_path(valuation), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" }, class: "text-red-600 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
<%= link_to valuation_path(valuation.original), data: { turbo_method: :delete, turbo_confirm: "Are you sure?" }, class: "text-red-600 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
||||||
<%= lucide_icon("trash-2", class: "w-5 h-5 shrink-0") %>
|
<%= lucide_icon("trash-2", class: "w-5 h-5 shrink-0") %>
|
||||||
<span class="text-sm">Delete entry</span>
|
<span class="text-sm">Delete entry</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% unless index == valuation_series.data.size - 1 %>
|
<% unless index == valuation_series.values.size - 1 %>
|
||||||
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<%= turbo_stream_from @account %>
|
<%= turbo_stream_from @account %>
|
||||||
<% balance = Money.new(@account.balance, @account.currency) %>
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
@ -11,7 +10,7 @@
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="relative cursor-not-allowed">
|
<div class="relative cursor-not-allowed">
|
||||||
<div class="flex items-center gap-2 px-3 py-2">
|
<div class="flex items-center gap-2 px-3 py-2">
|
||||||
<span class="text-gray-900"><%= balance.currency.iso_code %> <%= balance.currency.symbol %></span>
|
<span class="text-gray-900"><%= @account.balance_money.currency.iso_code %> <%= @account.balance_money.currency.symbol %></span>
|
||||||
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
|
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,48 +1,47 @@
|
||||||
<%# locals: (account_group:) %>
|
<%# locals: (account_group:) %>
|
||||||
<% accountable_type, account_details = account_group%>
|
<% text_class = accountable_text_class(account_group.name) %>
|
||||||
<% text_class = accountable_text_class(accountable_type) %>
|
|
||||||
<details class="open:bg-gray-25 group">
|
<details class="open:bg-gray-25 group">
|
||||||
<summary class="flex p-4 items-center w-full rounded-lg font-medium hover:bg-gray-50 text-gray-500 text-sm font-medium cursor-pointer">
|
<summary class="flex p-4 items-center w-full rounded-lg font-medium hover:bg-gray-50 text-gray-500 text-sm font-medium cursor-pointer">
|
||||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5") %>
|
<%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5") %>
|
||||||
<%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5") %>
|
||||||
<div class="ml-4 h-2.5 w-2.5 rounded-full <%= accountable_bg_class(accountable_type) %>"></div>
|
<div class="ml-4 h-2.5 w-2.5 rounded-full <%= accountable_bg_class(account_group.name) %>"></div>
|
||||||
<p class="text-gray-900 ml-2"><%= to_accountable_title(Accountable.from_type(accountable_type)) %></p>
|
<p class="text-gray-900 ml-2"><%= to_accountable_title(Accountable.from_type(account_group.name)) %></p>
|
||||||
<span class="mx-1">·</span>
|
<span class="mx-1">·</span>
|
||||||
<div ><%= account_details[:accounts].size %></div>
|
<div ><%= account_group.children.count %></div>
|
||||||
<div class="ml-auto text-right flex items-center gap-10 text-sm font-medium text-gray-900">
|
<div class="ml-auto text-right flex items-center gap-10 text-sm font-medium text-gray-900">
|
||||||
<div class="flex items-center justify-end gap-2 w-24">
|
<div class="flex items-center justify-end gap-2 w-24">
|
||||||
<%= render partial: "shared/progress_circle", locals: { progress: account_details[:allocation], text_class: text_class } %>
|
<%= render partial: "shared/progress_circle", locals: { progress: account_group.percent_of_total, text_class: text_class } %>
|
||||||
<p><%= account_details[:allocation] %>%</p>
|
<p><%= account_group.percent_of_total.round(1) %>%</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<p><%= format_money account_details[:end_balance] %></p>
|
<p><%= format_money account_group.sum %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
<div class="w-40">
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: account_details[:trend] } %>
|
<%= render partial: "shared/trend_change", locals: { trend: account_group.series.trend } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="px-4 py-3 space-y-4">
|
<div class="px-4 py-3 space-y-4">
|
||||||
<% account_details[:accounts].map do |account| %>
|
<% account_group.children.map do |account| %>
|
||||||
<div class="flex items-center justify-between text-sm font-medium text-gray-900">
|
<div class="flex items-center justify-between text-sm font-medium text-gray-900">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex items-center justify-center w-8 h-8 rounded-full <%= text_class %> <%= accountable_bg_transparent_class(accountable_type) %>">
|
<div class="flex items-center justify-center w-8 h-8 rounded-full <%= text_class %> <%= accountable_bg_transparent_class(account_group.name) %>">
|
||||||
<%= account[:name][0].upcase %>
|
<%= account.name[0].upcase %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p><%= account[:name] %></p>
|
<p><%= account.name %></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-10 items-center text-right">
|
<div class="flex gap-10 items-center text-right">
|
||||||
<div class="flex items-center justify-end gap-2 w-24">
|
<div class="flex items-center justify-end gap-2 w-24">
|
||||||
<%= render partial: "shared/progress_circle", locals: { progress: account[:allocation], text_class: text_class } %>
|
<%= render partial: "shared/progress_circle", locals: { progress: account.percent_of_total, text_class: text_class } %>
|
||||||
<p><%= account[:allocation] %>%</p>
|
<p><%= account.percent_of_total %>%</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<p><%= format_money account[:end_balance] %></p>
|
<p><%= format_money account.sum %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
<div class="w-40">
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: account[:trend] } %>
|
<%= render partial: "shared/trend_change", locals: { trend: account.series.trend } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
<%# locals: (account_groups:) %>
|
<%# locals: (account_groups:) %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<% account_groups.each do |type, value| %>
|
<% account_groups.each do |group| %>
|
||||||
<div class="h-1.5 rounded-sm w-12 <%= accountable_bg_class(type) %>" style="width: <%= value[:allocation] %>%;"></div>
|
<div class="h-1.5 rounded-sm w-12 <%= accountable_bg_class(group.name) %>" style="width: <%= group.percent_of_total %>%;"></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<% account_groups.each do |type, value| %>
|
<% account_groups.each do |group| %>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<div class="h-2.5 w-2.5 rounded-full <%= accountable_bg_class(type) %>"></div>
|
<div class="h-2.5 w-2.5 rounded-full <%= accountable_bg_class(group.name) %>"></div>
|
||||||
<p class="text-gray-500"><%= to_accountable_title(Accountable.from_type(type)) %></p>
|
<p class="text-gray-500"><%= to_accountable_title(Accountable.from_type(group.name)) %></p>
|
||||||
<p class="text-black"><%= value[:allocation] %>%</p>
|
<p class="text-black"><%= group.percent_of_total.round(1) %>%</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
data-controller="trendline"
|
data-controller="trendline"
|
||||||
id="assetsTrendline"
|
id="assetsTrendline"
|
||||||
class="h-full w-2/5"
|
class="h-full w-2/5"
|
||||||
data-trendline-series-value="<%= @asset_series.serialize_for_d3_chart %>"
|
data-trendline-series-value="<%= @asset_series.to_json %>"
|
||||||
data-trendline-classification-value="asset"
|
data-trendline-classification-value="asset"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
data-controller="trendline"
|
data-controller="trendline"
|
||||||
id="liabilitiesTrendline"
|
id="liabilitiesTrendline"
|
||||||
class="h-full w-2/5"
|
class="h-full w-2/5"
|
||||||
data-trendline-series-value="<%= @liability_series.serialize_for_d3_chart %>"
|
data-trendline-series-value="<%= @liability_series.to_json %>"
|
||||||
data-trendline-classification-value="liability"
|
data-trendline-classification-value="liability"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -75,12 +75,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div data-tabs-target="tab" id="asset-summary-tab" class="space-y-6">
|
<div data-tabs-target="tab" id="asset-summary-tab" class="space-y-6">
|
||||||
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:asset][:groups] } %>
|
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:assets].children } %>
|
||||||
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:asset][:groups] } %>
|
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:assets].children } %>
|
||||||
</div>
|
</div>
|
||||||
<div data-tabs-target="tab" id="liability-summary-tab" class="space-y-6 hidden">
|
<div data-tabs-target="tab" id="liability-summary-tab" class="space-y-6 hidden">
|
||||||
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:liability][:groups] } %>
|
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:liabilities].children } %>
|
||||||
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:liability][:groups] } %>
|
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:liabilities].children } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (series:) %>
|
<%# locals: (series:) %>
|
||||||
<% if series %>
|
<% if series %>
|
||||||
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= series.serialize_for_d3_chart %>"></div>
|
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= series.to_json %>"></div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
<p class="text-gray-500">No data available for the selected period.</p>
|
<p class="text-gray-500">No data available for the selected period.</p>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<% if trend.direction == "flat" %>
|
<% if trend.direction == "flat" %>
|
||||||
<span>No change</span>
|
<span>No change</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span><%= styles[:symbol] %><%= format_money trend.amount.abs %></span>
|
<span><%= styles[:symbol] %><%= format_money trend.value.abs %></span>
|
||||||
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
|
@ -1 +1,7 @@
|
||||||
EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present?
|
EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present?
|
||||||
|
|
||||||
|
BALANCE_SHEET_CLASSIFICATIONS = {
|
||||||
|
asset: "asset",
|
||||||
|
liability: "liability",
|
||||||
|
equity: "equity"
|
||||||
|
}.freeze
|
||||||
|
|
|
@ -42,6 +42,10 @@ class Money
|
||||||
@currency.default_format.gsub("%n", formatted_amount).gsub("%u", @currency.symbol)
|
@currency.default_format.gsub("%n", formatted_amount).gsub("%u", @currency.symbol)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_json(*_args)
|
||||||
|
{ amount: @amount, currency: @currency.iso_code }.to_json
|
||||||
|
end
|
||||||
|
|
||||||
def <=>(other)
|
def <=>(other)
|
||||||
raise TypeError, "Money can only be compared with other Money objects except for 0" unless other.is_a?(Money) || other.eql?(0)
|
raise TypeError, "Money can only be compared with other Money objects except for 0" unless other.is_a?(Money) || other.eql?(0)
|
||||||
return @amount <=> other if other.is_a?(Numeric)
|
return @amount <=> other if other.is_a?(Numeric)
|
||||||
|
|
2
test/fixtures/account/investments.yml
vendored
2
test/fixtures/account/investments.yml
vendored
|
@ -1,2 +0,0 @@
|
||||||
one:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174004"
|
|
2
test/fixtures/account/loans.yml
vendored
2
test/fixtures/account/loans.yml
vendored
|
@ -1,2 +0,0 @@
|
||||||
one:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174005"
|
|
2
test/fixtures/account/other_liabilities.yml
vendored
2
test/fixtures/account/other_liabilities.yml
vendored
|
@ -1,2 +0,0 @@
|
||||||
one:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174006"
|
|
2
test/fixtures/account/properties.yml
vendored
2
test/fixtures/account/properties.yml
vendored
|
@ -1,2 +0,0 @@
|
||||||
one:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174007"
|
|
2
test/fixtures/account/vehicles.yml
vendored
2
test/fixtures/account/vehicles.yml
vendored
|
@ -1,2 +0,0 @@
|
||||||
one:
|
|
||||||
id: "123e4567-e89b-12d3-a456-426614174008"
|
|
|
@ -65,26 +65,18 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
liability_series = @family.snapshot[:liability_series]
|
liability_series = @family.snapshot[:liability_series]
|
||||||
net_worth_series = @family.snapshot[:net_worth_series]
|
net_worth_series = @family.snapshot[:net_worth_series]
|
||||||
|
|
||||||
assert_equal expected_snapshots.count, asset_series.data.count
|
assert_equal expected_snapshots.count, asset_series.values.count
|
||||||
assert_equal expected_snapshots.count, liability_series.data.count
|
assert_equal expected_snapshots.count, liability_series.values.count
|
||||||
assert_equal expected_snapshots.count, net_worth_series.data.count
|
assert_equal expected_snapshots.count, net_worth_series.values.count
|
||||||
|
|
||||||
expected_snapshots.each_with_index do |row, index|
|
expected_snapshots.each_with_index do |row, index|
|
||||||
expected = {
|
expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d))
|
||||||
date: row["date"],
|
expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d))
|
||||||
assets: row["assets"].to_d,
|
expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d))
|
||||||
liabilities: row["liabilities"].to_d,
|
|
||||||
net_worth: row["net_worth"].to_d
|
|
||||||
}
|
|
||||||
|
|
||||||
actual = {
|
assert_equal expected_assets, asset_series.values[index]
|
||||||
date: asset_series.data[index][:date],
|
assert_equal expected_liabilities, liability_series.values[index]
|
||||||
assets: asset_series.data[index][:value].amount,
|
assert_equal expected_net_worth, net_worth_series.values[index]
|
||||||
liabilities: liability_series.data[index][:value].amount,
|
|
||||||
net_worth: net_worth_series.data[index][:value].amount
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_equal expected, actual
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -103,80 +95,4 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
|
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
|
||||||
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
|
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates balances by type" do
|
|
||||||
verify_balances_by_type(
|
|
||||||
period: Period.all,
|
|
||||||
expected_asset_total: BigDecimal("25550"),
|
|
||||||
expected_liability_total: BigDecimal("1000"),
|
|
||||||
expected_asset_groups: {
|
|
||||||
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.15 },
|
|
||||||
"Account::Depository" => { end_balance: BigDecimal("25000"), start_balance: BigDecimal("25250"), allocation: 97.85 }
|
|
||||||
},
|
|
||||||
expected_liability_groups: {
|
|
||||||
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "calculates balances by type with a date range filter" do
|
|
||||||
verify_balances_by_type(
|
|
||||||
period: Period.new(name: "custom", date_range: 7.days.ago.to_date..2.days.ago.to_date),
|
|
||||||
expected_asset_total: BigDecimal("26050"),
|
|
||||||
expected_liability_total: BigDecimal("1000"),
|
|
||||||
expected_asset_groups: {
|
|
||||||
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("700"), allocation: 2.11 },
|
|
||||||
"Account::Depository" => { end_balance: BigDecimal("25500"), start_balance: BigDecimal("24510"), allocation: 97.89 }
|
|
||||||
},
|
|
||||||
expected_liability_groups: {
|
|
||||||
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("990"), allocation: 100 }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "calculates balances by type with disabled account" do
|
|
||||||
disabled_checking = accounts(:checking).update!(is_active: false)
|
|
||||||
|
|
||||||
verify_balances_by_type(
|
|
||||||
period: Period.all,
|
|
||||||
expected_asset_total: BigDecimal("20550"),
|
|
||||||
expected_liability_total: BigDecimal("1000"),
|
|
||||||
expected_asset_groups: {
|
|
||||||
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.68 },
|
|
||||||
"Account::Depository" => { end_balance: BigDecimal("20000"), start_balance: BigDecimal("21250"), allocation: 97.32 }
|
|
||||||
},
|
|
||||||
expected_liability_groups: {
|
|
||||||
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def verify_balances_by_type(period:, expected_asset_total:, expected_liability_total:, expected_asset_groups:, expected_liability_groups:)
|
|
||||||
result = @family.accounts.by_group(period)
|
|
||||||
|
|
||||||
asset_total = result[:asset][:total]
|
|
||||||
liability_total = result[:liability][:total]
|
|
||||||
|
|
||||||
assert_equal expected_asset_total, asset_total
|
|
||||||
assert_equal expected_liability_total, liability_total
|
|
||||||
|
|
||||||
asset_groups = result[:asset][:groups]
|
|
||||||
liability_groups = result[:liability][:groups]
|
|
||||||
|
|
||||||
assert_equal expected_asset_groups.keys, asset_groups.keys
|
|
||||||
expected_asset_groups.each do |type, expected_values|
|
|
||||||
assert_equal expected_values[:end_balance], asset_groups[type][:end_balance]
|
|
||||||
assert_equal expected_values[:start_balance], asset_groups[type][:start_balance]
|
|
||||||
assert_equal expected_values[:allocation], asset_groups[type][:allocation]
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal expected_liability_groups.keys, liability_groups.keys
|
|
||||||
expected_liability_groups.each do |type, expected_values|
|
|
||||||
assert_equal expected_values[:end_balance], liability_groups[type][:end_balance]
|
|
||||||
assert_equal expected_values[:start_balance], liability_groups[type][:start_balance]
|
|
||||||
assert_equal expected_values[:allocation], liability_groups[type][:allocation]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
45
test/models/time_series/trend_test.rb
Normal file
45
test/models/time_series/trend_test.rb
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TimeSeries::TrendTest < ActiveSupport::TestCase
|
||||||
|
test "handles money trend" do
|
||||||
|
trend = TimeSeries::Trend.new(current: Money.new(100), previous: Money.new(50))
|
||||||
|
assert_equal "up", trend.direction
|
||||||
|
assert_equal Money.new(50), trend.value
|
||||||
|
assert_equal 100.0, trend.percent
|
||||||
|
end
|
||||||
|
test "up" do
|
||||||
|
trend = TimeSeries::Trend.new(current: 100, previous: 50)
|
||||||
|
assert_equal "up", trend.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "down" do
|
||||||
|
trend = TimeSeries::Trend.new(current: 50, previous: 100)
|
||||||
|
assert_equal "down", trend.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flat" do
|
||||||
|
trend1 = TimeSeries::Trend.new(current: 100, previous: 100)
|
||||||
|
trend3 = TimeSeries::Trend.new(current: 100, previous: nil)
|
||||||
|
trend2 = TimeSeries::Trend.new(current: nil, previous: nil)
|
||||||
|
assert_equal "flat", trend1.direction
|
||||||
|
assert_equal "flat", trend2.direction
|
||||||
|
assert_equal "flat", trend3.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "infinitely up" do
|
||||||
|
trend = TimeSeries::Trend.new(current: 100, previous: 0)
|
||||||
|
assert_equal "up", trend.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "infinitely down" do
|
||||||
|
trend1 = TimeSeries::Trend.new(current: nil, previous: 100)
|
||||||
|
trend2 = TimeSeries::Trend.new(current: 0, previous: 100)
|
||||||
|
assert_equal "down", trend1.direction
|
||||||
|
assert_equal "down", trend2.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty" do
|
||||||
|
trend =TimeSeries::Trend.new
|
||||||
|
assert_equal "flat", trend.direction
|
||||||
|
end
|
||||||
|
end
|
75
test/models/time_series_test.rb
Normal file
75
test/models/time_series_test.rb
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TimeSeriesTest < ActiveSupport::TestCase
|
||||||
|
test "it can accept array of money values" do
|
||||||
|
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(100) }, { date: Date.current, value: Money.new(200) } ])
|
||||||
|
|
||||||
|
assert_equal Money.new(100), series.first.value
|
||||||
|
assert_equal Money.new(200), series.last.value
|
||||||
|
assert_equal :normal, series.type
|
||||||
|
assert_equal "up", series.trend.direction
|
||||||
|
assert_equal Money.new(100), series.trend.value
|
||||||
|
assert_equal 100.0, series.trend.percent
|
||||||
|
end
|
||||||
|
|
||||||
|
test "it can accept array of numeric values" do
|
||||||
|
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 100 }, { date: Date.current, value: 200 } ])
|
||||||
|
|
||||||
|
assert_equal 100, series.first.value
|
||||||
|
assert_equal 200, series.last.value
|
||||||
|
assert_equal 100, series.on(1.day.ago.to_date).value
|
||||||
|
assert_equal :normal, series.type
|
||||||
|
assert_equal "up", series.trend.direction
|
||||||
|
assert_equal 100, series.trend.value
|
||||||
|
assert_equal 100.0, series.trend.percent
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when nil or empty array passed, it returns empty series" do
|
||||||
|
series = TimeSeries.new(nil)
|
||||||
|
assert_equal [], series.values
|
||||||
|
|
||||||
|
series = TimeSeries.new([])
|
||||||
|
|
||||||
|
assert_nil series.first
|
||||||
|
assert_nil series.last
|
||||||
|
assert_equal({ values: [], trend: { type: "normal", direction: "flat", value: 0, percent: 0.0 }, type: "normal" }.to_json, series.to_json)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "money series can be serialized to json" do
|
||||||
|
expected_values = {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
date: "2024-03-17",
|
||||||
|
value: { amount: "100.0", currency: "USD" },
|
||||||
|
trend: { type: "normal", direction: "flat", value: { amount: "0.0", currency: "USD" }, percent: 0.0 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2024-03-18",
|
||||||
|
value: { amount: "200.0", currency: "USD" },
|
||||||
|
trend: { type: "normal", direction: "up", value: { amount: "100.0", currency: "USD" }, percent: 100.0 }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
trend: { type: "normal", direction: "up", value: { amount: "100.0", currency: "USD" }, percent: 100.0 },
|
||||||
|
type: "normal"
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(100) }, { date: Date.current, value: Money.new(200) } ])
|
||||||
|
|
||||||
|
assert_equal expected_values, series.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
test "numeric series can be serialized to json" do
|
||||||
|
expected_values = {
|
||||||
|
values: [
|
||||||
|
{ date: 1.day.ago.to_date, value: 100, trend: { type: "normal", direction: "flat", value: 0, percent: 0.0 } },
|
||||||
|
{ date: Date.current, value: 200, trend: { type: "normal", direction: "up", value: 100, percent: 100.0 } }
|
||||||
|
],
|
||||||
|
trend: { type: "normal", direction: "up", value: 100, percent: 100.0 },
|
||||||
|
type: "normal"
|
||||||
|
}.to_json
|
||||||
|
|
||||||
|
series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 100 }, { date: Date.current, value: 200 } ])
|
||||||
|
|
||||||
|
assert_equal expected_values, series.to_json
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,37 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class TrendTest < ActiveSupport::TestCase
|
|
||||||
test "up" do
|
|
||||||
trend = Trend.new(current: 100, previous: 50)
|
|
||||||
assert_equal "up", trend.direction
|
|
||||||
end
|
|
||||||
|
|
||||||
test "down" do
|
|
||||||
trend = Trend.new(current: 50, previous: 100)
|
|
||||||
assert_equal "down", trend.direction
|
|
||||||
end
|
|
||||||
|
|
||||||
test "flat" do
|
|
||||||
trend = Trend.new(current: 100, previous: 100)
|
|
||||||
assert_equal "flat", trend.direction
|
|
||||||
end
|
|
||||||
|
|
||||||
test "infinitely up" do
|
|
||||||
trend1 = Trend.new(current: 100, previous: nil)
|
|
||||||
trend2 = Trend.new(current: 100, previous: 0)
|
|
||||||
assert_equal "up", trend1.direction
|
|
||||||
assert_equal "up", trend2.direction
|
|
||||||
end
|
|
||||||
|
|
||||||
test "infinitely down" do
|
|
||||||
trend1 = Trend.new(current: nil, previous: 100)
|
|
||||||
trend2 = Trend.new(current: 0, previous: 100)
|
|
||||||
assert_equal "down", trend1.direction
|
|
||||||
assert_equal "down", trend2.direction
|
|
||||||
end
|
|
||||||
|
|
||||||
test "empty" do
|
|
||||||
trend = Trend.new
|
|
||||||
assert_equal "flat", trend.direction
|
|
||||||
end
|
|
||||||
end
|
|
145
test/models/value_group_test.rb
Normal file
145
test/models/value_group_test.rb
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ValueGroupTest < ActiveSupport::TestCase
|
||||||
|
setup do
|
||||||
|
checking = accounts(:checking)
|
||||||
|
savings = accounts(:savings_with_valuation_overrides)
|
||||||
|
collectable = accounts(:collectable)
|
||||||
|
|
||||||
|
# Level 1
|
||||||
|
@assets = ValueGroup.new("Assets")
|
||||||
|
|
||||||
|
# Level 2
|
||||||
|
@depositories = @assets.add_child_node("Depositories")
|
||||||
|
@other_assets = @assets.add_child_node("Other Assets")
|
||||||
|
|
||||||
|
# Level 3 (leaf/value nodes)
|
||||||
|
@checking_node = @depositories.add_value_node(checking)
|
||||||
|
@savings_node = @depositories.add_value_node(savings)
|
||||||
|
@collectable_node = @other_assets.add_value_node(collectable)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty group works" do
|
||||||
|
group = ValueGroup.new
|
||||||
|
|
||||||
|
assert_equal "Root", group.name
|
||||||
|
assert_equal [], group.children
|
||||||
|
assert_equal 0, group.sum
|
||||||
|
assert_equal 0, group.avg
|
||||||
|
assert_equal 100, group.percent_of_total
|
||||||
|
assert_nil group.parent
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group without value nodes has no value" do
|
||||||
|
assets = ValueGroup.new("Assets")
|
||||||
|
depositories = assets.add_child_node("Depositories")
|
||||||
|
|
||||||
|
assert_equal 0, assets.sum
|
||||||
|
assert_equal 0, depositories.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sum equals value at leaf level" do
|
||||||
|
assert_equal @checking_node.value, @checking_node.sum
|
||||||
|
assert_equal @savings_node.value, @savings_node.sum
|
||||||
|
assert_equal @collectable_node.value, @collectable_node.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
test "value is nil at rollup levels" do
|
||||||
|
assert_not_equal @depositories.value, @depositories.sum
|
||||||
|
assert_nil @depositories.value
|
||||||
|
assert_nil @other_assets.value
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates list of value nodes regardless of level in hierarchy" do
|
||||||
|
assert_equal [ @checking_node, @savings_node, @collectable_node ], @assets.value_nodes
|
||||||
|
assert_equal [ @checking_node, @savings_node ], @depositories.value_nodes
|
||||||
|
assert_equal [ @collectable_node ], @other_assets.value_nodes
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group with value nodes aggregates totals correctly" do
|
||||||
|
assert_equal 5000, @checking_node.sum
|
||||||
|
assert_equal 20000, @savings_node.sum
|
||||||
|
assert_equal 550, @collectable_node.sum
|
||||||
|
|
||||||
|
assert_equal 25000, @depositories.sum
|
||||||
|
assert_equal 550, @other_assets.sum
|
||||||
|
|
||||||
|
assert_equal 25550, @assets.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
test "group averages leaf nodes" do
|
||||||
|
assert_equal 5000, @checking_node.avg
|
||||||
|
assert_equal 20000, @savings_node.avg
|
||||||
|
assert_equal 550, @collectable_node.avg
|
||||||
|
|
||||||
|
assert_in_delta 12500, @depositories.avg, 0.01
|
||||||
|
assert_in_delta 550, @other_assets.avg, 0.01
|
||||||
|
assert_in_delta 8516.67, @assets.avg, 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
# Percentage of parent group (i.e. collectable is 100% of "Other Assets" group)
|
||||||
|
test "group calculates percent of parent total" do
|
||||||
|
assert_equal 100, @assets.percent_of_total
|
||||||
|
assert_in_delta 97.85, @depositories.percent_of_total, 0.1
|
||||||
|
assert_in_delta 2.15, @other_assets.percent_of_total, 0.1
|
||||||
|
assert_in_delta 80.0, @savings_node.percent_of_total, 0.1
|
||||||
|
assert_in_delta 20.0, @checking_node.percent_of_total, 0.1
|
||||||
|
assert_equal 100, @collectable_node.percent_of_total
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles unbalanced tree" do
|
||||||
|
vehicles = @assets.add_child_node("Vehicles")
|
||||||
|
|
||||||
|
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
|
||||||
|
assert_equal 25550, @assets.sum
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
test "can attach and aggregate time series" do
|
||||||
|
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 4000 }, { date: Date.current, value: 5000 } ])
|
||||||
|
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 19000 }, { date: Date.current, value: 20000 } ])
|
||||||
|
|
||||||
|
@checking_node.attach_series(checking_series)
|
||||||
|
@savings_node.attach_series(savings_series)
|
||||||
|
|
||||||
|
assert_not_nil @checking_node.series
|
||||||
|
assert_not_nil @savings_node.series
|
||||||
|
|
||||||
|
assert_equal @checking_node.sum, @checking_node.series.last.value
|
||||||
|
assert_equal @savings_node.sum, @savings_node.series.last.value
|
||||||
|
|
||||||
|
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
|
||||||
|
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
|
||||||
|
|
||||||
|
assert_equal aggregated_depository_series.values, @depositories.series.values
|
||||||
|
assert_equal aggregated_assets_series.values, @assets.series.values
|
||||||
|
end
|
||||||
|
|
||||||
|
test "attached series must be a TimeSeries" do
|
||||||
|
assert_raises(RuntimeError) do
|
||||||
|
@checking_node.attach_series([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cannot add time series to non-leaf node" do
|
||||||
|
assert_raises(RuntimeError) do
|
||||||
|
@assets.attach_series(TimeSeries.new([]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can only add value node at leaf level of tree" do
|
||||||
|
root = ValueGroup.new("Root Level")
|
||||||
|
grandparent = root.add_child_node("Grandparent")
|
||||||
|
parent = grandparent.add_child_node("Parent")
|
||||||
|
|
||||||
|
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||||
|
|
||||||
|
assert_raises(RuntimeError) do
|
||||||
|
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_raises(RuntimeError) do
|
||||||
|
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue