From e17d90ce5f723d0418da268e43d195ba28362b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= <johannes@kyriasis.com> Date: Mon, 7 Aug 2023 20:04:56 +0200 Subject: [PATCH] Add category spending report (#1382) --- .../src/components/accounts/MobileAccount.js | 8 +- .../src/components/accounts/MobileAccounts.js | 13 +- .../autocomplete/CategorySelect.tsx | 20 +- .../components/reports/CategorySelector.tsx | 171 ++++++++++++++ .../components/reports/CategorySpending.js | 192 ++++++++++++++++ .../src/components/reports/Header.js | 7 +- .../src/components/reports/Overview.js | 85 +++++-- .../src/components/reports/ReportRouter.js | 2 + .../reports/graphs/CategorySpendingGraph.tsx | 76 ++++++ .../reports/graphs/NetWorthGraph.tsx | 64 +----- .../graphs/category-spending-spreadsheet.tsx | 216 ++++++++++++++++++ .../src/components/reports/graphs/common.tsx | 67 ++++++ .../src/components/settings/Experimental.tsx | 4 + .../desktop-client/src/hooks/useCategories.ts | 18 ++ .../src/hooks/useFeatureFlag.ts | 1 + packages/loot-core/src/types/prefs.d.ts | 1 + upcoming-release-notes/1382.md | 6 + 17 files changed, 844 insertions(+), 107 deletions(-) create mode 100644 packages/desktop-client/src/components/reports/CategorySelector.tsx create mode 100644 packages/desktop-client/src/components/reports/CategorySpending.js create mode 100644 packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx create mode 100644 packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx create mode 100644 packages/desktop-client/src/components/reports/graphs/common.tsx create mode 100644 packages/desktop-client/src/hooks/useCategories.ts create mode 100644 upcoming-release-notes/1382.md diff --git a/packages/desktop-client/src/components/accounts/MobileAccount.js b/packages/desktop-client/src/components/accounts/MobileAccount.js index b4ec65436..ebf5c73f6 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccount.js +++ b/packages/desktop-client/src/components/accounts/MobileAccount.js @@ -21,6 +21,7 @@ import { } from 'loot-core/src/shared/transactions'; import { useActions } from '../../hooks/useActions'; +import useCategories from '../../hooks/useCategories'; import { useSetThemeColor } from '../../hooks/useSetThemeColor'; import { colors } from '../../style'; import SyncRefresh from '../SyncRefresh'; @@ -81,7 +82,6 @@ export default function Account(props) { let state = useSelector(state => ({ payees: state.queries.payees, newTransactions: state.queries.newTransactions, - categories: state.queries.categories.list, prefs: state.prefs.local, dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy', })); @@ -134,9 +134,6 @@ export default function Account(props) { } }); - if (state.categories.length === 0) { - await actionCreators.getCategories(); - } if (accounts.length === 0) { await actionCreators.getAccounts(); } @@ -152,6 +149,9 @@ export default function Account(props) { return () => unlisten(); }, []); + // Load categories if necessary. + useCategories(); + const updateSearchQuery = debounce(() => { if (searchText === '' && currentQuery) { updateQuery(currentQuery); diff --git a/packages/desktop-client/src/components/accounts/MobileAccounts.js b/packages/desktop-client/src/components/accounts/MobileAccounts.js index 7e9c6e792..4ce0c313a 100644 --- a/packages/desktop-client/src/components/accounts/MobileAccounts.js +++ b/packages/desktop-client/src/components/accounts/MobileAccounts.js @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import * as queries from 'loot-core/src/client/queries'; import { useActions } from '../../hooks/useActions'; +import useCategories from '../../hooks/useCategories'; import { useSetThemeColor } from '../../hooks/useSetThemeColor'; import { colors, styles } from '../../style'; import Button from '../common/Button'; @@ -233,7 +234,6 @@ export default function Accounts() { let accounts = useSelector(state => state.queries.accounts); let newTransactions = useSelector(state => state.queries.newTransactions); let updatedAccounts = useSelector(state => state.queries.updatedAccounts); - let categories = useSelector(state => state.queries.categories.list); let numberFormat = useSelector( state => state.prefs.local.numberFormat || 'comma-dot', ); @@ -241,19 +241,14 @@ export default function Accounts() { state => state.prefs.local.hideFraction || false, ); - let { getCategories, getAccounts } = useActions(); + const { list: categories } = useCategories(); + let { getAccounts } = useActions(); const transactions = useState({}); const navigate = useNavigate(); useEffect(() => { - (async () => { - if (categories.length === 0) { - await getCategories(); - } - - getAccounts(); - })(); + (async () => getAccounts())(); }, []); // const sync = async () => { diff --git a/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx b/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx index 858d6f55e..c10221373 100644 --- a/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategorySelect.tsx @@ -12,19 +12,21 @@ import View from '../common/View'; import Autocomplete, { defaultFilterSuggestion } from './Autocomplete'; -type CategoryGroup = { +export type Category = { id: string; + cat_group: unknown; + groupName: string; name: string; - categories: Array<{ id: string; name: string }>; }; -type CategoryListProps = { - items: Array<{ - id: string; - cat_group: unknown; - groupName: string; - name: string; - }>; +export type CategoryGroup = { + id: string; + name: string; + categories: Array<Category>; +}; + +export type CategoryListProps = { + items: Array<Category>; getItemProps?: (arg: { item }) => Partial<ComponentProps<typeof View>>; highlightedIndex: number; embedded: boolean; diff --git a/packages/desktop-client/src/components/reports/CategorySelector.tsx b/packages/desktop-client/src/components/reports/CategorySelector.tsx new file mode 100644 index 000000000..a891d22dd --- /dev/null +++ b/packages/desktop-client/src/components/reports/CategorySelector.tsx @@ -0,0 +1,171 @@ +import React, { useState } from 'react'; + +import Eye from '../../icons/v2/Eye'; +import EyeSlashed from '../../icons/v2/EyeSlashed'; +import { + type Category, + type CategoryGroup, + type CategoryListProps, +} from '../autocomplete/CategorySelect'; +import Button from '../common/Button'; +import { Checkbox } from '../forms'; + +type CategorySelectorProps = { + categoryGroups: Array<CategoryGroup>; + selectedCategories: CategoryListProps['items']; + setSelectedCategories: (selectedCategories: Category[]) => null; +}; + +export default function CategorySelector({ + categoryGroups, + selectedCategories, + setSelectedCategories, +}: CategorySelectorProps) { + const [uncheckedHidden, setUncheckedHidden] = useState(false); + + return ( + <> + <div> + <Button + type="bare" + style={{ padding: 4 }} + onClick={e => setUncheckedHidden(!uncheckedHidden)} + > + {uncheckedHidden ? ( + <> + <Eye width={20} height={20} /> + {'Checked'} + </> + ) : ( + <> + <EyeSlashed width={20} height={20} /> + {'All'} + </> + )} + </Button> + </div> + <ul + style={{ + listStyle: 'none', + marginLeft: 0, + paddingLeft: 0, + paddingRight: 10, + }} + > + {categoryGroups && + categoryGroups.map(categoryGroup => { + const allCategoriesInGroupSelected = categoryGroup.categories.every( + category => + selectedCategories.some( + selectedCategory => selectedCategory.id === category.id, + ), + ); + const noCategorySelected = categoryGroup.categories.every( + category => + !selectedCategories.some( + selectedCategory => selectedCategory.id === category.id, + ), + ); + return ( + <> + <li + style={{ + display: + noCategorySelected && uncheckedHidden ? 'none' : 'flex', + marginBottom: 4, + flexDirection: 'row', + }} + key={categoryGroup.id} + > + <Checkbox + id={`form_${categoryGroup.id}`} + checked={allCategoriesInGroupSelected} + onChange={e => { + const selectedCategoriesExcludingGroupCategories = + selectedCategories.filter( + selectedCategory => + !categoryGroup.categories.some( + groupCategory => + groupCategory.id === selectedCategory.id, + ), + ); + if (allCategoriesInGroupSelected) { + setSelectedCategories( + selectedCategoriesExcludingGroupCategories, + ); + } else { + setSelectedCategories( + selectedCategoriesExcludingGroupCategories.concat( + categoryGroup.categories, + ), + ); + } + }} + /> + <label + htmlFor={`form_${categoryGroup.id}`} + style={{ userSelect: 'none', fontWeight: 'bold' }} + > + {categoryGroup.name} + </label> + </li> + <li> + <ul + style={{ + listStyle: 'none', + marginLeft: 0, + marginBottom: 10, + paddingLeft: 10, + }} + > + {categoryGroup.categories.map((category, index) => { + const isChecked = selectedCategories.some( + selectedCategory => selectedCategory.id === category.id, + ); + return ( + <li + key={category.id} + style={{ + display: + !isChecked && uncheckedHidden ? 'none' : 'flex', + flexDirection: 'row', + marginBottom: 2, + }} + > + <Checkbox + id={`form_${category.id}`} + checked={isChecked} + onChange={e => { + if (isChecked) { + setSelectedCategories( + selectedCategories.filter( + selectedCategory => + selectedCategory.id !== category.id, + ), + ); + } else { + setSelectedCategories([ + ...selectedCategories, + category, + ]); + } + }} + /> + <label + htmlFor={`form_${category.id}`} + style={{ userSelect: 'none' }} + > + {category.name} + </label> + </li> + ); + })} + </ul> + </li> + </> + ); + })} + </ul> + </> + ); +} diff --git a/packages/desktop-client/src/components/reports/CategorySpending.js b/packages/desktop-client/src/components/reports/CategorySpending.js new file mode 100644 index 000000000..fd8d038bc --- /dev/null +++ b/packages/desktop-client/src/components/reports/CategorySpending.js @@ -0,0 +1,192 @@ +import React, { useState, useEffect, useMemo } from 'react'; + +import * as d from 'date-fns'; + +import { send } from 'loot-core/src/platform/client/fetch'; +import * as monthUtils from 'loot-core/src/shared/months'; + +import useCategories from '../../hooks/useCategories'; +import Filter from '../../icons/v2/Filter2'; +import { styles } from '../../style'; +import Button from '../common/Button'; +import Select from '../common/Select'; +import View from '../common/View'; + +import CategorySelector from './CategorySelector'; +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() { + const categories = useCategories(); + + const [selectedCategories, setSelectedCategories] = useState(null); + const [categorySelectorVisible, setCategorySelectorVisible] = useState(false); + + const [allMonths, setAllMonths] = useState(null); + + const [start, setStart] = useState( + monthUtils.subMonths(monthUtils.currentMonth(), 5), + ); + const [end, setEnd] = useState(monthUtils.currentMonth()); + + const [numberOfMonthsAverage, setNumberOfMonthsAverage] = useState(1); + + useEffect(() => { + if (selectedCategories === null && categories.list.length !== 0) { + setSelectedCategories(categories.list); + } + }, [categories, selectedCategories]); + + const getGraphData = useMemo(() => { + return categorySpendingSpreadsheet( + start, + end, + numberOfMonthsAverage, + (categories.list || []).filter( + category => + !category.is_income && + !category.hidden && + selectedCategories && + selectedCategories.some( + selectedCategory => selectedCategory.id === category.id, + ), + ), + ); + }, [start, end, numberOfMonthsAverage, categories, selectedCategories]); + const perCategorySpending = useReport('category_spending', getGraphData); + + 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 || !perCategorySpending) { + return null; + } + + const numberOfMonthsOptions = [ + { value: 1, description: 'No averaging' }, + { value: 3, description: '3 months' }, + { value: 6, description: '6 months' }, + { value: 12, description: '12 months' }, + { value: -1, description: 'All time' }, + ]; + const numberOfMonthsLine = numberOfMonthsOptions.length - 1; + + const headerPrefixItems = ( + <> + <Button + type="bare" + onClick={() => setCategorySelectorVisible(!categorySelectorVisible)} + > + <Filter + width={14} + height={14} + style={{ opacity: categorySelectorVisible ? 0.6 : 1 }} + /> + </Button> + + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 5, + }} + > + <View>Average: </View> + <Select + style={{ backgroundColor: 'white' }} + onChange={setNumberOfMonthsAverage} + value={numberOfMonthsAverage} + options={numberOfMonthsOptions.map(number => [ + number.value, + number.description, + ])} + line={numberOfMonthsLine} + /> + </View> + </> + ); + + return ( + <View style={[styles.page, { overflow: 'hidden' }]}> + <Header + title="Category Spending" + allMonths={allMonths} + start={start} + end={end} + onChangeDates={onChangeDates} + headerPrefixItems={headerPrefixItems} + /> + <View + style={{ display: 'flex', flexDirection: 'row', padding: 15, gap: 15 }} + > + <View + style={{ + height: '360', + overflowY: 'scroll', + width: !categorySelectorVisible ? 0 : 'auto', + }} + > + <CategorySelector + categoryGroups={categories.grouped.filter( + categoryGroup => !categoryGroup.is_income, + )} + selectedCategories={selectedCategories} + setSelectedCategories={setSelectedCategories} + /> + </View> + + <View + style={{ + flexGrow: 1, + backgroundColor: 'white', + padding: 30, + overflow: 'auto', + transition: 'flex-grow .3s linear', + }} + > + <CategorySpendingGraph + start={start} + end={end} + graphData={perCategorySpending} + /> + </View> + </View> + </View> + ); +} + +export default CategoryAverage; diff --git a/packages/desktop-client/src/components/reports/Header.js b/packages/desktop-client/src/components/reports/Header.js index b46aeba22..2973e17d4 100644 --- a/packages/desktop-client/src/components/reports/Header.js +++ b/packages/desktop-client/src/components/reports/Header.js @@ -60,6 +60,7 @@ function Header({ onUpdateFilter, onDeleteFilter, onCondOpChange, + headerPrefixItems, }) { return ( <View @@ -86,6 +87,8 @@ function Header({ gap: 15, }} > + {headerPrefixItems} + <View style={{ flexDirection: 'row', @@ -113,7 +116,7 @@ function Header({ /> </View> - <FilterButton onApply={onApply} /> + {filters && <FilterButton onApply={onApply} />} {show1Month && ( <Button @@ -142,7 +145,7 @@ function Header({ All Time </Button> </View> - {filters.length > 0 && ( + {filters && filters.length > 0 && ( <View style={{ marginTop: 5 }} spacing={2} diff --git a/packages/desktop-client/src/components/reports/Overview.js b/packages/desktop-client/src/components/reports/Overview.js index b4e4a7ed8..790feeb55 100644 --- a/packages/desktop-client/src/components/reports/Overview.js +++ b/packages/desktop-client/src/components/reports/Overview.js @@ -6,6 +6,8 @@ import { VictoryBar, VictoryGroup, VictoryVoronoiContainer } from 'victory'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import useCategories from '../../hooks/useCategories'; +import useFeatureFlag from '../../hooks/useFeatureFlag'; import { colors, styles } from '../../style'; import AnchorLink from '../common/AnchorLink'; import Block from '../common/Block'; @@ -17,6 +19,8 @@ import theme from './chart-theme'; import Container from './Container'; import DateRange from './DateRange'; import { simpleCashFlow } from './graphs/cash-flow-spreadsheet'; +import categorySpendingSpreadsheet from './graphs/category-spending-spreadsheet'; +import CategorySpendingGraph from './graphs/CategorySpendingGraph'; import netWorthSpreadsheet from './graphs/net-worth-spreadsheet'; import NetWorthGraph from './graphs/NetWorthGraph'; import Tooltip from './Tooltip'; @@ -251,7 +255,57 @@ function CashFlowCard() { ); } +function CategorySpendingCard() { + const categories = useCategories(); + + const end = monthUtils.currentDay(); + const start = monthUtils.subMonths(end, 3); + + const params = useMemo(() => { + return categorySpendingSpreadsheet( + start, + end, + 3, + (categories.list || []).filter( + category => !category.is_income && !category.hidden, + ), + ); + }, [start, end, categories]); + + const perCategorySpending = useReport('category_spending', params); + + return ( + <Card flex={1} to="/reports/category-spending"> + <View> + <View style={{ flexDirection: 'row', padding: '20px 20px 0' }}> + <View style={{ flex: 1 }}> + <Block + style={[styles.mediumText, { fontWeight: 500, marginBottom: 5 }]} + role="heading" + > + Spending + </Block> + <DateRange start={start} end={end} /> + </View> + </View> + </View> + {!perCategorySpending ? null : ( + <CategorySpendingGraph + start={start} + end={end} + graphData={perCategorySpending} + compact={true} + /> + )} + </Card> + ); +} + export default function Overview() { + let categorySpendingReportFeatureFlag = useFeatureFlag( + 'categorySpendingReport', + ); + let accounts = useSelector(state => state.queries.accounts); return ( <View @@ -270,27 +324,18 @@ export default function Overview() { <CashFlowCard /> </View> - <View - style={{ - flex: '0 0 auto', - flexDirection: 'row', - }} - > - <Card - style={[ - { - color: '#a0a0a0', - justifyContent: 'center', - alignItems: 'center', - width: 200, - }, - styles.mediumText, - ]} + {categorySpendingReportFeatureFlag && ( + <View + style={{ + flex: '0 0 auto', + flexDirection: 'row', + }} > - More reports - <br /> coming soon! - </Card> - </View> + <CategorySpendingCard /> + <div style={{ flex: 1 }} /> + <div style={{ flex: 1 }} /> + </View> + )} </View> ); } diff --git a/packages/desktop-client/src/components/reports/ReportRouter.js b/packages/desktop-client/src/components/reports/ReportRouter.js index 745a198c9..58a52a095 100644 --- a/packages/desktop-client/src/components/reports/ReportRouter.js +++ b/packages/desktop-client/src/components/reports/ReportRouter.js @@ -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'; @@ -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> ); } diff --git a/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx b/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx new file mode 100644 index 000000000..a5dfdb8db --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx @@ -0,0 +1,76 @@ +import * as d from 'date-fns'; +import { VictoryAxis, VictoryBar, VictoryChart, VictoryStack } 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; + compact?: boolean; +}; +function CategorySpendingGraph({ + start, + end, + graphData, + compact, +}: CategorySpendingGraphProps) { + if (!graphData || !graphData.data) { + return; + } + + return ( + <Container style={compact && { height: 'auto', flex: 1 }}> + {(width, height, portalHost) => ( + <VictoryChart + scale={{ x: 'time', y: 'linear' }} + theme={theme} + domainPadding={compact ? { x: 10, y: 5 } : { x: 50, y: 10 }} + width={width} + height={height} + > + <Area start={start} end={end} /> + <VictoryStack colorScale="qualitative"> + {graphData.categories.map(category => ( + <VictoryBar + key={category.id} + data={graphData.data[category.id]} + labelComponent={ + !compact ? <Tooltip portalHost={portalHost} /> : undefined + } + labels={item => item.premadeLabel} + /> + ))} + </VictoryStack> + {!compact && ( + <VictoryAxis + style={{ ticks: { stroke: 'red' } }} + // eslint-disable-next-line rulesdir/typography + tickFormat={x => d.format(x, "MMM ''yy")} + tickValues={graphData.tickValues} + tickCount={Math.max( + 1, + Math.min(width > 760 ? 12 : 5, graphData.tickValues.length), + )} + offsetY={50} + orientation="bottom" + /> + )} + <VictoryAxis + dependentAxis + crossAxis={false} + invertAxis + tickCount={compact ? 2 : undefined} + /> + </VictoryChart> + )} + </Container> + ); +} + +export default CategorySpendingGraph; diff --git a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx index 3eebc6c9d..5990ffa84 100644 --- a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx @@ -15,69 +15,7 @@ import theme from '../chart-theme'; import Container from '../Container'; import Tooltip from '../Tooltip'; -type AreaProps = { - start: string; - end: string; - scale?; - range?; -}; -function Area({ start, end, scale, range }: AreaProps) { - const zero = scale.y(0); - - const startX = scale.x(d.parseISO(start + '-01')); - const endX = scale.x(d.parseISO(end + '-01')); - - if (startX < 0 || endX < 0) { - return null; - } - - return ( - <svg> - <defs> - <clipPath id="positive"> - <rect - x={startX} - y={range.y[1]} - width={endX - startX} - height={zero - range.y[1] + 1} - fill="#ffffff" - /> - </clipPath> - <clipPath id="negative"> - <rect - x={startX} - y={zero + 1} - width={endX - startX} - height={Math.max(range.y[0] - zero - 1, 0)} - fill="#ffffff" - /> - </clipPath> - <linearGradient - id="positive-gradient" - gradientUnits="userSpaceOnUse" - x1={0} - y1={range.y[1]} - x2={0} - y2={zero} - > - <stop offset="0%" stopColor={theme.colors.blueFadeStart} /> - <stop offset="100%" stopColor={theme.colors.blueFadeEnd} /> - </linearGradient> - <linearGradient - id="negative-gradient" - gradientUnits="userSpaceOnUse" - x1={0} - y1={zero} - x2={0} - y2={range.y[0]} - > - <stop offset="0%" stopColor={theme.colors.redFadeEnd} /> - <stop offset="100%" stopColor={theme.colors.redFadeStart} /> - </linearGradient> - </defs> - </svg> - ); -} +import { Area } from './common'; type NetWorthGraphProps = { style?: CSSProperties; diff --git a/packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx b/packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx new file mode 100644 index 000000000..9dde112f7 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx @@ -0,0 +1,216 @@ +import React from 'react'; + +import * as d from 'date-fns'; + +import { rolloverBudget } from 'loot-core/src/client/queries'; +import q, { runQuery } from 'loot-core/src/client/query-helpers'; +import * as monthUtils from 'loot-core/src/shared/months'; +import { integerToAmount, integerToCurrency } from 'loot-core/src/shared/util'; + +import AlignedText from '../../common/AlignedText'; + +type CategoryGraphDataForMonth = { + x: number; + y: number; + premadeLabel: JSX.Element; + + average: number; + budgeted: number; + total: number; +}; + +export type CategorySpendingGraphData = { + categories: Category[]; + tickValues: number[]; + data: { [category: string]: CategoryGraphDataForMonth[] }; +}; + +type Category = { + id: string; + name: string; +}; + +type CategoryBudgetForMonth = { + budgeted: number; + sumAmount: number; + balance: number; +}; +type CategoryBudgetPerMonth = { + [month: string]: CategoryBudgetForMonth; +}; + +export default function createSpreadsheet( + start: string | null, + end: string | null, + numberOfMonthsAverage: number, + categories: Category[], +) { + return async ( + spreadsheet: { + get: (sheet: string, cell: string) => Promise<{ value: number }>; + }, + setData: (graphData: CategorySpendingGraphData) => void, + ) => { + if (start === null || end === null || categories.length === 0) { + setData({ categories: [], tickValues: [], data: {} }); + return; + } + + // Calculate the range of months that we will return data for. This will + // contain more months than the specified start-end range in case we're + // averaging data. + let months: string[]; + if (numberOfMonthsAverage === -1) { + // `numberOfMonthsAverage` is set to -1 to mean "all time." + const firstTransaction = await runQuery( + q('transactions') + .filter({ + $or: categories.map(category => ({ category: category.id })), + }) + .orderBy({ date: 'asc' }) + .limit(1) + .select('date'), + ); + if (firstTransaction.data.length === 0) { + setData({ categories: [], tickValues: [], data: {} }); + return; + } + + months = monthUtils.rangeInclusive( + monthUtils.monthFromDate(firstTransaction.data[0].date), + end, + ); + } else { + months = monthUtils.rangeInclusive( + monthUtils.subMonths(start, numberOfMonthsAverage), + end, + ); + } + + const budgetForMonth = async ( + sheet: string, + category: Category, + ): Promise<CategoryBudgetForMonth> => + Promise.all([ + spreadsheet + .get(sheet, rolloverBudget.catBudgeted(category.id)) + .then((cell: { value: number }) => cell.value ?? 0), + spreadsheet + .get(sheet, rolloverBudget.catSumAmount(category.id)) + .then((cell: { value: number }) => cell.value ?? 0), + spreadsheet + .get(sheet, rolloverBudget.catBalance(category.id)) + .then((cell: { value: number }) => cell.value ?? 0), + ]).then(([budgeted, sumAmount, balance]) => ({ + budgeted, + sumAmount, + balance, + })); + + const budgetPerMonth = async ( + category: Category, + ): Promise<CategoryBudgetPerMonth> => + months.reduce( + async (perMonth, month) => ({ + ...(await perMonth), + [month]: await budgetForMonth( + monthUtils.sheetForMonth(month), + category, + ), + }), + Promise.resolve({}), + ); + + const data: { [category: string]: CategoryGraphDataForMonth[] } = + await categories.reduce( + async (perCategory, category) => ({ + ...(await perCategory), + [category.id]: await budgetPerMonth(category).then(perMonth => + recalculate(start, end, category, numberOfMonthsAverage, perMonth), + ), + }), + Promise.resolve({}), + ); + + setData({ + categories, + tickValues: data[categories[0].id].map(item => item.x), + data, + }); + }; +} + +function recalculate( + start: string, + end: string, + category: Category, + numberOfMonthsAverage: number, + budgetPerMonth: CategoryBudgetPerMonth, +): CategoryGraphDataForMonth[] { + const months = monthUtils.rangeInclusive(start, end); + const [averagedData, _] = months.reduce( + ([arr, idx], month) => { + const thisMonth = budgetPerMonth[month]; + const x = d.parseISO(`${month}-01`); + + const months = numberOfMonthsAverage === -1 ? idx : numberOfMonthsAverage; + const sumAmounts = []; + for (let i = 0; i < months; i++) { + sumAmounts.push( + budgetPerMonth[monthUtils.subMonths(month, i)].sumAmount, + ); + } + const average = sumAmounts.reduce((a, b) => a + b) / sumAmounts.length; + + const label = ( + <div> + <div style={{ marginBottom: 10 }}> + <strong>{category.name}</strong> + </div> + <div style={{ lineHeight: 1.5 }}> + {numberOfMonthsAverage !== 0 && ( + <> + <AlignedText + left="Average:" + right={integerToCurrency(Math.round(average))} + /> + <hr /> + </> + )} + <AlignedText + left="Budgeted:" + right={integerToCurrency(thisMonth.budgeted)} + /> + <AlignedText + left="Spent:" + right={integerToCurrency(thisMonth.sumAmount)} + /> + <AlignedText + left="Balance:" + right={integerToCurrency(thisMonth.balance)} + /> + </div> + </div> + ); + + return [ + [ + ...arr, + { + x, + y: integerToAmount(Math.round(average)), + premadeLabel: label, + + average, + budgeted: thisMonth.budgeted, + total: thisMonth.sumAmount, + }, + ], + idx + 1, + ]; + }, + [[], 1], + ); + + return averagedData; +} diff --git a/packages/desktop-client/src/components/reports/graphs/common.tsx b/packages/desktop-client/src/components/reports/graphs/common.tsx new file mode 100644 index 000000000..4c384cf54 --- /dev/null +++ b/packages/desktop-client/src/components/reports/graphs/common.tsx @@ -0,0 +1,67 @@ +import * as d from 'date-fns'; + +import theme from '../chart-theme'; + +type AreaProps = { + start: string; + end: string; + scale?; + range?; +}; +export function Area({ start, end, scale, range }: AreaProps) { + const zero = scale.y(0); + + const startX = scale.x(d.parseISO(start + '-01')); + const endX = scale.x(d.parseISO(end + '-01')); + + if (startX < 0 || endX < 0) { + return null; + } + + return ( + <svg> + <defs> + <clipPath id="positive"> + <rect + x={startX} + y={range.y[1]} + width={endX - startX} + height={zero - range.y[1] + 1} + fill="#ffffff" + /> + </clipPath> + <clipPath id="negative"> + <rect + x={startX} + y={zero + 1} + width={endX - startX} + height={Math.max(range.y[0] - zero - 1, 0)} + fill="#ffffff" + /> + </clipPath> + <linearGradient + id="positive-gradient" + gradientUnits="userSpaceOnUse" + x1={0} + y1={range.y[1]} + x2={0} + y2={zero} + > + <stop offset="0%" stopColor={theme.colors.blueFadeStart} /> + <stop offset="100%" stopColor={theme.colors.blueFadeEnd} /> + </linearGradient> + <linearGradient + id="negative-gradient" + gradientUnits="userSpaceOnUse" + x1={0} + y1={zero} + x2={0} + y2={range.y[0]} + > + <stop offset="0%" stopColor={theme.colors.redFadeEnd} /> + <stop offset="100%" stopColor={theme.colors.redFadeStart} /> + </linearGradient> + </defs> + </svg> + ); +} diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 7c989a19a..dd32aaef1 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -89,6 +89,10 @@ export default function ExperimentalFeatures() { primaryAction={ expanded ? ( <View style={{ gap: '1em' }}> + <FeatureToggle flag="categorySpendingReport"> + Category spending report + </FeatureToggle> + <ReportBudgetFeature /> <FeatureToggle flag="goalTemplatesEnabled"> diff --git a/packages/desktop-client/src/hooks/useCategories.ts b/packages/desktop-client/src/hooks/useCategories.ts new file mode 100644 index 000000000..548180acf --- /dev/null +++ b/packages/desktop-client/src/hooks/useCategories.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; + +import { useActions } from './useActions'; + +export default function useCategories() { + const { getCategories } = useActions(); + + const categories = useSelector(state => state.queries.categories.list); + + useEffect(() => { + if (categories.length === 0) { + getCategories(); + } + }, []); + + return useSelector(state => state.queries.categories); +} diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts index fcb97731b..39feda954 100644 --- a/packages/desktop-client/src/hooks/useFeatureFlag.ts +++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import type { FeatureFlag } from 'loot-core/src/types/prefs'; const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = { + categorySpendingReport: false, reportBudget: false, goalTemplatesEnabled: false, privacyMode: false, diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index 4906c326e..ece250533 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -1,6 +1,7 @@ import { type numberFormats } from '../shared/util'; export type FeatureFlag = + | 'categorySpendingReport' | 'reportBudget' | 'goalTemplatesEnabled' | 'privacyMode' diff --git a/upcoming-release-notes/1382.md b/upcoming-release-notes/1382.md new file mode 100644 index 000000000..3372e2861 --- /dev/null +++ b/upcoming-release-notes/1382.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [kyrias, ovbm] +--- + +Add category spending report. -- GitLab