Functional sketch of new dashboard

This commit is contained in:
Zach Gollwitzer
2025-02-13 21:14:36 -05:00
parent a8c4fa9c7f
commit bc65f40954
35 changed files with 317 additions and 1041 deletions

View File

@@ -3,30 +3,8 @@ class PagesController < ApplicationController
def dashboard
@period = Period.from_param(params[:period])
snapshot = Current.family.snapshot(@period)
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@liability_series = snapshot[:liability_series]
snapshot_transactions = Current.family.snapshot_transactions
@income_series = snapshot_transactions[:income_series]
@spending_series = snapshot_transactions[:spending_series]
@savings_rate_series = snapshot_transactions[:savings_rate_series]
snapshot_account_transactions = Current.family.snapshot_account_transactions
@top_spenders = snapshot_account_transactions[:top_spenders]
@top_earners = snapshot_account_transactions[:top_earners]
@top_savers = snapshot_account_transactions[:top_savers]
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
@transactions = Current.family.transactions.limit(6).reverse_chronological.with_default_inclusions
# TODO: Placeholders for trendlines
placeholder_series_data = 10.times.map do |i|
{ date: Date.current - i.days, value: Money.new(0, Current.family.currency) }
end
@investing_series = TimeSeries.new(placeholder_series_data)
@net_worth_series = Current.family.net_worth_series(@period)
@accounts = Current.family.accounts.active
end
def changelog

View File

@@ -65,6 +65,16 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
def accountable_groups_v2(accounts, classification: nil)
filtered_accounts = if classification
accounts.select { |a| a.classification == classification }
else
accounts
end
filtered_accounts.group_by(&:accountable_type).transform_keys { |k| Accountable.from_type(k) }
end
def accountable_groups(accounts, classification: nil)
filtered_accounts = if classification
accounts.select { |a| a.classification == classification }

View File

@@ -1,13 +0,0 @@
module ValueGroupsHelper
def value_group_pie_data(value_group)
value_group.children.filter { |c| c.sum > 0 }.map do |child|
{
label: to_accountable_title(Accountable.from_type(child.name)),
percent_of_total: child.percent_of_total.round(1).to_f,
formatted_value: format_money(child.sum, precision: 0),
bg_color: accountable_bg_class(child.name),
fill_color: accountable_fill_class(child.name)
}
end.to_json
end
end

View File

@@ -1,154 +0,0 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
// Connects to data-controller="pie-chart"
export default class extends Controller {
static values = {
data: Array,
total: String,
label: String,
};
#d3SvgMemo = null;
#d3GroupMemo = null;
#d3ContentMemo = null;
#d3ViewboxWidth = 200;
#d3ViewboxHeight = 200;
connect() {
this.#draw();
document.addEventListener("turbo:load", this.#redraw);
}
disconnect() {
this.#teardown();
document.removeEventListener("turbo:load", this.#redraw);
}
#redraw = () => {
this.#teardown();
this.#draw();
};
#teardown() {
this.#d3SvgMemo = null;
this.#d3GroupMemo = null;
this.#d3ContentMemo = null;
this.#d3Container.selectAll("*").remove();
}
#draw() {
this.#d3Container.attr("class", "relative");
this.#d3Content.html(this.#contentSummaryTemplate());
const pie = d3
.pie()
.value((d) => d.percent_of_total)
.padAngle(0.06);
const arc = d3
.arc()
.innerRadius(this.#radius - 8)
.outerRadius(this.#radius)
.cornerRadius(2);
const arcs = this.#d3Group
.selectAll("arc")
.data(pie(this.dataValue))
.enter()
.append("g")
.attr("class", "arc");
const paths = arcs
.append("path")
.attr("class", (d) => d.data.fill_color)
.attr("d", arc);
paths
.on("mouseover", (event) => {
this.#d3Svg.selectAll(".arc path").attr("class", "fill-gray-200");
d3.select(event.target).attr("class", (d) => d.data.fill_color);
this.#d3ContentMemo.html(
this.#contentDetailTemplate(d3.select(event.target).datum().data),
);
})
.on("mouseout", () => {
this.#d3Svg
.selectAll(".arc path")
.attr("class", (d) => d.data.fill_color);
this.#d3ContentMemo.html(this.#contentSummaryTemplate());
});
}
#contentSummaryTemplate() {
return `<span class="text-xl text-primary font-medium">${this.totalValue}</span> <span class="text-xs">${this.labelValue}</span>`;
}
#contentDetailTemplate(datum) {
return `
<span class="text-xl text-primary font-medium">${datum.formatted_value}</span>
<div class="flex flex-row text-xs gap-2 items-center">
<div class="w-[10px] h-[10px] rounded-full ${datum.bg_color}"></div>
<span>${datum.label}</span>
<span>${datum.percent_of_total}%</span>
</div>
`;
}
get #radius() {
return Math.min(this.#d3ViewboxWidth, this.#d3ViewboxHeight) / 2;
}
get #d3Container() {
return d3.select(this.element);
}
get #d3Svg() {
if (!this.#d3SvgMemo) {
this.#d3SvgMemo = this.#createMainSvg();
}
return this.#d3SvgMemo;
}
get #d3Group() {
if (!this.#d3GroupMemo) {
this.#d3GroupMemo = this.#createMainGroup();
}
return this.#d3GroupMemo;
}
get #d3Content() {
if (!this.#d3ContentMemo) {
this.#d3ContentMemo = this.#createContent();
}
return this.#d3ContentMemo;
}
#createMainSvg() {
return this.#d3Container
.append("svg")
.attr("width", "100%")
.attr("class", "relative aspect-1")
.attr("viewBox", [0, 0, this.#d3ViewboxWidth, this.#d3ViewboxHeight]);
}
#createMainGroup() {
return this.#d3Svg
.append("g")
.attr(
"transform",
`translate(${this.#d3ViewboxWidth / 2},${this.#d3ViewboxHeight / 2})`,
);
}
#createContent() {
this.#d3ContentMemo = this.#d3Container
.append("div")
.attr(
"class",
"absolute inset-0 w-full text-center flex flex-col items-center justify-center",
);
return this.#d3ContentMemo;
}
}

