Skip to content

Commit

Permalink
Merge pull request #45 from dsaltares/feat/40
Browse files Browse the repository at this point in the history
feat: 40 - categorised income over time report
  • Loading branch information
dsaltares authored Jan 5, 2025
2 parents dde3245 + 52449ad commit ff12467
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 133 deletions.
127 changes: 7 additions & 120 deletions src/components/Reports/CategorizedExpensesOverTimeReport.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,12 @@
import Stack from '@mui/material/Stack';
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import stringToColor from 'string-to-color';
import Paper from '@mui/material/Paper';
import TableContainer from '@mui/material/TableContainer';
import Table from '@mui/material/Table';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TableCell from '@mui/material/TableCell';
import TableBody from '@mui/material/TableBody';
import Typography from '@mui/material/Typography';
import { useTheme } from '@mui/material/styles';
import { useMemo } from 'react';
import { formatAmount } from '@lib/format';
import type { TimeGranularity } from '@server/reports/types';
import getDateFilter from '@lib/getDateFilter';
import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
import useFiltersFromUrl from '@lib/useFiltersFromUrl';
import client from '@lib/client';
import CategoryChip from '@components/CategoryChip';
import ChartContainer from './ChartContainer';
import NoTransactionsFound from './NoTransactionsFound';
import CategoryOverTimeReport from './CategoryOverTimeReport';

export default function CategorizedExpensesOverTimeReport() {
const theme = useTheme();
const { filtersByField } = useFiltersFromUrl();
const { data, isLoading: isLoadingReport } =
client.getBucketedCategoryReport.useQuery({
Expand All @@ -44,16 +20,6 @@ export default function CategorizedExpensesOverTimeReport() {
const { data: categories, isLoading: isLoadingCategories } =
client.getCategories.useQuery();
const currency = filtersByField.currency || 'EUR';
const categoryIds = useMemo(
() => new Set(filtersByField.categories?.split(',').map(Number)),
[filtersByField.categories],
);
const selectedCategories = useMemo(() => {
if (categoryIds.size === 0) {
return categories;
}
return categories?.filter((category) => categoryIds.has(category.id));
}, [categories, categoryIds]);

if (isLoadingReport || isLoadingCategories) {
return <FullScreenSpinner />;
Expand All @@ -62,90 +28,11 @@ export default function CategorizedExpensesOverTimeReport() {
}

return (
<Stack gap={2} justifyContent="center">
<ChartContainer>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="bucket" />
<YAxis />
<Legend
formatter={(_value, _entry, index) =>
selectedCategories?.[index]?.name
}
/>
<Tooltip
formatter={(value, _name, _props, index) => [
formatAmount(value as number, currency),
selectedCategories?.[index]?.name,
]}
wrapperStyle={{ zIndex: 1 }}
/>
{selectedCategories?.map((category) => (
<Bar
key={category.name}
name={category.name}
dataKey={(datum) => datum.categories[category.name] || 0}
stackId="a"
fill={stringToColor(category.name)}
/>
))}
</BarChart>
</ChartContainer>
<Stack>
<Paper>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
{selectedCategories?.map((category) => (
<TableCell key={category.id}>
<CategoryChip id={category.id} name={category.name} />
</TableCell>
))}
<TableCell>Total</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((datum) => (
<TableRow key={datum.bucket}>
<TableCell>{datum.bucket}</TableCell>
{selectedCategories?.map((category) => (
<TableCell key={category.id}>
<Typography
color={
datum.categories[category.name] > 0
? theme.palette.error.main
: undefined
}
variant="inherit"
>
{formatAmount(
datum.categories[category.name],
currency,
)}
</Typography>
</TableCell>
))}
<TableCell>
<Typography
color={
datum.total > 0
? theme.palette.error.main
: theme.palette.success.main
}
variant="inherit"
>
{formatAmount(datum.total, currency)}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Stack>
</Stack>
<CategoryOverTimeReport
data={data}
currency={currency}
categories={categories}
numberType="negative"
/>
);
}
38 changes: 38 additions & 0 deletions src/components/Reports/CategorizedIncomeOverTimeReport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { TimeGranularity } from '@server/reports/types';
import getDateFilter from '@lib/getDateFilter';
import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
import useFiltersFromUrl from '@lib/useFiltersFromUrl';
import client from '@lib/client';
import NoTransactionsFound from './NoTransactionsFound';
import CategoryOverTimeReport from './CategoryOverTimeReport';

export default function CategorizedIncomeOverTimeReport() {
const { filtersByField } = useFiltersFromUrl();
const { data, isLoading: isLoadingReport } =
client.getBucketedCategoryReport.useQuery({
type: 'Income',
date: getDateFilter(filtersByField),
accounts: filtersByField.accounts?.split(',').map(Number),
categories: filtersByField.categories?.split(',').map(Number),
currency: filtersByField.currency,
granularity: filtersByField.timeGranularity as TimeGranularity,
});
const { data: categories, isLoading: isLoadingCategories } =
client.getCategories.useQuery();
const currency = filtersByField.currency || 'EUR';

if (isLoadingReport || isLoadingCategories) {
return <FullScreenSpinner />;
} else if (!data || data.length === 0) {
return <NoTransactionsFound />;
}

return (
<CategoryOverTimeReport
data={data}
currency={currency}
categories={categories}
numberType="positive"
/>
);
}
141 changes: 141 additions & 0 deletions src/components/Reports/CategoryOverTimeReport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Stack from '@mui/material/Stack';
import {
Bar,
BarChart,
CartesianGrid,
Legend,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import stringToColor from 'string-to-color';
import Paper from '@mui/material/Paper';
import TableContainer from '@mui/material/TableContainer';
import Table from '@mui/material/Table';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TableCell from '@mui/material/TableCell';
import TableBody from '@mui/material/TableBody';
import Typography from '@mui/material/Typography';
import { useMemo } from 'react';
import { formatAmount } from '@lib/format';
import CategoryChip from '@components/CategoryChip';
import type { CategoryBucket } from '@server/reports/types';
import type { Category } from '@server/category/types';
import useFiltersFromUrl from '@lib/useFiltersFromUrl';
import ChartContainer from './ChartContainer';
import { numberTypeToColor, type NumberType } from './utils';

type Props = {
data: CategoryBucket[];
currency: string;
categories?: Category[];
numberType?: NumberType;
};

export default function CategoryOverTimeReport({
data,
currency,
categories,
numberType,
}: Props) {
const { filtersByField } = useFiltersFromUrl();
const filteredCategoryIds = useMemo(
() => new Set(filtersByField.categories?.split(',').map(Number)),
[filtersByField.categories],
);
const categoriesInReport = useMemo(
() => new Set(data?.map((datum) => Object.keys(datum.categories)).flat()),
[data],
);
const selectedCategories = useMemo(
() =>
categories?.filter(
(category) =>
categoriesInReport.has(category.name) &&
(filteredCategoryIds.size === 0 ||
filteredCategoryIds.has(category.id)),
),
[categories, filteredCategoryIds, categoriesInReport],
);

return (
<Stack gap={2} justifyContent="center">
<ChartContainer>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="bucket" />
<YAxis />
<Legend
formatter={(_value, _entry, index) =>
selectedCategories?.[index]?.name
}
/>
<Tooltip
formatter={(value, _name, _props, index) => [
formatAmount(value as number, currency),
selectedCategories?.[index]?.name,
]}
wrapperStyle={{ zIndex: 1 }}
/>
{selectedCategories?.map((category) => (
<Bar
key={category.name}
name={category.name}
dataKey={(datum) => datum.categories[category.name] || 0}
stackId="a"
fill={stringToColor(category.name)}
/>
))}
</BarChart>
</ChartContainer>
<Stack>
<Paper>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
{selectedCategories?.map((category) => (
<TableCell key={category.id}>
<CategoryChip id={category.id} name={category.name} />
</TableCell>
))}
<TableCell>Total</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.map((datum) => (
<TableRow key={datum.bucket}>
<TableCell>{datum.bucket}</TableCell>
{selectedCategories?.map((category) => (
<TableCell key={category.id}>
<Typography
color={numberTypeToColor(numberType)}
variant="inherit"
>
{formatAmount(
datum.categories[category.name],
currency,
)}
</Typography>
</TableCell>
))}
<TableCell>
<Typography
color={numberTypeToColor(numberType)}
variant="inherit"
>
{formatAmount(datum.total, currency)}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Stack>
</Stack>
);
}
14 changes: 1 addition & 13 deletions src/components/Reports/CategoryReport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import CategoryChip from '../CategoryChip';
import useIsMobile from '@lib/useIsMobile';
import ChartContainer from './ChartContainer';
import PieLabel from './PieLabel';

type NumberType = 'positive' | 'negative' | 'neutral';
import { numberTypeToColor, type NumberType } from './utils';

type Props = {
data: GetCategoryReportOutput;
Expand Down Expand Up @@ -96,14 +95,3 @@ export default function CategoryReport({
</Grid>
);
}

function numberTypeToColor(numberType: NumberType = 'neutral') {
switch (numberType) {
case 'positive':
return 'success.main';
case 'negative':
return 'error';
default:
return 'inherit';
}
}
12 changes: 12 additions & 0 deletions src/components/Reports/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type NumberType = 'positive' | 'negative' | 'neutral';

export function numberTypeToColor(numberType: NumberType = 'neutral') {
switch (numberType) {
case 'positive':
return 'success.main';
case 'negative':
return 'error';
default:
return 'inherit';
}
}
5 changes: 5 additions & 0 deletions src/routes/InsightsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import IncomeVsExpensesReport from '@components/Reports/IncomeVsExpensesReport';
import AccountBalancesReport from '@components/Reports/AccountBalancesReport';
import BalanceForecastReport from '@components/Reports/BalanceForecastReport';
import CategorizedExpensesOverTimeReport from '@components/Reports/CategorizedExpensesOverTimeReport';
import CategorizedIncomeOverTimeReport from '@components/Reports/CategorizedIncomeOverTimeReport';
import AppName from '@lib/appName';
import client from '@lib/client';
import useFiltersFromUrl from '@lib/useFiltersFromUrl';
Expand All @@ -33,6 +34,10 @@ const Reports = {
name: 'Where the money goes over time',
Component: CategorizedExpensesOverTimeReport,
},
categorizedIncomeOverTime: {
name: 'Where the money comes from over time',
Component: CategorizedIncomeOverTimeReport,
},
incomeVsExpenses: {
name: 'Income vs Expenses',
Component: IncomeVsExpensesReport,
Expand Down

0 comments on commit ff12467

Please sign in to comment.