Skip to content

Commit

Permalink
Add category spending report
Browse files Browse the repository at this point in the history
Signed-off-by: Johannes Löthberg <[email protected]>
  • Loading branch information
kyrias committed Jul 21, 2023
1 parent c6e480e commit f306787
Show file tree
Hide file tree
Showing 8 changed files with 521 additions and 65 deletions.
184 changes: 184 additions & 0 deletions packages/desktop-client/src/components/reports/CategorySpending.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import React, { useState, useEffect, useMemo } from 'react';
import { connect } from 'react-redux';

import * as d from 'date-fns';
import { bindActionCreators } from 'redux';

import * as actions from 'loot-core/src/client/actions';
import { send } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months';
import { integerToCurrency } from 'loot-core/src/shared/util';

import { colors, styles } from '../../style';
import { AlignedText, Block, Text, View } from '../common';

import categorySpendingSpreadsheet from './graphs/category-spending-spreadsheet';
import CategorySpendingGraph from './graphs/CategorySpendingGraph';
import Header from './Header';
import useReport from './useReport';
import { fromDateRepr } from './util';

function CategoryAverage({ getCategories }) {
const [categoryGroups, setCategoryGroups] = useState([]);

const [allMonths, setAllMonths] = useState(null);

const [start, setStart] = useState(
monthUtils.subMonths(monthUtils.currentMonth(), 5),
);
const [end, setEnd] = useState(monthUtils.currentMonth());

const [numberOfMonthsAverage, setNumberOfMonthsAverage] = useState(3);

const [selectedCategoryId, setSelectedCategoryId] = useState(null);

const getData = useMemo(
() =>
categorySpendingSpreadsheet(
start,
end,
numberOfMonthsAverage,
selectedCategoryId,
),
[start, end, numberOfMonthsAverage, selectedCategoryId],
);
const graphData = useReport('category_spending', getData);

useEffect(() => {
getCategories().then(categories => setCategoryGroups(categories.grouped));
}, []);

useEffect(() => {
async function run() {
const trans = await send('get-earliest-transaction');
const currentMonth = monthUtils.currentMonth();
let earliestMonth = trans
? monthUtils.monthFromDate(d.parseISO(fromDateRepr(trans.date)))
: currentMonth;

// Make sure the month selects are at least populates with a
// year's worth of months. We can undo this when we have fancier
// date selects.
const yearAgo = monthUtils.subMonths(monthUtils.currentMonth(), 12);
if (earliestMonth > yearAgo) {
earliestMonth = yearAgo;
}

const allMonths = monthUtils
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy'),
}))
.reverse();

setAllMonths(allMonths);
}
run();
}, []);

function onChangeDates(start, end) {
setStart(start);
setEnd(end);
}

if (!allMonths || !graphData) {
return null;
}

return (
<View style={[styles.page, { minWidth: 650, overflow: 'hidden' }]}>
<Header
title="Category Spending"
allMonths={allMonths}
start={start}
end={end}
onChangeDates={onChangeDates}
category={selectedCategoryId}
categoryGroups={categoryGroups}
onChangeCategory={setSelectedCategoryId}
numberOfMonths={numberOfMonthsAverage}
numberOfMonthsOptions={[1, 3, 6, 12]}
onChangeNumberOfMonths={setNumberOfMonthsAverage}
/>

<View
style={{
backgroundColor: 'white',
padding: 30,
paddingTop: 0,
overflow: 'auto',
}}
>
<View
style={{
paddingTop: 20,
paddingRight: 20,
flexShrink: 0,
alignItems: 'flex-end',
color: colors.n3,
}}
>
<AlignedText
style={{ marginBottom: 5, minWidth: 160 }}
left={<Block>Budgeted:</Block>}
right={
<Text style={{ fontWeight: 600 }}>
{integerToCurrency(
graphData.length === 0
? 0
: graphData.reduce(
(total, month) => total + month.budgeted,
0,
),
)}
</Text>
}
/>
<AlignedText
style={{ marginBottom: 5, minWidth: 160 }}
left={<Block>Spent:</Block>}
right={
<Text style={{ fontWeight: 600 }}>
{integerToCurrency(
graphData.length === 0
? 0
: graphData.reduce(
(total, month) => total + month.total,
0,
),
)}
</Text>
}
/>
<AlignedText
style={{ marginBottom: 5, minWidth: 160 }}
left={<Block>Average:</Block>}
right={
<Text style={{ fontWeight: 600 }}>
{integerToCurrency(
Math.round(
graphData.length === 0
? 0
: graphData.reduce(
(total, month) => total + month.total,
0,
) / graphData.length,
),
)}
</Text>
}
/>
</View>

<CategorySpendingGraph start={start} end={end} graphData={graphData} />
</View>
</View>
);
}