View File

@@ -3,13 +3,15 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="sidebar"
export default class extends Controller {
static values = { userId: String };
static targets = ["panel"];
static targets = ["panel", "content"];
toggle() {
this.panelTarget.classList.toggle("w-0");
this.panelTarget.classList.toggle("opacity-0");
this.panelTarget.classList.toggle("w-[260px]");
this.panelTarget.classList.toggle("opacity-100");
this.contentTarget.classList.toggle("max-w-4xl");
this.contentTarget.classList.toggle("max-w-5xl");
fetch(`/users/${this.userIdValue}`, {
method: "PATCH",

View File

@@ -16,15 +16,18 @@ export default class extends Controller {
_d3InitialContainerWidth = 0;
_d3InitialContainerHeight = 0;
_normalDataPoints = [];
_resizeObserver = null;
connect() {
this._install();
document.addEventListener("turbo:load", this._reinstall);
this._setupResizeObserver();
}
disconnect() {
this._teardown();
document.removeEventListener("turbo:load", this._reinstall);
this._resizeObserver?.disconnect();
}
_reinstall = () => {
@@ -545,4 +548,11 @@ export default class extends Controller {
.rangeRound([this._d3ContainerHeight, 0])
.domain([dataMin - padding, dataMax + padding]);
}
_setupResizeObserver() {
this._resizeObserver = new ResizeObserver(() => {
this._reinstall();
});
this._resizeObserver.observe(this.element);
}
}

View File

@@ -32,34 +32,7 @@ class Account < ApplicationRecord
accepts_nested_attributes_for :accountable, update_only: true
def institution_domain
return nil unless plaid_account&.plaid_item&.institution_url.present?
URI.parse(plaid_account.plaid_item.institution_url).host.gsub(/^www\./, "")
end
class << self
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types|
types.each do |type|
accounts = self.where(accountable_type: type)
if accounts.any?
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
accounts.each do |account|
group.add_value_node(
account,
account.balance_money.exchange_to(currency, fallback_rate: 0),
account.series(period: period, currency: currency)
)
end
end
end
end
grouped_accounts
end
class << self
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
@@ -89,6 +62,16 @@ class Account < ApplicationRecord
end
end
def institution_domain
return nil unless plaid_account&.plaid_item&.institution_url.present?
URI.parse(plaid_account.plaid_item.institution_url).host.gsub(/^www\./, "")
end
def weight
accountable_total = family.account_stats.totals_by_type.select { |t| t.classification == accountable.classification }.sum { |t| t.total_money.amount }
accountable_total.zero? ? 0 : balance / accountable_total.to_f * 100
end
def destroy_later
update!(scheduled_for_deletion: true, is_active: false)
DestroyJob.perform_later(self)

View File

@@ -34,17 +34,6 @@ class Account::Entry < ApplicationRecord
)
}
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
select(
"account_entries.*",
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
}
def sync_account_later
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
account.sync_later(start_date: sync_start_date)
@@ -70,33 +59,7 @@ class Account::Entry < ApplicationRecord
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
30.years.ago.to_date
end
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
daily_totals = select(
"gs.date",
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
)
.from(entries.with_converted_amount(currency), :e)
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
.group("gs.date")
# Extend the period to include the rolling window
period_with_rolling = period.extend_backward(period.date_range.count.days)
# Aggregate the rolling sum of spending and income based on daily totals
rolling_totals = from(daily_totals)
.select(
"*",
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
)
.order(:date)
# Trim the results to the original period
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
end
def bulk_update!(bulk_update_params)
bulk_attributes = {

View File

@@ -0,0 +1,34 @@
class AccountStats
attr_reader :family
def initialize(family)
@family = family
end
def totals_by_type
totals = family.accounts
.active
.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 = :family_currency",
{ current_date: Date.current.to_s, family_currency: family.currency }
]))
.group(:accountable_type)
.sum("accounts.balance * COALESCE(exchange_rates.rate, 1)")
.transform_keys { |key| Accountable.from_type(key) }
classification_totals = totals.group_by { |accountable, _| accountable.classification }
asset_total = totals.select { |accountable, _| accountable.classification == "asset" }.sum { |_, total| total }
liability_total = totals.select { |accountable, _| accountable.classification == "liability" }.sum { |_, total| total }
totals.map do |accountable, total|
group_total = accountable.classification == "asset" ? asset_total : liability_total
weight = group_total.zero? ? 0 : total / group_total.to_f * 100
TypeTotal.new(accountable: accountable, total_money: Money.new(total, family.currency), weight: weight, color: accountable.color, classification: accountable.classification)
end
end
private
TypeTotal = Struct.new(:accountable, :total_money, :weight, :color, :classification, keyword_init: true)
end

View File

@@ -19,6 +19,10 @@ module Accountable
end
class_methods do
def classification
self.name.in?(ASSET_TYPES) ? "asset" : "liability"
end
def display_name
self.name.humanize
end
@@ -91,4 +95,8 @@ module Accountable
def display_name
self.class.display_name
end
def classification
self.class.classification
end
end

View File

@@ -13,8 +13,14 @@ class CreditCard < ApplicationRecord
annual_fee ? Money.new(annual_fee, account.currency) : nil
end
class << self
def color
"#F13636"
end
end
def color
"#F13636"
self.class.color
end
def icon

View File

@@ -1,8 +1,14 @@
class Crypto < ApplicationRecord
include Accountable
class << self
def color
"#737373"
end
end
def color
"#737373"
self.class.color
end
def icon

View File

@@ -10,10 +10,14 @@ class Depository < ApplicationRecord
def display_name
"Cash"
end
def color
"#875BF7"
end
end
def color
"#875BF7"
self.class.color
end
def icon

View File

@@ -19,20 +19,51 @@ module Family::Aggregatable
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def income_categories_with_totals(date: Date.current)
categories_with_stats(classification: "income", date: date)
end
def net_worth_series(period = Period.last_30_days)
start_date = period.date_range.first
end_date = period.date_range.last
def expense_categories_with_totals(date: Date.current)
categories_with_stats(classification: "expense", date: date)
end
total_days = (end_date - start_date).to_i
date_interval = if total_days > 30
"7 days"
else
"1 day"
end
def category_stats
CategoryStats.new(self)
end
query = <<~SQL
WITH dates as (
SELECT generate_series(DATE :start_date, DATE :end_date, :date_interval::interval)::date as date
)
SELECT
d.date,
COALESCE(SUM(ab.balance * COALESCE(er.rate, 1)), 0) as balance,
COUNT(CASE WHEN a.currency <> :family_currency AND er.rate IS NULL THEN 1 END) as missing_rates
FROM dates d
LEFT JOIN accounts a ON (a.family_id = :family_id)
LEFT JOIN account_balances ab ON (
ab.date = d.date AND
ab.currency = a.currency AND
ab.account_id = a.id
)
LEFT JOIN exchange_rates er ON (
er.date = ab.date AND
er.from_currency = a.currency AND
er.to_currency = :family_currency
)
GROUP BY d.date
ORDER BY d.date
SQL
def budgeting_stats
BudgetingStats.new(self)
balances = Account::Balance.find_by_sql([
query,
family_id: self.id,
family_currency: self.currency,
start_date: start_date,
end_date: end_date,
date_interval: date_interval
])
TimeSeries.from_collection(balances, :balance)
end
def snapshot(period = Period.all)
@@ -59,66 +90,24 @@ module Family::Aggregatable
}
end
def snapshot_account_transactions
period = Period.last_30_days
results = accounts.active
.joins(:entries)
.joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
.select(
"accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
)
.where("account_entries.date >= ?", period.date_range.begin)
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.entryable_type = 'Account::Transaction'")
.where("transfers.id IS NULL")
.group("accounts.id")
.having("SUM(ABS(account_entries.amount)) > 0")
.to_a
results.each do |r|
r.define_singleton_method(:savings_rate) do
(income - spending) / income
end
end
{
top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse,
top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse,
top_savers: results.sort_by { |a| a.savings_rate }.reverse
}
def income_categories_with_totals(date: Date.current)
categories_with_stats(classification: "income", date: date)
end
def snapshot_transactions
candidate_entries = entries.account_transactions
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
def expense_categories_with_totals(date: Date.current)
categories_with_stats(classification: "expense", date: date)
end
spending = []
income = []
savings = []
rolling_totals.each do |r|
spending << {
date: r.date,
value: Money.new(r.rolling_spend, self.currency)
}
def category_stats
CategoryStats.new(self)
end
income << {
date: r.date,
value: Money.new(r.rolling_income, self.currency)
}
def budgeting_stats
BudgetingStats.new(self)
end
savings << {
date: r.date,
value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d
}
end
{
income_series: TimeSeries.new(income, favorable_direction: "up"),
spending_series: TimeSeries.new(spending, favorable_direction: "down"),
savings_rate_series: TimeSeries.new(savings, favorable_direction: "up")
}
def account_stats
AccountStats.new(self)
end
private

