refactor(cashflow): render money-flow Sankey with recharts
Replace the bespoke SVG Sankey with recharts' <Sankey> inside a ResponsiveContainer so the money-flow chart fits the viewport instead of forcing a fixed-min-width horizontal scroll (broken on mobile). Keeps: income -> cashflow -> expense flow, small-category 'Other' grouping, privacy-mode masking, and click-through to a category's transactions. Drops: subcategory drill-down (expand/collapse) and the 'Other' popover breakdown; the per-category detail already lives in the breakdown cards below the chart.
This commit is contained in:
parent
dada23cd84
commit
5242a7deec
|
|
@ -1,17 +1,46 @@
|
|||
import { SankeyData } from '@/hooks/use-cashflow-data';
|
||||
import { Category } from '@/types/category';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SankeyChart } from './sankey-chart';
|
||||
|
||||
// ResponsiveContainer measures its DOM parent, which is 0x0 in jsdom, so the
|
||||
// chart never lays out. Clone the child chart with a fixed size instead —
|
||||
// Sankey reports whatever width/height it receives as props.
|
||||
vi.mock('recharts', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('recharts');
|
||||
return {
|
||||
...actual,
|
||||
ResponsiveContainer: ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement<{ width?: number; height?: number }>;
|
||||
}) => React.cloneElement(children, { width: 800, height: 400 }),
|
||||
};
|
||||
});
|
||||
|
||||
const privacyMode = {
|
||||
isPrivacyModeEnabled: false,
|
||||
togglePrivacyMode: vi.fn(),
|
||||
setPrivacyMode: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/contexts/privacy-mode-context', () => ({
|
||||
usePrivacyMode: () => ({ isPrivacyModeEnabled: false }),
|
||||
usePrivacyMode: vi.fn(() => privacyMode),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-locale', () => ({
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-chart-color-scheme', () => ({
|
||||
useChartColors: () => ({
|
||||
cashflowIncomeColor: '#22c55e',
|
||||
cashflowExpenseColor: '#ef4444',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
router: { visit: vi.fn() },
|
||||
}));
|
||||
|
|
@ -20,6 +49,8 @@ vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
|
|||
index: () => ({ url: '/transactions' }),
|
||||
}));
|
||||
|
||||
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
|
||||
|
||||
function category(id: string, name: string, color = '#ccc'): Category {
|
||||
return { id, name, color } as Category;
|
||||
}
|
||||
|
|
@ -37,7 +68,6 @@ const data: SankeyData = {
|
|||
category: category('food', 'Food'),
|
||||
category_id: 'food',
|
||||
amount: 310,
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
category: category('rent', 'Rent'),
|
||||
|
|
@ -49,165 +79,84 @@ const data: SankeyData = {
|
|||
total_expense: 810,
|
||||
};
|
||||
|
||||
const foodChildren: SankeyData = {
|
||||
income_categories: [],
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('groceries', 'Groceries'),
|
||||
category_id: 'groceries',
|
||||
amount: 200,
|
||||
},
|
||||
{
|
||||
category: category('other-groceries', 'Other groceries'),
|
||||
category_id: 'other-groceries',
|
||||
amount: 110,
|
||||
},
|
||||
],
|
||||
total_income: 0,
|
||||
total_expense: 310,
|
||||
};
|
||||
|
||||
const period = {
|
||||
from: new Date('2026-06-01'),
|
||||
to: new Date('2026-06-30'),
|
||||
};
|
||||
|
||||
describe('SankeyChart', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: async () => foodChildren,
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders an expand affordance on parent categories with children', () => {
|
||||
it('renders income, center and expense nodes', () => {
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Expand Food' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands a category into its subcategories without replacing the chart', async () => {
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Other groceries')).toBeInTheDocument();
|
||||
// The original chart is untouched: center + parent nodes remain.
|
||||
expect(screen.getByText('Salary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cashflow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rent')).toBeInTheDocument();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('parent=food'),
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long category names with an ellipsis and keeps the full name as a title', () => {
|
||||
const longName = 'Sports, and sport goods and equipment rentals';
|
||||
it('shows an empty state when there is no cashflow', () => {
|
||||
render(
|
||||
<SankeyChart
|
||||
data={{
|
||||
...data,
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('long', longName),
|
||||
category_id: 'long',
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
total_expense: 500,
|
||||
income_categories: [],
|
||||
expense_categories: [],
|
||||
total_income: 0,
|
||||
total_expense: 0,
|
||||
}}
|
||||
period={period}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The full name stays in the DOM (with a title for hover); CSS handles
|
||||
// the visual ellipsis via the `truncate` utility.
|
||||
const label = screen.getByTitle(longName);
|
||||
expect(label).toHaveTextContent(longName);
|
||||
expect(label).toHaveClass('truncate');
|
||||
expect(
|
||||
screen.getByText('No cashflow data for this period'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses any other expanded category so only one stays open', async () => {
|
||||
const multiParent: SankeyData = {
|
||||
...data,
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('food', 'Food'),
|
||||
category_id: 'food',
|
||||
amount: 310,
|
||||
has_children: true,
|
||||
},
|
||||
{
|
||||
category: category('rent', 'Rent'),
|
||||
category_id: 'rent',
|
||||
amount: 500,
|
||||
has_children: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
it('groups small categories into an "Other" node', () => {
|
||||
const many = Array.from({ length: 8 }, (_, i) => ({
|
||||
category: category(`c${i}`, `Category ${i}`),
|
||||
category_id: `c${i}`,
|
||||
// First three are large, the rest are tiny (<3%).
|
||||
amount: i < 3 ? 300 : 5,
|
||||
}));
|
||||
|
||||
const rentChildren: SankeyData = {
|
||||
income_categories: [],
|
||||
expense_categories: [
|
||||
{
|
||||
category: category('mortgage', 'Mortgage'),
|
||||
category_id: 'mortgage',
|
||||
amount: 500,
|
||||
},
|
||||
],
|
||||
total_income: 0,
|
||||
total_expense: 500,
|
||||
};
|
||||
render(
|
||||
<SankeyChart
|
||||
data={{
|
||||
income_categories: [
|
||||
{
|
||||
category: category('salary', 'Salary'),
|
||||
category_id: 'salary',
|
||||
amount: 925,
|
||||
},
|
||||
],
|
||||
expense_categories: many,
|
||||
total_income: 925,
|
||||
total_expense: 925,
|
||||
}}
|
||||
period={period}
|
||||
/>,
|
||||
);
|
||||
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
const json = url.includes('parent=rent')
|
||||
? rentChildren
|
||||
: foodChildren;
|
||||
|
||||
return Promise.resolve({ json: async () => json });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(<SankeyChart data={multiParent} period={period} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Rent' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mortgage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Food's subcategories are gone now that Rent is open.
|
||||
expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Other')).toBeInTheDocument();
|
||||
// The tiny categories are folded away, not rendered individually.
|
||||
expect(screen.queryByText('Category 7')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses an expanded category when toggled again', async () => {
|
||||
it('masks amounts in privacy mode', () => {
|
||||
vi.mocked(usePrivacyMode).mockReturnValue({
|
||||
...privacyMode,
|
||||
isPrivacyModeEnabled: true,
|
||||
});
|
||||
|
||||
render(<SankeyChart data={data} period={period} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Groceries')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse Food' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Groceries')).not.toBeInTheDocument();
|
||||
});
|
||||
// No raw digits leak into the labels when privacy mode is on.
|
||||
expect(screen.getByText('Salary')).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toMatch(/1,?000/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -74,11 +74,3 @@ export function groupSmallCategories(
|
|||
// Don't group - return all categories as main
|
||||
return { main: sortedCategories, other: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the percentage of a value relative to a total
|
||||
*/
|
||||
export function calculatePercentage(value: number, total: number): number {
|
||||
if (total === 0) return 0;
|
||||
return (value / total) * 100;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue