From d908560ed904e2c71d198ba948eaa9ddaf008f05 Mon Sep 17 00:00:00 2001 From: Will Wilson Date: Thu, 11 Jun 2026 20:21:56 +0100 Subject: [PATCH] feat(cashflow): deep-link category labels to filtered transactions (#2083) * feat(cashflow): deep-link category labels to filtered transactions Clicking a category's text label in the dashboard cashflow Sankey chart now navigates to the transactions page filtered by that category and the cashflow's active period date range. The colored node bar keeps its existing zoom-into-subcategories behavior; structural nodes (Cash Flow, Surplus) do not navigate. URL-building lives in a pure, unit-tested utils/transactions_filter_url module (mirroring utils/sankey_zoom) pinned in the importmap. Period dates are threaded from the dashboard view into the Stimulus controller via data values. This matches the existing donut chart's click-to-filter behavior. * Update app/javascript/controllers/sankey_chart_controller.js Co-authored-by: Guillem Arias Fauste Signed-off-by: Will Wilson --------- Signed-off-by: Will Wilson Co-authored-by: Guillem Arias Fauste --- .../controllers/sankey_chart_controller.js | 25 +++++++++- .../utils/transactions_filter_url.mjs | 23 +++++++++ .../pages/dashboard/_cashflow_sankey.html.erb | 4 +- .../dashboard/_cashflow_sankey_chart.html.erb | 4 +- config/importmap.rb | 1 + .../utils/transactions_filter_url_test.mjs | 49 +++++++++++++++++++ 6 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 app/javascript/utils/transactions_filter_url.mjs create mode 100644 test/javascript/utils/transactions_filter_url_test.mjs diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index eba61846b..21d1478cf 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -3,6 +3,10 @@ import * as d3 from "d3"; import { sankey } from "d3-sankey"; import { CHART_TOOLTIP_CLASSES } from "utils/chart_tooltip"; import { sankeyNodeHasChildren, zoomSankeyData } from "utils/sankey_zoom"; +import { + buildCategoryTransactionsUrl, + isNavigableCategoryNode, +} from "utils/transactions_filter_url"; // Connects to data-controller="sankey-chart" export default class extends Controller { @@ -13,6 +17,8 @@ export default class extends Controller { nodeWidth: { type: Number, default: 15 }, nodePadding: { type: Number, default: 20 }, currencySymbol: { type: String, default: "$" }, + startDate: String, + endDate: String, }; // Visual constants @@ -163,6 +169,22 @@ export default class extends Controller { this.#draw({ animate: true }); } + #navigateToTransactions(d) { + if (!isNavigableCategoryNode(d.id)) { + // Structural node (Cash Flow / Surplus): keep current zoom behavior. + this.#zoomIn(d); + return; + } + + Turbo.visit( + buildCategoryTransactionsUrl({ + name: d.name, + startDate: this.startDateValue, + endDate: this.endDateValue, + }), + ); + } + // Dynamic padding prevents padding from dominating when there are many nodes #calculateNodePadding(nodeCount, height) { const margin = this.constructor.EXTENT_MARGIN; @@ -495,6 +517,7 @@ export default class extends Controller { nodeGroups .selectAll("text") .style("cursor", (d) => + isNavigableCategoryNode(d.id) || sankeyNodeHasChildren(this.#visibleData(), d.id) ? "pointer" : "default", @@ -514,7 +537,7 @@ export default class extends Controller { .on("mousemove", (event) => this.#updateTooltipPosition(event)) .on("click", (event, d) => { event.stopPropagation(); - this.#zoomIn(d); + this.#navigateToTransactions(d); }) .on("mouseleave", () => { resetHover(); diff --git a/app/javascript/utils/transactions_filter_url.mjs b/app/javascript/utils/transactions_filter_url.mjs new file mode 100644 index 000000000..b10c9b01b --- /dev/null +++ b/app/javascript/utils/transactions_filter_url.mjs @@ -0,0 +1,23 @@ +// Category nodes in the cashflow Sankey have ids prefixed income_/expense_ +// (incl. *_sub_). Structural nodes (cash_flow_node, surplus_node) are not +// categories and must not deep-link to transactions. +export function isNavigableCategoryNode(id) { + return /^(income|expense)_/.test(id); +} + +// Builds a relative deep link to the transactions index, filtered by a single +// category name and (optionally) a start/end date range. Mirrors the params +// the transactions search form submits: q[categories][], q[start_date], +// q[end_date]. +export function buildCategoryTransactionsUrl({ + name, + startDate, + endDate, + basePath = "/transactions", +}) { + const params = new URLSearchParams(); + params.append("q[categories][]", name); + if (startDate) params.append("q[start_date]", startDate); + if (endDate) params.append("q[end_date]", endDate); + return `${basePath}?${params.toString()}`; +} diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index 2b91dbc72..fa244cd61 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -2,13 +2,13 @@
<% if sankey_data[:links].present? %>
- <%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data %> + <%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data, period: period %>
<%= render DS::Dialog.new(id: "cashflow-expanded-dialog", auto_open: false, width: "custom", disable_frame: true, content_class: "!w-[96vw] max-w-[1650px]", data: { action: "close->cashflow-expand#restore" }) do |dialog| %> <% dialog.with_header(title: t("pages.dashboard.cashflow_sankey.title")) %> <% dialog.with_body do %>
- <%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data %> + <%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data, period: period %>
<% end %> <% end %> diff --git a/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb b/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb index d22b305ce..424794383 100644 --- a/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey_chart.html.erb @@ -1,8 +1,10 @@ -<%# locals: (sankey_data:) %> +<%# locals: (sankey_data:, period:) %>
<%= render DS::Button.new( diff --git a/config/importmap.rb b/config/importmap.rb index 308591cbd..e47127f12 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -9,6 +9,7 @@ pin_all_from "app/components", under: "controllers", to: "" pin_all_from "app/javascript/services", under: "services", to: "services" pin_all_from "app/javascript/utils", under: "utils", to: "utils" pin "utils/sankey_zoom", to: "utils/sankey_zoom.mjs" +pin "utils/transactions_filter_url", to: "utils/transactions_filter_url.mjs" pin "@github/hotkey", to: "@github--hotkey.js" # @3.1.1 pin "@simonwep/pickr", to: "@simonwep--pickr.js" # @1.9.1 diff --git a/test/javascript/utils/transactions_filter_url_test.mjs b/test/javascript/utils/transactions_filter_url_test.mjs new file mode 100644 index 000000000..c7d2109fa --- /dev/null +++ b/test/javascript/utils/transactions_filter_url_test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isNavigableCategoryNode, + buildCategoryTransactionsUrl, +} from "../../../app/javascript/utils/transactions_filter_url.mjs"; + +test("category nodes are navigable, structural nodes are not", () => { + assert.equal(isNavigableCategoryNode("expense_15"), true); + assert.equal(isNavigableCategoryNode("income_3"), true); + assert.equal(isNavigableCategoryNode("expense_sub_19"), true); + assert.equal(isNavigableCategoryNode("income_sub_7"), true); + assert.equal(isNavigableCategoryNode("cash_flow_node"), false); + assert.equal(isNavigableCategoryNode("surplus_node"), false); + // Bare aggregate ids are not category nodes. + assert.equal(isNavigableCategoryNode("income"), false); + assert.equal(isNavigableCategoryNode("expense"), false); +}); + +test("builds a transactions deep link with category and date range", () => { + const url = buildCategoryTransactionsUrl({ + name: "Groceries", + startDate: "2026-05-01", + endDate: "2026-05-31", + }); + assert.equal( + url, + "/transactions?q%5Bcategories%5D%5B%5D=Groceries&q%5Bstart_date%5D=2026-05-01&q%5Bend_date%5D=2026-05-31", + ); +}); + +test("encodes category names with special characters", () => { + const url = buildCategoryTransactionsUrl({ + name: "Food & Drink", + startDate: "2026-05-01", + endDate: "2026-05-31", + }); + assert.match(url, /q%5Bcategories%5D%5B%5D=Food\+%26\+Drink/); +}); + +test("omits date params when dates are blank", () => { + const url = buildCategoryTransactionsUrl({ + name: "Groceries", + startDate: "", + endDate: "", + }); + assert.equal(url, "/transactions?q%5Bcategories%5D%5B%5D=Groceries"); +});