View File

@@ -16,8 +16,14 @@ class Investment < ApplicationRecord
[ "Angel", "angel" ]
].freeze
class << self
def color
"#1570EF"
end
end
def color
"#1570EF"
self.class.color
end
def icon

View File

@@ -17,8 +17,14 @@ class Loan < ApplicationRecord
Money.new(payment.round, account.currency)
end
class << self
def color
"#D444F1"
end
end
def color
"#D444F1"
self.class.color
end
def icon

View File

@@ -1,8 +1,14 @@
class OtherAsset < ApplicationRecord
include Accountable
class << self
def color
"#12B76A"
end
end
def color
"#12B76A"
self.class.color
end
def icon

View File

@@ -1,8 +1,14 @@
class OtherLiability < ApplicationRecord
include Accountable
class << self
def color
"#737373"
end
end
def color
"#737373"
self.class.color
end
def icon

View File

@@ -28,8 +28,14 @@ class Property < ApplicationRecord
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
class << self
def color
"#06AED4"
end
end
def color
"#06AED4"
self.class.color
end
def icon

View File

@@ -1,104 +0,0 @@
class ValueGroup
attr_accessor :parent, :original
attr_reader :name, :children, :value, :currency
def initialize(name, currency = Money.default_currency)
@name = name
@currency = Money::Currency.new(currency)
@children = []
end
def sum
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
children.sum(&:sum)
end
def avg
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
leaf_values = value_nodes.map(&:value)
leaf_values.compact.sum / leaf_values.compact.size
end
def series
return @series 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
first_child = children.first
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
TimeSeries.new(summed_series, favorable_direction: first_child&.series&.favorable_direction || "up")
end
def series=(series)
raise "Cannot set series on a non-leaf node" unless is_value_node?
_series = series || TimeSeries.new([])
raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
@series = _series
end
def value_nodes
return [ self ] unless value.nil?
children.flat_map { |child| child.value_nodes }
end
def empty?
value_nodes.empty?
end
def percent_of_total
return 100 if parent.nil? || parent.sum.zero?
((sum / parent.sum) * 100).round(1)
end
def add_child_group(name, currency = Money.default_currency)
raise "Cannot add subgroup to node with a value" if is_value_node?
child = self.class.new(name, currency)
child.parent = self
@children << child
child
end
def add_value_node(original, value, series = nil)
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
child = self.class.new(original.name)
child.original = original
child.value = value
child.series = series
child.parent = self
@children << child
child
end
def value=(value)
raise "Cannot set value on a non-leaf node" unless is_leaf_node?
raise "Value must be an instance of Money" unless value.is_a?(Money)
@value = value
@currency = value.currency
end
def is_leaf_node?
children.empty?
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
end

