Functional sketch of new dashboard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
34
app/models/account_stats.rb
Normal file
34
app/models/account_stats.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">·</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user