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