View File

@@ -15,8 +15,14 @@ class Vehicle < ApplicationRecord
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
class << self
def color
"#F23E94"
end
end
def color
"#F23E94"
self.class.color
end
def icon

View File

@@ -1,61 +0,0 @@
<%# locals: (group:) -%>
<% type = Accountable.from_type(group.name) %>
<% if group && group.children.any? %>
<% group_trend = group.series.trend %>
<details
class="mb-1 text-sm group"
data-controller="account-collapse"
data-account-collapse-type-value="<%= type %>">
<summary class="flex gap-4 px-3 py-2 items-center w-full rounded-[10px] font-medium
hover:bg-gray-100 cursor-pointer">
<%= lucide_icon("chevron-down",
class: "hidden group-open:block text-secondary w-5 h-5") %>
<%= lucide_icon("chevron-right",
class: "group-open:hidden text-secondary w-5 h-5") %>
<div class="text-left"><%= type.model_name.human %></div>
<div class="ml-auto flex flex-col items-end">
<p class="text-right"><%= format_money group.sum %></p>
<div class="flex items-center gap-1">
<div class="h-3 w-8">
<%= render "shared/sparkline", series: group.series, id: "#{group.name}_sparkline" %>
</div>
<span class="text-xs" style="color: <%= group_trend.color %>"><%= group_trend.value.positive? ? "+" : "" %><%= group_trend.percent.infinite? ? "∞" : number_to_percentage(group_trend.percent, precision: 0) %></span>
</div>
</div>
</summary>
<% group.children.sort_by(&:name).each do |account_value_node| %>
<% account = account_value_node.original %>
<% account_trend = account_value_node.series.trend %>
<%= link_to account, class: "flex items-center w-full gap-3 px-3 py-2 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
<%= render "accounts/logo", account: account, size: "sm" %>
<div class="overflow-hidden">
<p class="font-medium truncate"><%= account_value_node.name %></p>
<% if account.subtype %>
<p class="text-xs text-secondary"><%= account.subtype&.humanize %></p>
<% end %>
</div>
<div class="flex flex-col items-end font-medium text-right ml-auto">
<p><%= format_money account.balance_money %></p>
<div class="flex items-center gap-1">
<div class="h-3 w-8">
<%= render "shared/sparkline", series: account_value_node.series, id: dom_id(account, :list_sparkline) %>
</div>
<span class="text-xs" style="color: <%= account_trend.color %>">
<%= account_trend.value.positive? ? "+" : "" %><%= account_trend.percent.infinite? ? "∞" : number_to_percentage(account_trend.percent, precision: 0) %>
</span>
</div>
</div>
<% end %>
<% end %>
<%= link_to new_polymorphic_path(type, step: "method_select"), class: "flex items-center min-h-10 gap-4 px-3 py-2 mb-1 text-secondary text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<%= t(".new_account", type: type.model_name.human.downcase) %>
<% end %>
</details>
<% end %>

View File

@@ -2,10 +2,8 @@
<details class="group" data-controller="account-collapse" data-account-collapse-type-value="<%= accountable %>">
<summary class="px-3 py-2 flex items-center gap-3 cursor-pointer h-10">
<%= lucide_icon("chevron-down",
class: "hidden group-open:block text-secondary w-5 h-5") %>
<%= lucide_icon("chevron-right",
class: "group-open:hidden text-secondary w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:rotate-90 text-secondary w-5 h-5") %>
<%= tag.p accountable.display_name, class: "text-sm font-medium" %>
<div class="ml-auto text-right grow">

View File

@@ -1,5 +0,0 @@
<%= turbo_frame_tag "account-list" do %>
<% account_groups(period: @period).each do |group| %>
<%= render "accounts/account_list", group: group %>
<% end %>
<% end %>

View File

