mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Dashboard View and Calculations (#521)
* Handle Turbo updates with tabs Fixes #491 * Add Filterable concern for controllers * Add trendline chart * Extract common UI to partials * Series refactor * Put placeholders for calculations in * Add classification generated column to account * Add basic net worth calculation * Add net worth tests * Get net worth graph working * Fix lint errors * Implement asset grouping query * Make trends and series more intuitive * Fully functional dashboard * Remove logging
This commit is contained in:
parent
680a91d807
commit
6f0e410684
37 changed files with 594 additions and 74 deletions
|
@ -1,6 +1,5 @@
|
||||||
class AccountsController < ApplicationController
|
class AccountsController < ApplicationController
|
||||||
include Filterable
|
include Filterable
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
@ -12,8 +11,8 @@ class AccountsController < ApplicationController
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@account = Current.family.accounts.find(params[:id])
|
@account = Current.family.accounts.find(params[:id])
|
||||||
@balance_series = @account.balance_series(@period)
|
@balance_series = @account.balances.to_series(@account, @period)
|
||||||
@valuation_series = @account.valuation_series
|
@valuation_series = @account.valuations.to_series(@account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
class PagesController < ApplicationController
|
class PagesController < ApplicationController
|
||||||
|
include Filterable
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
|
||||||
def dashboard
|
def dashboard
|
||||||
|
@asset_series = Current.family.asset_series(@period)
|
||||||
|
@liability_series = Current.family.liability_series(@period)
|
||||||
|
@net_worth_series = Current.family.net_worth_series(@period)
|
||||||
|
@account_groups = Current.family.accounts.by_group(@period)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,4 +2,31 @@ module AccountsHelper
|
||||||
def to_accountable_title(accountable)
|
def to_accountable_title(accountable)
|
||||||
accountable.model_name.human
|
accountable.model_name.human
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def accountable_text_class(accountable_type)
|
||||||
|
class_mapping(accountable_type)[:text]
|
||||||
|
end
|
||||||
|
|
||||||
|
def accountable_bg_class(accountable_type)
|
||||||
|
class_mapping(accountable_type)[:bg]
|
||||||
|
end
|
||||||
|
|
||||||
|
def accountable_bg_transparent_class(accountable_type)
|
||||||
|
class_mapping(accountable_type)[:bg_transparent]
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def class_mapping(accountable_type)
|
||||||
|
{
|
||||||
|
"Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10" },
|
||||||
|
"Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10" },
|
||||||
|
"Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" },
|
||||||
|
"Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10" },
|
||||||
|
"Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10" },
|
||||||
|
"Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10" },
|
||||||
|
"Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10" },
|
||||||
|
"Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10" }
|
||||||
|
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -48,8 +48,10 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Styles to use when displaying a change in value
|
|
||||||
def trend_styles(trend)
|
def trend_styles(trend)
|
||||||
|
fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" }
|
||||||
|
return fallback if trend.nil? || trend.direction == "flat"
|
||||||
|
|
||||||
bg_class, text_class, symbol, icon = case trend.direction
|
bg_class, text_class, symbol, icon = case trend.direction
|
||||||
when "up"
|
when "up"
|
||||||
trend.type == "liability" ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
|
trend.type == "liability" ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ]
|
||||||
|
|
94
app/javascript/controllers/trendline_controller.js
Normal file
94
app/javascript/controllers/trendline_controller.js
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus";
|
||||||
|
import tailwindColors from "@maybe/tailwindcolors";
|
||||||
|
import * as d3 from "d3";
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { series: Object, classification: String };
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.renderChart(this.seriesValue);
|
||||||
|
document.addEventListener("turbo:load", this.renderChart.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
document.removeEventListener("turbo:load", this.renderChart.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderChart() {
|
||||||
|
this.drawChart(this.seriesValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawChart(series) {
|
||||||
|
const data = series.data.map((d) => ({
|
||||||
|
...d,
|
||||||
|
date: new Date(d.date + "T00:00:00"),
|
||||||
|
amount: +d.amount,
|
||||||
|
}));
|
||||||
|
const chartContainer = d3.select(this.element);
|
||||||
|
chartContainer.selectAll("*").remove();
|
||||||
|
const initialDimensions = {
|
||||||
|
width: chartContainer.node().clientWidth,
|
||||||
|
height: chartContainer.node().clientHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const svg = chartContainer
|
||||||
|
.append("svg")
|
||||||
|
.attr("width", initialDimensions.width)
|
||||||
|
.attr("height", initialDimensions.height)
|
||||||
|
.attr("viewBox", [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
initialDimensions.width,
|
||||||
|
initialDimensions.height,
|
||||||
|
])
|
||||||
|
.attr("style", "max-width: 100%; height: auto; height: intrinsic;");
|
||||||
|
|
||||||
|
const margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||||
|
const width = initialDimensions.width - margin.left - margin.right;
|
||||||
|
const height = initialDimensions.height - margin.top - margin.bottom;
|
||||||
|
|
||||||
|
const isLiability = this.classificationValue === "liability";
|
||||||
|
const trendDirection = data[data.length - 1].amount - data[0].amount;
|
||||||
|
let lineColor;
|
||||||
|
|
||||||
|
if (trendDirection > 0) {
|
||||||
|
lineColor = isLiability
|
||||||
|
? tailwindColors.error
|
||||||
|
: tailwindColors.green[500];
|
||||||
|
} else if (trendDirection < 0) {
|
||||||
|
lineColor = isLiability
|
||||||
|
? tailwindColors.green[500]
|
||||||
|
: tailwindColors.error;
|
||||||
|
} else {
|
||||||
|
lineColor = tailwindColors.gray[500];
|
||||||
|
}
|
||||||
|
|
||||||
|
const xScale = d3
|
||||||
|
.scaleTime()
|
||||||
|
.rangeRound([0, width])
|
||||||
|
.domain(d3.extent(data, (d) => d.date));
|
||||||
|
|
||||||
|
const PADDING = 0.05;
|
||||||
|
const dataMin = d3.min(data, (d) => d.amount);
|
||||||
|
const dataMax = d3.max(data, (d) => d.amount);
|
||||||
|
const padding = (dataMax - dataMin) * PADDING;
|
||||||
|
|
||||||
|
const yScale = d3
|
||||||
|
.scaleLinear()
|
||||||
|
.rangeRound([height, 0])
|
||||||
|
.domain([dataMin - padding, dataMax + padding]);
|
||||||
|
|
||||||
|
const line = d3
|
||||||
|
.line()
|
||||||
|
.x((d) => xScale(d.date))
|
||||||
|
.y((d) => yScale(d.amount));
|
||||||
|
|
||||||
|
svg
|
||||||
|
.append("path")
|
||||||
|
.datum(data)
|
||||||
|
.attr("fill", "none")
|
||||||
|
.attr("stroke", lineColor)
|
||||||
|
.attr("stroke-width", 2)
|
||||||
|
.attr("d", line);
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,27 +11,110 @@ class Account < ApplicationRecord
|
||||||
|
|
||||||
before_create :check_currency
|
before_create :check_currency
|
||||||
|
|
||||||
def balance_series(period)
|
def trend(period = Period.all)
|
||||||
MoneySeries.new(
|
first = balances.in_period(period).order(:date).first
|
||||||
balances.in_period(period).order(:date),
|
last = balances.in_period(period).order(date: :desc).first
|
||||||
{ trend_type: classification }
|
Trend.new(current: last.balance, previous: first.balance, type: classification)
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def valuation_series
|
# TODO: We will need a better way to encapsulate large queries & transformation logic, but leaving all in one spot until
|
||||||
MoneySeries.new(
|
# we have a better understanding of the requirements
|
||||||
valuations.order(:date),
|
def self.by_group(period = Period.all)
|
||||||
{ trend_type: classification, amount_accessor: :value }
|
ranked_balances_cte = joins(:balances)
|
||||||
)
|
.select("
|
||||||
end
|
account_balances.account_id,
|
||||||
|
account_balances.balance,
|
||||||
|
account_balances.date,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY account_balances.account_id ORDER BY date ASC) AS rn_asc,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY account_balances.account_id ORDER BY date DESC) AS rn_desc
|
||||||
|
")
|
||||||
|
|
||||||
def check_currency
|
if period.date_range
|
||||||
if self.currency == self.family.currency
|
ranked_balances_cte = ranked_balances_cte.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end)
|
||||||
self.converted_balance = self.balance
|
|
||||||
self.converted_currency = self.currency
|
|
||||||
else
|
|
||||||
self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance)
|
|
||||||
self.converted_currency = self.family.currency
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
accounts_with_period_balances = AccountBalance.with(
|
||||||
|
ranked_balances: ranked_balances_cte
|
||||||
|
)
|
||||||
|
.from("ranked_balances AS rb")
|
||||||
|
.joins("JOIN accounts a ON a.id = rb.account_id")
|
||||||
|
.select("
|
||||||
|
a.name,
|
||||||
|
a.accountable_type,
|
||||||
|
a.classification,
|
||||||
|
SUM(CASE WHEN rb.rn_asc = 1 THEN rb.balance ELSE 0 END) AS start_balance,
|
||||||
|
MAX(CASE WHEN rb.rn_asc = 1 THEN rb.date ELSE NULL END) as start_date,
|
||||||
|
SUM(CASE WHEN rb.rn_desc = 1 THEN rb.balance ELSE 0 END) AS end_balance,
|
||||||
|
MAX(CASE WHEN rb.rn_desc = 1 THEN rb.date ELSE NULL END) as end_date
|
||||||
|
")
|
||||||
|
.where("rb.rn_asc = 1 OR rb.rn_desc = 1")
|
||||||
|
.group("a.id")
|
||||||
|
.order("end_balance")
|
||||||
|
.to_a
|
||||||
|
|
||||||
|
assets = accounts_with_period_balances.select { |row| row.classification == "asset" }
|
||||||
|
liabilities = accounts_with_period_balances.select { |row| row.classification == "liability" }
|
||||||
|
|
||||||
|
total_assets = assets.sum(&:end_balance)
|
||||||
|
total_liabilities = liabilities.sum(&:end_balance)
|
||||||
|
|
||||||
|
{
|
||||||
|
asset: {
|
||||||
|
total: total_assets,
|
||||||
|
groups: assets.group_by(&:accountable_type).transform_values do |rows|
|
||||||
|
end_balance = rows.sum(&:end_balance)
|
||||||
|
start_balance = rows.sum(&:start_balance)
|
||||||
|
{
|
||||||
|
start_balance: start_balance,
|
||||||
|
end_balance: end_balance,
|
||||||
|
allocation: (end_balance / total_assets * 100).round(2),
|
||||||
|
trend: Trend.new(current: end_balance, previous: start_balance, type: "asset"),
|
||||||
|
accounts: rows.map do |account|
|
||||||
|
{
|
||||||
|
name: account.name,
|
||||||
|
start_balance: account.start_balance,
|
||||||
|
end_balance: account.end_balance,
|
||||||
|
allocation: (account.end_balance / total_assets * 100).round(2),
|
||||||
|
trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: "asset")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
},
|
||||||
|
liability: {
|
||||||
|
total: total_liabilities,
|
||||||
|
groups: liabilities.group_by(&:accountable_type).transform_values do |rows|
|
||||||
|
end_balance = rows.sum(&:end_balance)
|
||||||
|
start_balance = rows.sum(&:start_balance)
|
||||||
|
{
|
||||||
|
start_balance: start_balance,
|
||||||
|
end_balance: end_balance,
|
||||||
|
allocation: (end_balance / total_liabilities * 100).round(2),
|
||||||
|
trend: Trend.new(current: end_balance, previous: start_balance, type: "liability"),
|
||||||
|
accounts: rows.map do |account|
|
||||||
|
{
|
||||||
|
name: account.name,
|
||||||
|
start_balance: account.start_balance,
|
||||||
|
end_balance: account.end_balance,
|
||||||
|
allocation: (account.end_balance / total_liabilities * 100).round(2),
|
||||||
|
trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: "liability")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_currency
|
||||||
|
if self.currency == self.family.currency
|
||||||
|
self.converted_balance = self.balance
|
||||||
|
self.converted_currency = self.currency
|
||||||
|
else
|
||||||
|
self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance)
|
||||||
|
self.converted_currency = self.family.currency
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,10 @@ class AccountBalance < ApplicationRecord
|
||||||
|
|
||||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||||
|
|
||||||
def trend(previous)
|
def self.to_series(account, period = Period.all)
|
||||||
Trend.new(balance, previous&.balance)
|
MoneySeries.new(
|
||||||
|
in_period(period).order(:date),
|
||||||
|
{ trend_type: account.classification }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,4 +22,10 @@ class Period
|
||||||
]
|
]
|
||||||
|
|
||||||
INDEX = BUILTIN.index_by(&:name)
|
INDEX = BUILTIN.index_by(&:name)
|
||||||
|
|
||||||
|
BUILTIN.each do |period|
|
||||||
|
define_singleton_method(period.name) do
|
||||||
|
period
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,17 +1,16 @@
|
||||||
class Trend
|
class Trend
|
||||||
attr_reader :current, :previous, :type
|
attr_reader :current, :previous, :type
|
||||||
|
|
||||||
def initialize(current:, previous: nil, type: "asset")
|
def initialize(current: nil, previous: nil, type: "asset")
|
||||||
@current = current
|
@current = current
|
||||||
@previous = previous
|
@previous = previous
|
||||||
@type = type # asset means positive trend is good, liability means negative trend is good
|
@type = type # asset means positive trend is good, liability means negative trend is good
|
||||||
end
|
end
|
||||||
|
|
||||||
def direction
|
def direction
|
||||||
return "flat" unless @previous
|
return "flat" if @current == @previous
|
||||||
return "up" if @current > @previous
|
return "up" if @previous.nil? || (@current && @current > @previous)
|
||||||
return "down" if @current < @previous
|
"down"
|
||||||
"flat"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def amount
|
def amount
|
||||||
|
@ -22,6 +21,6 @@ class Trend
|
||||||
def percent
|
def percent
|
||||||
return 0 if @previous.nil?
|
return 0 if @previous.nil?
|
||||||
return Float::INFINITY if @previous == 0
|
return Float::INFINITY if @previous == 0
|
||||||
((@current - @previous).abs / @previous.to_f * 100).round(1)
|
((@current - @previous).abs / @previous.abs.to_f * 100).round(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,8 +3,13 @@ class Valuation < ApplicationRecord
|
||||||
|
|
||||||
after_commit :sync_account
|
after_commit :sync_account
|
||||||
|
|
||||||
def trend(previous)
|
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||||
Trend.new(current: value, previous: previous&.value, type: account.classification)
|
|
||||||
|
def self.to_series(account, period = Period.all)
|
||||||
|
MoneySeries.new(
|
||||||
|
in_period(period).order(:date),
|
||||||
|
{ trend_type: account.classification, amount_accessor: :value }
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<%# locals: (account:, valuation_series:) %>
|
<%# locals: (account:, valuations:) %>
|
||||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="font-medium text-lg">History</h3>
|
<h3 class="font-medium text-lg">History</h3>
|
||||||
|
@ -21,7 +21,7 @@
|
||||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||||
<%= turbo_frame_tag dom_id(Valuation.new) %>
|
<%= turbo_frame_tag dom_id(Valuation.new) %>
|
||||||
<%= turbo_frame_tag "valuations_list" do %>
|
<%= turbo_frame_tag "valuations_list" do %>
|
||||||
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuation_series, classification: account.classification } %>
|
<%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuations } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (valuation_series:, classification:) %>
|
<%# locals: (valuation_series:) %>
|
||||||
<% valuation_series.data.reverse_each.with_index do |valuation_item, index| %>
|
<% valuation_series.data.reverse_each.with_index do |valuation_item, index| %>
|
||||||
<% valuation, trend = valuation_item.values_at(:raw, :trend) %>
|
<% valuation, trend = valuation_item.values_at(:raw, :trend) %>
|
||||||
<% valuation_styles = trend_styles(valuation_item[:trend]) %>
|
<% valuation_styles = trend_styles(trend) %>
|
||||||
<%= turbo_frame_tag dom_id(valuation) do %>
|
<%= turbo_frame_tag dom_id(valuation) do %>
|
||||||
<div class="p-4 flex items-center">
|
<div class="p-4 flex items-center">
|
||||||
<div class="w-16">
|
<div class="w-16">
|
||||||
|
|
|
@ -34,18 +34,10 @@
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
</div>
|
</div>
|
||||||
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", html: { class: "" } do |f| %>
|
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: account_path(@account) } %>
|
||||||
<%= f.select :period, options_for_select([['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']], selected: params[:period]), {}, { class: "block w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm py-2 pr-8 pl-2 cursor-pointer", onchange: "this.form.submit();" } %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||||
<% if @balance_series %>
|
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %>
|
||||||
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= @balance_series.serialize_for_d3_chart %>"></div>
|
|
||||||
<% else %>
|
|
||||||
<div class="w-full h-full flex items-center justify-center">
|
|
||||||
<p class="text-gray-500">No data available for the selected period.</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div data-controller="tabs" data-tabs-active-class="bg-gray-100" data-tabs-default-tab-value="account-history-tab">
|
<div data-controller="tabs" data-tabs-active-class="bg-gray-100" data-tabs-default-tab-value="account-history-tab">
|
||||||
|
@ -55,7 +47,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-[800px]">
|
<div class="min-h-[800px]">
|
||||||
<div data-tabs-target="tab" id="account-history-tab">
|
<div data-tabs-target="tab" id="account-history-tab">
|
||||||
<%= render partial: "accounts/account_history", locals: { account: @account, valuation_series: @valuation_series } %>
|
<%= render partial: "accounts/account_history", locals: { account: @account, valuations: @valuation_series } %>
|
||||||
</div>
|
</div>
|
||||||
<div data-tabs-target="tab" id="account-transactions-tab" class="hidden">
|
<div data-tabs-target="tab" id="account-transactions-tab" class="hidden">
|
||||||
<%= render partial: "accounts/transactions", locals: { transactions: @account.transactions.order(date: :desc) } %>
|
<%= render partial: "accounts/transactions", locals: { transactions: @account.transactions.order(date: :desc) } %>
|
||||||
|
|
51
app/views/pages/_account_group_disclosure.erb
Normal file
51
app/views/pages/_account_group_disclosure.erb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<%# locals: (account_group:) %>
|
||||||
|
<% accountable_type, account_details = account_group%>
|
||||||
|
<% text_class = accountable_text_class(accountable_type) %>
|
||||||
|
<details class="open:bg-gray-25 group">
|
||||||
|
<summary class="flex p-4 items-center w-full rounded-lg font-medium hover:bg-gray-50 text-gray-500 text-sm font-medium cursor-pointer">
|
||||||
|
<%= 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_type) %>"></div>
|
||||||
|
<p class="text-gray-900 ml-2"><%= to_accountable_title(Accountable.from_type(accountable_type)) %></p>
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
<div ><%= account_details[:accounts].size %></div>
|
||||||
|
<div class="ml-auto text-right flex items-center gap-10 text-sm font-medium text-gray-900">
|
||||||
|
<div class="flex items-center justify-end gap-2 w-24">
|
||||||
|
<%= render partial: "shared/progress_circle", locals: { progress: account_details[:allocation], text_class: text_class } %>
|
||||||
|
<p><%= account_details[:allocation] %>%</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<p><%= format_currency account_details[:end_balance] %></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-40">
|
||||||
|
<%= render partial: "shared/trend_change", locals: { trend: account_details[:trend] } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div class="px-4 py-3 space-y-4">
|
||||||
|
<% account_details[:accounts].map do |account| %>
|
||||||
|
<div class="flex items-center justify-between text-sm font-medium text-gray-900">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center justify-center w-8 h-8 rounded-full <%= text_class %> <%= accountable_bg_transparent_class(accountable_type) %>">
|
||||||
|
<%= account[:name][0].upcase %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p><%= account[: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[:allocation], text_class: text_class } %>
|
||||||
|
<p><%= account[:allocation] %>%</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<p><%= format_currency account[:end_balance] %></p>
|
||||||
|
</div>
|
||||||
|
<div class="w-40">
|
||||||
|
<%= render partial: "shared/trend_change", locals: { trend: account[:trend] } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
17
app/views/pages/_account_percentages_bar.html.erb
Normal file
17
app/views/pages/_account_percentages_bar.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<%# locals: (account_groups:) %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<% account_groups.each do |type, value| %>
|
||||||
|
<div class="h-1.5 rounded-sm w-12 <%= accountable_bg_class(type) %>" style="width: <%= value[:allocation] %>%;"></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<% account_groups.each do |type, value| %>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<div class="h-2.5 w-2.5 rounded-full <%= accountable_bg_class(type) %>"></div>
|
||||||
|
<p class="text-gray-500"><%= to_accountable_title(Accountable.from_type(type)) %></p>
|
||||||
|
<p class="text-black"><%= value[:allocation] %>%</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
20
app/views/pages/_account_percentages_table.html.erb
Normal file
20
app/views/pages/_account_percentages_table.html.erb
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<%# 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-gray-500">
|
||||||
|
<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: "account_group_disclosure", collection: account_groups, as: :account_group %>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1 +1,90 @@
|
||||||
<h1 class="text-3xl font-semibold font-display"><%= t('.title')%></h1>
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="sr-only">Dashboard</h1>
|
||||||
|
<p class="text-xl font-medium text-gray-900 mb-1"><%= t('.greeting', name: Current.user.first_name )%></p>
|
||||||
|
<p class="text-gray-500 text-sm font-medium"><%= Date.current.strftime('%A, %b %d') %></p>
|
||||||
|
</div>
|
||||||
|
<section class="bg-white rounded-xl shadow-xs border border-alpha-black-25">
|
||||||
|
<div class="flex justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<%= render partial: "shared/balance_heading", locals: {
|
||||||
|
label: "Net Worth",
|
||||||
|
period: @period,
|
||||||
|
balance: Money.from_amount(Current.family.net_worth, Current.family.currency),
|
||||||
|
trend: @net_worth_series.trend
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
</div>
|
||||||
|
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: root_path } %>
|
||||||
|
</div>
|
||||||
|
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||||
|
<%= render partial: "shared/line_chart", locals: { series: @net_worth_series } %>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-t-alpha-black-100 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/balance_heading", locals: {
|
||||||
|
label: "Assets",
|
||||||
|
period: @period,
|
||||||
|
balance: Money.from_amount(Current.family.assets, Current.family.currency),
|
||||||
|
trend: @asset_series.trend
|
||||||
|
} %>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-controller="trendline"
|
||||||
|
id="assetsTrendline"
|
||||||
|
class="h-full w-2/5"
|
||||||
|
data-trendline-series-value="<%= @asset_series.serialize_for_d3_chart %>"
|
||||||
|
data-trendline-classification-value="asset"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 p-4 flex items-stretch justify-between">
|
||||||
|
<div class="space-y-2 grow">
|
||||||
|
<%= render partial: "shared/balance_heading", locals: {
|
||||||
|
label: "Liabilities",
|
||||||
|
period: @period,
|
||||||
|
size: "md",
|
||||||
|
balance: Money.from_amount(Current.family.liabilities, Current.family.currency),
|
||||||
|
trend: @liability_series.trend
|
||||||
|
} %>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-controller="trendline"
|
||||||
|
id="liabilitiesTrendline"
|
||||||
|
class="h-full w-2/5"
|
||||||
|
data-trendline-series-value="<%= @liability_series.serialize_for_d3_chart %>"
|
||||||
|
data-trendline-classification-value="liability"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="p-4 bg-white rounded-xl shadow-xs border border-alpha-black-25">
|
||||||
|
<div data-controller="tabs" data-tabs-active-class="bg-white border border-alpha-black-25 shadow-xs" data-tabs-default-tab-value="asset-summary-tab">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-1 flex gap-1 text-sm text-gray-900 font-medium">
|
||||||
|
<button data-id="asset-summary-tab" class="px-2 py-1 rounded-md" data-tabs-target="btn" data-action="tabs#select">Assets</button>
|
||||||
|
<button data-id="liability-summary-tab" class="px-2 py-1 rounded-md" data-tabs-target="btn" data-action="tabs#select">Liabilities</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<%= link_to new_account_path, class: "flex items-center gap-1 p-2 text-gray-900 text-sm font-medium bg-gray-50 rounded-lg hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
|
||||||
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
||||||
|
<p><%= t('.new') %></p>
|
||||||
|
<% end %>
|
||||||
|
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: root_path } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div data-tabs-target="tab" id="asset-summary-tab" class="space-y-6">
|
||||||
|
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:asset][:groups] } %>
|
||||||
|
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:asset][:groups] } %>
|
||||||
|
</div>
|
||||||
|
<div data-tabs-target="tab" id="liability-summary-tab" class="space-y-6 hidden">
|
||||||
|
<%= render partial: "account_percentages_bar", locals: { account_groups: @account_groups[:liability][:groups] } %>
|
||||||
|
<%= render partial: "account_percentages_table", locals: { account_groups: @account_groups[:liability][:groups] } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
|
@ -15,11 +15,9 @@
|
||||||
<% elsif trend.direction == "flat" %>
|
<% elsif trend.direction == "flat" %>
|
||||||
<p class="text-sm text-gray-500">No change vs. prior period</p>
|
<p class="text-sm text-gray-500">No change vs. prior period</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<% styles = trend_styles(trend) %>
|
<div class="flex items-center gap-2">
|
||||||
<p class="text-sm <%= styles[:text_class] %>">
|
<%= render partial: "shared/trend_change", locals: { trend: trend } %>
|
||||||
<span><%= styles[:symbol] %><%= format_currency(trend.amount.abs, precision: balance.precision) %></span>
|
<span class="text-sm text-gray-500"><%= period_label(period) %></span>
|
||||||
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
</div>
|
||||||
<span class="text-gray-500"><%= period_label(period) %></span>
|
|
||||||
</p>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
8
app/views/shared/_line_chart.html.erb
Normal file
8
app/views/shared/_line_chart.html.erb
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<%# locals: (series:) %>
|
||||||
|
<% if series %>
|
||||||
|
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= series.serialize_for_d3_chart %>"></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="w-full h-full flex items-center justify-center">
|
||||||
|
<p class="text-gray-500">No data available for the selected period.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
10
app/views/shared/_progress_circle.html.erb
Normal file
10
app/views/shared/_progress_circle.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %>
|
||||||
|
<% circumference = Math::PI * 2 * radius %>
|
||||||
|
<% progress_percent = progress.clamp(0, 100) %>
|
||||||
|
<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %>
|
||||||
|
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
|
||||||
|
<!-- Background Circle -->
|
||||||
|
<circle class="fill-transparent stroke-current text-gray-300" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" />
|
||||||
|
<!-- Foreground Circle -->
|
||||||
|
<circle class="fill-transparent stroke-current <%= text_class %>" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" stroke-dasharray="<%= circumference %>" stroke-dashoffset="<%= stroke_dashoffset %>" transform="rotate(-90, <%= radius + stroke / 2 %>, <%= radius + stroke / 2 %>)" />
|
||||||
|
</svg>
|
10
app/views/shared/_trend_change.html.erb
Normal file
10
app/views/shared/_trend_change.html.erb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
<%# locals: (trend:) %>
|
||||||
|
<% styles = trend_styles(trend) %>
|
||||||
|
<p class="text-sm <%= styles[:text_class] %>">
|
||||||
|
<% if trend.direction == "flat" %>
|
||||||
|
<span>No change</span>
|
||||||
|
<% else %>
|
||||||
|
<span><%= styles[:symbol] %><%= format_currency(trend.amount.abs) %></span>
|
||||||
|
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
|
@ -2,4 +2,5 @@
|
||||||
en:
|
en:
|
||||||
pages:
|
pages:
|
||||||
dashboard:
|
dashboard:
|
||||||
title: Dashboard
|
greeting: Welcome back, %{name}
|
||||||
|
new: New
|
||||||
|
|
|
@ -5,6 +5,8 @@ namespace :demo_data do
|
||||||
user = User.find_or_create_by(email: "user@maybe.local") do |u|
|
user = User.find_or_create_by(email: "user@maybe.local") do |u|
|
||||||
u.password = "password"
|
u.password = "password"
|
||||||
u.family = family
|
u.family = family
|
||||||
|
u.first_name = "User"
|
||||||
|
u.last_name = "Demo"
|
||||||
end
|
end
|
||||||
|
|
||||||
puts "Reset user: #{user.email} with family: #{family.name}"
|
puts "Reset user: #{user.email} with family: #{family.name}"
|
||||||
|
|
3
test/fixtures/account/credits.yml
vendored
3
test/fixtures/account/credits.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174003"
|
||||||
|
|
6
test/fixtures/account/depositories.yml
vendored
6
test/fixtures/account/depositories.yml
vendored
|
@ -1,2 +1,4 @@
|
||||||
checking: {}
|
checking:
|
||||||
savings: {}
|
id: "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
savings:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174001"
|
||||||
|
|
3
test/fixtures/account/investments.yml
vendored
3
test/fixtures/account/investments.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174004"
|
||||||
|
|
3
test/fixtures/account/loans.yml
vendored
3
test/fixtures/account/loans.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174005"
|
||||||
|
|
3
test/fixtures/account/other_assets.yml
vendored
3
test/fixtures/account/other_assets.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174002"
|
||||||
|
|
3
test/fixtures/account/other_liabilities.yml
vendored
3
test/fixtures/account/other_liabilities.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174006"
|
||||||
|
|
3
test/fixtures/account/properties.yml
vendored
3
test/fixtures/account/properties.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174007"
|
||||||
|
|
3
test/fixtures/account/vehicles.yml
vendored
3
test/fixtures/account/vehicles.yml
vendored
|
@ -1 +1,2 @@
|
||||||
one: {}
|
one:
|
||||||
|
id: "123e4567-e89b-12d3-a456-426614174008"
|
||||||
|
|
4
test/fixtures/accounts.yml
vendored
4
test/fixtures/accounts.yml
vendored
|
@ -4,6 +4,7 @@ collectable:
|
||||||
name: Collectable Account
|
name: Collectable Account
|
||||||
balance: 550
|
balance: 550
|
||||||
accountable_type: Account::OtherAsset
|
accountable_type: Account::OtherAsset
|
||||||
|
accountable_id: "123e4567-e89b-12d3-a456-426614174002"
|
||||||
|
|
||||||
# Account with only transactions
|
# Account with only transactions
|
||||||
checking:
|
checking:
|
||||||
|
@ -11,6 +12,7 @@ checking:
|
||||||
name: Checking Account
|
name: Checking Account
|
||||||
balance: 5000
|
balance: 5000
|
||||||
accountable_type: Account::Depository
|
accountable_type: Account::Depository
|
||||||
|
accountable_id: "123e4567-e89b-12d3-a456-426614174000"
|
||||||
|
|
||||||
# Account with both transactions and valuations
|
# Account with both transactions and valuations
|
||||||
savings_with_valuation_overrides:
|
savings_with_valuation_overrides:
|
||||||
|
@ -18,6 +20,7 @@ savings_with_valuation_overrides:
|
||||||
name: Savings account with valuation overrides
|
name: Savings account with valuation overrides
|
||||||
balance: 20000
|
balance: 20000
|
||||||
accountable_type: Account::Depository
|
accountable_type: Account::Depository
|
||||||
|
accountable_id: "123e4567-e89b-12d3-a456-426614174001"
|
||||||
|
|
||||||
# Liability account
|
# Liability account
|
||||||
credit_card:
|
credit_card:
|
||||||
|
@ -25,3 +28,4 @@ credit_card:
|
||||||
name: Credit Card
|
name: Credit Card
|
||||||
balance: 1000
|
balance: 1000
|
||||||
accountable_type: Account::Credit
|
accountable_type: Account::Credit
|
||||||
|
accountable_id: "123e4567-e89b-12d3-a456-426614174003"
|
||||||
|
|
|
@ -3,7 +3,6 @@ require "test_helper"
|
||||||
class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||||
test "syncs account with only valuations" do
|
test "syncs account with only valuations" do
|
||||||
account = accounts(:collectable)
|
account = accounts(:collectable)
|
||||||
account.accountable = account_other_assets(:one)
|
|
||||||
|
|
||||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||||
|
|
||||||
|
@ -19,7 +18,6 @@ class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "syncs account with only transactions" do
|
test "syncs account with only transactions" do
|
||||||
account = accounts(:checking)
|
account = accounts(:checking)
|
||||||
account.accountable = account_depositories(:checking)
|
|
||||||
|
|
||||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||||
|
|
||||||
|
@ -35,7 +33,6 @@ class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "syncs account with both valuations and transactions" do
|
test "syncs account with both valuations and transactions" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings_with_valuation_overrides)
|
||||||
account.accountable = account_depositories(:savings)
|
|
||||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||||
|
|
||||||
expected_balances = [
|
expected_balances = [
|
||||||
|
@ -50,7 +47,6 @@ class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "syncs liability account" do
|
test "syncs liability account" do
|
||||||
account = accounts(:credit_card)
|
account = accounts(:credit_card)
|
||||||
account.accountable = account_credits(:one)
|
|
||||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||||
|
|
||||||
expected_balances = [
|
expected_balances = [
|
||||||
|
|
|
@ -3,14 +3,12 @@ require "test_helper"
|
||||||
class Account::SyncableTest < ActiveSupport::TestCase
|
class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
test "account has no balances until synced" do
|
test "account has no balances until synced" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings_with_valuation_overrides)
|
||||||
account.accountable = account_depositories(:savings)
|
|
||||||
|
|
||||||
assert_equal 0, account.balances.count
|
assert_equal 0, account.balances.count
|
||||||
end
|
end
|
||||||
|
|
||||||
test "account has balances after syncing" do
|
test "account has balances after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings_with_valuation_overrides)
|
||||||
account.accountable = account_depositories(:savings)
|
|
||||||
account.sync
|
account.sync
|
||||||
|
|
||||||
assert_equal 31, account.balances.count
|
assert_equal 31, account.balances.count
|
||||||
|
@ -18,7 +16,6 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
test "stale balances are purged after syncing" do
|
test "stale balances are purged after syncing" do
|
||||||
account = accounts(:savings_with_valuation_overrides)
|
account = accounts(:savings_with_valuation_overrides)
|
||||||
account.accountable = account_depositories(:savings)
|
|
||||||
|
|
||||||
# Create old, stale balances that should be purged (since they are before account start date)
|
# Create old, stale balances that should be purged (since they are before account start date)
|
||||||
account.balances.create!(date: 1.year.ago, balance: 1000)
|
account.balances.create!(date: 1.year.ago, balance: 1000)
|
||||||
|
|
|
@ -2,9 +2,7 @@ require "test_helper"
|
||||||
|
|
||||||
class AccountTest < ActiveSupport::TestCase
|
class AccountTest < ActiveSupport::TestCase
|
||||||
def setup
|
def setup
|
||||||
depository = account_depositories(:checking)
|
|
||||||
@account = accounts(:checking)
|
@account = accounts(:checking)
|
||||||
@account.accountable = depository
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "new account should be valid" do
|
test "new account should be valid" do
|
||||||
|
|
|
@ -5,7 +5,6 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
@family = families(:dylan_family)
|
@family = families(:dylan_family)
|
||||||
|
|
||||||
@family.accounts.each do |account|
|
@family.accounts.each do |account|
|
||||||
account.accountable = account.classification == "asset" ? account_other_assets(:one) : account_other_liabilities(:one)
|
|
||||||
account.sync
|
account.sync
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -78,4 +77,63 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_equal expected_balances, @family.net_worth_series.data.map { |b| b[:value].amount }
|
assert_equal expected_balances, @family.net_worth_series.data.map { |b| b[:value].amount }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "calculates balances by type" do
|
||||||
|
verify_balances_by_type(
|
||||||
|
period: Period.all,
|
||||||
|
expected_asset_total: BigDecimal("25550"),
|
||||||
|
expected_liability_total: BigDecimal("1000"),
|
||||||
|
expected_asset_groups: {
|
||||||
|
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.15 },
|
||||||
|
"Account::Depository" => { end_balance: BigDecimal("25000"), start_balance: BigDecimal("25250"), allocation: 97.85 }
|
||||||
|
},
|
||||||
|
expected_liability_groups: {
|
||||||
|
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates balances by type with a date range filter" do
|
||||||
|
verify_balances_by_type(
|
||||||
|
period: Period.new(name: "custom", date_range: 7.days.ago.to_date..2.days.ago.to_date),
|
||||||
|
expected_asset_total: BigDecimal("26050"),
|
||||||
|
expected_liability_total: BigDecimal("1000"),
|
||||||
|
expected_asset_groups: {
|
||||||
|
"Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("700"), allocation: 2.11 },
|
||||||
|
"Account::Depository" => { end_balance: BigDecimal("25500"), start_balance: BigDecimal("24510"), allocation: 97.89 }
|
||||||
|
},
|
||||||
|
expected_liability_groups: {
|
||||||
|
"Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("990"), allocation: 100 }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def verify_balances_by_type(period:, expected_asset_total:, expected_liability_total:, expected_asset_groups:, expected_liability_groups:)
|
||||||
|
result = @family.accounts.by_group(period)
|
||||||
|
|
||||||
|
asset_total = result[:asset][:total]
|
||||||
|
liability_total = result[:liability][:total]
|
||||||
|
|
||||||
|
assert_equal expected_asset_total, asset_total
|
||||||
|
assert_equal expected_liability_total, liability_total
|
||||||
|
|
||||||
|
asset_groups = result[:asset][:groups]
|
||||||
|
liability_groups = result[:liability][:groups]
|
||||||
|
|
||||||
|
assert_equal expected_asset_groups.keys, asset_groups.keys
|
||||||
|
expected_asset_groups.each do |type, expected_values|
|
||||||
|
assert_equal expected_values[:end_balance], asset_groups[type][:end_balance]
|
||||||
|
assert_equal expected_values[:start_balance], asset_groups[type][:start_balance]
|
||||||
|
assert_equal expected_values[:allocation], asset_groups[type][:allocation]
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal expected_liability_groups.keys, liability_groups.keys
|
||||||
|
expected_liability_groups.each do |type, expected_values|
|
||||||
|
assert_equal expected_values[:end_balance], liability_groups[type][:end_balance]
|
||||||
|
assert_equal expected_values[:start_balance], liability_groups[type][:start_balance]
|
||||||
|
assert_equal expected_values[:allocation], liability_groups[type][:allocation]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
37
test/models/trend_test.rb
Normal file
37
test/models/trend_test.rb
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class TrendTest < ActiveSupport::TestCase
|
||||||
|
test "up" do
|
||||||
|
trend = Trend.new(current: 100, previous: 50)
|
||||||
|
assert_equal "up", trend.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "down" do
|
||||||
|
trend = Trend.new(current: 50, previous: 100)
|
||||||
|
assert_equal "down", trend.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "flat" do
|
||||||
|
trend = Trend.new(current: 100, previous: 100)
|
||||||
|
assert_equal "flat", trend.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "infinitely up" do
|
||||||
|
trend1 = Trend.new(current: 100, previous: nil)
|
||||||
|
trend2 = Trend.new(current: 100, previous: 0)
|
||||||
|
assert_equal "up", trend1.direction
|
||||||
|
assert_equal "up", trend2.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "infinitely down" do
|
||||||
|
trend1 = Trend.new(current: nil, previous: 100)
|
||||||
|
trend2 = Trend.new(current: 0, previous: 100)
|
||||||
|
assert_equal "down", trend1.direction
|
||||||
|
assert_equal "down", trend2.direction
|
||||||
|
end
|
||||||
|
|
||||||
|
test "empty" do
|
||||||
|
trend = Trend.new
|
||||||
|
assert_equal "flat", trend.direction
|
||||||
|
end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue