mirror of
https://github.com/we-promise/sure.git
synced 2026-05-24 21:14:56 +00:00
* feat(dashboard): zoom into cashflow sankey categories Click a category node on the dashboard cashflow Sankey to focus on it and its descendants only; a back button restores the full view. Clicking the Cash Flow node zooms to the expense (outbound) side. - Pure utility (app/javascript/utils/sankey_zoom.js) computes the descendant subgraph from a clicked node, with direction inferred by reachability from the cash flow node (outbound for expense, inbound for income). - Stable node ids emitted from the controller so the JS can identify nodes across re-renders. - Stimulus controller adds chart + zoomOutButton targets, fade transition, and only sets a pointer cursor when a node has children. - Node:test coverage for expense, income, cash-flow, and malformed-data cases; \"type\": \"module\" added to package.json so the .js util is ESM-compatible under Node. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dashboard): extract cashflow sankey chart partial Deduplicate sankey chart markup between inline and expanded dialog views, and reset zoom state when chart data changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(js): rename sankey_zoom util to .mjs to drop project-wide ESM flag Removes "type": "module" from package.json to avoid implicitly switching every .js file in the project to ESM (a future footgun for any .js config file added by Biome, Vite, etc.). Renames the utility to .mjs so node --test can import the ES module directly, and adds an explicit importmap pin since pin_all_from only globs .js/.jsm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(assets): register .mjs MIME type for Propshaft Propshaft derives Content-Type from Mime::Type.lookup_by_extension, which returns nil for :mjs by default. Browsers refuse to execute ES modules served with an empty Content-Type, breaking the sankey_zoom util loaded via importmap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
3.6 KiB
JavaScript
129 lines
3.6 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
sankeyNodeHasChildren,
|
|
zoomSankeyData,
|
|
} from "../../../app/javascript/utils/sankey_zoom.mjs";
|
|
|
|
test("zooms an expense category to the clicked root and descendants", () => {
|
|
const data = {
|
|
nodes: [
|
|
{ id: "cash_flow_node", name: "Cash Flow" },
|
|
{ id: "expense_shopping", name: "Shopping" },
|
|
{ id: "expense_sub_groceries", name: "Groceries" },
|
|
{ id: "expense_sub_clothes", name: "Clothes" },
|
|
{ id: "expense_dining", name: "Dining" },
|
|
],
|
|
links: [
|
|
{ source: 0, target: 1, value: 150 },
|
|
{ source: 1, target: 2, value: 100 },
|
|
{ source: 1, target: 3, value: 50 },
|
|
{ source: 0, target: 4, value: 75 },
|
|
],
|
|
currency_symbol: "$",
|
|
};
|
|
|
|
assert.equal(sankeyNodeHasChildren(data, "expense_shopping"), true);
|
|
assert.equal(sankeyNodeHasChildren(data, "expense_sub_groceries"), false);
|
|
|
|
const zoomed = zoomSankeyData(data, "expense_shopping");
|
|
|
|
assert.deepEqual(zoomed.nodes.map((node) => node.id), [
|
|
"expense_shopping",
|
|
"expense_sub_groceries",
|
|
"expense_sub_clothes",
|
|
]);
|
|
assert.deepEqual(
|
|
zoomed.links.map((link) => [link.source, link.target, link.value]),
|
|
[
|
|
[0, 1, 100],
|
|
[0, 2, 50],
|
|
],
|
|
);
|
|
assert.equal(zoomed.currency_symbol, "$");
|
|
});
|
|
|
|
test("zooms an income category by following incoming child links", () => {
|
|
const data = {
|
|
nodes: [
|
|
{ id: "income_salary", name: "Salary" },
|
|
{ id: "cash_flow_node", name: "Cash Flow" },
|
|
{ id: "income_sub_bonus", name: "Bonus" },
|
|
{ id: "income_sub_equity", name: "Equity" },
|
|
{ id: "income_interest", name: "Interest" },
|
|
],
|
|
links: [
|
|
{ source: 0, target: 1, value: 250 },
|
|
{ source: 2, target: 0, value: 100 },
|
|
{ source: 3, target: 0, value: 150 },
|
|
{ source: 4, target: 1, value: 25 },
|
|
],
|
|
currency_symbol: "$",
|
|
};
|
|
|
|
assert.equal(sankeyNodeHasChildren(data, "income_salary"), true);
|
|
assert.equal(sankeyNodeHasChildren(data, "income_sub_bonus"), false);
|
|
|
|
const zoomed = zoomSankeyData(data, "income_salary");
|
|
|
|
assert.deepEqual(zoomed.nodes.map((node) => node.id), [
|
|
"income_salary",
|
|
"income_sub_bonus",
|
|
"income_sub_equity",
|
|
]);
|
|
assert.deepEqual(
|
|
zoomed.links.map((link) => [link.source, link.target, link.value]),
|
|
[
|
|
[1, 0, 100],
|
|
[2, 0, 150],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("zooms the cashflow node to its expense (outbound) descendants", () => {
|
|
const data = {
|
|
nodes: [
|
|
{ id: "income_salary", name: "Salary" },
|
|
{ id: "cash_flow_node", name: "Cash Flow" },
|
|
{ id: "expense_shopping", name: "Shopping" },
|
|
{ id: "expense_sub_groceries", name: "Groceries" },
|
|
],
|
|
links: [
|
|
{ source: 0, target: 1, value: 200 },
|
|
{ source: 1, target: 2, value: 150 },
|
|
{ source: 2, target: 3, value: 100 },
|
|
],
|
|
};
|
|
|
|
assert.equal(sankeyNodeHasChildren(data, "cash_flow_node"), true);
|
|
|
|
const zoomed = zoomSankeyData(data, "cash_flow_node");
|
|
|
|
assert.deepEqual(zoomed.nodes.map((node) => node.id), [
|
|
"cash_flow_node",
|
|
"expense_shopping",
|
|
"expense_sub_groceries",
|
|
]);
|
|
assert.deepEqual(
|
|
zoomed.links.map((link) => [link.source, link.target, link.value]),
|
|
[
|
|
[0, 1, 150],
|
|
[1, 2, 100],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("does not zoom malformed data without a cashflow node", () => {
|
|
const data = {
|
|
nodes: [
|
|
{ id: "expense_shopping", name: "Shopping" },
|
|
{ id: "expense_sub_groceries", name: "Groceries" },
|
|
],
|
|
links: [{ source: 0, target: 1, value: 100 }],
|
|
};
|
|
|
|
assert.equal(sankeyNodeHasChildren(data, "expense_shopping"), false);
|
|
assert.equal(zoomSankeyData(data, "expense_shopping"), data);
|
|
});
|