@@ -1,92 +0,0 @@
<% period = Period.from_param(params[:period]) %>
<div class="space-y-4">
<%= render "accounts/summary/header" %>
<div class="bg-white rounded-xl shadow-xs border border-tertiary flex divide-x divide-gray-200">
<div class="w-1/2 p-4 flex items-stretch justify-between">
<div class="space-y-2 grow">
<%= render partial: "shared/value_heading", locals: {
label: "Assets",
period: period,
value: Current.family.assets,
trend: @asset_series.trend
} %>
</div>
<div
id="assetsChart"
class="h-full w-2/5"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= @asset_series.to_json %>"
data-time-series-chart-use-labels-value="false"></div>
</div>
<div class="w-1/2 p-4 flex items-stretch justify-between">
<div class="space-y-2 grow">
<%= render partial: "shared/value_heading", locals: {
label: "Liabilities",
period: period,
size: "md",
value: Current.family.liabilities,
trend: @liability_series.trend
} %>
</div>
<div
id="liabilitiesChart"
class="h-full w-2/5"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= @liability_series.to_json %>"
data-time-series-chart-use-labels-value="false"></div>
</div>
</div>
<div class="p-4 bg-white rounded-xl shadow-xs border border-alpha-black-25 space-y-4">
<div class="flex justify-between items-center mb-5">
<h2 class="text-lg font-medium text-primary">Assets</h2>
<div class="flex items-center gap-2">
<%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-secondary") %>
<p><%= t(".new") %></p>
<% end %>
<%= form_with url: summary_accounts_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
<%= period_select form: form, selected: period.name %>
<% end %>
</div>
</div>
<% if @account_groups[:assets].children.any? %>
<%= render partial: "pages/account_percentages_bar", locals: { account_groups: @account_groups[:assets].children } %>
<%= render partial: "pages/account_percentages_table", locals: { account_groups: @account_groups[:assets].children } %>
<% else %>
<div class="py-20 flex flex-col items-center">
<%= lucide_icon "blocks", class: "w-6 h-6 shrink-0 text-secondary" %>
<p class="text-primary text-sm font-medium mb-1 mt-4"><%= t(".no_assets") %></p>
<p class="text-secondary text-sm max-w-xs text-center"><%= t(".no_assets_description") %></p>
</div>
<% end %>
</div>
<div class="p-4 bg-white rounded-xl shadow-xs border border-alpha-black-25 space-y-4">
<div class="flex justify-between items-center mb-5">
<h2 class="text-lg font-medium text-primary">Liabilities</h2>
<div class="flex items-center gap-2">
<%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-secondary") %>
<p><%= t(".new") %></p>
<% end %>
<%= form_with url: summary_accounts_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
<%= period_select form: form, selected: period.name %>
<% end %>
</div>
</div>
<% if @account_groups[:liabilities].children.any? %>
<%= render partial: "pages/account_percentages_bar", locals: { account_groups: @account_groups[:liabilities].children } %>
<%= render partial: "pages/account_percentages_table", locals: { account_groups: @account_groups[:liabilities].children } %>
<% else %>
<div class="py-20 flex flex-col items-center">
<%= lucide_icon "scale", class: "w-6 h-6 shrink-0 text-secondary" %>
<p class="text-primary text-sm font-medium mb-1 mt-4"><%= t(".no_liabilities") %></p>
<p class="text-secondary text-sm max-w-xs text-center"><%= t(".no_liabilities_description") %></p>
</div>
<% end %>
</div>
</div>

View File

@@ -26,7 +26,7 @@
</div>
</nav>
<div class="py-4 w-[260px] shrink-0 h-full overflow-y-auto transition-all duration-300" data-sidebar-target="panel">
<%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300", Current.user.show_sidebar? ? "w-[260px]" : "w-0"), data: { sidebar_target: "panel" } do %>
<% if content_for?(:sidebar) %>
<%= yield :sidebar %>
<% else %>
@@ -34,14 +34,16 @@
<%= render "accounts/account_sidebar_tabs", accounts: Current.family.accounts.active.to_a %>
</div>
<% end %>
</div>
<% end %>
<main class="container mx-auto px-10 py-4 h-full <%= require_upgrade? ? "relative overflow-hidden" : "overflow-y-auto" %>">
<%= tag.main class: class_names("py-4 px-10 grow h-full", require_upgrade? ? "relative overflow-hidden" : "overflow-y-auto") do %>
<% if require_upgrade? %>
<%= render "shared/subscribe_modal" %>
<% end %>
<%= yield %>
</main>
<%= tag.div class: class_names("mx-auto w-full pb-32", Current.user.show_sidebar? ? "max-w-4xl" : "max-w-5xl"), data: { sidebar_target: "content" } do %>
<%= yield %>
<% end %>
<% end %>
</div>
<% end %>

View File

@@ -1,48 +0,0 @@
<%# locals: (accountable_group:) %>
<% text_class = accountable_text_class(accountable_group.name) %>
<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-secondary text-sm font-medium cursor-pointer">
<%= 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") %>
<div class="ml-4 h-2.5 w-2.5 rounded-full <%= accountable_bg_class(accountable_group.name) %>"></div>
<p class="text-primary ml-2"><%= to_accountable_title(Accountable.from_type(accountable_group.name)) %></p>
<span class="mx-1">&middot;</span>
<div><%= accountable_group.children.count %></div>
<div class="ml-auto text-right flex items-center gap-10 text-sm font-medium text-primary">
<div class="flex items-center justify-end gap-2 w-24">
<%= render partial: "shared/progress_circle", locals: { progress: accountable_group.percent_of_total, text_class: text_class } %>
<p><%= accountable_group.percent_of_total.round(1) %>%</p>
</div>
<div class="w-24">
<p><%= format_money accountable_group.sum %></p>
</div>
<div class="w-40">
<%= render partial: "shared/trend_change", locals: { trend: accountable_group.series.trend } %>
</div>
</div>
</summary>
<div class="px-4 py-3 space-y-4">
<% accountable_group.children.map do |account_value_node| %>
<div class="flex items-center justify-between text-sm font-medium text-primary">
<div class="flex items-center gap-4 overflow-hidden">
<%= render "accounts/logo", account: account_value_node.original, size: "sm" %>
<div class="truncate">
<p><%= account_value_node.name %></p>
</div>
</div>
<div class="flex gap-10 items-center text-right">
<div class="flex items-center justify-end gap-2 w-24">
<%= render partial: "shared/progress_circle", locals: { progress: account_value_node.percent_of_total, text_class: text_class } %>
<p><%= account_value_node.percent_of_total %>%</p>
</div>
<div class="w-24">
<p><%= format_money account_value_node.original.balance_money %></p>
</div>
<div class="w-40">
<%= render partial: "shared/trend_change", locals: { trend: account_value_node.original.series.trend } %>
</div>
</div>
</div>
<% end %>
</div>
</details>

