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