export default connect(
// Deleting this leads to "Uncaught TypeError: dispatch is not a function"
state => ({}),
dispatch => bindActionCreators(actions, dispatch),
)(CategoryAverage);
51 changes: 49 additions & 2 deletions packages/desktop-client/src/components/reports/Header.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as monthUtils from 'loot-core/src/shared/months';

import ArrowLeft from '../../icons/v1/ArrowLeft';
import { styles } from '../../style';
import CategoryAutocomplete from '../autocomplete/CategorySelect';
import { View, Button, ButtonLink, Select } from '../common';
import { FilterButton, AppliedFilters } from '../filters/FiltersMenu';

Expand Down Expand Up @@ -57,6 +58,12 @@ function Header({
onUpdateFilter,
onDeleteFilter,
onCondOpChange,
category,
categoryGroups,
onChangeCategory,
numberOfMonths,
numberOfMonthsOptions,
onChangeNumberOfMonths,
}) {
return (
<View
Expand All @@ -83,6 +90,46 @@ function Header({
gap: 15,
}}
>
{categoryGroups && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
}}
>
<CategoryAutocomplete
categoryGroups={categoryGroups}
value={category}
inputProps={{
placeholder: 'Select category...',
}}
onSelect={newValue => onChangeCategory(newValue)}
/>
</View>
)}

{numberOfMonthsOptions && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 5,
}}
>
<View>Average</View>
<Select
style={{ backgroundColor: 'white' }}
onChange={onChangeNumberOfMonths}
value={numberOfMonths}
options={numberOfMonthsOptions.map(number => [
number,
number === 1 ? 'No averaging' : `${number} months`,
])}
/>
</View>
)}

<View
style={{
flexDirection: 'row',
Expand Down Expand Up @@ -110,7 +157,7 @@ function Header({
/>
</View>

<FilterButton onApply={onApply} />
{filters && <FilterButton onApply={onApply} />}

{show1Month && (
<Button bare onClick={() => onChangeDates(...getLatestRange(1))}>
Expand All @@ -130,7 +177,7 @@ function Header({
All Time
</Button>
</View>
{filters.length > 0 && (
{filters && filters.length > 0 && (
<View
style={{ marginTop: 5 }}
spacing={2}
Expand Down
1 change: 1 addition & 0 deletions packages/desktop-client/src/components/reports/Overview.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ function Overview({ accounts }) {
>
<NetWorthCard accounts={accounts} />
<CashFlowCard />
<AnchorLink to="/reports/category-spending">Foo</AnchorLink>
</View>

<View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { Route, Routes } from 'react-router-dom';

import CashFlow from './CashFlow';
import CategorySpending from './CategorySpending';
import NetWorth from './NetWorth';
import Overview from './Overview';

Expand All @@ -11,6 +12,7 @@ export function ReportRouter() {
<Route path="/" element={<Overview />} />
<Route path="/net-worth" element={<NetWorth />} />
<Route path="/cash-flow" element={<CashFlow />} />
<Route path="/category-spending" element={<CategorySpending />} />
</Routes>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import * as d from 'date-fns';
import {
VictoryArea,
VictoryAxis,
VictoryChart,
VictoryVoronoiContainer,
} from 'victory';

import theme from '../chart-theme';
import Container from '../Container';
import Tooltip from '../Tooltip';

import { type CategorySpendingGraphData } from './category-spending-spreadsheet';
import { Area } from './common';

type CategorySpendingGraphProps = {
start: string;
end: string;
graphData: CategorySpendingGraphData;
};
function CategorySpendingGraph({
start,
end,
graphData,
}: CategorySpendingGraphProps) {
return (
<Container>
{(width, height, portalHost) => (
<VictoryChart
scale={{ x: 'time', y: 'linear' }}
theme={theme}
domainPadding={{ x: 0, y: 10 }}
width={width}
height={height}
containerComponent={<VictoryVoronoiContainer voronoiDimension="x" />}
>
<Area start={start} end={end} />
<VictoryArea
data={graphData}
labelComponent={<Tooltip portalHost={portalHost} />}
labels={item => item.premadeLabel}
style={{
data: {
clipPath: 'url(#positive)',
fill: 'url(#positive-gradient)',
},
}}
/>
<VictoryArea
data={graphData}
style={{
data: {
clipPath: 'url(#negative)',
fill: 'url(#negative-gradient)',
stroke: theme.colors.red,
strokeLinejoin: 'round',
},
}}
/>
<VictoryAxis
style={{ ticks: { stroke: 'red' } }}
// eslint-disable-next-line rulesdir/typography
tickFormat={x => d.format(x, "MMM ''yy")}
tickValues={graphData.map(item => item.x)}
tickCount={Math.max(1, Math.min(5, graphData.length))}
offsetY={50}
orientation="bottom"
/>
<VictoryAxis dependentAxis crossAxis={false} />
</VictoryChart>
)}
</Container>
);
}

export default CategorySpendingGraph;
Loading

0 comments on commit f306787

Please sign in to comment.