View File

@@ -1,17 +0,0 @@
<%# locals: (account_groups:) %>
<div class="space-y-4">
<div class="flex gap-1">
<% account_groups.sort_by(&:percent_of_total).reverse.each do |group| %>
<div class="h-1.5 rounded-sm w-12 <%= accountable_bg_class(group.name) %>" style="width: <%= group.percent_of_total %>%;"></div>
<% end %>
</div>
<div class="flex gap-4">
<% account_groups.sort_by(&:percent_of_total).reverse.each do |group| %>
<div class="flex items-center gap-2 text-sm">
<div class="h-2.5 w-2.5 rounded-full <%= accountable_bg_class(group.name) %>"></div>
<p class="text-secondary"><%= to_accountable_title(Accountable.from_type(group.name)) %></p>
<p class="text-black"><%= group.percent_of_total.round(1) %>%</p>
</div>
<% end %>
</div>
</div>

View File

@@ -1,20 +0,0 @@
<%# locals: (account_groups:) %>
<div class="bg-gray-25 p-1 rounded-xl">
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary">
<div>Name</div>
<div class="ml-auto text-right flex items-center gap-10">
<div class="w-24">
<p>% of total</p>
</div>
<div class="w-24">
<p>Value</p>
</div>
<div class="w-40">
<p>Change</p>
</div>
</div>
</div>
<div class="bg-white border border-alpha-black-25 shadow-xs rounded-lg divide-y divide-alpha-black-50">
<%= render partial: "pages/account_group_disclosure", collection: account_groups.sort_by(&:name), as: :accountable_group %>
</div>
</div>

View File

@@ -19,9 +19,120 @@
</header>
<section>
<div class="bg-white py-4 rounded-xl shadow-xs">
<div class="bg-white py-4 rounded-xl shadow-border-xs">
<div class="flex justify-between p-4">
<div>
<%= render partial: "shared/value_heading", locals: {
label: t(".net_worth"),
period: @period,
value: Current.family.net_worth,
trend: @net_worth_series.trend
} %>
</div>
<%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do |form| %>
<%= period_select form: form, selected: @period.name %>
<% end %>
</div>
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @net_worth_series } %>
</div>
</section>
<section></section>
<% accountable_totals = Current.family.account_stats.totals_by_type %>
<% ["asset", "liability"].each do |classification| %>
<% display = classification == "asset" ? "Assets" : "Debts" %>
<section class="bg-white shadow-border-xs rounded-xl space-y-4 p-4">
<h2 class="text-lg font-medium"><%= display %></h2>
<% classification_totals = accountable_totals.select { |t| t.classification == classification } %>
<% if classification_totals.any? { |t| t.total_money.amount.positive? } %>
<div class="space-y-4">
<div class="flex gap-1">
<% classification_totals.each do |accountable_total| %>
<div class="h-1.5 rounded-sm" style="width: <%= accountable_total.weight %>%; background-color: <%= accountable_total.color %>;"></div>
<% end %>
</div>
<div class="flex gap-4">
<% classification_totals.each do |accountable_total| %>
<div class="flex items-center gap-2 text-sm">
<div class="h-2.5 w-2.5 rounded-full" style="background-color: <%= accountable_total.color %>;"></div>
<p class="text-secondary"><%= accountable_total.accountable.display_name %></p>
<p class="text-black"><%= accountable_total.weight.ceil.round(0) %>%</p>
</div>
<% end %>
</div>
</div>
<div class="bg-surface rounded-xl p-1 space-y-1">
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary">
<div>Name</div>
<div class="ml-auto text-right flex items-center gap-6">
<div class="w-24">
<p>Weight</p>
</div>
<div class="w-40">
<p>Value</p>
</div>
</div>
</div>
<div class="shadow-border-xs rounded-lg bg-white">
<% classification_totals.each do |accountable_total| %>
<% accounts = Current.family.accounts.active.where(accountable_type: accountable_total.accountable.name) %>
<details class="group rounded-lg open:bg-surface font-medium text-sm">
<summary class="cursor-pointer p-4 group-open:bg-surface bg-white rounded-lg flex items-center justify-between">
<div class="flex items-center gap-4">
<%= lucide_icon("chevron-right", class: "group-open:rotate-90 text-secondary w-5 h-5") %>
<p><%= accountable_total.accountable.display_name %></p>
</div>
<div class="ml-auto flex items-center text-right gap-6">
<div class="w-24 flex items-center justify-end gap-2">
<%= render partial: "shared/progress_circle", locals: { progress: accountable_total.weight, text_class: accountable_total.color } %>
<p><%= number_to_percentage accountable_total.weight, precision: 0 %></p>
</div>
<div class="w-40">
<p><%= format_money(accountable_total.total_money) %></p>
</div>
</div>
</summary>
<div>
<% accounts.each do |account| %>
<div class="pl-12 pr-4 py-3 flex items-center justify-between text-sm font-medium">
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account, size: "sm" %>
<p><%= account.name %></p>
</div>
<div class="ml-auto flex items-center text-right gap-6">
<div class="w-24 flex items-center justify-end gap-2">
<%= render partial: "shared/progress_circle", locals: { progress: account.weight, text_class: accountable_total.color } %>
<p><%= number_to_percentage account.weight, precision: 0 %></p>
</div>
<div class="w-40">
<p><%= format_money(account.balance_money) %></p>
</div>
</div>
</div>
<% end %>
</div>
</details>
<% end %>
</div>
</div>
<% else %>
<div class="py-20 flex flex-col items-center">
<%= lucide_icon classification == "asset" ? "blocks" : "scale", class: "w-6 h-6 shrink-0 text-secondary" %>
<p class="text-primary text-sm font-medium mb-1 mt-4">No <%= display %></p>
<p class="text-secondary text-sm max-w-xs text-center"><%= "You have no #{display.singularize.downcase} accounts" %></p>
</div>
<% end %>
</section>
<% end %>
</div>

