diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c6adff74..cf00cc20 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,4 +1,6 @@ class AccountsController < ApplicationController + include Filterable + before_action :authenticate_user! def new @@ -10,19 +12,6 @@ class AccountsController < ApplicationController def show @account = Current.family.accounts.find(params[:id]) - - @period = Period.find_by_name(params[:period]) - if @period.nil? - start_date = params[:start_date].presence&.to_date - end_date = params[:end_date].presence&.to_date - if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date - @period = Period.new(name: "custom", date_range: start_date..end_date) - else - params[:period] = "last_30_days" - @period = Period.find_by_name(params[:period]) - end - end - @balance_series = @account.balance_series(@period) @valuation_series = @account.valuation_series end diff --git a/app/controllers/concerns/filterable.rb b/app/controllers/concerns/filterable.rb new file mode 100644 index 00000000..65ed8eed --- /dev/null +++ b/app/controllers/concerns/filterable.rb @@ -0,0 +1,23 @@ +module Filterable + extend ActiveSupport::Concern + + included do + before_action :set_period + end + + private + + def set_period + @period = Period.find_by_name(params[:period]) + if @period.nil? + start_date = params[:start_date].presence&.to_date + end_date = params[:end_date].presence&.to_date + if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date + @period = Period.new(name: "custom", date_range: start_date..end_date) + else + params[:period] = "last_30_days" + @period = Period.find_by_name(params[:period]) + end + end + end +end diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js index ffd7f099..d6820e4b 100644 --- a/app/javascript/controllers/line_chart_controller.js +++ b/app/javascript/controllers/line_chart_controller.js @@ -4,7 +4,7 @@ import * as d3 from "d3"; // Connects to data-controller="line-chart" export default class extends Controller { - static values = { series: Array }; + static values = { series: Object }; connect() { this.renderChart(this.seriesValue); @@ -36,26 +36,26 @@ export default class extends Controller { }[trendDirection]; } - drawChart(balances) { - const data = balances.map((b) => ({ - date: new Date(b.data.date + "T00:00:00"), - value: +b.data.balance, + drawChart(series) { + const data = series.data.map((b) => ({ + date: new Date(b.date + "T00:00:00"), + value: +b.amount, styles: this.trendStyles(b.trend.direction), trend: b.trend, formatted: { value: Intl.NumberFormat("en-US", { style: "currency", - currency: b.data.currency || "USD", - }).format(b.data.balance), + currency: b.currency || "USD", + }).format(b.amount), change: Intl.NumberFormat("en-US", { style: "currency", - currency: b.data.currency || "USD", + currency: b.currency || "USD", signDisplay: "always", }).format(b.trend.amount), }, })); - const chartContainer = d3.select("#lineChart"); + const chartContainer = d3.select(this.element); // Clear any existing chart chartContainer.selectAll("svg").remove(); diff --git a/app/models/account.rb b/app/models/account.rb index 70851434..eb421166 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -28,29 +28,17 @@ class Account < ApplicationRecord end def balance_series(period) - filtered_balances = balances.in_period(period).order(:date) - return nil if filtered_balances.empty? - - series_data = [ nil, *filtered_balances ].each_cons(2).map do |previous, current| - trend = current&.trend(previous) - { data: current, trend: { amount: trend&.amount, direction: trend&.direction, percent: trend&.percent } } - end - - last_balance = series_data.last[:data] - - { - series_data: series_data, - last_balance: last_balance.balance, - trend: last_balance.trend(series_data.first[:data]) - } + MoneySeries.new( + balances.in_period(period).order(:date), + { trend_type: classification } + ) end def valuation_series - series_data = [ nil, *valuations.order(:date) ].each_cons(2).map do |previous, current| - { value: current, trend: current&.trend(previous) } - end - - series_data.reverse_each + MoneySeries.new( + valuations.order(:date), + { trend_type: classification, amount_accessor: :value } + ) end def check_currency diff --git a/app/models/money.rb b/app/models/money.rb new file mode 100644 index 00000000..b61c1fb3 --- /dev/null +++ b/app/models/money.rb @@ -0,0 +1,32 @@ +class Money + attr_reader :amount, :currency + + def self.from_amount(amount, currency = "USD") + Money.new(amount, currency) + end + + def initialize(amount, currency = :USD) + @amount = amount + @currency = currency + end + + def cents(precision: nil) + _precision = precision || CURRENCY_OPTIONS[@currency.to_sym][:precision] + return "" unless _precision.positive? + + fractional_part = @amount.to_s.split(".")[1] || "" + fractional_part = fractional_part[0, _precision].ljust(_precision, "0") + end + + def symbol + CURRENCY_OPTIONS[@currency.to_sym][:symbol] + end + + def separator + CURRENCY_OPTIONS[@currency.to_sym][:separator] + end + + def precision + CURRENCY_OPTIONS[@currency.to_sym][:precision] + end +end diff --git a/app/models/money_series.rb b/app/models/money_series.rb new file mode 100644 index 00000000..df493343 --- /dev/null +++ b/app/models/money_series.rb @@ -0,0 +1,60 @@ +class MoneySeries + def initialize(series, options = {}) + @trend_type = options[:trend_type] || :asset # Defines whether a positive trend is good or bad + @accessor = options[:amount_accessor] || :balance + @series = series + end + + def valid? + @series.length > 1 + end + + def data + [ nil, *@series ].each_cons(2).map do |previous, current| + { + raw: current, + date: current.date, + value: Money.from_amount(current.send(@accessor), current.currency), + trend: Trend.new( + current: current.send(@accessor), + previous: previous&.send(@accessor), + type: @trend_type + ) + } + end + end + + def trend + return Trend.new(current: 0, type: @trend_type) unless valid? + + Trend.new( + current: @series.last.send(@accessor), + previous: @series.first&.send(@accessor), + type: @trend_type + ) + end + + def serialize_for_d3_chart + { + data: data.map do |datum| + { + date: datum[:date], + amount: datum[:value].amount, + currency: datum[:value].currency, + trend: { + amount: datum[:trend].amount, + percent: datum[:trend].percent, + direction: datum[:trend].direction, + type: datum[:trend].type + } + } + end, + trend: { + amount: trend.amount, + percent: trend.percent, + direction: trend.direction, + type: trend.type + } + }.to_json + end +end diff --git a/app/models/trend.rb b/app/models/trend.rb index 0d59ee33..d7a5c64f 100644 --- a/app/models/trend.rb +++ b/app/models/trend.rb @@ -1,26 +1,27 @@ class Trend - attr_reader :current, :previous + attr_reader :current, :previous, :type - def initialize(current, previous) - @current = current - @previous = previous - end + def initialize(current:, previous: nil, type: :asset) + @current = current + @previous = previous + @type = type # :asset means positive trend is good, :liability means negative trend is good + end - def direction - return "flat" unless @previous - return "up" if @current > @previous - return "down" if @current < @previous - "flat" - end + def direction + return "flat" unless @previous + return "up" if @current > @previous + return "down" if @current < @previous + "flat" + end - def amount - return 0 if @previous.nil? - @current - @previous - end + def amount + return 0 if @previous.nil? + @current - @previous + end - def percent - return 0 if @previous.nil? - return Float::INFINITY if @previous == 0 - ((@current - @previous).abs / @previous.to_f * 100).round(1) - end + def percent + return 0 if @previous.nil? + return Float::INFINITY if @previous == 0 + ((@current - @previous).abs / @previous.to_f * 100).round(1) + end end diff --git a/app/models/valuation.rb b/app/models/valuation.rb index 6e7a6bb1..f28e6dc4 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -4,7 +4,7 @@ class Valuation < ApplicationRecord after_commit :sync_account def trend(previous) - Trend.new(value, previous&.value) + Trend.new(current: value, previous: previous&.value, type: account.classification) end private diff --git a/app/views/accounts/_account_valuation_list.html.erb b/app/views/accounts/_account_valuation_list.html.erb index 9a2c7336..5efd509a 100644 --- a/app/views/accounts/_account_valuation_list.html.erb +++ b/app/views/accounts/_account_valuation_list.html.erb @@ -1,6 +1,6 @@ <%# locals: (valuation_series:, classification:) %> -<% valuation_series.with_index do |valuation_item, index| %> - <% valuation, trend = valuation_item.values_at(:value, :trend) %> +<% valuation_series.data.reverse_each.with_index do |valuation_item, index| %> + <% valuation, trend = valuation_item.values_at(:raw, :trend) %> <% valuation_styles = trend_styles(valuation_item[:trend], mode: classification) %> <%= turbo_frame_tag dom_id(valuation) do %>
Total Value
- <%# TODO: Will need a better way to split a formatted monetary value into these 3 parts %>- <%= @account.currency.unit %> - <%= format_currency(@account.balance, precision: 0, unit: '') %> - <%- if @account.currency.precision.positive? -%> - <%= @account.currency.separator %><%= @account.balance.cents(precision: @account.currency.precision) %> + <%= balance.symbol %> + <%= format_currency(balance.amount, precision: 0, unit: '') %> + <%- if balance.precision.positive? -%> + <%= balance.separator %><%= balance.cents %> <% end %>
<% if @balance_series.nil? %>Data not available for the selected period
- <% elsif @balance_series[:trend].amount == 0 %> + <% elsif @balance_series.trend.amount == 0 %>No change vs. prior period
<% else %>- <%= balance_trend_styles[:symbol] %><%= number_to_currency(@balance_series[:trend].amount.abs, precision: 2) %> - (<%= lucide_icon(@balance_series[:trend].amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= @balance_series[:trend].percent %>%) + <%= balance_trend_styles[:symbol] %><%= number_to_currency(@balance_series.trend.amount.abs, precision: 2) %> + (<%= lucide_icon(@balance_series.trend.amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= @balance_series.trend.percent %>%) <%= trend_label(@period) %>
<% end %> @@ -53,7 +53,7 @@No data available for the selected period.
diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index 1ec5f7ec..fa6cc653 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -1,18 +1,18 @@ -default_currency_options = { unit: "$", precision: 2, delimiter: ",", separator: "." } +default_currency_options = { symbol: "$", precision: 2, delimiter: ",", separator: "." } CURRENCY_OPTIONS = Hash.new { |hash, key| hash[key] = default_currency_options.dup }.merge( - "USD": { unit: "$", precision: 2, delimiter: ",", separator: "." }, - "EUR": { unit: "€", precision: 2, delimiter: ".", separator: "," }, - "GBP": { unit: "£", precision: 2, delimiter: ",", separator: "." }, - "CAD": { unit: "C$", precision: 2, delimiter: ",", separator: "." }, - "MXN": { unit: "MX$", precision: 2, delimiter: ",", separator: "." }, - "HKD": { unit: "HK$", precision: 2, delimiter: ",", separator: "." }, - "CHF": { unit: "CHF", precision: 2, delimiter: ".", separator: "," }, - "SGD": { unit: "S$", precision: 2, delimiter: ",", separator: "." }, - "NZD": { unit: "NZ$", precision: 2, delimiter: ",", separator: "." }, - "AUD": { unit: "A$", precision: 2, delimiter: ",", separator: "." }, - "KRW": { unit: "₩", precision: 0, delimiter: ",", separator: "." }, - "INR": { unit: "₹", precision: 2, delimiter: ",", separator: "." } + "USD": { symbol: "$", precision: 2, delimiter: ",", separator: "." }, + "EUR": { symbol: "€", precision: 2, delimiter: ".", separator: "," }, + "GBP": { symbol: "£", precision: 2, delimiter: ",", separator: "." }, + "CAD": { symbol: "C$", precision: 2, delimiter: ",", separator: "." }, + "MXN": { symbol: "MX$", precision: 2, delimiter: ",", separator: "." }, + "HKD": { symbol: "HK$", precision: 2, delimiter: ",", separator: "." }, + "CHF": { symbol: "CHF", precision: 2, delimiter: ".", separator: "," }, + "SGD": { symbol: "S$", precision: 2, delimiter: ",", separator: "." }, + "NZD": { symbol: "NZ$", precision: 2, delimiter: ",", separator: "." }, + "AUD": { symbol: "A$", precision: 2, delimiter: ",", separator: "." }, + "KRW": { symbol: "₩", precision: 0, delimiter: ",", separator: "." }, + "INR": { symbol: "₹", precision: 2, delimiter: ",", separator: "." } ) EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present? diff --git a/config/initializers/numeric_extensions.rb b/config/initializers/numeric_extensions.rb deleted file mode 100644 index e86b21ad..00000000 --- a/config/initializers/numeric_extensions.rb +++ /dev/null @@ -1,11 +0,0 @@ -class Numeric - def cents(precision: 2) - return "" unless precision.positive? - - cents = self.to_s.split(".")[1] - cents = "" unless cents.to_i.positive? - - zero_padded_cents = cents.ljust(precision, "0") - zero_padded_cents[0..precision - 1] - end -end diff --git a/config/initializers/string_extensions.rb b/config/initializers/string_extensions.rb deleted file mode 100644 index 83af5bec..00000000 --- a/config/initializers/string_extensions.rb +++ /dev/null @@ -1,13 +0,0 @@ -class String - def unit - CURRENCY_OPTIONS[self.to_sym][:unit] - end - - def separator - CURRENCY_OPTIONS[self.to_sym][:separator] - end - - def precision - CURRENCY_OPTIONS[self.to_sym][:precision] - end -end diff --git a/test/initializers/numeric_extensions_test.rb b/test/initializers/numeric_extensions_test.rb deleted file mode 100644 index 6346761d..00000000 --- a/test/initializers/numeric_extensions_test.rb +++ /dev/null @@ -1,28 +0,0 @@ -# test/initializers/big_decimal_extensions_test.rb -require "test_helper" - -class NumericExtensionsTest < ActiveSupport::TestCase - test "#cents returns the cents part with 2 precisions by default" do - amount = 123.45 - assert_equal "45", amount.cents - end - - test "#cents returns empty when precision is 0" do - amount = 123.45 - assert_equal "", amount.cents(precision: 0) - end - - test "#cents returns the cents part of the string with given precision" do - amount = 123.4862 - assert_equal "4", amount.cents(precision: 1) - assert_equal "486", amount.cents(precision: 3) - end - - test "#cents pads the cents part with zeros up to the specified precision" do - amount_without_decimal = 123 - amount_with_decimal = 123.4 - - assert_equal "00", amount_without_decimal.cents - assert_equal "40", amount_with_decimal.cents - end -end diff --git a/test/initializers/string_extensions_test.rb b/test/initializers/string_extensions_test.rb deleted file mode 100644 index 4812fe98..00000000 --- a/test/initializers/string_extensions_test.rb +++ /dev/null @@ -1,19 +0,0 @@ -# test/string_extensions_test.rb -require "test_helper" - -class StringExtensionsTest < ActiveSupport::TestCase - test "#unit returns the currency unit for a given currency code" do - assert_equal "$", "USD".unit - assert_equal "€", "EUR".unit - end - - test "#separator returns the currency separator for a given currency code" do - assert_equal ".", "USD".separator - assert_equal ",", "EUR".separator - end - - test "#precision returns the currency's precision for a given currency code" do - assert_equal 2, "USD".precision - assert_equal 0, "KRW".precision - end -end diff --git a/test/models/money_test.rb b/test/models/money_test.rb new file mode 100644 index 00000000..f3cc4855 --- /dev/null +++ b/test/models/money_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class MoneyTest < ActiveSupport::TestCase + test "#symbol returns the currency symbol for a given currency code" do + assert_equal "$", Money.from_amount(0, "USD").symbol + assert_equal "€", Money.from_amount(0, "EUR").symbol + end + + test "#separator returns the currency separator for a given currency code" do + assert_equal ".", Money.from_amount(0, "USD").separator + assert_equal ",", Money.from_amount(0, "EUR").separator + end + + test "#precision returns the currency's precision for a given currency code" do + assert_equal 2, Money.from_amount(0, "USD").precision + assert_equal 0, Money.from_amount(123.45, "KRW").precision + end + + test "#cents returns the cents part with 2 precisions by default" do + assert_equal "45", Money.from_amount(123.45, "USD").cents + end + + test "#cents returns empty when precision is 0" do + assert_equal "", Money.from_amount(123.45, "USD").cents(precision: 0) + end + + test "#cents returns the cents part of the string with given precision" do + amount = Money.from_amount(123.4862, "USD") + assert_equal "4", amount.cents(precision: 1) + assert_equal "486", amount.cents(precision: 3) + end + + test "#cents pads the cents part with zeros up to the specified precision" do + amount_without_decimal = Money.from_amount(123, "USD") + amount_with_decimal = Money.from_amount(123.4, "USD") + + assert_equal "00", amount_without_decimal.cents + assert_equal "40", amount_with_decimal.cents + end + + test "works with BigDecimal" do + amount = Money.from_amount(BigDecimal("123.45"), "USD") + assert_equal "45", amount.cents + end +end