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>
This commit is contained in:
Anthony
2026-07-25 04:59:29 +02:00
committed by GitHub
parent 849578a84a
commit e3a7107271
10 changed files with 591 additions and 3 deletions

View File

@@ -0,0 +1,159 @@
import { Controller } from "@hotwired/stimulus";
import * as d3 from "d3";
import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip";
// Grouped bar chart used by the dashboard "money flow" widget — each month
// shows an expense bar (red) and an income bar (green) side by side.
// Modeled after time_series_chart_controller's lifecycle (install/teardown,
// ResizeObserver, turbo:load reinstall, page-relative tooltip positioning)
// but with scaleBand/scaleLinear instead of a line.
export default class extends Controller {
static values = {
data: Array,
currency: { type: String, default: "USD" },
incomeLabel: { type: String, default: "Income" },
expenseLabel: { type: String, default: "Expenses" },
};
_resizeObserver = null;
connect() {
this._install();
document.addEventListener("turbo:load", this._reinstall);
this._resizeObserver = new ResizeObserver(() => this._reinstall());
this._resizeObserver.observe(this.element);
}
disconnect() {
this._teardown();
document.removeEventListener("turbo:load", this._reinstall);
this._resizeObserver?.disconnect();
}
_reinstall = () => {
this._teardown();
this._install();
};
_teardown() {
d3.select(this.element).selectAll("*").remove();
}
_install() {
const width = this.element.clientWidth;
const height = this.element.clientHeight;
const data = this.dataValue || [];
if (width < 50 || height < 50 || data.length === 0) return;
const margin = { top: 16, right: 4, bottom: 24, left: 4 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const svg = d3
.select(this.element)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height]);
const group = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const series = ["income", "expense"];
const seriesColor = { expense: "var(--color-gray-400)", income: "var(--color-success)" };
const x0 = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, innerWidth])
.padding(0.3);
const x1 = d3.scaleBand().domain(series).range([0, x0.bandwidth()]).padding(0.15);
const maxValue = d3.max(data, (d) => Math.max(d.income, d.expense)) || 1;
const y = d3.scaleLinear().domain([0, maxValue * 1.1]).range([innerHeight, 0]);
// Floor tiny-but-nonzero bars (e.g. an in-progress month) at 2px so they stay visible.
const barHeight = (v) => (v > 0 ? Math.max(2, innerHeight - y(v)) : 0);
const tooltip = d3
.select(this.element)
.append("div")
.attr("class", `${CHART_TOOLTIP_CLASSES} opacity-0 top-0`);
const showTooltip = (event, month, key) => {
const estimatedTooltipWidth = 200;
const pageWidth = document.body.clientWidth;
const tooltipX = event.pageX + 10;
const overflowX = tooltipX + estimatedTooltipWidth - pageWidth;
const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX;
tooltip
.html(this._tooltipTemplate(month, key))
.style("opacity", 1)
.style("left", `${adjustedX}px`)
.style("top", `${event.pageY - 10}px`);
};
const hideTooltip = () => tooltip.style("opacity", 0);
const monthGroups = group
.selectAll("g.month")
.data(data)
.join("g")
.attr("class", "month")
.attr("transform", (d) => `translate(${x0(d.label)},0)`);
monthGroups
.selectAll("rect")
.data((d) => series.map((key) => ({ key, value: d[key], month: d })))
.join("rect")
.attr("x", (d) => x1(d.key))
.attr("y", (d) => innerHeight - barHeight(d.value))
.attr("width", x1.bandwidth())
.attr("height", (d) => barHeight(d.value))
.attr("rx", 3)
.attr("fill", (d) => seriesColor[d.key])
// In-progress month (period capped at today) reads as provisional.
.attr("fill-opacity", (d) => (d.month.partial ? 0.5 : 1))
.on("mousemove", (event, d) => showTooltip(event, d.month, d.key))
.on("mouseleave", hideTooltip);
group
.append("g")
.attr("transform", `translate(0,${innerHeight})`)
.call(d3.axisBottom(x0).tickSize(0))
.call((g) => g.select(".domain").remove())
.selectAll("text")
.attr("class", (_d, i) => (data[i].highlighted ? "text-primary fill-current" : "text-secondary fill-current"))
.style("font-size", "12px")
.style("font-weight", (_d, i) => (data[i].highlighted ? 600 : 500));
}
_tooltipTemplate(month, key) {
const label = key === "income" ? this.incomeLabelValue : this.expenseLabelValue;
// Match the bar/legend palette — expenses render gray, not destructive red.
const color = key === "income" ? "var(--color-success)" : "var(--color-gray-400)";
return `
<div class="text-xs text-secondary mb-1">${month.label}</div>
<div class="flex items-center gap-1.5 text-primary font-medium tabular-nums">
<span class="inline-block w-2 h-2 rounded-full" style="background-color: ${color};"></span>
${label}: ${this._formatCurrency(month[key])}
</div>
`;
}
_formatCurrency(value) {
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: this.currencyValue,
maximumFractionDigits: 0,
}).format(value);
} catch {
return value;
}
}
}