View File

@@ -1,29 +0,0 @@
<%# locals: (account_groups:) -%>
<div data-controller="tabs" data-tabs-active-class="bg-white border-alpha-black-25 shadow-xs text-primary" data-tabs-default-tab-value="asset-tab">
<div class="bg-gray-25 rounded-lg p-1 flex gap-1 text-sm text-secondary font-medium">
<button data-id="asset-tab" class="w-1/2 px-2 py-1 rounded-md border border-transparent" data-tabs-target="btn" data-action="tabs#select"><%= t(".assets") %></button>
<button data-id="liability-tab" class="w-1/2 px-2 py-1 rounded-md border border-transparent" data-tabs-target="btn" data-action="tabs#select"><%= t(".debts") %></button>
</div>
<div>
<div data-tabs-target="tab" id="asset-tab" class="space-y-6">
<div class="text-secondary flex items-center justify-center py-6">
<div
data-controller="pie-chart"
class="w-full aspect-1"
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:assets]) %>"
data-pie-chart-total-value="<%= format_money(account_groups[:assets].sum, precision: 0) %>">
</div>
</div>
</div>
<div data-tabs-target="tab" id="liability-tab" class="space-y-6 hidden">
<div class="text-secondary flex items-center justify-center py-6">
<div
data-controller="pie-chart"
class="w-full aspect-1"
data-pie-chart-data-value="<%= value_group_pie_data(account_groups[:liabilities]) %>"
data-pie-chart-total-value="<%= format_money(account_groups[:liabilities].sum, precision: 0) %>">
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,149 +0,0 @@
<div class="space-y-4">
<% if self_hosted? %>
<% if Current.family&.synth_overage? %>
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
Your Synth API credit limit has been exceeded. Please visit your <a href="https://dashboard.synthfinance.com/settings" class="font-medium underline hover:text-yellow-900">Synth billing settings</a> to upgrade your plan or wait for your credits to reset.
</div>
<% elsif !Current.family&.synth_valid? %>
<div class="bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative" role="alert">
Your Synth API Key is invalid. Please visit your <a href="https://dashboard.synthfinance.com/dashboard" class="font-medium underline hover:text-yellow-900">Synth dashboard</a> and verify that your API key is correct.
</div>
<% end %>
<% end %>
<header class="flex items-center justify-between">
<div>
<h1 class="sr-only"><%= t(".title") %></h1>
<p class="text-xl font-medium text-primary mb-1">
<%= Current.user.first_name.present? ? t(".greeting", name: Current.user.first_name ) : t(".fallback_greeting") %>
</p>
<% unless @accounts.blank? %>
<p class="text-secondary text-sm"><%= t(".subtitle") %></p>
<% end %>
</div>
<div class="flex items-center gap-2">
<%= contextual_menu do %>
<div class="w-48 p-1 text-sm leading-6 text-primary bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "hard-drive-upload" %>
</div>
<% end %>
<%= link_to new_account_path, class: "flex items-center gap-1 btn btn--primary", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span><%= t(".new") %></span>
<% end %>
</div>
</header>
<% if @accounts.empty? %>
<%= render "pages/dashboard/no_account_empty_state" %>
<% else %>
<section class="flex gap-4">
<div class="bg-white border border-alpha-black-25 shadow-xs rounded-xl w-3/4 min-h-48 flex flex-col">
<div class="flex justify-between p-4">
<div>
<%= render partial: "shared/value_heading", locals: {
label: t(".net_worth"),
period: @period,
value: Current.family.net_worth,
trend: @net_worth_series.trend
} %>
</div>
<%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do |form| %>
<%= period_select form: form, selected: @period.name %>
<% end %>
</div>
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @net_worth_series } %>
</div>
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl w-1/4">
<%= render partial: "pages/dashboard/allocation_chart", locals: { account_groups: @account_groups } %>
</div>
</section>
<section class="grid grid-cols-2 gap-4">
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<div class="flex flex-col gap-4 h-full">
<div class="flex gap-4">
<div class="grow">
<%= render partial: "shared/value_heading", locals: {
label: t(".income"),
period: Period.last_30_days,
value: @income_series.last&.value,
trend: @income_series.trend
} %>
</div>
<div
id="incomeChart"
class="h-full w-2/5"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= @income_series.to_json %>"
data-time-series-chart-use-labels-value="false"
data-time-series-chart-use-tooltip-value="false"></div>
</div>
<div class="flex gap-1.5 mt-auto">
<% @top_earners.first(3).each do |account| %>
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-primary font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %>
<%= render "accounts/logo", account: account, size: "sm" %>
<span>+<%= Money.new(account.income, account.currency) %></span>
<%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %>
<% end %>
<% end %>
<% if @top_earners.count > 3 %>
<div class="bg-gray-25 rounded-full flex h-full aspect-1 items-center justify-center text-xs font-medium text-secondary">+<%= @top_earners.count - 3 %></div>
<% end %>
</div>
</div>
</div>
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
<div class="flex flex-col gap-4 h-full">
<div class="flex gap-4">
<div class="grow">
<%= render partial: "shared/value_heading", locals: {
label: t(".spending"),
period: Period.last_30_days,
value: @spending_series.last&.value,
trend: @spending_series.trend
} %>
</div>
<div
id="spendingChart"
class="h-full w-2/5"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= @spending_series.to_json %>"
data-time-series-chart-use-labels-value="false"
data-time-series-chart-use-tooltip-value="false"></div>
</div>
<div class="mt-auto flex gap-1.5">
<% @top_spenders.first(3).each do |account| %>
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-primary font-medium hover:bg-gray-25", data: { controller: "tooltip" } do %>
<%= render "accounts/logo", account: account, size: "sm" %>
-<%= Money.new(account.spending, account.currency) %>
<%= render partial: "shared/text_tooltip", locals: { tooltip_text: account.name } %>
<% end %>
<% end %>
<% if @top_spenders.count > 3 %>
<div class="bg-gray-25 rounded-full flex h-full aspect-1 items-center justify-center text-xs font-medium text-secondary">+<%= @top_spenders.count - 3 %></div>
<% end %>
</div>
</div>
</div>
</section>
<section class="w-full">
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl space-y-4">
<h2 class="text-lg font-medium text-primary"><%= t(".transactions") %></h2>
<% if @transactions.empty? %>
<div class="text-secondary flex items-center justify-center py-12">
<p><%= t(".no_transactions") %></p>
</div>
<% else %>
<div class="text-secondary p-1 space-y-1 bg-gray-25 rounded-xl">
<%= entries_by_date(@transactions.map(&:entry)) do |entries| %>
<%= render entries %>
<% end %>
<p class="py-2 text-sm text-center"><%= link_to t(".view_all"), transactions_path %></p>
</div>
<% end %>
</div>
</section>
<% end %>
</div>

