<% end %>
<% end %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb
index e52c8da2..bf0e3331 100644
--- a/app/views/transactions/_transaction.html.erb
+++ b/app/views/transactions/_transaction.html.erb
@@ -6,7 +6,7 @@
<%= turbo_frame_tag dom_id(transaction) do %>
">
-
">
+
<%= check_box_tag dom_id(entry, "selection"),
disabled: transaction.transfer.present?,
class: "checkbox checkbox--light",
@@ -93,22 +93,11 @@
<%= render "transactions/transaction_category", transaction: transaction %>
-
+
<%= content_tag :p,
transaction.transfer? && view_ctx == "global" ? "+/- #{format_money(entry.amount_money.abs)}" : format_money(-entry.amount_money),
class: ["text-green-600": entry.amount.negative?] %>
-
- <% if view_ctx != "global" %>
-
- <% if balance_trend&.trend %>
- <%= tag.p format_money(balance_trend.trend.current),
- class: "font-medium text-sm text-primary" %>
- <% else %>
- <%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
- <% end %>
-
- <% end %>
<% end %>
<% end %>
diff --git a/app/views/valuations/_confirmation_contents.html.erb b/app/views/valuations/_confirmation_contents.html.erb
index 19d2ff5f..d8fdf032 100644
--- a/app/views/valuations/_confirmation_contents.html.erb
+++ b/app/views/valuations/_confirmation_contents.html.erb
@@ -2,8 +2,8 @@
<% if account.investment? %>
- <% holdings_value = reconciliation_dry_run.new_balance - reconciliation_dry_run.new_cash_balance %>
- <% brokerage_cash = reconciliation_dry_run.new_cash_balance %>
+ <% brokerage_cash = reconciliation_dry_run.new_cash_balance || 0 %>
+ <% holdings_value = reconciliation_dry_run.new_balance - brokerage_cash %>
This will <%= action_verb %> the account value on <%= entry.date.strftime("%B %d, %Y") %> to:
diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb
index eeef903c..9bb41ce1 100644
--- a/app/views/valuations/_valuation.html.erb
+++ b/app/views/valuations/_valuation.html.erb
@@ -1,9 +1,9 @@
-<%# locals: (entry:, balance_trend: nil, **) %>
+<%# locals: (entry:, **) %>
<% valuation = entry.entryable %>
-<% color = balance_trend&.trend&.color || "#D444F1" %>
-<% icon = balance_trend&.trend&.icon || "plus" %>
+<% color = valuation.opening_anchor? ? "#D444F1" : "var(--color-gray)" %>
+<% icon = valuation.opening_anchor? ? "plus" : "minus" %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= turbo_frame_tag dom_id(valuation) do %>
@@ -26,7 +26,7 @@
- <%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
+ <%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %>
<% end %>
diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb
index f58be03e..089d98a4 100644
--- a/app/views/valuations/show.html.erb
+++ b/app/views/valuations/show.html.erb
@@ -24,7 +24,7 @@
max: Date.current %>
<%= f.money_field :amount,
- label: t(".amount"),
+ label: "Account value on date",
disable_currency: true %>
diff --git a/test/components/previews/tooltip_component_preview.rb b/test/components/previews/tooltip_component_preview.rb
new file mode 100644
index 00000000..68bd6c32
--- /dev/null
+++ b/test/components/previews/tooltip_component_preview.rb
@@ -0,0 +1,32 @@
+class TooltipComponentPreview < ViewComponent::Preview
+ # @param text text
+ # @param placement select [top, right, bottom, left]
+ # @param offset number
+ # @param cross_axis number
+ # @param icon text
+ # @param size select [xs, sm, md, lg, xl, 2xl]
+ # @param color select [default, white, success, warning, destructive, current]
+ def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
+ render DS::Tooltip.new(
+ text: text,
+ placement: placement,
+ offset: offset,
+ cross_axis: cross_axis,
+ icon: icon,
+ size: size,
+ color: color
+ )
+ end
+
+ def with_block_content
+ render DS::Tooltip.new(icon: "help-circle", color: "warning") do
+ tag.div do
+ tag.p("Custom content with formatting:", class: "font-medium mb-1") +
+ tag.ul(class: "list-disc list-inside text-xs") do
+ tag.li("First item") +
+ tag.li("Second item")
+ end
+ end
+ end
+ end
+end
diff --git a/test/models/account/activity_feed_data_test.rb b/test/models/account/activity_feed_data_test.rb
new file mode 100644
index 00000000..2139076c
--- /dev/null
+++ b/test/models/account/activity_feed_data_test.rb
@@ -0,0 +1,355 @@
+require "test_helper"
+
+class Account::ActivityFeedDataTest < ActiveSupport::TestCase
+ include EntriesTestHelper
+
+ setup do
+ @family = families(:empty)
+ @checking = @family.accounts.create!(name: "Test Checking", accountable: Depository.new, currency: "USD", balance: 0)
+ @savings = @family.accounts.create!(name: "Test Savings", accountable: Depository.new, currency: "USD", balance: 0)
+ @investment = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
+
+ @test_period_start = Date.current - 4.days
+
+ setup_test_data
+ end
+
+ test "calculates balance trend with complete balance history" do
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+
+ assert_not_nil day2_activity
+ trend = day2_activity.balance_trend
+ assert_equal 1100, trend.current.amount.to_i # End of day 2
+ assert_equal 1000, trend.previous.amount.to_i # End of day 1
+ assert_equal 100, trend.value.amount.to_i
+ assert_equal "up", trend.direction.to_s
+ end
+
+ test "calculates balance trend for first day with zero starting balance" do
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+ day1_activity = find_activity_for_date(activities, @test_period_start)
+
+ assert_not_nil day1_activity
+ trend = day1_activity.balance_trend
+ assert_equal 1000, trend.current.amount.to_i # End of first day
+ assert_equal 0, trend.previous.amount.to_i # Fallback to 0
+ assert_equal 1000, trend.value.amount.to_i
+ end
+
+ test "uses last observed balance when intermediate balances are missing" do
+ @checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all
+
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+
+ # When day 2 balance is missing, both start and end use day 1 balance
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+ assert_not_nil day2_activity
+ trend = day2_activity.balance_trend
+ assert_equal 1000, trend.current.amount.to_i # LOCF from day 1
+ assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1
+ assert_equal 0, trend.value.amount.to_i
+ assert_equal "flat", trend.direction.to_s
+ end
+
+ test "returns zero balance when no balance history exists" do
+ @checking.balances.destroy_all
+
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+ # Use first day which has a transaction
+ day1_activity = find_activity_for_date(activities, @test_period_start)
+
+ assert_not_nil day1_activity
+ trend = day1_activity.balance_trend
+ assert_equal 0, trend.current.amount.to_i # Fallback to 0
+ assert_equal 0, trend.previous.amount.to_i # Fallback to 0
+ assert_equal 0, trend.value.amount.to_i
+ assert_equal "flat", trend.direction.to_s
+ end
+
+ test "calculates cash and holdings trends for investment accounts" do
+ entries = @investment.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@investment, entries)
+
+ activities = feed_data.entries_by_date
+ day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
+
+ assert_not_nil day3_activity
+
+ # Cash trend for day 3 (after foreign currency transaction)
+ cash_trend = day3_activity.cash_balance_trend
+ assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance
+ assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance
+ assert_equal(-100, cash_trend.value.amount.to_i)
+ assert_equal "down", cash_trend.direction.to_s
+
+ # Holdings trend for day 3 (after trade)
+ holdings_trend = day3_activity.holdings_value_trend
+ assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance
+ assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade
+ assert_equal 1500, holdings_trend.value.amount.to_i
+ assert_equal "up", holdings_trend.direction.to_s
+ end
+
+ test "identifies transfers for a specific date" do
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+
+ # Day 2 has the transfer
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+ assert_not_nil day2_activity
+ assert_equal 1, day2_activity.transfers.size
+ assert_equal @transfer, day2_activity.transfers.first
+
+ # Other days have no transfers
+ day1_activity = find_activity_for_date(activities, @test_period_start)
+ assert_not_nil day1_activity
+ assert_empty day1_activity.transfers
+ end
+
+ test "returns complete ActivityDateData objects with all required fields" do
+ entries = @investment.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@investment, entries)
+
+ activities = feed_data.entries_by_date
+
+ # Check that we get ActivityDateData objects
+ assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) }
+
+ # Check that each ActivityDate has the required fields
+ activities.each do |activity|
+ assert_respond_to activity, :date
+ assert_respond_to activity, :entries
+ assert_respond_to activity, :balance_trend
+ assert_respond_to activity, :cash_balance_trend
+ assert_respond_to activity, :holdings_value_trend
+ assert_respond_to activity, :transfers
+ end
+ end
+
+ test "handles valuations correctly by summing entry changes" do
+ # Create account with known balances
+ account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
+
+ # Day 1: Starting balance
+ account.balances.create!(
+ date: @test_period_start,
+ balance: 7321.56,
+ cash_balance: 1000,
+ currency: "USD"
+ )
+
+ # Day 2: Add transactions, trades and a valuation
+ account.balances.create!(
+ date: @test_period_start + 1.day,
+ balance: 8500, # Valuation sets this
+ cash_balance: 1070, # Cash increased by transactions
+ currency: "USD"
+ )
+
+ # Create transactions
+ create_transaction(
+ account: account,
+ date: @test_period_start + 1.day,
+ amount: -50,
+ name: "Interest payment"
+ )
+ create_transaction(
+ account: account,
+ date: @test_period_start + 1.day,
+ amount: -20,
+ name: "Interest payment"
+ )
+
+ # Create a trade
+ create_trade(
+ securities(:aapl),
+ account: account,
+ qty: 5,
+ date: @test_period_start + 1.day,
+ price: 150 # 5 * 150 = 750
+ )
+
+ # Create valuation
+ create_valuation(
+ account: account,
+ date: @test_period_start + 1.day,
+ amount: 8500
+ )
+
+ entries = account.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(account, entries)
+
+ activities = feed_data.entries_by_date
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+
+ assert_not_nil day2_activity
+
+ # Cash change should be $70 (50 + 20 from transactions only, not trades)
+ assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i
+
+ # Holdings change should be 750 (from the trade)
+ assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i
+
+ # Total balance change
+ assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01
+ end
+
+ test "normalizes multi-currency entries on valuation days" do
+ # Create EUR account
+ eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0)
+
+ # Day 1: Starting balance
+ eur_account.balances.create!(
+ date: @test_period_start,
+ balance: 1000,
+ cash_balance: 500,
+ currency: "EUR"
+ )
+
+ # Day 2: Multi-currency transactions and valuation
+ eur_account.balances.create!(
+ date: @test_period_start + 1.day,
+ balance: 2000,
+ cash_balance: 600,
+ currency: "EUR"
+ )
+
+ # Create USD transaction (should be converted to EUR)
+ create_transaction(
+ account: eur_account,
+ date: @test_period_start + 1.day,
+ amount: -100,
+ currency: "USD",
+ name: "USD Payment"
+ )
+
+ # Create exchange rate: 1 USD = 0.9 EUR
+ ExchangeRate.create!(
+ date: @test_period_start + 1.day,
+ from_currency: "USD",
+ to_currency: "EUR",
+ rate: 0.9
+ )
+
+ # Create valuation
+ create_valuation(
+ account: eur_account,
+ date: @test_period_start + 1.day,
+ amount: 2000
+ )
+
+ entries = eur_account.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(eur_account, entries)
+
+ activities = feed_data.entries_by_date
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+
+ assert_not_nil day2_activity
+
+ # Cash change should be 90 EUR (100 USD * 0.9)
+ # The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR
+ assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i
+ assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code
+ end
+
+ private
+ def find_activity_for_date(activities, date)
+ activities.find { |a| a.date == date }
+ end
+
+ def setup_test_data
+ # Create daily balances for checking account
+ 5.times do |i|
+ date = @test_period_start + i.days
+ @checking.balances.create!(
+ date: date,
+ balance: 1000 + (i * 100),
+ currency: "USD"
+ )
+ end
+
+ # Create daily balances for investment account with cash_balance
+ @investment.balances.create!(
+ date: @test_period_start,
+ balance: 500,
+ cash_balance: 500,
+ currency: "USD"
+ )
+ @investment.balances.create!(
+ date: @test_period_start + 1.day,
+ balance: 500,
+ cash_balance: 500,
+ currency: "USD"
+ )
+ @investment.balances.create!(
+ date: @test_period_start + 2.days,
+ balance: 1900, # 1500 holdings + 400 cash
+ cash_balance: 400, # After -100 EUR transaction
+ currency: "USD"
+ )
+
+ # Day 1: Regular transaction
+ create_transaction(
+ account: @checking,
+ date: @test_period_start,
+ amount: -50,
+ name: "Grocery Store"
+ )
+
+ # Day 2: Transfer between accounts
+ @transfer = create_transfer(
+ from_account: @checking,
+ to_account: @savings,
+ amount: 200,
+ date: @test_period_start + 1.day
+ )
+
+ # Day 3: Trade in investment account
+ create_trade(
+ securities(:aapl),
+ account: @investment,
+ qty: 10,
+ date: @test_period_start + 2.days,
+ price: 150
+ )
+
+ # Day 3: Foreign currency transaction
+ create_transaction(
+ account: @investment,
+ date: @test_period_start + 2.days,
+ amount: -100,
+ currency: "EUR",
+ name: "International Wire"
+ )
+
+ # Create exchange rate for foreign currency
+ ExchangeRate.create!(
+ date: @test_period_start + 2.days,
+ from_currency: "EUR",
+ to_currency: "USD",
+ rate: 1.1
+ )
+
+ # Day 4: Valuation
+ create_valuation(
+ account: @investment,
+ date: @test_period_start + 3.days,
+ amount: 25
+ )
+ end
+end
diff --git a/test/support/entries_test_helper.rb b/test/support/entries_test_helper.rb
index 35e5450f..f586b148 100644
--- a/test/support/entries_test_helper.rb
+++ b/test/support/entries_test_helper.rb
@@ -15,33 +15,6 @@ module EntriesTestHelper
Entry.create! entry_defaults.merge(entry_attributes)
end
- def create_opening_anchor_valuation(account:, balance:, date:)
- create_valuation(
- account: account,
- kind: "opening_anchor",
- amount: balance,
- date: date
- )
- end
-
- def create_reconciliation_valuation(account:, balance:, date:)
- create_valuation(
- account: account,
- kind: "reconciliation",
- amount: balance,
- date: date
- )
- end
-
- def create_current_anchor_valuation(account:, balance:, date: Date.current)
- create_valuation(
- account: account,
- kind: "current_anchor",
- amount: balance,
- date: date
- )
- end
-
def create_valuation(attributes = {})
entry_attributes = attributes.except(:kind)
valuation_attributes = attributes.slice(:kind)
@@ -77,4 +50,33 @@ module EntriesTestHelper
currency: currency,
entryable: trade
end
+
+ def create_transfer(from_account:, to_account:, amount:, date: Date.current, currency: "USD")
+ outflow_transaction = Transaction.create!(kind: "funds_movement")
+ inflow_transaction = Transaction.create!(kind: "funds_movement")
+
+ transfer = Transfer.create!(
+ outflow_transaction: outflow_transaction,
+ inflow_transaction: inflow_transaction
+ )
+
+ # Create entries for both accounts
+ from_account.entries.create!(
+ name: "Transfer to #{to_account.name}",
+ date: date,
+ amount: -amount.abs,
+ currency: currency,
+ entryable: outflow_transaction
+ )
+
+ to_account.entries.create!(
+ name: "Transfer from #{from_account.name}",
+ date: date,
+ amount: amount.abs,
+ currency: currency,
+ entryable: inflow_transaction
+ )
+
+ transfer
+ end
end
diff --git a/test/system/settings/api_keys_test.rb b/test/system/settings/api_keys_test.rb
index 839068bb..d6beeeee 100644
--- a/test/system/settings/api_keys_test.rb
+++ b/test/system/settings/api_keys_test.rb
@@ -124,17 +124,14 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
# Click the revoke button to open the modal
click_button "Revoke Key"
- # Wait for the modal to appear and click Confirm
- # The dialog might take a moment to appear
- sleep 0.5
-
+ # Wait for the dialog and then confirm
+ assert_selector "#confirm-dialog", visible: true
within "#confirm-dialog" do
- assert_text "Are you sure you want to revoke this API key?"
click_button "Confirm"
end
- # Wait for the page to update after revoke
- sleep 0.5
+ # Wait for redirect after revoke
+ assert_no_selector "#confirm-dialog"
assert_text "Create Your API Key"
assert_text "Get programmatic access to your Maybe data"
diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb
index ad9cc926..be1f9b54 100644
--- a/test/system/transactions_test.rb
+++ b/test/system/transactions_test.rb
@@ -118,22 +118,25 @@ class TransactionsTest < ApplicationSystemTestCase
assert_text "No entries found"
+ # Wait for Turbo to finish updating the DOM
+ sleep 0.5
+
# Page reload doesn't affect results
visit current_url
assert_text "No entries found"
- within "ul#transaction-search-filters" do
- find("li", text: account.name).first("button").click
- find("li", text: "on or after #{10.days.ago.to_date}").first("button").click
- find("li", text: "on or before #{1.day.ago.to_date}").first("button").click
- find("li", text: "Income").first("button").click
- find("li", text: "less than 200").first("button").click
- find("li", text: category.name).first("button").click
- find("li", text: merchant.name).first("button").click
+ # Remove all filters by clicking their X buttons
+ # Get all the filter buttons at once to avoid stale elements
+ filter_count = page.all("ul#transaction-search-filters li button").count
+
+ # Click each one with a small delay to let Turbo update
+ filter_count.times do
+ page.all("ul#transaction-search-filters li button").first.click
+ sleep 0.1
end
- assert_selector "#" + dom_id(@transaction), count: 1
+ assert_text @transaction.name
end
test "can select and deselect entire page of transactions" do
@@ -191,7 +194,7 @@ class TransactionsTest < ApplicationSystemTestCase
fill_in "Date", with: transfer_date
fill_in "model[amount]", with: 175.25
click_button "Add transaction"
- within "#entry-group-" + transfer_date.to_s do
+ within "#" + dom_id(investment_account, "entries_#{transfer_date}") do
assert_text "175.25"
end
end