diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index de05babd..1fd81196 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -10,10 +10,12 @@ class PagesController < ApplicationController snapshot_transactions = Current.family.snapshot_transactions @income_series = snapshot_transactions[:income_series] @spending_series = snapshot_transactions[:spending_series] + @savings_rate_series = snapshot_transactions[:savings_rate_series] snapshot_account_transactions = Current.family.snapshot_account_transactions @top_spenders = snapshot_account_transactions[:top_spenders] @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) @transactions = Current.family.transactions.limit(5).order(date: :desc) @@ -22,7 +24,6 @@ class PagesController < ApplicationController placeholder_series_data = 10.times.map do |i| { date: Date.current - i.days, value: Money.new(0) } end - @savings_rate_series = TimeSeries.new(10.times.map { |i| { date: Date.current - i.days, value: 0 } }) @investing_series = TimeSeries.new(placeholder_series_data) end diff --git a/app/models/family.rb b/app/models/family.rb index c25499fa..16b81599 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -30,7 +30,6 @@ class Family < ApplicationRecord def snapshot_account_transactions period = Period.last_30_days - results = accounts.active.joins(:transactions) .select( "accounts.*", @@ -42,9 +41,16 @@ class Family < ApplicationRecord .group("id") .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_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 @@ -53,6 +59,7 @@ class Family < ApplicationRecord spending = [] income = [] + savings = [] rolling_totals.each do |r| spending << { date: r.date, @@ -63,11 +70,17 @@ class Family < ApplicationRecord date: r.date, 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 { 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 diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 7008229a..8d1d1937 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -101,22 +101,38 @@
-
-
- <%= render partial: "shared/value_heading", locals: { - label: t(".savings_rate"), - period: @period, - value: @savings_rate_series.last.value, - trend: @savings_rate_series.trend, - is_percentage: true - } %> +
+
+
+ <%= render partial: "shared/value_heading", locals: { + label: t(".savings_rate"), + period: Period.last_30_days, + value: @savings_rate_series.last&.value, + trend: @savings_rate_series.trend, + is_percentage: true + } %> +
+
+
+
+ <% @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 %> +
+ <%= account.name[0].upcase %> +
+ <%= account.savings_rate > 0 ? "+" : "-" %><%= number_to_percentage(account.savings_rate.abs * 100, precision: 2) %> + <% end %> + <% end %> + <% if @top_savers.count > 3 %> +
+<%= @top_savers.count - 3 %>
+ <% end %>
-
diff --git a/app/views/shared/_trend_change.html.erb b/app/views/shared/_trend_change.html.erb index e71aef0d..3edd4c24 100644 --- a/app/views/shared/_trend_change.html.erb +++ b/app/views/shared/_trend_change.html.erb @@ -4,7 +4,7 @@ <% if trend.direction.flat? %> No change <% else %> - <%= styles[:symbol] %><%= trend.value.is_a?(Money) ? format_money(trend.value.abs) : trend.value.abs %> + <%= styles[:symbol] %><%= trend.value.is_a?(Money) ? format_money(trend.value.abs) : trend.value.abs.round(2) %> (<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%) <% end %>

diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 5eaa7477..2c214c4a 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -13,7 +13,7 @@ en: new: New account no_transactions: You have no recent transactions recurring: Recurring - savings_rate: Savings Rate (coming soon...) + savings_rate: Savings Rate spending: Spending subtitle: Here's what's happening today transactions: Transactions diff --git a/test/fixtures/family/expected_snapshots.csv b/test/fixtures/family/expected_snapshots.csv index 0ee7088e..5f5e6d48 100644 --- a/test/fixtures/family/expected_snapshots.csv +++ b/test/fixtures/family/expected_snapshots.csv @@ -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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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 --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,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 \ No newline at end of file +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,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,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,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,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,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,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,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,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,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,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,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,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,-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,-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,-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,-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,-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,-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,-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,-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,-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,-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,-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,-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,-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,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,-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,-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,-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,-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.3132574667 \ No newline at end of file diff --git a/test/models/family_test.rb b/test/models/family_test.rb index df5ed0dc..ed01ac8b 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -18,7 +18,8 @@ class FamilyTest < ActiveSupport::TestCase "assets" => row["assets"], "liabilities" => row["liabilities"], "rolling_spend" => row["rolling_spend"], - "rolling_income" => row["rolling_income"] + "rolling_income" => row["rolling_income"], + "savings_rate" => row["savings_rate"] } end end @@ -88,16 +89,20 @@ class FamilyTest < ActiveSupport::TestCase test "should calculate transaction snapshot correctly" do spending_series = @family.snapshot_transactions[:spending_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, income_series.values.count + assert_equal @expected_snapshots.count, savings_rate_series.values.count @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_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_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