mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 12:12:13 +00:00
* Add "Money In / Out" dashboard widget Adds a new dashboard section showing a monthly bar chart of cash activity alongside a summary card (net balance, income, expenses), with per-widget month navigation and account filtering. - IncomeStatement#totals_for computes income/expense totals for an arbitrary period, optionally scoped to a set of account ids - New bar_chart_controller.js (D3) renders the monthly bars - Income/expense rows link through to filtered transactions Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Fix tooltip/dropdown positioning and split money flow chart by income/expense The widget's @container wrapper established a new containing block for position:fixed descendants, so the month-picker and account-filter menus (floating-ui, strategy: fixed) and the D3 tooltip (container- relative coordinates) rendered away from their trigger/bar. Drop @container in favor of regular viewport breakpoints for this full-width widget, and position the tooltip with page-relative coordinates like the other chart controllers. Also replace the single combined bar per month with a grouped expense/income pair (red/green, with a legend) so each month's inflow and outflow are visible independently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Cap and scroll the money flow widget's month picker Add an optional max_height to DS::Menu (opt-in, backward compatible) so a long item list scrolls inside a fixed-height panel instead of overflowing the viewport. Use it for the money flow widget's 12-month picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Use design system tokens for income/expense indicators in money flow widget Swap the four bg-green-500/bg-red-500 dot indicators (legend + income/expense row links) in the money flow widget for bg-success/bg-destructive, matching the "functional tokens only" rule. The same partial already uses text-success/text-destructive for the balance figure, and bg-success/ bg-destructive are already established elsewhere (DS::Alert, budget_categories/_budget_category.html.erb). * Fix future-month 500 and pending-transaction link mismatch in money flow widget Two CI review findings on the money flow widget: - A future month passed via ?money_flow_month= (e.g. a bookmarked/hand-edited URL) made end_date earlier than month_start once capped at Date.current, which Period.custom rejects, causing a 500. money_flow_month_param now clamps future months to the current month, same as it already does for malformed input. - The income/expense row links passed type/date/account filters but no status, so Transaction::Search included pending transactions even though the displayed totals (IncomeStatement#totals_for) exclude them via excluding_pending. Add status: ["confirmed"] to both links so the linked list matches the card total. Also strengthens the widget's controller tests: asserts the highlighted bar's actual income/expense values instead of just its presence, and adds a regression test proving an account id outside the current user's accessible accounts is dropped (falls back to the unfiltered state) rather than leaking or erroring. * Fix account filter eligibility and SVG dark mode fill in money flow widget Two more CI review findings on the money flow widget: The account filter iterated over all visible/accessible accounts, a broader set than IncomeStatement actually counts (accounts excluded from reports, tax-advantaged accounts like 401k/IRA, or shared accounts not included in the user's finances). Selecting one of these silently computed to zero while its drill-down link could still list its transactions. Add IncomeStatement#eligible_accounts, mirroring the same criteria already applied in the totals SQL, and use it for both the checkbox list and the account_ids intersection. The D3 axis tick text elements used text-primary/text-secondary, which set CSS color, not SVG fill, so labels rendered with the default black fill and were unreadable in dark mode. Add fill-current, matching the pattern already used in sankey_chart_controller.js. Adds controller/model tests for eligible_accounts (excluded from the account filter, ignored when passed as a filter id, excluded from totals) and verifies the dark-mode fill fix visually. * Extract duplicated bar-chart JSON parsing into a test helper The css_select("[data-controller='bar-chart']").first + JSON.parse(chart["data-bar-chart-data-value"]) pattern was repeated across four money flow widget tests in pages_controller_test.rb. Extract it into a private money_flow_bars helper and use it everywhere instead. * Preserve eligible account scope in money flow drill-down links when unfiltered The income/expense drill-down links used money_flow_data[:account_ids] directly, which is nil in the widget's default unfiltered state, so .compact dropped the account filter entirely from the link. TransactionsController treats an absent account_ids as all accessible accounts, a broader set than IncomeStatement#eligible_accounts (which excludes tax-advantaged, excluded-from-reports, and non-finance shared accounts). Users with any such account could click a displayed total and see transactions that were never counted in it. Use selected_account_ids (already computed as money_flow_data[:account_ids] || accounts.map(&:id)) for both links instead, so they always pin to the same eligible accounts backing the total, filtered or not. Left the month-picker link on money_flow_data[:account_ids] so navigating months while unfiltered doesn't bloat the URL with every eligible account id. Adds a regression test confirming the default (unfiltered) links include account_ids and exclude an ineligible account's id. * Refine Money In/Out dashboard widget - 6-month window (was 3), half-width default with responsive stack, income-first bars, 2px floor + faded in-progress (partial) month - Expense series/figures neutral (gray/text-primary) — app reserves red for negative/overspend; neutral zero balance - Account filter: outline DS::Button + list-filter icon trigger with in-panel search (DS::SearchInput + list-filter), matching the app's filter convention - i18n search_accounts (en + fr); bump money_flow bar-count test 3 -> 6 * Clarify Money In/Out scope: month label, 6-month caption, filter "All" Addresses confusion between the widget's own month picker and the dashboard's global period, and between the picked month and the 6-month chart span. - Card now headed with the selected month (e.g. "July 2026") so it's clear the totals below are that month's, driven by the picker - Chart legend row captioned "Last N months" so the trailing window is explicit - Info tooltip by the month picker: the widget scopes to the month you pick here, independent of the dashboard's top-level period - Account filter trigger reads "All accounts" when nothing is filtered out, instead of "Filter accounts (N)" - i18n (en + fr) for the new strings * Match tooltip expense dot to the gray bar palette --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Guillem Arias <accounts@gariasf.com>
301 lines
10 KiB
Ruby
301 lines
10 KiB
Ruby
require "test_helper"
|
|
|
|
class PagesControllerTest < ActionDispatch::IntegrationTest
|
|
include EntriesTestHelper
|
|
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@intro_user = users(:intro_user)
|
|
@family = @user.family
|
|
end
|
|
|
|
test "dashboard" do
|
|
get root_path
|
|
assert_response :ok
|
|
end
|
|
|
|
test "update_preferences persists dashboard section layout height" do
|
|
patch "/dashboard/preferences", params: {
|
|
preferences: { dashboard_section_layout: { net_worth_chart: { height: "compact" } } }
|
|
}, as: :json
|
|
|
|
assert_response :ok
|
|
assert_equal "compact", @user.reload.dashboard_section_height("net_worth_chart")
|
|
end
|
|
|
|
test "update_preferences persists dashboard section width" do
|
|
patch "/dashboard/preferences", params: {
|
|
preferences: { dashboard_section_layout: { cashflow_sankey: { col_span: "single" } } }
|
|
}, as: :json
|
|
|
|
assert_response :ok
|
|
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
|
|
end
|
|
|
|
test "update_preferences ignores malformed dashboard_section_layout without erroring" do
|
|
previous_height = @user.reload.dashboard_section_height("net_worth_chart")
|
|
|
|
patch "/dashboard/preferences", params: {
|
|
preferences: { dashboard_section_layout: "not-a-hash" }
|
|
}, as: :json
|
|
|
|
assert_response :ok
|
|
assert_equal previous_height, @user.reload.dashboard_section_height("net_worth_chart")
|
|
end
|
|
|
|
test "dashboard memoizes income statement period totals while rendering" do
|
|
income_statement = IncomeStatement.new(@family)
|
|
IncomeStatement.stubs(:new).returns(income_statement)
|
|
|
|
fake_expense_period_total = IncomeStatement::PeriodTotal.new(
|
|
classification: "expense",
|
|
total: 0,
|
|
currency: @family.currency,
|
|
category_totals: []
|
|
)
|
|
|
|
fake_income_period_total = IncomeStatement::PeriodTotal.new(
|
|
classification: "income",
|
|
total: 0,
|
|
currency: @family.currency,
|
|
category_totals: []
|
|
)
|
|
|
|
income_statement.expects(:build_period_total)
|
|
.with(classification: "expense", period: kind_of(Period))
|
|
.once
|
|
.returns(fake_expense_period_total)
|
|
|
|
income_statement.expects(:build_period_total)
|
|
.with(classification: "income", period: kind_of(Period))
|
|
.once
|
|
.returns(fake_income_period_total)
|
|
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
end
|
|
|
|
test "intro page requires guest role" do
|
|
get intro_path
|
|
|
|
assert_redirected_to root_path
|
|
assert_equal "Intro is only available to guest users.", flash[:alert]
|
|
end
|
|
|
|
test "intro page is accessible for guest users" do
|
|
sign_in @intro_user
|
|
|
|
get intro_path
|
|
|
|
assert_response :ok
|
|
end
|
|
|
|
test "dashboard renders sankey chart with subcategories" do
|
|
# Create parent category with subcategory
|
|
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
|
|
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
|
|
|
|
# Create transactions using helper
|
|
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
|
|
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
|
|
|
|
get root_path
|
|
assert_response :ok
|
|
assert_select "[data-controller='sankey-chart']"
|
|
end
|
|
|
|
test "dashboard renders sankey chart zoom controls and stable node ids" do
|
|
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
|
|
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
|
|
|
|
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
|
|
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
|
|
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
assert_select "[data-sankey-chart-target='zoomOutButton'][hidden]", count: 2
|
|
|
|
chart = css_select("[data-controller='sankey-chart']").first
|
|
sankey_data = JSON.parse(chart["data-sankey-chart-data-value"])
|
|
|
|
assert_includes sankey_data.fetch("nodes").map { |node| node.fetch("id") }, "cash_flow_node"
|
|
assert sankey_data.fetch("nodes").any? { |node| node.fetch("id").start_with?("expense_") }
|
|
end
|
|
|
|
test "dashboard renders money flow widget" do
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
assert_select "[data-controller='bar-chart']"
|
|
end
|
|
|
|
test "dashboard scopes money flow widget to selected month and accounts" do
|
|
# Dedicated account (rather than @family.accounts.first) so fixture
|
|
# transactions on other accounts can't skew the computed totals.
|
|
account = @family.accounts.create!(name: "Money Flow Test Checking", currency: @family.currency, balance: 0, accountable: Depository.new)
|
|
selected_month = 1.month.ago.beginning_of_month.to_date
|
|
create_transaction(account: account, name: "Groceries", amount: 50, date: selected_month + 1.day)
|
|
create_transaction(account: account, name: "Paycheck", amount: -200, date: selected_month + 2.days)
|
|
|
|
get root_path, params: {
|
|
money_flow_month: selected_month.iso8601,
|
|
money_flow_account_ids: [ account.id ]
|
|
}
|
|
|
|
assert_response :ok
|
|
bars = money_flow_bars
|
|
|
|
assert_equal 6, bars.size
|
|
highlighted = bars.find { |bar| bar["highlighted"] }
|
|
assert_equal selected_month.iso8601, highlighted["date"]
|
|
assert_equal 50.0, highlighted["expense"]
|
|
assert_equal 200.0, highlighted["income"]
|
|
end
|
|
|
|
test "dashboard money flow widget ignores account ids not accessible to the current user" do
|
|
other_family = Family.create!(name: "Other Family", currency: "USD")
|
|
other_account = other_family.accounts.create!(name: "Other Family Checking", currency: "USD", balance: 0, accountable: Depository.new)
|
|
create_transaction(account: other_account, name: "Not mine", amount: 999)
|
|
|
|
get root_path
|
|
default_bars = money_flow_bars
|
|
|
|
get root_path, params: { money_flow_account_ids: [ other_account.id ] }
|
|
|
|
assert_response :ok
|
|
filtered_bars = money_flow_bars
|
|
|
|
# An id outside the current user's accessible accounts is dropped entirely
|
|
# (money_flow_account_ids_param intersects against accessible ids), so the
|
|
# widget falls back to its unfiltered "all accessible accounts" state
|
|
# rather than scoping to a foreign account or erroring.
|
|
assert_equal default_bars, filtered_bars
|
|
end
|
|
|
|
test "dashboard money flow widget excludes accounts ineligible for cashflow totals from its account filter" do
|
|
excluded_account = @family.accounts.create!(
|
|
name: "Excluded From Reports",
|
|
currency: @family.currency,
|
|
balance: 0,
|
|
exclude_from_reports: true,
|
|
accountable: Depository.new
|
|
)
|
|
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
assert_select "input[type='checkbox'][value=?]", excluded_account.id.to_s, count: 0
|
|
end
|
|
|
|
test "dashboard money flow widget ignores account ids excluded from cashflow totals" do
|
|
excluded_account = @family.accounts.create!(
|
|
name: "Excluded From Reports",
|
|
currency: @family.currency,
|
|
balance: 0,
|
|
exclude_from_reports: true,
|
|
accountable: Depository.new
|
|
)
|
|
create_transaction(account: excluded_account, name: "Not counted", amount: 999)
|
|
|
|
get root_path
|
|
default_bars = money_flow_bars
|
|
|
|
get root_path, params: { money_flow_account_ids: [ excluded_account.id ] }
|
|
|
|
assert_response :ok
|
|
filtered_bars = money_flow_bars
|
|
|
|
# An account excluded from reports is visible/accessible but not eligible
|
|
# for cashflow totals, so selecting only it must fall back to the
|
|
# unfiltered state instead of silently computing to zero.
|
|
assert_equal default_bars, filtered_bars
|
|
end
|
|
|
|
test "dashboard clamps a future money flow month instead of erroring" do
|
|
get root_path, params: { money_flow_month: 1.month.from_now.beginning_of_month.iso8601 }
|
|
|
|
assert_response :ok
|
|
bars = money_flow_bars
|
|
|
|
assert_equal Date.current.beginning_of_month.iso8601, bars.last["date"]
|
|
end
|
|
|
|
test "dashboard money flow income/expense links exclude pending transactions" do
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
assert_select "a[href*='q%5Btypes%5D%5B%5D=income'][href*='q%5Bstatus%5D%5B%5D=confirmed']"
|
|
assert_select "a[href*='q%5Btypes%5D%5B%5D=expense'][href*='q%5Bstatus%5D%5B%5D=confirmed']"
|
|
end
|
|
|
|
test "dashboard money flow income/expense links stay scoped to eligible accounts by default" do
|
|
excluded_account = @family.accounts.create!(
|
|
name: "Excluded From Reports",
|
|
currency: @family.currency,
|
|
balance: 0,
|
|
exclude_from_reports: true,
|
|
accountable: Depository.new
|
|
)
|
|
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
income_href = css_select("a[href*='q%5Btypes%5D%5B%5D=income']").first["href"]
|
|
expense_href = css_select("a[href*='q%5Btypes%5D%5B%5D=expense']").first["href"]
|
|
|
|
# The default (unfiltered) state must still pin the drill-down links to
|
|
# the eligible accounts backing the displayed totals, not the broader
|
|
# accessible-accounts set transactions_path defaults to when account_ids
|
|
# is absent.
|
|
assert_includes income_href, "q%5Baccount_ids%5D%5B%5D="
|
|
assert_not_includes income_href, excluded_account.id
|
|
assert_includes expense_href, "q%5Baccount_ids%5D%5B%5D="
|
|
assert_not_includes expense_href, excluded_account.id
|
|
end
|
|
|
|
test "changelog" do
|
|
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
|
|
get changelog_path
|
|
assert_response :ok
|
|
end
|
|
end
|
|
|
|
test "changelog with nil release notes" do
|
|
# Mock the GitHub provider to return nil (simulating API failure or no releases)
|
|
github_provider = mock
|
|
github_provider.expects(:fetch_latest_release_notes).returns(nil)
|
|
Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)
|
|
|
|
get changelog_path
|
|
assert_response :ok
|
|
assert_select "h2", text: "Release notes unavailable"
|
|
assert_select "a[href='https://github.com/we-promise/sure/releases']"
|
|
end
|
|
|
|
test "changelog with incomplete release notes" do
|
|
# Mock the GitHub provider to return incomplete data (missing some fields)
|
|
github_provider = mock
|
|
incomplete_data = {
|
|
avatar: nil,
|
|
username: "maybe-finance",
|
|
name: "Test Release",
|
|
published_at: nil,
|
|
body: nil
|
|
}
|
|
github_provider.expects(:fetch_latest_release_notes).returns(incomplete_data)
|
|
Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)
|
|
|
|
get changelog_path
|
|
assert_response :ok
|
|
assert_select "h2", text: "Test Release"
|
|
# Should not crash even with nil values
|
|
end
|
|
|
|
private
|
|
def money_flow_bars
|
|
JSON.parse(css_select("[data-controller='bar-chart']").first["data-bar-chart-data-value"])
|
|
end
|
|
end
|