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"); +});