mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Implement savings rate insight card (#670)
This commit is contained in:
parent
461fa672ff
commit
b3f8ab78d9
7 changed files with 89 additions and 54 deletions
|
@ -10,10 +10,12 @@ class PagesController < ApplicationController
|
||||||
snapshot_transactions = Current.family.snapshot_transactions
|
snapshot_transactions = Current.family.snapshot_transactions
|
||||||
@income_series = snapshot_transactions[:income_series]
|
@income_series = snapshot_transactions[:income_series]
|
||||||
@spending_series = snapshot_transactions[:spending_series]
|
@spending_series = snapshot_transactions[:spending_series]
|
||||||
|
@savings_rate_series = snapshot_transactions[:savings_rate_series]
|
||||||
|
|
||||||
snapshot_account_transactions = Current.family.snapshot_account_transactions
|
snapshot_account_transactions = Current.family.snapshot_account_transactions
|
||||||
@top_spenders = snapshot_account_transactions[:top_spenders]
|
@top_spenders = snapshot_account_transactions[:top_spenders]
|
||||||
@top_earners = snapshot_account_transactions[:top_earners]
|
@top_earners = snapshot_account_transactions[:top_earners]
|
||||||
|
@top_savers = snapshot_account_transactions[:top_savers]
|
||||||
|
|
||||||
@account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency)
|
@account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency)
|
||||||
@transactions = Current.family.transactions.limit(5).order(date: :desc)
|
@transactions = Current.family.transactions.limit(5).order(date: :desc)
|
||||||
|
@ -22,7 +24,6 @@ class PagesController < ApplicationController
|
||||||
placeholder_series_data = 10.times.map do |i|
|
placeholder_series_data = 10.times.map do |i|
|
||||||
{ date: Date.current - i.days, value: Money.new(0) }
|
{ date: Date.current - i.days, value: Money.new(0) }
|
||||||
end
|
end
|
||||||
@savings_rate_series = TimeSeries.new(10.times.map { |i| { date: Date.current - i.days, value: 0 } })
|
|
||||||
@investing_series = TimeSeries.new(placeholder_series_data)
|
@investing_series = TimeSeries.new(placeholder_series_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,6 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
def snapshot_account_transactions
|
def snapshot_account_transactions
|
||||||
period = Period.last_30_days
|
period = Period.last_30_days
|
||||||
|
|
||||||
results = accounts.active.joins(:transactions)
|
results = accounts.active.joins(:transactions)
|
||||||
.select(
|
.select(
|
||||||
"accounts.*",
|
"accounts.*",
|
||||||
|
@ -42,9 +41,16 @@ class Family < ApplicationRecord
|
||||||
.group("id")
|
.group("id")
|
||||||
.to_a
|
.to_a
|
||||||
|
|
||||||
|
results.each do |r|
|
||||||
|
r.define_singleton_method(:savings_rate) do
|
||||||
|
(income - spending) / income
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse,
|
top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse,
|
||||||
top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse
|
top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse,
|
||||||
|
top_savers: results.sort_by { |a| a.savings_rate }.reverse
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -53,6 +59,7 @@ class Family < ApplicationRecord
|
||||||
|
|
||||||
spending = []
|
spending = []
|
||||||
income = []
|
income = []
|
||||||
|
savings = []
|
||||||
rolling_totals.each do |r|
|
rolling_totals.each do |r|
|
||||||
spending << {
|
spending << {
|
||||||
date: r.date,
|
date: r.date,
|
||||||
|
@ -63,11 +70,17 @@ class Family < ApplicationRecord
|
||||||
date: r.date,
|
date: r.date,
|
||||||
value: Money.new(r.rolling_income, self.currency)
|
value: Money.new(r.rolling_income, self.currency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
savings << {
|
||||||
|
date: r.date,
|
||||||
|
value: r.rolling_income != 0 ? (r.rolling_income - r.rolling_spend) / r.rolling_income : 0.to_d
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
{
|
||||||
income_series: TimeSeries.new(income, favorable_direction: "up"),
|
income_series: TimeSeries.new(income, favorable_direction: "up"),
|
||||||
spending_series: TimeSeries.new(spending, favorable_direction: "down")
|
spending_series: TimeSeries.new(spending, favorable_direction: "down"),
|
||||||
|
savings_rate_series: TimeSeries.new(savings, favorable_direction: "up")
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -101,22 +101,38 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
||||||
<div class="flex gap-4 h-full">
|
<div class="flex flex-col gap-4 h-full">
|
||||||
<div class="grow">
|
<div class="flex gap-4">
|
||||||
<%= render partial: "shared/value_heading", locals: {
|
<div class="grow">
|
||||||
label: t(".savings_rate"),
|
<%= render partial: "shared/value_heading", locals: {
|
||||||
period: @period,
|
label: t(".savings_rate"),
|
||||||
value: @savings_rate_series.last.value,
|
period: Period.last_30_days,
|
||||||
trend: @savings_rate_series.trend,
|
value: @savings_rate_series.last&.value,
|
||||||
is_percentage: true
|
trend: @savings_rate_series.trend,
|
||||||
} %>
|
is_percentage: true
|
||||||
|
} %>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="savingsRateChart"
|
||||||
|
class="h-full w-2/5"
|
||||||
|
data-controller="time-series-chart"
|
||||||
|
data-time-series-chart-data-value="<%= @savings_rate_series.to_json %>"
|
||||||
|
data-time-series-chart-use-labels-value="false"
|
||||||
|
data-time-series-chart-use-tooltip-value="false"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<% @top_savers.first(3).each do |account| %>
|
||||||
|
<%= link_to account, class: "border border-alpha-black-25 rounded-full p-1 pr-2 flex items-center gap-1 text-xs text-gray-900 font-medium hover:bg-gray-25" do %>
|
||||||
|
<div class="flex items-center justify-center text-xs w-5 h-5 rounded-full <%= accountable_text_class(account.accountable_type) %> <%= accountable_bg_transparent_class(account.accountable_type) %>">
|
||||||
|
<%= account.name[0].upcase %>
|
||||||
|
</div>
|
||||||
|
<span><%= account.savings_rate > 0 ? "+" : "-" %><%= number_to_percentage(account.savings_rate.abs * 100, precision: 2) %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% if @top_savers.count > 3 %>
|
||||||
|
<div class="bg-gray-25 rounded-full flex h-full aspect-1 items-center justify-center text-xs font-medium text-gray-500">+<%= @top_savers.count - 3 %></div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="savingsRateChart"
|
|
||||||
class="h-full w-2/5"
|
|
||||||
data-controller="time-series-chart"
|
|
||||||
data-time-series-chart-data-value="<%= @savings_rate_series.to_json %>"
|
|
||||||
data-time-series-chart-use-labels-value="false"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
<div class="bg-white p-4 border border-alpha-black-25 shadow-xs rounded-xl">
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<% if trend.direction.flat? %>
|
<% if trend.direction.flat? %>
|
||||||
<span>No change</span>
|
<span>No change</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span><%= styles[:symbol] %><%= trend.value.is_a?(Money) ? format_money(trend.value.abs) : trend.value.abs %></span>
|
<span><%= styles[:symbol] %><%= trend.value.is_a?(Money) ? format_money(trend.value.abs) : trend.value.abs.round(2) %></span>
|
||||||
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -13,7 +13,7 @@ en:
|
||||||
new: New account
|
new: New account
|
||||||
no_transactions: You have no recent transactions
|
no_transactions: You have no recent transactions
|
||||||
recurring: Recurring
|
recurring: Recurring
|
||||||
savings_rate: Savings Rate (coming soon...)
|
savings_rate: Savings Rate
|
||||||
spending: Spending
|
spending: Spending
|
||||||
subtitle: Here's what's happening today
|
subtitle: Here's what's happening today
|
||||||
transactions: Transactions
|
transactions: Transactions
|
||||||
|
|
64
test/fixtures/family/expected_snapshots.csv
vendored
64
test/fixtures/family/expected_snapshots.csv
vendored
|
@ -1,32 +1,32 @@
|
||||||
date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income
|
date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income,savings_rate
|
||||||
-30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00,0,0,0,0
|
-30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00,0,0,0,0,0
|
||||||
-29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00,15,1018.8,15,1018.8
|
-29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00,15,1018.8,15,1018.8,0.9852767962
|
||||||
-28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
-28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
||||||
-27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
-27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
||||||
-26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
-26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
||||||
-25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
-25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
||||||
-24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
-24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
||||||
-23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8
|
-23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
|
||||||
-22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,1075,15,2093.8
|
-22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,1075,15,2093.8,0.992835992
|
||||||
-21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8
|
-21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8,0.992835992
|
||||||
-20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8
|
-20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8,0.992835992
|
||||||
-19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,216.44,0,231.44,2093.8
|
-19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,216.44,0,231.44,2093.8,0.8894641322
|
||||||
-18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00,2000,0,2231.44,2093.8
|
-18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00,2000,0,2231.44,2093.8,-0.06573693763
|
||||||
-17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8
|
-17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8,-0.06573693763
|
||||||
-16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8
|
-16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8,-0.06573693763
|
||||||
-15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00,40,0,2271.44,2093.8
|
-15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00,40,0,2271.44,2093.8,-0.08484095902
|
||||||
-14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8
|
-14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8,-0.08484095902
|
||||||
-13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8
|
-13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8,-0.08484095902
|
||||||
-12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00,60,50,2331.44,2143.8
|
-12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00,60,50,2331.44,2143.8,-0.08752682153
|
||||||
-11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8
|
-11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8,-0.08752682153
|
||||||
-10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8
|
-10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8,-0.08752682153
|
||||||
-9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,103.915,2331.44,2247.715
|
-9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,103.915,2331.44,2247.715,-0.03724893948
|
||||||
-8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715
|
-8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
|
||||||
-7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715
|
-7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
|
||||||
-6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715
|
-6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
|
||||||
-5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00,20,200,2351.44,2447.715
|
-5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00,20,200,2351.44,2447.715,0.03933260204
|
||||||
-4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,863.04,0,3214.48,2447.715
|
-4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,863.04,0,3214.48,2447.715,-0.3132574667
|
||||||
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
||||||
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
||||||
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
||||||
0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715
|
0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
|
|
|
@ -18,7 +18,8 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
"assets" => row["assets"],
|
"assets" => row["assets"],
|
||||||
"liabilities" => row["liabilities"],
|
"liabilities" => row["liabilities"],
|
||||||
"rolling_spend" => row["rolling_spend"],
|
"rolling_spend" => row["rolling_spend"],
|
||||||
"rolling_income" => row["rolling_income"]
|
"rolling_income" => row["rolling_income"],
|
||||||
|
"savings_rate" => row["savings_rate"]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -88,16 +89,20 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
test "should calculate transaction snapshot correctly" do
|
test "should calculate transaction snapshot correctly" do
|
||||||
spending_series = @family.snapshot_transactions[:spending_series]
|
spending_series = @family.snapshot_transactions[:spending_series]
|
||||||
income_series = @family.snapshot_transactions[:income_series]
|
income_series = @family.snapshot_transactions[:income_series]
|
||||||
|
savings_rate_series = @family.snapshot_transactions[:savings_rate_series]
|
||||||
|
|
||||||
assert_equal @expected_snapshots.count, spending_series.values.count
|
assert_equal @expected_snapshots.count, spending_series.values.count
|
||||||
assert_equal @expected_snapshots.count, income_series.values.count
|
assert_equal @expected_snapshots.count, income_series.values.count
|
||||||
|
assert_equal @expected_snapshots.count, savings_rate_series.values.count
|
||||||
|
|
||||||
@expected_snapshots.each_with_index do |row, index|
|
@expected_snapshots.each_with_index do |row, index|
|
||||||
expected_spending = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_spend"].to_d))
|
expected_spending = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_spend"].to_d))
|
||||||
expected_income = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_income"].to_d))
|
expected_income = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_income"].to_d))
|
||||||
|
expected_savings_rate = TimeSeries::Value.new(date: row["date"], value: Money.new(row["savings_rate"].to_d))
|
||||||
|
|
||||||
assert_in_delta expected_spending.value.amount, Money.new(spending_series.values[index].value).amount, 0.01
|
assert_in_delta expected_spending.value.amount, Money.new(spending_series.values[index].value).amount, 0.01
|
||||||
assert_in_delta expected_income.value.amount, Money.new(income_series.values[index].value).amount, 0.01
|
assert_in_delta expected_income.value.amount, Money.new(income_series.values[index].value).amount, 0.01
|
||||||
|
assert_in_delta expected_savings_rate.value.amount, savings_rate_series.values[index].value, 0.01
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue