Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/components/UI/account/chart.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,32 @@
<%= render partial: "shared/trend_change", locals: { trend: trend, comparison_label: comparison_label } %>
</div>

<% if secondary_series.present? %>
<div class="px-4 flex items-center gap-4 text-xs font-medium text-secondary">
<div class="flex items-center gap-2">
<span class="inline-block h-0.5 w-5 rounded-full" style="background-color: <%= trend.color %>"></span>
<span><%= primary_series_label %></span>
</div>

<div class="flex items-center gap-2">
<span class="inline-block w-5 border-t-2 border-dashed border-secondary"></span>
<span><%= secondary_series_label %></span>
</div>
</div>
<% end %>

<div class="h-64 pb-4">
<% if series.any? %>
<div
id="lineChart"
class="w-full h-full privacy-sensitive"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
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 %>></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-secondary text-sm">No data available</p>
Expand Down
24 changes: 23 additions & 1 deletion app/components/UI/account/chart.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 114 additions & 13 deletions app/javascript/controllers/time_series_chart_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
Expand All @@ -17,6 +24,7 @@ export default class extends Controller {
_d3InitialContainerWidth = 0;
_d3InitialContainerHeight = 0;
_normalDataPoints = [];
_secondaryDataPoints = [];
_resizeObserver = null;

connect() {
Expand All @@ -41,6 +49,7 @@ export default class extends Controller {
this._d3GroupMemo = null;
this._d3Tooltip = null;
this._normalDataPoints = [];
this._secondaryDataPoints = [];

this._d3Container.selectAll("*").remove();
}
Expand All @@ -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,
Expand Down Expand Up @@ -115,6 +131,10 @@ export default class extends Controller {
}

_drawChart() {
if (this._secondaryDataPoints.length >= 2) {
this._drawSecondaryTrendline();
}

this._drawTrendline();

if (this.useLabelsValue) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -385,27 +418,66 @@ export default class extends Controller {
}

_tooltipTemplate(datum) {
const secondaryDatum = this._findSecondaryDatum(datum.date);

if (!secondaryDatum) {
return `
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
${datum.date_formatted}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-primary">
<div class="flex items-center justify-center h-4 w-4">
${this._getTrendIcon(datum)}
</div>
${this._extractFormattedValue(datum.trend.current)}
</div>

${
datum.trend.value === 0
? `<span class="w-20"></span>`
: `
<span style="color: ${datum.trend.color};">
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
</span>
`
}
</div>
`;
}

return `
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
${datum.date_formatted}
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-primary">
<div class="flex items-center justify-center h-4 w-4">
${this._getTrendIcon(datum)}
<div class="space-y-2">
<div class="flex items-center justify-between gap-6">
<div class="flex items-center gap-2 text-primary">
<div class="flex items-center justify-center h-4 w-4">
${this._getTrendIcon(datum)}
</div>
<span>${this._primarySeriesLabel}</span>
</div>
${this._extractFormattedValue(datum.trend.current)}
<span class="text-primary">
${this._extractFormattedValue(datum.trend.current)}
</span>
</div>

${
datum.trend.value === 0
? `<span class="w-20"></span>`
? ""
: `
<span style="color: ${datum.trend.color};">
<div style="color: ${datum.trend.color};">
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
</span>
</div>
`
}
<div class="flex items-center justify-between gap-6 text-secondary">
<div class="flex items-center gap-2">
<div style="width: 16px; border-top: 2px dashed ${this.secondaryStrokeColorValue};"></div>
<span>${this._secondarySeriesLabel}</span>
</div>
<span>${this._extractFormattedValue(secondaryDatum.value)}</span>
</div>
</div>
`;
}
Expand All @@ -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);
Expand Down Expand Up @@ -505,23 +583,46 @@ 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()
.x((d) => this._d3XScale(d.date))
.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) {
Expand Down
27 changes: 27 additions & 0 deletions app/models/balance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Loading
Loading