View File

@@ -78,8 +78,6 @@ Rails.application.routes.draw do
resources :accounts, only: %i[index new] do
collection do
get :summary
get :list
post :sync_all
end

View File

@@ -14,35 +14,6 @@ class AccountTest < ActiveSupport::TestCase
end
end
test "groups accounts by type" do
result = @family.accounts.by_group(period: Period.all)
assets = result[:assets]
liabilities = result[:liabilities]
assert_equal @family.assets, assets.sum
assert_equal @family.liabilities, liabilities.sum
depositories = assets.children.find { |group| group.name == "Depository" }
properties = assets.children.find { |group| group.name == "Property" }
vehicles = assets.children.find { |group| group.name == "Vehicle" }
investments = assets.children.find { |group| group.name == "Investment" }
other_assets = assets.children.find { |group| group.name == "OtherAsset" }
credits = liabilities.children.find { |group| group.name == "CreditCard" }
loans = liabilities.children.find { |group| group.name == "Loan" }
other_liabilities = liabilities.children.find { |group| group.name == "OtherLiability" }
assert_equal 2, depositories.children.count
assert_equal 1, properties.children.count
assert_equal 1, vehicles.children.count
assert_equal 1, investments.children.count
assert_equal 1, other_assets.children.count
assert_equal 1, credits.children.count
assert_equal 1, loans.children.count
assert_equal 1, other_liabilities.children.count
end
test "generates balance series" do
assert_equal 2, @account.series.values.count
end

View File

@@ -1,141 +0,0 @@
require "test_helper"
require "ostruct"
class ValueGroupTest < ActiveSupport::TestCase
setup do
# Level 1
@assets = ValueGroup.new("Assets", :usd)
# Level 2
@depositories = @assets.add_child_group("Depositories", :usd)
@other_assets = @assets.add_child_group("Other Assets", :usd)
# Level 3 (leaf/value nodes)
@checking_node = @depositories.add_value_node(OpenStruct.new({ name: "Checking", value: Money.new(5000) }), Money.new(5000))
@savings_node = @depositories.add_value_node(OpenStruct.new({ name: "Savings", value: Money.new(20000) }), Money.new(20000))
@collectable_node = @other_assets.add_value_node(OpenStruct.new({ name: "Collectable", value: Money.new(550) }), Money.new(550))
end
test "empty group works" do
group = ValueGroup.new("Root", :usd)
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_group("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 Money.new(5000), @checking_node.sum
assert_equal Money.new(20000), @savings_node.sum
assert_equal Money.new(550), @collectable_node.sum
assert_equal Money.new(25000), @depositories.sum
assert_equal Money.new(550), @other_assets.sum
assert_equal Money.new(25550), @assets.sum
end
test "group averages leaf nodes" do
assert_equal Money.new(5000), @checking_node.avg
assert_equal Money.new(20000), @savings_node.avg
assert_equal Money.new(550), @collectable_node.avg
assert_in_delta 12500, @depositories.avg.amount, 0.01
assert_in_delta 550, @other_assets.avg.amount, 0.01
assert_in_delta 8516.67, @assets.avg.amount, 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_group("Vehicles")
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
assert_equal Money.new(25550), @assets.sum
end
test "can attach and aggregate time series" do
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(4000) }, { date: Date.current, value: Money.new(5000) } ])
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(19000) }, { date: Date.current, value: Money.new(20000) } ])
@checking_node.series = checking_series
@savings_node.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: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(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.series = []
end
end
test "cannot add time series to non-leaf node" do
assert_raises(RuntimeError) do
@assets.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_group("Grandparent")
parent = grandparent.add_child_group("Parent")
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
assert_raises(RuntimeError) do
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
end
assert_raises(RuntimeError) do
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
end
end
end