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..5023b777b74
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/CategorySpending.js
@@ -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 (
+
+
+
+
+
+ Budgeted:}
+ right={
+
+ {integerToCurrency(
+ graphData.length === 0
+ ? 0
+ : graphData.reduce(
+ (total, month) => total + month.budgeted,
+ 0,
+ ),
+ )}
+
+ }
+ />
+ Spent:}
+ right={
+
+ {integerToCurrency(
+ graphData.length === 0
+ ? 0
+ : graphData.reduce(
+ (total, month) => total + month.total,
+ 0,
+ ),
+ )}
+
+ }
+ />
+ Average:}
+ right={
+
+ {integerToCurrency(
+ Math.round(
+ graphData.length === 0
+ ? 0
+ : graphData.reduce(
+ (total, month) => total + month.total,
+ 0,
+ ) / graphData.length,
+ ),
+ )}
+
+ }
+ />
+
+
+
+
+
+ );
+}
+
+export default connect(
+ // Deleting this leads to "Uncaught TypeError: dispatch is not a function"
+ state => ({}),
+ dispatch => bindActionCreators(actions, dispatch),
+)(CategoryAverage);
diff --git a/packages/desktop-client/src/components/reports/Header.js b/packages/desktop-client/src/components/reports/Header.js
index 70dc7d054ca..afd99c0f595 100644
--- a/packages/desktop-client/src/components/reports/Header.js
+++ b/packages/desktop-client/src/components/reports/Header.js
@@ -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';
@@ -57,6 +58,12 @@ function Header({
onUpdateFilter,
onDeleteFilter,
onCondOpChange,
+ category,
+ categoryGroups,
+ onChangeCategory,
+ numberOfMonths,
+ numberOfMonthsOptions,
+ onChangeNumberOfMonths,
}) {
return (
+ {categoryGroups && (
+
+ onChangeCategory(newValue)}
+ />
+
+ )}
+
+ {numberOfMonthsOptions && (
+
+ Average
+
+ )}
+
-
+ {filters && }
{show1Month && (
- {filters.length > 0 && (
+ {filters && filters.length > 0 && (
+ Foo
} />
} />
} />
+ } />
);
}
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 00000000000..1d84d8559f9
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/graphs/CategorySpendingGraph.tsx
@@ -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 (
+
+ {(width, height, portalHost) => (
+ }
+ >
+
+ }
+ labels={item => item.premadeLabel}
+ style={{
+ data: {
+ clipPath: 'url(#positive)',
+ fill: 'url(#positive-gradient)',
+ },
+ }}
+ />
+
+ d.format(x, "MMM ''yy")}
+ tickValues={graphData.map(item => item.x)}
+ tickCount={Math.max(1, Math.min(5, graphData.length))}
+ offsetY={50}
+ orientation="bottom"
+ />
+
+
+ )}
+
+ );
+}
+
+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 3eebc6c9d83..5990ffa84b0 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 (
-
- );
-}
+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 00000000000..176cb53ec64
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/graphs/category-spending-spreadsheet.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+
+import * as d from 'date-fns';
+
+import { rolloverBudget } from 'loot-core/src/client/queries';
+import * as monthUtils from 'loot-core/src/shared/months';
+import { integerToAmount, integerToCurrency } from 'loot-core/src/shared/util';
+
+import { AlignedText } from '../../common';
+
+export type CategorySpendingGraphData = {
+ x: number;
+ y: number;
+ premadeLabel: JSX.Element;
+
+ month: string;
+ average: number;
+ budgeted: number;
+ total: number;
+}[];
+
+type MonthlyBudget = {
+ month: string;
+ budgeted: number;
+ sumAmount: number;
+ balance: number;
+};
+
+export default function createSpreadsheet(
+ start: string | null,
+ end: string | null,
+ numberOfMonthsAverage: number,
+ categoryId: string | null,
+) {
+ return async (
+ spreadsheet,
+ setData: (graphData: CategorySpendingGraphData) => void,
+ ) => {
+ if (start === null || end === null || categoryId === null) {
+ setData([]);
+ return;
+ }
+
+ const months = monthUtils.rangeInclusive(
+ monthUtils.subMonths(start, numberOfMonthsAverage),
+ end,
+ );
+
+ const data: MonthlyBudget[] = await Promise.all(
+ months.map(async month => {
+ const sheet = monthUtils.sheetForMonth(month);
+
+ return Promise.all([
+ spreadsheet
+ .get(sheet, rolloverBudget.catBudgeted(categoryId))
+ .then(cell => cell.value ?? 0),
+ spreadsheet
+ .get(sheet, rolloverBudget.catSumAmount(categoryId))
+ .then(cell => cell.value ?? 0),
+ spreadsheet
+ .get(sheet, rolloverBudget.catBalance(categoryId))
+ .then(cell => cell.value ?? 0),
+ ]).then(([budgeted, sumAmount, balance]) => ({
+ month,
+ budgeted,
+ sumAmount,
+ balance,
+ }));
+ }),
+ );
+
+ setData(recalculate(start, end, numberOfMonthsAverage, data));
+ };
+}
+
+function recalculate(
+ start: string,
+ end: string,
+ numberOfMonthsAverage: number,
+ data: MonthlyBudget[],
+): CategorySpendingGraphData {
+ const budgetPerMonth = data.reduce(
+ (obj, { month, budgeted, sumAmount, balance }) => ({
+ ...obj,
+ [month]: { budgeted, sumAmount, balance },
+ }),
+ {},
+ );
+
+ const months = monthUtils.rangeInclusive(start, end);
+ return months.reduce((arr, month) => {
+ const thisMonth = budgetPerMonth[month];
+ const x = d.parseISO(`${month}-01`);
+
+ const sumAmounts = [];
+ for (let i = 0; i < numberOfMonthsAverage; i++) {
+ sumAmounts.push(budgetPerMonth[monthUtils.subMonths(month, i)].sumAmount);
+ }
+ const average = sumAmounts.reduce((a, b) => a + b) / sumAmounts.length;
+
+ const label = (
+
+
+ {d.format(x, 'MMMM yyyy')}
+
+
+
+ );
+
+ return [
+ ...arr,
+ {
+ x,
+ y: integerToAmount(Math.round(average)),
+ premadeLabel: label,
+
+ month,
+ average,
+ budgeted: thisMonth.budgeted,
+ total: thisMonth.sumAmount,
+ },
+ ];
+ }, []);
+}
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 00000000000..4c384cf54e2
--- /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 (
+
+ );
+}