diff --git a/app/components/UI/account/chart.html.erb b/app/components/UI/account/chart.html.erb index efcdca7d66b..5999b159dbb 100644 --- a/app/components/UI/account/chart.html.erb +++ b/app/components/UI/account/chart.html.erb @@ -41,13 +41,32 @@ <%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: comparison_label } %> + <% if secondary_series.present? %> +
+
+ + <%= primary_series_label %> +
+ +
+ + <%= secondary_series_label %> +
+
+ <% end %> +
<% if series.any? %>
+ data-time-series-chart-data-value="<%= series.to_json %>" + <% if secondary_series.present? %> + data-time-series-chart-primary-series-label-value="<%= primary_series_label %>" + data-time-series-chart-secondary-data-value="<%= secondary_series.to_json %>" + data-time-series-chart-secondary-series-label-value="<%= secondary_series_label %>" + <% end %>>
<% else %>

No data available

diff --git a/app/components/UI/account/chart.rb b/app/components/UI/account/chart.rb index ee55e499a8b..24a6c8b7489 100644 --- a/app/components/UI/account/chart.rb +++ b/app/components/UI/account/chart.rb @@ -62,12 +62,34 @@ def converted_balance_money end end + def primary_series + @primary_series ||= account.balance_series(period: period, view: view) + end + + def secondary_series + return nil unless foreign_currency? + + @secondary_series ||= account.balance_series( + period: period, + view: view, + currency: account.family.currency + ) + end + + def primary_series_label + account.currency + end + + def secondary_series_label + "#{account.family.currency} equivalent" + end + def view @view ||= "balance" end def series - account.balance_series(period: period, view: view) + primary_series end def trend diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 98baffc17f7..fb3373b36db 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -6,7 +6,14 @@ const parseLocalDate = d3.timeParse("%Y-%m-%d"); export default class extends Controller { static values = { data: Object, + secondaryData: Object, strokeWidth: { type: Number, default: 2 }, + secondaryStrokeColor: { + type: String, + default: "var(--color-gray-400)", + }, + primarySeriesLabel: String, + secondarySeriesLabel: String, useLabels: { type: Boolean, default: true }, useTooltip: { type: Boolean, default: true }, }; @@ -17,6 +24,7 @@ export default class extends Controller { _d3InitialContainerWidth = 0; _d3InitialContainerHeight = 0; _normalDataPoints = []; + _secondaryDataPoints = []; _resizeObserver = null; connect() { @@ -41,6 +49,7 @@ export default class extends Controller { this._d3GroupMemo = null; this._d3Tooltip = null; this._normalDataPoints = []; + this._secondaryDataPoints = []; this._d3Container.selectAll("*").remove(); } @@ -52,7 +61,14 @@ export default class extends Controller { } _normalizeDataPoints() { - this._normalDataPoints = (this.dataValue.values || []).map((d) => ({ + this._normalDataPoints = this._normalizeSeriesPoints(this.dataValue); + this._secondaryDataPoints = this.hasSecondaryDataValue + ? this._normalizeSeriesPoints(this.secondaryDataValue) + : []; + } + + _normalizeSeriesPoints(seriesData) { + return (seriesData?.values || []).map((d) => ({ date: parseLocalDate(d.date), date_formatted: d.date_formatted, value: d.value, @@ -115,6 +131,10 @@ export default class extends Controller { } _drawChart() { + if (this._secondaryDataPoints.length >= 2) { + this._drawSecondaryTrendline(); + } + this._drawTrendline(); if (this.useLabelsValue) { @@ -142,6 +162,19 @@ export default class extends Controller { .attr("stroke-width", this.strokeWidthValue); } + _drawSecondaryTrendline() { + this._d3Group + .append("path") + .datum(this._secondaryDataPoints) + .attr("fill", "none") + .attr("stroke", this.secondaryStrokeColorValue) + .attr("stroke-dasharray", "6 4") + .attr("d", this._d3SecondaryLine) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .attr("stroke-width", Math.max(1.5, this.strokeWidthValue - 0.25)); + } + _installTrendlineSplit() { const gradient = this._d3Svg .append("defs") @@ -385,27 +418,66 @@ export default class extends Controller { } _tooltipTemplate(datum) { + const secondaryDatum = this._findSecondaryDatum(datum.date); + + if (!secondaryDatum) { + return ` +
+ ${datum.date_formatted} +
+
+
+
+ ${this._getTrendIcon(datum)} +
+ ${this._extractFormattedValue(datum.trend.current)} +
+ + ${ + datum.trend.value === 0 + ? `` + : ` + + ${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) + + ` + } +
+ `; + } + return `
${datum.date_formatted}
-
-
-
- ${this._getTrendIcon(datum)} +
+
+
+
+ ${this._getTrendIcon(datum)} +
+ ${this._primarySeriesLabel}
- ${this._extractFormattedValue(datum.trend.current)} + + ${this._extractFormattedValue(datum.trend.current)} +
- ${ datum.trend.value === 0 - ? `` + ? "" : ` - +
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted}) - +
` } +
+
+
+ ${this._secondarySeriesLabel} +
+ ${this._extractFormattedValue(secondaryDatum.value)} +
`; } @@ -431,6 +503,12 @@ export default class extends Controller { return this._extractNumericValue(datum.value); }; + _findSecondaryDatum(date) { + return this._secondaryDataPoints.find( + (datum) => datum.date?.getTime() === date?.getTime(), + ); + } + _extractNumericValue = (numeric) => { if (typeof numeric === "object" && "amount" in numeric) { return Number(numeric.amount); @@ -505,6 +583,18 @@ export default class extends Controller { return this.dataValue.trend.color; } + get _primarySeriesLabel() { + return this.hasPrimarySeriesLabelValue + ? this.primarySeriesLabelValue + : "Primary"; + } + + get _secondarySeriesLabel() { + return this.hasSecondarySeriesLabelValue + ? this.secondarySeriesLabelValue + : "Secondary"; + } + get _d3Line() { return d3 .line() @@ -512,16 +602,27 @@ export default class extends Controller { .y((d) => this._d3YScale(this._getDatumValue(d))); } + get _d3SecondaryLine() { + return d3 + .line() + .x((d) => this._d3XScale(d.date)) + .y((d) => this._d3YScale(this._getDatumValue(d))); + } + + get _allDataPoints() { + return [...this._normalDataPoints, ...this._secondaryDataPoints]; + } + get _d3XScale() { return d3 .scaleTime() .rangeRound([0, this._d3ContainerWidth]) - .domain(d3.extent(this._normalDataPoints, (d) => d.date)); + .domain(d3.extent(this._allDataPoints, (d) => d.date)); } get _d3YScale() { - const dataMin = d3.min(this._normalDataPoints, this._getDatumValue); - const dataMax = d3.max(this._normalDataPoints, this._getDatumValue); + const dataMin = d3.min(this._allDataPoints, this._getDatumValue); + const dataMax = d3.max(this._allDataPoints, this._getDatumValue); // Handle edge case where all values are the same if (dataMin === dataMax) { diff --git a/app/models/balance.rb b/app/models/balance.rb index 3b6f74ce21c..73db0dbb04d 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -6,6 +6,9 @@ class Balance < ApplicationRecord validates :account, :date, :balance, presence: true validates :flows_factor, inclusion: { in: [ -1, 1 ] } + before_save :calculate_derived_balances + after_initialize :calculate_derived_balances + monetize :balance, :cash_balance, :start_cash_balance, :start_non_cash_balance, :start_balance, :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows, @@ -28,4 +31,28 @@ def balance_trend def favorable_direction flows_factor == -1 ? "down" : "up" end + + def calculate_derived_balances + raise ArgumentError, "flows_factor is nil" if flows_factor.nil? + raise ArgumentError, "start_cash_balance is nil" if start_cash_balance.nil? + raise ArgumentError, "start_non_cash_balance is nil" if start_non_cash_balance.nil? + raise ArgumentError, "cash_inflows is nil" if cash_inflows.nil? + raise ArgumentError, "cash_outflows is nil" if cash_outflows.nil? + raise ArgumentError, "non_cash_inflows is nil" if non_cash_inflows.nil? + raise ArgumentError, "non_cash_outflows is nil" if non_cash_outflows.nil? + raise ArgumentError, "net_market_flows is nil" if net_market_flows.nil? + raise ArgumentError, "cash_adjustments is nil" if cash_adjustments.nil? + raise ArgumentError, "non_cash_adjustments is nil" if non_cash_adjustments.nil? + # Calculate start_balance + self.start_balance = start_cash_balance + start_non_cash_balance + + # Calculate end_cash_balance + self.end_cash_balance = (start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor)) + cash_adjustments + + # Calculate end_non_cash_balance + self.end_non_cash_balance = ((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor)) + net_market_flows) + non_cash_adjustments + + # Calculate end_balance + self.end_balance = end_cash_balance + end_non_cash_balance + end end diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index c8c733579e4..9fe20bbd02d 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -65,23 +65,43 @@ def build_series_for(column) ) end - def query_data - @query_data ||= Balance.find_by_sql([ - query, - { - account_ids: account_ids, - target_currency: currency, - start_date: period.start_date, - end_date: period.end_date, - interval: interval, - sign_multiplier: sign_multiplier - } - ]) - rescue => e - Rails.logger.error "Query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}" - raise + def accounts + @accounts ||= Account.where(id: account_ids).select(:id, :currency, :name) end + def exchange_rates + @exchange_rates ||= begin + ExchangeRate.where(date: (period.start_date - 30.days)..period.end_date) # extend the range so exchange rates are likely to be available + .and(ExchangeRate.where(to_currency: currency)) + .and(ExchangeRate.where(from_currency: accounts.pluck(:currency).uniq)) + .select(:id, :date, :rate, :from_currency, :to_currency) + .group_by { |er| [ er.from_currency, er.to_currency ] } + .transform_values { |rates| rates.sort_by(&:date).reverse } + end + end + + def balances + @balances ||= Balance.where(account_id: account_ids) + .where(date: period.date_range) + .select("*") + .group_by { |b| [ b.account_id, b.date ] } + .transform_values { |balances| balances.sort_by(&:date).last } + end + + def starting_balances + @starting_balances ||= begin + latest_dates = Balance.where("date <= ?", period.start_date) + .where(account_id: account_ids) + .group(:account_id) + .maximum(:date) + + Balance.where(account_id: latest_dates.keys) + .where(date: latest_dates.values) + .select(:account_id, :date, :end_balance, :end_cash_balance, :end_non_cash_balance, :start_balance, :start_cash_balance, :start_non_cash_balance, :flows_factor, :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows, :cash_adjustments, :non_cash_adjustments) + .group_by { |b| b.account_id } + .transform_values { |balances| balances.sort_by(&:date).last } + end + end # Since the query aggregates the *net* of assets - liabilities, this means that if we're looking at # a single liability account, we'll get a negative set of values. This is not what the user expects # to see. When favorable direction is "down" (i.e. liability, decrease is "good"), we need to invert @@ -90,72 +110,75 @@ def sign_multiplier favorable_direction == "down" ? -1 : 1 end - def query - <<~SQL - WITH dates AS ( - SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date - UNION DISTINCT - SELECT :end_date::date -- Ensure end date is included - ) - SELECT - d.date, - -- Use flows_factor: already handles asset (+1) vs liability (-1) - COALESCE(SUM(last_bal.end_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_balance, - COALESCE(SUM(last_bal.end_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS end_cash_balance, - -- Holdings only for assets (flows_factor = 1) - COALESCE(SUM( - CASE WHEN last_bal.flows_factor = 1 - THEN last_bal.end_non_cash_balance - ELSE 0 - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ), 0) AS end_holdings_balance, - -- Previous balances - COALESCE(SUM(last_bal.start_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_balance, - COALESCE(SUM(last_bal.start_cash_balance * last_bal.flows_factor * COALESCE(er.rate, 1) * :sign_multiplier::integer), 0) AS start_cash_balance, - COALESCE(SUM( - CASE WHEN last_bal.flows_factor = 1 - THEN last_bal.start_non_cash_balance - ELSE 0 - END * COALESCE(er.rate, 1) * :sign_multiplier::integer - ), 0) AS start_holdings_balance - FROM dates d - CROSS JOIN accounts - LEFT JOIN LATERAL ( - SELECT b.end_balance, - b.end_cash_balance, - b.end_non_cash_balance, - b.start_balance, - b.start_cash_balance, - b.start_non_cash_balance, - b.flows_factor - FROM balances b - WHERE b.account_id = accounts.id - AND b.currency = accounts.currency - AND b.date <= d.date - ORDER BY b.date DESC - LIMIT 1 - ) last_bal ON TRUE - LEFT JOIN LATERAL ( - SELECT COALESCE( - (SELECT er.rate - FROM exchange_rates er - WHERE er.from_currency = accounts.currency - AND er.to_currency = :target_currency - AND er.date <= d.date - ORDER BY er.date DESC - LIMIT 1), - (SELECT er.rate - FROM exchange_rates er - WHERE er.from_currency = accounts.currency - AND er.to_currency = :target_currency - AND er.date > d.date - ORDER BY er.date ASC - LIMIT 1) - ) AS rate - ) er ON TRUE - WHERE accounts.id = ANY(array[:account_ids]::uuid[]) - GROUP BY d.date - ORDER BY d.date - SQL + def rate_for(from, to, date) + if from == to + return 1 + end + rates = exchange_rates.dig([ from, to ]) || [] + closest_rate = rates.bsearch { |rate| rate.date <= date } + closest_rate&.rate || 1 + end + + def query_data + @query_data ||= begin + result = date_series.map do |date| + OpenStruct.new( + date: date, + end_balance: 0, + end_cash_balance: 0, + end_holdings_balance: 0, + start_balance: 0, + start_cash_balance: 0, + start_holdings_balance: 0 + ) + end + accounts.each do |account| + previous = starting_balances.dig(account.id) + date_series.map.with_index.each do |date, index| + balance = balances.dig([ account.id, date ]) || previous + previous = balance + rate = rate_for(account.currency, currency, date) + if balance + factor = balance.flows_factor * sign_multiplier * rate + result[index].end_balance += balance.end_balance * factor + result[index].end_cash_balance += balance.end_cash_balance * factor + result[index].start_balance += balance.start_balance * factor + result[index].start_cash_balance += balance.start_cash_balance * factor + if balance.flows_factor == 1 + result[index].end_holdings_balance += balance.end_non_cash_balance * factor + result[index].start_holdings_balance += balance.start_non_cash_balance * factor + end + end + end + end + result + end + rescue => e + Rails.logger.error "Query data error: #{e.message} for accounts #{account_ids}, period #{period.start_date} to #{period.end_date}" + raise + end + + def date_series + @date_series ||= begin + dates = [] + current_date = period.start_date + + while current_date <= period.end_date + dates << current_date + current_date = case interval + when "1 day" + current_date + 1.day + when "1 week" + current_date + 1.week + else + # Default to daily if interval is not recognized + current_date + 1.day + end + end + + # Ensure end date is included + dates << period.end_date unless dates.include?(period.end_date) + dates.sort + end end end diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb index 7e180ef365e..ec946d34298 100644 --- a/test/models/balance/chart_series_builder_test.rb +++ b/test/models/balance/chart_series_builder_test.rb @@ -4,6 +4,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase include BalanceTestHelper setup do + Account.all.each do |account| + account.save! # trigger callback to set classification + end end test "balance series with fallbacks and gapfills" do