Skip to content

Commit

Permalink
Add sankey diagram for assets (#678)
Browse files Browse the repository at this point in the history
  • Loading branch information
argaen authored Feb 28, 2024
1 parent a04059f commit 47983c0
Show file tree
Hide file tree
Showing 16 changed files with 485 additions and 17 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"aws-amplify": "^5.3.12",
"chart.js": "^4.4.1",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-chart-sankey": "^0.12.0",
"chartjs-chart-treemap": "^2.3.0",
"chartjs-plugin-annotation": "^3.0.1",
"chartjs-plugin-autocolors": "^0.2.2",
Expand Down
Binary file modified public/books/demo.sqlite.gz
Binary file not shown.
180 changes: 180 additions & 0 deletions src/__tests__/components/charts/AssetSankey.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import * as query from '@tanstack/react-query';
import type { UseQueryResult } from '@tanstack/react-query';

import { AssetSankey } from '@/components/charts';
import Sankey from '@/components/charts/Sankey';
import * as apiHooks from '@/hooks/api';
import type { Commodity } from '@/book/entities';

jest.mock('@tanstack/react-query');
jest.mock('@/hooks/api');

jest.mock('@/components/charts/Sankey', () => jest.fn(
() => <div data-testid="Sankey" />,
));

describe('AssetSankey', () => {
beforeEach(() => {
jest.spyOn(query, 'useQuery').mockReturnValue({ data: undefined } as UseQueryResult);
jest.spyOn(apiHooks, 'useMainCurrency').mockReturnValue(
{ data: { mnemonic: 'EUR' } as Commodity } as UseQueryResult<Commodity>,
);
});

afterEach(() => {
jest.clearAllMocks();
});

it('renders with no data', () => {
render(<AssetSankey guid="guid" />);

screen.getByText('No movements this month yet', { exact: false });
});

it('generates data as expected', () => {
jest.spyOn(query, 'useQuery').mockReturnValue(
{
data: [
{
guid: 'guid1',
name: '1',
type: 'BANK',
total: 10,
},
{
guid: 'guid2',
name: '2',
type: 'INCOME',
total: -10,
},
{
guid: 'guid3',
name: '3',
type: 'INCOME',
total: -20,
},
{
guid: 'guid4',
name: '4',
type: 'EXPENSE',
total: 10,
},
{
guid: 'guid5',
name: '5',
type: 'LIABILITY',
total: 20,
},
{
guid: 'guid6',
name: '6',
type: 'ASSET',
total: 30,
},

],
} as UseQueryResult,
);

render(<AssetSankey guid="guid1" />);

expect(Sankey).toBeCalledWith(
{
height: 250,
options: {
plugins: {
title: {
display: true,
text: 'Cash flow',
align: 'start',
padding: {
top: 0,
bottom: 30,
},
font: {
size: 18,
},
},
tooltip: {
backgroundColor: '#323b44',
displayColors: false,
callbacks: {
label: expect.any(Function),
},
},
},
},
data: {
datasets: [
{
borderWidth: 0,
color: '#94A3B8',
colorFrom: expect.any(Function),
colorTo: expect.any(Function),
nodeWidth: 2,
data: [
{
flow: 10,
from: '2',
fromType: 'INCOME',
to: '1',
toType: 'ASSET',
},
{
flow: 20,
from: '3',
fromType: 'INCOME',
to: '1',
toType: 'ASSET',
},
{
flow: 10,
from: '1',
fromType: 'ASSET',
to: '4',
toType: 'EXPENSE',
},
{
flow: 20,
from: '1',
fromType: 'ASSET',
to: '5',
toType: 'LIABILITY',
},
{
flow: 30,
from: '1',
fromType: 'ASSET',
to: '6',
toType: 'ASSET',
},
],
},
],
},
},
{},
);

const { colorTo, colorFrom } = (Sankey as jest.Mock).mock.calls[0][0].data.datasets[0];

expect(colorTo({})).toEqual('');
expect(colorTo({ raw: { toType: 'ASSET' } })).toEqual('#0891B2');
expect(colorTo({ raw: { toType: 'LIABILITY' } })).toEqual('#EA580C');
expect(colorTo({ raw: { toType: 'INCOME' } })).toEqual('#16A34A');
expect(colorTo({ raw: { toType: 'EXPENSE' } })).toEqual('#DC2626');

expect(colorFrom({})).toEqual('');
expect(colorFrom({ raw: { fromType: 'ASSET' } })).toEqual('#0891B2');
expect(colorFrom({ raw: { fromType: 'LIABILITY' } })).toEqual('#EA580C');
expect(colorFrom({ raw: { fromType: 'INCOME' } })).toEqual('#16A34A');
expect(colorFrom({ raw: { fromType: 'EXPENSE' } })).toEqual('#DC2626');

const { label } = (Sankey as jest.Mock).mock.calls[0][0].options.plugins.tooltip.callbacks;

expect(label({ raw: { flow: 10, toType: 'EXPENSE' } })).toEqual('€10.00 (16.67 %)');
expect(label({ raw: { flow: 10, toType: 'ASSET' } })).toEqual('€10.00 (33.33 %)');
});
});
41 changes: 41 additions & 0 deletions src/__tests__/components/charts/Sankey.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import { render } from '@testing-library/react';

import ChartJS from '@/components/charts/ChartJS';
import Sankey from '@/components/charts/Sankey';

jest.mock('@/components/charts/ChartJS', () => jest.fn(
() => <div data-testid="ChartJS" />,
));

describe('Sankey', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('creates chartJS with default parameters', () => {
render(
<Sankey
data={{ datasets: [] }}
options={{}}
/>,
);

expect(ChartJS).toHaveBeenCalledWith(
{
data: {
datasets: [],
},
options: {
font: {
family: 'sans-serif',
size: 12,
weight: 400,
},
},
type: 'sankey',
},
{},
);
});
});
10 changes: 7 additions & 3 deletions src/__tests__/components/forms/price/PriceForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,18 +257,22 @@ describe('PriceForm', () => {
);

const priceInput = screen.getByLabelText('Price');
const dateInput = screen.getByLabelText('Date');

await user.clear(priceInput);
await user.type(priceInput, '120');

await user.clear(dateInput);
await user.type(dateInput, '2023-10-01');

expect(screen.getByText('update')).not.toBeDisabled();
await user.click(screen.getByText('update'));

const prices = await Price.find();
expect(prices).toEqual([
{
guid: expect.any(String),
date: DateTime.fromISO('2023-01-01'),
date: DateTime.fromISO('2023-10-01'),
fk_commodity: eur,
fk_currency: usd,
valueDenom: 1,
Expand All @@ -277,8 +281,8 @@ describe('PriceForm', () => {
},
]);
expect(mockSave).toBeCalledWith({
guid: expect.any(String),
date: DateTime.fromISO('2023-01-01'),
guid: undefined,
date: DateTime.fromISO('2023-10-01'),
fk_commodity: eur,
fk_currency: usd,
valueDenom: 1,
Expand Down
7 changes: 6 additions & 1 deletion src/__tests__/components/pages/account/AssetInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
import type { UseQueryResult } from '@tanstack/react-query';

import { AssetInfo } from '@/components/pages/account';
import { NetWorthHistogram } from '@/components/charts';
import { NetWorthHistogram, AssetSankey } from '@/components/charts';
import StatisticsWidget from '@/components/StatisticsWidget';
import * as apiHook from '@/hooks/api';
import Money from '@/book/Money';
Expand All @@ -20,6 +20,10 @@ jest.mock('@/components/charts/NetWorthHistogram', () => jest.fn(
() => <div data-testid="NetWorthHistogram" />,
));

jest.mock('@/components/charts/AssetSankey', () => jest.fn(
() => <div data-testid="AssetSankey" />,
));

jest.mock('@/components/StatisticsWidget', () => jest.fn(
() => <div data-testid="StatisticsWidget" />,
));
Expand Down Expand Up @@ -64,6 +68,7 @@ describe('AssetInfo', () => {
},
{},
);
expect(AssetSankey).toBeCalledWith({ guid: 'guid' }, {});
expect(StatisticsWidget).toHaveBeenNthCalledWith(
1,
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ exports[`AssetInfo renders as expected when no splits 1`] = `
/>
</div>
<div
class="col-span-8"
/>
class="card col-span-8"
>
<div
data-testid="AssetSankey"
/>
</div>
<div
class="col-span-4"
/>
Expand Down
16 changes: 14 additions & 2 deletions src/book/__tests__/helpers/accountType.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,29 @@ describe('isInvestment', () => {
describe('isAsset', () => {
it.each(
ASSET_ACCOUNTS,
)('returns true for %s', (type) => {
)('returns true for %s account', (type) => {
expect(isAsset({ type } as Account)).toBe(true);
});

it.each(
ASSET_ACCOUNTS,
)('returns true for %s string', (type) => {
expect(isAsset(type)).toBe(true);
});
});

describe('isLiability', () => {
it.each(
LIABILITY_ACCOUNTS,
)('returns true for %s', (type) => {
)('returns true for %s account', (type) => {
expect(isLiability({ type } as Account)).toBe(true);
});

it.each(
LIABILITY_ACCOUNTS,
)('returns true for %s string', (type) => {
expect(isLiability(type)).toBe(true);
});
});

describe('getAllowedSubAccounts', () => {
Expand Down
14 changes: 10 additions & 4 deletions src/book/helpers/accountType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,22 @@ export function isInvestment(account: Account): boolean {
return false;
}

export function isAsset(account: Account): boolean {
if (ASSET_ACCOUNTS.includes(account.type)) {
export function isAsset(account: Account | string): boolean {
if (
ASSET_ACCOUNTS.includes(account as string)
|| ASSET_ACCOUNTS.includes((account as Account).type)
) {
return true;
}

return false;
}

export function isLiability(account: Account): boolean {
if (LIABILITY_ACCOUNTS.includes(account.type)) {
export function isLiability(account: Account | string): boolean {
if (
LIABILITY_ACCOUNTS.includes(account as string)
|| LIABILITY_ACCOUNTS.includes((account as Account).type)
) {
return true;
}

Expand Down
Loading

0 comments on commit 47983c0

Please sign in to comment.