Files
sure/app/views/pages/dashboard/_money_flow.html.erb
Anthony e3a7107271 Feature/dashboard Add "Money In / Out" dashboard widget with monthly bar chart (#2594)
* 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>
2026-07-25 04:59:29 +02:00

138 lines
7.4 KiB
Plaintext

<%# locals: (money_flow_data:, accounts:, col_span: "single") %>
<%
pill_button_class = "inline-flex items-center gap-1.5 bg-container border border-secondary font-medium rounded-lg pl-3 pr-2 py-2 text-sm cursor-pointer text-primary hover:bg-container-inset-hover focus:outline-hidden focus:ring-0 whitespace-nowrap"
selected_account_ids = money_flow_data[:account_ids] || accounts.map { |a| a.id.to_s }
all_accounts_selected = selected_account_ids.size == accounts.size
period = money_flow_data[:period]
# At full width the chart and summary sit side by side; at single/half width
# they stack so the chart gets the full column and the month labels don't crowd.
stacked = col_span != "full"
%>
<div id="money-flow-section" class="px-4 space-y-4">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-3">
<p class="text-sm text-secondary">
<%= t(".date_range", start_date: l(period.date_range.begin, format: :long), end_date: l(period.date_range.end, format: :long)) %>
</p>
<div class="flex items-center gap-2">
<%= render DS::Tooltip.new(text: t(".period_scope_hint"), placement: "top-start") %>
<%= render DS::Menu.new(variant: :button, placement: "bottom-end", max_height: "18rem") do |menu| %>
<% menu.with_button(type: "button", class: pill_button_class, aria: { label: t(".month_picker_aria_label") }) do %>
<span class="capitalize"><%= I18n.l(money_flow_data[:month], format: :month_year) %></span>
<%= icon("chevron-down", size: "sm") %>
<% end %>
<% (0..11).each do |i| %>
<% month = Date.current.beginning_of_month - i.months %>
<% menu.with_item(
variant: :link,
text: I18n.l(month, format: :month_year).capitalize,
href: root_path({ money_flow_month: month.iso8601, money_flow_account_ids: money_flow_data[:account_ids] }.compact),
frame: "dashboard_sections",
selected: month == money_flow_data[:month]
) %>
<% end %>
<% end %>
<%= render DS::Popover.new(variant: :button, no_padding: true) do |popover| %>
<% popover.with_button(
type: "button",
text: all_accounts_selected ? t(".filter_accounts_all") : t(".filter_accounts", count: selected_account_ids.size),
variant: "outline",
icon: "list-filter"
) %>
<% popover.with_custom_content do %>
<%= form_with url: root_path, method: :get, data: { turbo_frame: "dashboard_sections" }, class: "p-3 w-56 space-y-2" do %>
<%= hidden_field_tag :money_flow_month, money_flow_data[:month].iso8601 %>
<div data-controller="list-filter">
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t(".search_accounts"),
aria_label: t(".search_accounts"),
data: { list_filter_target: "input", action: "input->list-filter#filter" }
) %>
<div class="my-2 max-h-64 overflow-y-auto space-y-1" data-list-filter-target="list">
<% accounts.each do |account| %>
<label class="filterable-item flex items-center gap-2 p-1.5 rounded-md hover:bg-container-inset-hover cursor-pointer" data-filter-name="<%= account.name %>">
<%= check_box_tag "money_flow_account_ids[]", account.id, selected_account_ids.include?(account.id.to_s), id: nil, class: "checkbox checkbox--light" %>
<span class="text-sm text-primary truncate"><%= account.name %></span>
</label>
<% end %>
</div>
</div>
<%= render DS::Button.new(type: "submit", text: t(".apply"), variant: "primary", full_width: true) %>
<% end %>
<% end %>
<% end %>
</div>
</div>
<div class="flex flex-col gap-6<%= " xl:flex-row" unless stacked %>">
<div class="w-full space-y-2<%= " xl:w-3/5" unless stacked %>">
<div class="flex items-center justify-between gap-4 px-1">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1.5 text-xs text-secondary">
<span class="w-2 h-2 rounded-full bg-success"></span>
<%= t(".income") %>
</span>
<span class="flex items-center gap-1.5 text-xs text-secondary">
<span class="w-2 h-2 rounded-full bg-gray-400"></span>
<%= t(".expenses") %>
</span>
</div>
<span class="text-xs text-secondary"><%= t(".chart_span", count: money_flow_data[:bars].size) %></span>
</div>
<div class="w-full h-44 privacy-sensitive"
data-controller="bar-chart"
data-bar-chart-data-value="<%= money_flow_data[:bars].to_json %>"
data-bar-chart-currency-value="<%= Current.family.currency %>"
data-bar-chart-income-label-value="<%= t(".income") %>"
data-bar-chart-expense-label-value="<%= t(".expenses") %>">
</div>
</div>
<div class="w-full space-y-4<%= " xl:w-2/5" unless stacked %>">
<p class="text-sm font-medium text-primary capitalize"><%= I18n.l(money_flow_data[:month], format: :month_year) %></p>
<div class="flex items-center justify-between text-sm">
<span class="text-secondary"><%= t(".balance") %></span>
<span class="font-medium text-lg tabular-nums privacy-sensitive <%= money_flow_data[:balance].negative? ? "text-destructive" : (money_flow_data[:balance].zero? ? "text-primary" : "text-success") %>">
<%= format_money money_flow_data[:balance] %>
</span>
</div>
<%= link_to transactions_path(q: { types: [ "income" ], status: [ "confirmed" ], start_date: period.date_range.begin, end_date: period.date_range.end, account_ids: selected_account_ids }.compact),
class: "flex items-center justify-between p-3 border border-secondary rounded-lg hover:bg-container-inset-hover transition-colors group",
data: { turbo_frame: "_top" } do %>
<span class="flex items-center gap-2 text-sm text-primary">
<span class="w-2.5 h-2.5 rounded-full bg-success"></span>
<%= t(".income") %>
</span>
<span class="flex items-center gap-2">
<span class="font-medium tabular-nums text-success privacy-sensitive"><%= format_money money_flow_data[:income] %></span>
<%= icon("chevron-right", size: "sm", class: "text-secondary group-hover:text-primary transition-colors") %>
</span>
<% end %>
<%= link_to transactions_path(q: { types: [ "expense" ], status: [ "confirmed" ], start_date: period.date_range.begin, end_date: period.date_range.end, account_ids: selected_account_ids }.compact),
class: "flex items-center justify-between p-3 border border-secondary rounded-lg hover:bg-container-inset-hover transition-colors group",
data: { turbo_frame: "_top" } do %>
<span class="flex items-center gap-2 text-sm text-primary">
<span class="w-2.5 h-2.5 rounded-full bg-gray-400"></span>
<%= t(".expenses") %>
</span>
<span class="flex items-center gap-2">
<span class="font-medium tabular-nums text-primary privacy-sensitive"><%= format_money money_flow_data[:expense] %></span>
<%= icon("chevron-right", size: "sm", class: "text-secondary group-hover:text-primary transition-colors") %>
</span>
<% end %>
</div>
</div>
</div>