Skip to content

Commit

Permalink
refactor: Rework <InsightsDialog />
Browse files Browse the repository at this point in the history
  • Loading branch information
tklein1801 committed Nov 11, 2024
1 parent 449c4dd commit 28911f4
Show file tree
Hide file tree
Showing 10 changed files with 378 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export const FullScreenDialog: React.FC<TFullScreenDialogProps> = ({
</Toolbar>
</AppBar>

<Box {...boxProps} sx={{p: 2, flex: 1, ...boxProps?.sx}}>
<Box {...boxProps} sx={{m: 3, flex: 1, ...boxProps?.sx}}>
{dialogProps.children}
</Box>
</React.Fragment>
Expand Down
258 changes: 258 additions & 0 deletions src/features/Insights/InsightsDialog/InsightsDialog.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import {PocketBaseCollection, TCategory, type TTransaction} from '@budgetbuddyde/types';
import {CloudDownloadRounded} from '@mui/icons-material';
import {Box, Button, Checkbox, FormControlLabel, Stack, Typography} from '@mui/material';
import {format, parseISO} from 'date-fns';
import React from 'react';

import {ActionPaper} from '@/components/Base/ActionPaper';
import {BarChart} from '@/components/Base/Charts';
import {FullScreenDialog, type TFullScreenDialogProps} from '@/components/Base/FullScreenDialog';
import {DateRange, type TDateRange} from '@/components/Base/Input';
import {useCategories} from '@/features/Category';
import {pb} from '@/pocketbase';
import {Formatter} from '@/services/Formatter';
import {downloadAsJson} from '@/utils';

import {SelectCategories, type TSelectCategoriesOption} from './SelectCategories';
import {SelectData, TSelectDataOption} from './SelectData';

type TState = {
type: TSelectDataOption['value'];
dateRange: TDateRange;
showStats: boolean;
categories: TSelectCategoriesOption[];
transactions: TTransaction[];
};

type TStateAction =
| {action: 'SET_DATE_RANGE'; range: TState['dateRange']}
| {action: 'SET_TYPE'; type: TState['type']}
| {action: 'SET_SHOW_STATS'; showStats: TState['showStats']}
| {action: 'SET_TRANSACTIONS'; transactions: TState['transactions']}
| {action: 'SET_CATEGORIES'; categories: TState['categories']};

function StateReducer(state: TState, action: TStateAction): TState {
switch (action.action) {
case 'SET_DATE_RANGE':
return {...state, dateRange: action.range};

case 'SET_TYPE':
return {...state, type: action.type};

case 'SET_SHOW_STATS':
return {...state, showStats: action.showStats};

case 'SET_TRANSACTIONS':
return {...state, transactions: action.transactions};

case 'SET_CATEGORIES':
return {...state, categories: action.categories};

default:
throw new Error('Trying to execute unknown action');
}
}

export type TInsightsDialogProps = {} & Pick<TFullScreenDialogProps, 'open' | 'onClose'>;

export const InsightsDialog: React.FC<TInsightsDialogProps> = ({open, onClose}) => {
const {isLoading: isLoadingCategories, data: categories} = useCategories();
const now = new Date();
const [state, dispatch] = React.useReducer(StateReducer, {
type: 'EXPENSES',
dateRange: {startDate: new Date(now.getFullYear(), 0, 1), endDate: now},
showStats: false,
categories: [
// {value: 'cconlx727r6a32w', label: 'Essen bestellen'},
// {value: 'tyx3smt3wzv7vil', label: 'Lebensmittel'},
],
transactions: [],
} as TState);

const categoryOptions: TSelectCategoriesOption[] = React.useMemo(() => {
return (categories ?? []).map(({id, name}) => ({value: id, label: name}));
}, [categories]);

const dateRangeLabels: string[] = React.useMemo(() => {
const {dateRange} = state;
const labels: string[] = [];

let tempDate = new Date(dateRange.startDate);
while (tempDate <= dateRange.endDate) {
labels.push(format(tempDate, 'yyyy-MM'));
tempDate = new Date(tempDate.getFullYear(), tempDate.getMonth() + 1, 1);
}
return labels;
}, [state.dateRange]);

const chartData: {name: string; data: number[]}[] = React.useMemo(() => {
const stats: Map<TCategory['id'], {name: string; data: Map<string, number>}> = new Map();
for (const {
processed_at,
expand: {
category: {id: categoryId, name: categoryName},
},
transfer_amount,
} of state.transactions) {
const dateKey = format(parseISO(processed_at + ''), 'yyyy-MM');
if (stats.has(categoryId)) {
const {data} = stats.get(categoryId)!;

if (data.has(dateKey)) {
const sum = data.get(dateKey)!;
data.set(dateKey, sum + Math.abs(transfer_amount));
} else {
data.set(dateKey, Math.abs(transfer_amount));
}
} else {
stats.set(categoryId, {name: categoryName, data: new Map([[dateKey, Math.abs(transfer_amount)]])});
}
}

// Ensure every month has a value for each category
for (const [, {data}] of stats) {
for (const label of dateRangeLabels) {
if (!data.has(label)) {
data.set(label, 0);
}
}
}

// Now transform map to chart data
return Array.from(stats).map(([, {name, data}]) => ({
name,
data: dateRangeLabels.map(label => data.get(label) ?? 0),
}));
}, [state.transactions]);

const stats = React.useMemo(() => {

Check failure on line 128 in src/features/Insights/InsightsDialog/InsightsDialog.component.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 18.x)

'stats' is declared but its value is never read.

Check failure on line 128 in src/features/Insights/InsightsDialog/InsightsDialog.component.tsx

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 20.x)

'stats' is declared but its value is never read.
if (!state.showStats) return [];
return chartData.map(({name, data}) => {
const total = data.reduce((acc, val) => acc + val, 0);
return {
name,
total,
average: total / data.length,
};
});
}, [chartData, state.showStats]);

const fetchData = React.useCallback(async () => {
const {categories, dateRange, type} = state;
if (categories.length === 0) return;
const records = await pb.collection(PocketBaseCollection.TRANSACTION).getFullList({
expand: 'payment_method,category',
sort: '-processed_at',
filter: pb.filter(
`processed_at >= {:start} && processed_at <= {:end} && transfer_amount ${type === 'INCOME' ? '>' : '<'} 0 && (${categories.map(({value}) => `category.id = '${value}'`).join(' || ')})`,
{
start: dateRange.startDate,
end: dateRange.endDate,
},
),
});
dispatch({action: 'SET_TRANSACTIONS', transactions: records as unknown as TTransaction[]});
}, [state.categories, state.dateRange, state.type]);

React.useEffect(() => {
if (!open) return;
fetchData();
}, [open, state.categories, state.dateRange, state.type]);

return (
<FullScreenDialog
title="Insights"
open={open}
onClose={onClose}
boxProps={{sx: {display: 'flex', flexDirection: 'column', flex: 1}}}>
<Stack flexDirection={'row'} justifyContent={'space-between'}>
<Stack flexDirection={'row'}>
<SelectData value={state.type} onChange={type => dispatch({action: 'SET_TYPE', type})} />

{!isLoadingCategories && (
<SelectCategories
options={categoryOptions}
value={state.categories}
onChange={values => {
dispatch({action: 'SET_CATEGORIES', categories: values});
}}
/>
)}

<FormControlLabel
control={<Checkbox />}
onChange={(_, checked) => dispatch({action: 'SET_SHOW_STATS', showStats: checked})}
label="Show stats"
/>

<Button
startIcon={<CloudDownloadRounded />}
onClick={() => {
downloadAsJson([], `bb_category_insights_${format(new Date(), 'yyyy_mm_dd')}`);
}}>
Export
</Button>
</Stack>

<DateRange
inputSize="small"
defaultStartDate={state.dateRange.startDate}
defaultEndDate={state.dateRange.endDate}
onDateChange={range => dispatch({action: 'SET_DATE_RANGE', range})}
/>
</Stack>

<Box sx={{mt: 2, flex: 1}}>
{state.categories.length > 0 && chartData.length > 0 ? (
<Box
sx={{
width: '100%',
height: '99.99%',
}}>
<BarChart
xAxis={[
{
scaleType: 'band',
data: dateRangeLabels.map(dateStr => {
const date = new Date(dateStr);
return `${Formatter.formatDate().shortMonthName(date)} ${date.getFullYear()}`;
}),
},
]}
yAxis={[
{
valueFormatter: value => Formatter.formatBalance(value ?? 0),
},
]}
series={chartData.map(({name, data}) => ({
label: name,
data: data,
valueFormatter: value => Formatter.formatBalance(value ?? 0),
}))}
margin={{left: 80, right: 20, top: 20, bottom: 20}}
grid={{horizontal: true}}
/>
</Box>
) : (
<ActionPaper
sx={{
display: 'flex',
width: '100%',
height: '100%',
p: 2,
justifyContent: 'center',
alignItems: 'center',
}}>
<Typography variant={'h1'} textAlign={'center'}>
{state.categories.length === 0 ? 'No categories selected' : 'No data available'}
</Typography>
</ActionPaper>
)}
</Box>

{/* <Box sx={{backgroundColor: 'blue', mt: 2, flex: 1}}>
<h1>Chart</h1>
</Box> */}
</FullScreenDialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {Autocomplete, TextField} from '@mui/material';
import React from 'react';

import {StyledAutocompleteOption} from '@/components/Base/Input';

export type TSelectCategoriesOption = {label: string; value: string};

export type TSelectCategoriesProps = {
value?: TSelectCategoriesOption[];
onChange: (values: TSelectCategoriesOption[]) => void;
options: TSelectCategoriesOption[];
};

export const SelectCategories: React.FC<TSelectCategoriesProps> = ({value = [], onChange, options}) => {
return (
<Autocomplete
sx={{width: {xs: '100%', sm: '50%'}, maxWidth: {xs: 'unset', sm: '500px'}, mb: {xs: 2, sm: 0}}}
renderInput={params => (
<TextField
{...params}
// inputRef={input => {
// autocompleteRef.current = input;
// }}
label="Categories"
placeholder={'Select categories'}
/>
)}
onChange={(_, values) => onChange(values)}
value={value}
options={options}
renderOption={(props, option, {selected}) => (
<StyledAutocompleteOption {...props} selected={selected}>
{option.label}
</StyledAutocompleteOption>
)}
disableCloseOnSelect
multiple
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SelectCategories.component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {ToggleButton, ToggleButtonGroup} from '@mui/material';
import React from 'react';

import {ActionPaper} from '@/components/Base/ActionPaper';

export const SELECT_DATA_OPTIONS = [
{label: 'Income', value: 'INCOME'},
{label: 'Expenses', value: 'EXPENSES'},
] as const;

export type TSelectDataOption = {
value: (typeof SELECT_DATA_OPTIONS)[number]['value'];
onChange: (value: (typeof SELECT_DATA_OPTIONS)[number]['value']) => void;
};

/**
* A React functional component that renders a selection interface using a ToggleButtonGroup.
*/
export const SelectData: React.FC<TSelectDataOption> = ({value, onChange}) => {
return (
<ActionPaper sx={{width: 'min-content'}}>
<ToggleButtonGroup
size="small"
color="primary"
value={value}
onChange={(_, newValue) => {
if (newValue === value) return;
onChange(newValue);
}}
exclusive>
{SELECT_DATA_OPTIONS.map(({label, value}) => (
<ToggleButton key={value} value={value}>
{label}
</ToggleButton>
))}
</ToggleButtonGroup>
</ActionPaper>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {fireEvent, render, screen} from '@testing-library/react';

import {SELECT_DATA_OPTIONS, SelectData} from './SelectData.component';

describe('SelectData', () => {
it('renders all options', () => {
const mockOnChange = vi.fn();
render(<SelectData value={SELECT_DATA_OPTIONS[0].value} onChange={mockOnChange} />);

SELECT_DATA_OPTIONS.forEach(option => {
expect(screen.getByText(option.label)).toBeInTheDocument();
});
});

it('calls onChange with the correct value when an option is clicked', () => {
const mockOnChange = vi.fn();
render(<SelectData value={SELECT_DATA_OPTIONS[0].value} onChange={mockOnChange} />);

const option = screen.getByText(SELECT_DATA_OPTIONS[1].label);
fireEvent.click(option);

expect(mockOnChange).toHaveBeenCalledWith(SELECT_DATA_OPTIONS[1].value);
});

it('displays the correct selected value', () => {
const mockOnChange = vi.fn();
render(<SelectData value={SELECT_DATA_OPTIONS[1].value} onChange={mockOnChange} />);

const selectedOption = screen.getByRole('button', {pressed: true});
expect(selectedOption).toHaveTextContent(SELECT_DATA_OPTIONS[1].label);
});
});
1 change: 1 addition & 0 deletions src/features/Insights/InsightsDialog/SelectData/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SelectData.component';
1 change: 1 addition & 0 deletions src/features/Insights/InsightsDialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './InsightsDialog.component';
1 change: 1 addition & 0 deletions src/features/Insights/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './InsightsDialog';
Loading

0 comments on commit 28911f4

Please sign in to comment.