From e96990f5c2a7bc9167f33be63990f799eadcbcd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20L=C3=B6thberg?= 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 b4ec6543684..ebf5c73f615 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 7e9c6e792f2..4ce0c313ac2 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 858d6f55e07..c10221373e7 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; +}; + +export type CategoryListProps = { + items: Array; getItemProps?: (arg: { item }) => Partial>; 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 00000000000..a891d22dd71 --- /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; + selectedCategories: CategoryListProps['items']; + setSelectedCategories: (selectedCategories: Category[]) => null; +}; + +export default function CategorySelector({ + categoryGroups, + selectedCategories, + setSelectedCategories, +}: CategorySelectorProps) { + const [uncheckedHidden, setUncheckedHidden] = useState(false); + + return ( + <> +
+ +
+
    + {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 ( + <> +
  • + { + const selectedCategoriesExcludingGroupCategories = + selectedCategories.filter( + selectedCategory => + !categoryGroup.categories.some( + groupCategory => + groupCategory.id === selectedCategory.id, + ), + ); + if (allCategoriesInGroupSelected) { + setSelectedCategories( + selectedCategoriesExcludingGroupCategories, + ); + } else { + setSelectedCategories( + selectedCategoriesExcludingGroupCategories.concat( + categoryGroup.categories, + ), + ); + } + }} + /> + +
  • +
  • +
      + {categoryGroup.categories.map((category, index) => { + const isChecked = selectedCategories.some( + selectedCategory => selectedCategory.id === category.id, + ); + return ( +
    • + { + if (isChecked) { + setSelectedCategories( + selectedCategories.filter( + selectedCategory => + selectedCategory.id !== category.id, + ), + ); + } else { + setSelectedCategories([ + ...selectedCategories, + category, + ]); + } + }} + /> + +
    • + ); + })} +
    +
  • + + ); + })} +
+ + ); +} 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 00000000000..fd8d038bc72 --- /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 = ( + <> + + + + Average: +