From 461fa672ff7c2fccec819571e043620fdc5b13bf Mon Sep 17 00:00:00 2001 From: Josh Brown Date: Wed, 24 Apr 2024 13:34:50 +0100 Subject: [PATCH] Add income and spending insight cards to dashboard (#668) * Generate time series for rolling 30 day sum of income and spending * Highlight accounts with most income and spending in the last 30 days * Aggregate chips after 3 top accounts in insight card * Refactor aggregation filter I think this is easier to read and understand whats happening at a glance * Refactor and tidy * Use family currency for insight cards * Further reduce risk of sql injection * Fix lint * Refactor rolling total queries * Add test for transaction snapshot --- app/controllers/pages_controller.rb | 11 ++- app/models/family.rb | 43 +++++++++++ app/models/period.rb | 4 + app/models/transaction.rb | 39 ++++++++++ app/views/pages/dashboard.html.erb | 86 ++++++++++++++------- config/locales/views/pages/en.yml | 4 +- test/fixtures/family/expected_snapshots.csv | 64 +++++++-------- test/models/family_test.rb | 20 ++++- 8 files changed, 207 insertions(+), 64 deletions(-) diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index ece66263..de05babd 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -6,6 +6,15 @@ class PagesController < ApplicationController @net_worth_series = snapshot[:net_worth_series] @asset_series = snapshot[:asset_series] @liability_series = snapshot[:liability_series] + + snapshot_transactions = Current.family.snapshot_transactions + @income_series = snapshot_transactions[:income_series] + @spending_series = snapshot_transactions[:spending_series] + + snapshot_account_transactions = Current.family.snapshot_account_transactions + @top_spenders = snapshot_account_transactions[:top_spenders] + @top_earners = snapshot_account_transactions[:top_earners] + @account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency) @transactions = Current.family.transactions.limit(5).order(date: :desc) @@ -13,8 +22,6 @@ class PagesController < ApplicationController placeholder_series_data = 10.times.map do |i| { date: Date.current - i.days, value: Money.new(0) } end - @income_series = TimeSeries.new(placeholder_series_data) - @spending_series = TimeSeries.new(placeholder_series_data) @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 27b1b526..c25499fa 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -28,6 +28,49 @@ class Family < ApplicationRecord } end + def snapshot_account_transactions + period = Period.last_30_days + + results = accounts.active.joins(:transactions) + .select( + "accounts.*", + "COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending", + "COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income" + ) + .where("transactions.date >= ?", period.date_range.begin) + .where("transactions.date <= ?", period.date_range.end) + .group("id") + .to_a + + { + top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse, + top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse + } + end + + def snapshot_transactions + rolling_totals = Transaction.daily_rolling_totals(transactions, period: Period.last_30_days, currency: self.currency) + + spending = [] + income = [] + rolling_totals.each do |r| + spending << { + date: r.date, + value: Money.new(r.rolling_spend, self.currency) + } + + income << { + date: r.date, + value: Money.new(r.rolling_income, self.currency) + } + end + + { + income_series: TimeSeries.new(income, favorable_direction: "up"), + spending_series: TimeSeries.new(spending, favorable_direction: "down") + } + end + def effective_start_date accounts.active.joins(:balances).minimum("account_balances.date") || Date.current end diff --git a/app/models/period.rb b/app/models/period.rb index e5413843..3a10f15e 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -14,6 +14,10 @@ class Period @date_range = date_range end + def extend_backward(duration) + Period.new(name: name + "_extended", date_range: (date_range.first - duration)..date_range.last) + end + BUILTIN = [ new(name: "all", date_range: nil..Date.current), new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current), diff --git a/app/models/transaction.rb b/app/models/transaction.rb index b778cd08..baa8005d 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -11,6 +11,45 @@ class Transaction < ApplicationRecord scope :inflows, -> { where("amount > 0") } scope :outflows, -> { where("amount < 0") } scope :active, -> { where(excluded: false) } + scope :with_converted_amount, ->(currency = Current.family.currency) { + # Join with exchange rates to convert the amount to the given currency + # If no rate is available, exclude the transaction from the results + select( + "transactions.*", + "transactions.amount * COALESCE(er.rate, 1) AS converted_amount" + ) + .joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON transactions.date = er.date AND transactions.currency = er.base_currency AND er.converted_currency = ?", currency ])) + .where("er.rate IS NOT NULL OR transactions.currency = ?", currency) + } + + def self.daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency) + # Sum spending and income for each day in the period with the given currency + select( + "gs.date", + "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending", + "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income" + ) + .from(transactions.with_converted_amount(currency), :t) + .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ])) + .group("gs.date") + end + + def self.daily_rolling_totals(transactions, period: Period.last_30_days, currency: Current.family.currency) + # Extend the period to include the rolling window + period_with_rolling = period.extend_backward(period.date_range.count.days) + + # Aggregate the rolling sum of spending and income based on daily totals + rolling_totals = from(daily_totals(transactions, period: period_with_rolling, currency: currency)) + .select( + "*", + sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]), + sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ]) + ) + .order("date") + + # Trim the results to the original period + select("*").from(rolling_totals).where("date >= ?", period.date_range.first) + end def self.ransackable_attributes(auth_object = nil) %w[name amount date] diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 8ea2a662..7008229a 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -33,39 +33,71 @@
-
-
+
+
+
<%= render partial: "shared/value_heading", locals: { - label: t(".income"), - period: @period, - value: @income_series.last.value, - trend: @income_series.trend - } %> + label: t(".income"), + period: Period.last_30_days, + value: @income_series.last&.value, + trend: @income_series.trend + } %> +
+
+
+
+ <% @top_earners.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 %> +
+ +<%= Money.new(account.income, account.currency) %> + <% end %> + <% end %> + <% if @top_earners.count > 3 %> +
+<%= @top_earners.count - 3 %>
+ <% end %>
-
-
-
- <%= render partial: "shared/value_heading", locals: { - label: t(".spending"), - period: @period, - value: @spending_series.last.value, - trend: @spending_series.trend - } %> +
+
+
+ <%= render partial: "shared/value_heading", locals: { + label: t(".spending"), + period: Period.last_30_days, + value: @spending_series.last&.value, + trend: @spending_series.trend + } %> +
+
+
+
+ <% @top_spenders.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 %> +
+ -<%= Money.new(account.spending, account.currency) %> + <% end %> + <% end %> + <% if @top_spenders.count > 3 %> +
+<%= @top_spenders.count - 3 %>
+ <% end %>
-
diff --git a/config/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 45bce88e..5eaa7477 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -7,14 +7,14 @@ en: debts: Debts categories: Categories greeting: Welcome back, %{name} - income: Income (coming soon...) + income: Income investing: Investing (coming soon...) net_worth: Net Worth new: New account no_transactions: You have no recent transactions recurring: Recurring savings_rate: Savings Rate (coming soon...) - spending: Spending (coming soon...) + spending: Spending subtitle: Here's what's happening today transactions: Transactions view_all: View all diff --git a/test/fixtures/family/expected_snapshots.csv b/test/fixtures/family/expected_snapshots.csv index 13f907b3..0ee7088e 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 --30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00 --29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00 --15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00 --14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00 --13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00 --12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00 --5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00 --4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00 --3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00 --2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00 --1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00 -0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00 \ 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 +-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 diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 7e01a9c8..df5ed0dc 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -16,7 +16,9 @@ class FamilyTest < ActiveSupport::TestCase "date" => (Date.current + row["date_offset"].to_i.days).to_date, "net_worth" => row["net_worth"], "assets" => row["assets"], - "liabilities" => row["liabilities"] + "liabilities" => row["liabilities"], + "rolling_spend" => row["rolling_spend"], + "rolling_income" => row["rolling_income"] } end end @@ -83,6 +85,22 @@ class FamilyTest < ActiveSupport::TestCase end end + test "should calculate transaction snapshot correctly" do + spending_series = @family.snapshot_transactions[:spending_series] + income_series = @family.snapshot_transactions[:income_series] + + assert_equal @expected_snapshots.count, spending_series.values.count + assert_equal @expected_snapshots.count, income_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)) + + 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 + end + end + test "should exclude disabled accounts from calculations" do assets_before = @family.assets liabilities_before = @family.liabilities