From 04bc0c3c64815ff3e980b996be39805b8b9a7386 Mon Sep 17 00:00:00 2001
From: Neil <55785687+carkom@users.noreply.github.com>
Date: Fri, 3 May 2024 20:34:21 +0100
Subject: [PATCH] Monthly spending report (#2622)

* monthly Spending

* Add Average

* notes

* title

* date filter

* TS fixes and hide average when no data

* fix average tooltip and relabel x-axis

* Wording/verbiage

* filters changes

* feature flag

* networth card fix
---
 .../src/components/reports/Header.jsx         |   2 +-
 .../src/components/reports/Overview.jsx       |   3 +
 .../src/components/reports/ReportRouter.jsx   |   2 +
 .../reports/graphs/SpendingGraph.tsx          | 303 ++++++++++++++++++
 .../components/reports/reports/Spending.tsx   | 286 +++++++++++++++++
 .../reports/reports/SpendingCard.tsx          |  86 +++++
 .../spreadsheets/spending-spreadsheet.ts      | 180 +++++++++++
 .../src/components/settings/Experimental.tsx  |   3 +
 .../src/hooks/useFeatureFlag.ts               |   1 +
 .../loot-core/src/types/models/reports.d.ts   |  33 ++
 packages/loot-core/src/types/prefs.d.ts       |   1 +
 upcoming-release-notes/2622.md                |   6 +
 12 files changed, 905 insertions(+), 1 deletion(-)
 create mode 100644 packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx
 create mode 100644 packages/desktop-client/src/components/reports/reports/Spending.tsx
 create mode 100644 packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
 create mode 100644 packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
 create mode 100644 upcoming-release-notes/2622.md

diff --git a/packages/desktop-client/src/components/reports/Header.jsx b/packages/desktop-client/src/components/reports/Header.jsx
index 3df44ca88..f9ad57bb9 100644
--- a/packages/desktop-client/src/components/reports/Header.jsx
+++ b/packages/desktop-client/src/components/reports/Header.jsx
@@ -57,7 +57,7 @@ export function Header({
       </Link>
       <View style={styles.veryLargeText}>{title}</View>
 
-      {path !== '/reports/custom' && (
+      {!['/reports/custom', '/reports/spending'].includes(path) && (
         <View
           style={{
             flexDirection: isNarrowWidth ? 'column' : 'row',
diff --git a/packages/desktop-client/src/components/reports/Overview.jsx b/packages/desktop-client/src/components/reports/Overview.jsx
index 2cefe41a6..06ecef0c8 100644
--- a/packages/desktop-client/src/components/reports/Overview.jsx
+++ b/packages/desktop-client/src/components/reports/Overview.jsx
@@ -15,6 +15,7 @@ import { View } from '../common/View';
 import { CashFlowCard } from './reports/CashFlowCard';
 import { CustomReportListCards } from './reports/CustomReportListCards';
 import { NetWorthCard } from './reports/NetWorthCard';
+import { SpendingCard } from './reports/SpendingCard';
 
 export function Overview() {
   const customReports = useReports();
@@ -24,6 +25,7 @@ export function Overview() {
   sessionStorage.setItem('url', location.pathname);
 
   const customReportsFeatureFlag = useFeatureFlag('customReports');
+  const spendingReportFeatureFlag = useFeatureFlag('spendingReport');
 
   const accounts = useAccounts();
   return (
@@ -61,6 +63,7 @@ export function Overview() {
       >
         <NetWorthCard accounts={accounts} />
         <CashFlowCard />
+        {spendingReportFeatureFlag && <SpendingCard />}
       </View>
       {customReportsFeatureFlag && (
         <CustomReportListCards reports={customReports} />
diff --git a/packages/desktop-client/src/components/reports/ReportRouter.jsx b/packages/desktop-client/src/components/reports/ReportRouter.jsx
index 463e9484d..2fadf9f39 100644
--- a/packages/desktop-client/src/components/reports/ReportRouter.jsx
+++ b/packages/desktop-client/src/components/reports/ReportRouter.jsx
@@ -5,6 +5,7 @@ import { Overview } from './Overview';
 import { CashFlow } from './reports/CashFlow';
 import { CustomReport } from './reports/CustomReport';
 import { NetWorth } from './reports/NetWorth';
+import { Spending } from './reports/Spending';
 
 export function ReportRouter() {
   return (
@@ -13,6 +14,7 @@ export function ReportRouter() {
       <Route path="/net-worth" element={<NetWorth />} />
       <Route path="/cash-flow" element={<CashFlow />} />
       <Route path="/custom" element={<CustomReport />} />
+      <Route path="/spending" element={<Spending />} />
     </Routes>
   );
 }
diff --git a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx
new file mode 100644
index 000000000..e74f64630
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx
@@ -0,0 +1,303 @@
+// @ts-strict-ignore
+import React from 'react';
+
+import { css } from 'glamor';
+import {
+  AreaChart,
+  Area,
+  CartesianGrid,
+  XAxis,
+  YAxis,
+  Tooltip,
+  ResponsiveContainer,
+} from 'recharts';
+
+import * as monthUtils from 'loot-core/src/shared/months';
+import {
+  amountToCurrency,
+  amountToCurrencyNoDecimal,
+} from 'loot-core/src/shared/util';
+import { type SpendingEntity } from 'loot-core/src/types/models/reports';
+
+import { usePrivacyMode } from '../../../hooks/usePrivacyMode';
+import { theme } from '../../../style';
+import { type CSSProperties } from '../../../style';
+import { AlignedText } from '../../common/AlignedText';
+import { Container } from '../Container';
+import { numberFormatterTooltip } from '../numberFormatter';
+
+type PayloadItem = {
+  value: number;
+  payload: {
+    totalAssets: number | string;
+    totalDebts: number | string;
+    totalTotals: number | string;
+    day: string;
+    months: {
+      date: string;
+      cumulative: number | string;
+    };
+  };
+};
+
+type CustomTooltipProps = {
+  active?: boolean;
+  payload?: PayloadItem[];
+  balanceTypeOp?: string;
+  thisMonth?: string;
+  selection?: string;
+};
+
+const CustomTooltip = ({
+  active,
+  payload,
+  balanceTypeOp,
+  thisMonth,
+  selection,
+}: CustomTooltipProps) => {
+  if (active && payload && payload.length) {
+    const comparison =
+      selection === 'average'
+        ? payload[0].payload[selection] * -1
+        : payload[0].payload.months[selection].cumulative * -1;
+    return (
+      <div
+        className={`${css({
+          zIndex: 1000,
+          pointerEvents: 'none',
+          borderRadius: 2,
+          boxShadow: '0 1px 6px rgba(0, 0, 0, .20)',
+          backgroundColor: theme.menuBackground,
+          color: theme.menuItemText,
+          padding: 10,
+        })}`}
+      >
+        <div>
+          <div style={{ marginBottom: 10 }}>
+            <strong>
+              Day:{' '}
+              {Number(payload[0].payload.day) >= 28
+                ? '28+'
+                : payload[0].payload.day}
+            </strong>
+          </div>
+          <div style={{ lineHeight: 1.5 }}>
+            {payload[0].payload.months[thisMonth].cumulative && (
+              <AlignedText
+                left="This month:"
+                right={amountToCurrency(
+                  payload[0].payload.months[thisMonth].cumulative * -1,
+                )}
+              />
+            )}
+            {['cumulative'].includes(balanceTypeOp) && (
+              <AlignedText
+                left={selection === 'average' ? 'Average' : 'Last month:'}
+                right={amountToCurrency(comparison)}
+              />
+            )}
+            {payload[0].payload.months[thisMonth].cumulative && (
+              <AlignedText
+                left="Difference:"
+                right={amountToCurrency(
+                  payload[0].payload.months[thisMonth].cumulative * -1 -
+                    comparison,
+                )}
+              />
+            )}
+          </div>
+        </div>
+      </div>
+    );
+  }
+};
+
+type SpendingGraphProps = {
+  style?: CSSProperties;
+  data: SpendingEntity;
+  compact?: boolean;
+  mode: string;
+};
+
+export function SpendingGraph({
+  style,
+  data,
+  compact,
+  mode,
+}: SpendingGraphProps) {
+  const privacyMode = usePrivacyMode();
+  const balanceTypeOp = 'cumulative';
+  const thisMonth = monthUtils.currentMonth();
+  const lastMonth = monthUtils.subMonths(monthUtils.currentMonth(), 1);
+  const selection = mode.toLowerCase() === 'average' ? 'average' : lastMonth;
+  const thisMonthMax = data.intervalData.reduce((a, b) =>
+    a.months[thisMonth][balanceTypeOp] < b.months[thisMonth][balanceTypeOp]
+      ? a
+      : b,
+  ).months[thisMonth][balanceTypeOp];
+  const selectionMax =
+    selection === 'average'
+      ? data.intervalData[27].average
+      : data.intervalData.reduce((a, b) =>
+          a.months[lastMonth][balanceTypeOp] <
+          b.months[lastMonth][balanceTypeOp]
+            ? a
+            : b,
+        ).months[lastMonth][balanceTypeOp];
+  const maxYAxis = selectionMax > thisMonthMax;
+  const dataMax = Math.max(...data.intervalData.map(i => i[balanceTypeOp]));
+  const dataMin = Math.min(...data.intervalData.map(i => i[balanceTypeOp]));
+
+  const tickFormatter = tick => {
+    if (!privacyMode) return `${amountToCurrencyNoDecimal(tick)}`; // Formats the tick values as strings with commas
+    return '...';
+  };
+
+  const gradientOffset = () => {
+    if (dataMax <= 0) {
+      return 0;
+    }
+    if (dataMin >= 0) {
+      return 1;
+    }
+
+    return dataMax / (dataMax - dataMin);
+  };
+
+  const off = gradientOffset();
+
+  const getVal = (obj, month) => {
+    if (month === 'average') {
+      return obj[month] && -1 * obj[month];
+    } else {
+      return (
+        obj.months[month][balanceTypeOp] &&
+        -1 * obj.months[month][balanceTypeOp]
+      );
+    }
+  };
+
+  const getDate = obj => {
+    return Number(obj.day) >= 28 ? '28+' : obj.day;
+  };
+
+  return (
+    <Container
+      style={{
+        ...style,
+        ...(compact && { height: 'auto' }),
+      }}
+    >
+      {(width, height) =>
+        data.intervalData && (
+          <ResponsiveContainer>
+            <div>
+              {!compact && <div style={{ marginTop: '15px' }} />}
+              <AreaChart
+                width={width}
+                height={height}
+                data={data.intervalData}
+                margin={{
+                  top: 0,
+                  right: 0,
+                  left: 0,
+                  bottom: 0,
+                }}
+              >
+                {compact ? null : (
+                  <CartesianGrid strokeDasharray="3 3" vertical={false} />
+                )}
+                {compact ? null : (
+                  <XAxis
+                    dataKey={val => getDate(val)}
+                    tick={{ fill: theme.pageText }}
+                    tickLine={{ stroke: theme.pageText }}
+                  />
+                )}
+                {compact ? null : (
+                  <YAxis
+                    dataKey={val =>
+                      getVal(val, maxYAxis ? thisMonth : selection)
+                    }
+                    domain={[0, 'auto']}
+                    tickFormatter={tickFormatter}
+                    tick={{ fill: theme.pageText }}
+                    tickLine={{ stroke: theme.pageText }}
+                    tickSize={0}
+                  />
+                )}
+                <Tooltip
+                  content={
+                    <CustomTooltip
+                      balanceTypeOp={balanceTypeOp}
+                      thisMonth={thisMonth}
+                      selection={selection}
+                    />
+                  }
+                  formatter={numberFormatterTooltip}
+                  isAnimationActive={false}
+                />
+                <defs>
+                  <linearGradient
+                    id={`fill${balanceTypeOp}`}
+                    x1="0"
+                    y1="0"
+                    x2="0"
+                    y2="1"
+                  >
+                    <stop
+                      offset={off}
+                      stopColor={theme.reportsGreen}
+                      stopOpacity={0.2}
+                    />
+                  </linearGradient>
+                  <linearGradient
+                    id={`stroke${balanceTypeOp}`}
+                    x1="0"
+                    y1="0"
+                    x2="0"
+                    y2="1"
+                  >
+                    <stop
+                      offset={off}
+                      stopColor={theme.reportsGreen}
+                      stopOpacity={1}
+                    />
+                  </linearGradient>
+                </defs>
+
+                <Area
+                  type="linear"
+                  dot={false}
+                  activeDot={{
+                    fill: theme.reportsGreen,
+                    fillOpacity: 1,
+                    r: 10,
+                  }}
+                  animationDuration={0}
+                  dataKey={val => getVal(val, thisMonth)}
+                  stroke={`url(#stroke${balanceTypeOp})`}
+                  strokeWidth={3}
+                  fill={`url(#fill${balanceTypeOp})`}
+                  fillOpacity={1}
+                />
+                <Area
+                  type="linear"
+                  dot={false}
+                  activeDot={false}
+                  animationDuration={0}
+                  dataKey={val => getVal(val, selection)}
+                  stroke="gray"
+                  strokeDasharray="10 10"
+                  strokeWidth={3}
+                  fill="gray"
+                  fillOpacity={0.2}
+                />
+              </AreaChart>
+            </div>
+          </ResponsiveContainer>
+        )
+      }
+    </Container>
+  );
+}
diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx
new file mode 100644
index 000000000..c52921324
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx
@@ -0,0 +1,286 @@
+import React, { useState, useMemo } from 'react';
+
+import * as monthUtils from 'loot-core/src/shared/months';
+import { amountToCurrency } from 'loot-core/src/shared/util';
+import { type RuleConditionEntity } from 'loot-core/types/models/rule';
+
+import { useCategories } from '../../../hooks/useCategories';
+import { useFilters } from '../../../hooks/useFilters';
+import { SvgArrowLeft } from '../../../icons/v1/ArrowLeft';
+import { theme, styles } from '../../../style';
+import { AlignedText } from '../../common/AlignedText';
+import { Block } from '../../common/Block';
+import { Link } from '../../common/Link';
+import { Paragraph } from '../../common/Paragraph';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
+import { AppliedFilters } from '../../filters/AppliedFilters';
+import { FilterButton } from '../../filters/FiltersMenu';
+import { PrivacyFilter } from '../../PrivacyFilter';
+import { SpendingGraph } from '../graphs/SpendingGraph';
+import { LoadingIndicator } from '../LoadingIndicator';
+import { ModeButton } from '../ModeButton';
+import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet';
+import { useReport } from '../useReport';
+
+export function Spending() {
+  const categories = useCategories();
+
+  const {
+    filters,
+    conditionsOp,
+    onApply: onApplyFilter,
+    onDelete: onDeleteFilter,
+    onUpdate: onUpdateFilter,
+    onCondOpChange,
+  } = useFilters<RuleConditionEntity>();
+
+  const [dataCheck, setDataCheck] = useState(false);
+  const [mode, setMode] = useState('Last month');
+
+  const getGraphData = useMemo(() => {
+    setDataCheck(false);
+    return createSpendingSpreadsheet({
+      categories,
+      conditions: filters,
+      conditionsOp,
+      setDataCheck,
+    });
+  }, [categories, filters, conditionsOp]);
+
+  const data = useReport('default', getGraphData);
+
+  if (!data) {
+    return null;
+  }
+  const showAverage =
+    data.intervalData[27].months[
+      monthUtils.subMonths(monthUtils.currentDay(), 3)
+    ].daily !== 0;
+
+  return (
+    <View style={{ ...styles.page, minWidth: 650, overflow: 'hidden' }}>
+      <View
+        style={{
+          flexDirection: 'row',
+          flexShrink: 0,
+        }}
+      >
+        <View
+          style={{
+            padding: 10,
+            paddingTop: 0,
+            flexShrink: 0,
+          }}
+        >
+          <Link
+            variant="button"
+            type="bare"
+            to="/reports"
+            style={{ marginBottom: '15', alignSelf: 'flex-start' }}
+          >
+            <SvgArrowLeft width={10} height={10} style={{ marginRight: 5 }} />{' '}
+            Back
+          </Link>
+          <Text style={{ ...styles.veryLargeText, marginBottom: 10 }}>
+            Monthly Spending
+          </Text>
+
+          {filters && (
+            <View style={{ flexDirection: 'row' }}>
+              <FilterButton
+                onApply={onApplyFilter}
+                compact={false}
+                hover={false}
+                exclude={['date']}
+              />
+              <View style={{ flex: 1 }} />
+            </View>
+          )}
+        </View>
+      </View>
+      <View
+        style={{
+          display: 'flex',
+          flexDirection: 'row',
+          padding: 15,
+          paddingTop: 0,
+          flexGrow: 1,
+        }}
+      >
+        <View
+          style={{
+            flexGrow: 1,
+          }}
+        >
+          {filters && filters.length > 0 && (
+            <View
+              style={{
+                marginBottom: 10,
+                marginLeft: 5,
+                flexShrink: 0,
+                flexDirection: 'row',
+                spacing: 2,
+                justifyContent: 'flex-start',
+                alignContent: 'flex-start',
+              }}
+            >
+              <AppliedFilters
+                filters={filters}
+                onUpdate={onUpdateFilter}
+                onDelete={onDeleteFilter}
+                conditionsOp={conditionsOp}
+                onCondOpChange={onCondOpChange}
+              />
+            </View>
+          )}
+          <View
+            style={{
+              backgroundColor: theme.tableBackground,
+              flexGrow: 1,
+            }}
+          >
+            <View
+              style={{
+                flexDirection: 'column',
+                flexGrow: 1,
+                padding: 10,
+                paddingTop: 10,
+              }}
+            >
+              <View
+                style={{
+                  alignItems: 'flex-end',
+                  paddingTop: 10,
+                }}
+              >
+                <View
+                  style={{
+                    ...styles.mediumText,
+                    fontWeight: 500,
+                    marginBottom: 5,
+                  }}
+                >
+                  <AlignedText
+                    left={<Block>Spent MTD:</Block>}
+                    right={
+                      <Text>
+                        <PrivacyFilter blurIntensity={5}>
+                          {amountToCurrency(
+                            Math.abs(
+                              data.intervalData[
+                                monthUtils.getDay(monthUtils.currentDay()) >= 29
+                                  ? 28
+                                  : monthUtils.getDay(monthUtils.currentDay()) -
+                                    1
+                              ].thisMonth,
+                            ),
+                          )}
+                        </PrivacyFilter>
+                      </Text>
+                    }
+                  />
+                  <AlignedText
+                    left={<Block>Spent Last MTD:</Block>}
+                    right={
+                      <Text>
+                        <PrivacyFilter blurIntensity={5}>
+                          {amountToCurrency(
+                            Math.abs(
+                              data.intervalData[
+                                monthUtils.getDay(monthUtils.currentDay()) >= 29
+                                  ? 28
+                                  : monthUtils.getDay(monthUtils.currentDay()) -
+                                    1
+                              ].lastMonth,
+                            ),
+                          )}
+                        </PrivacyFilter>
+                      </Text>
+                    }
+                  />
+                  {showAverage && (
+                    <AlignedText
+                      left={<Block>Spent Average MTD:</Block>}
+                      right={
+                        <Text>
+                          <PrivacyFilter blurIntensity={5}>
+                            {amountToCurrency(
+                              Math.abs(
+                                data.intervalData[
+                                  monthUtils.getDay(monthUtils.currentDay()) >=
+                                  29
+                                    ? 28
+                                    : monthUtils.getDay(
+                                        monthUtils.currentDay(),
+                                      ) - 1
+                                ].average,
+                              ),
+                            )}
+                          </PrivacyFilter>
+                        </Text>
+                      }
+                    />
+                  )}
+                </View>
+              </View>
+              <View
+                style={{
+                  alignItems: 'center',
+                  flexDirection: 'row',
+                }}
+              >
+                <Text
+                  style={{
+                    paddingRight: 10,
+                  }}
+                >
+                  Compare this month to:
+                </Text>
+                <ModeButton
+                  selected={mode === 'Last month'}
+                  onSelect={() => setMode('Last month')}
+                >
+                  Last month
+                </ModeButton>
+                {showAverage && (
+                  <ModeButton
+                    selected={mode === 'Average'}
+                    onSelect={() => setMode('Average')}
+                  >
+                    Average
+                  </ModeButton>
+                )}
+              </View>
+
+              {dataCheck ? (
+                <SpendingGraph
+                  style={{ flexGrow: 1 }}
+                  compact={false}
+                  data={data}
+                  mode={mode}
+                />
+              ) : (
+                <LoadingIndicator message="Loading report..." />
+              )}
+
+              {showAverage && (
+                <View style={{ marginTop: 30 }}>
+                  <Paragraph>
+                    <strong>
+                      How are “Average” and “Spent Average MTD” calculated?
+                    </strong>
+                  </Paragraph>
+                  <Paragraph>
+                    They are both the average cumulative spending by day for the
+                    last three months.
+                  </Paragraph>
+                </View>
+              )}
+            </View>
+          </View>
+        </View>
+      </View>
+    </View>
+  );
+}
diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
new file mode 100644
index 000000000..cd6e5ae2e
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
@@ -0,0 +1,86 @@
+import React, { useState, useMemo } from 'react';
+
+import * as monthUtils from 'loot-core/src/shared/months';
+import { amountToCurrency } from 'loot-core/src/shared/util';
+
+import { useCategories } from '../../../hooks/useCategories';
+import { styles } from '../../../style/styles';
+import { Block } from '../../common/Block';
+import { View } from '../../common/View';
+import { PrivacyFilter } from '../../PrivacyFilter';
+import { DateRange } from '../DateRange';
+import { SpendingGraph } from '../graphs/SpendingGraph';
+import { LoadingIndicator } from '../LoadingIndicator';
+import { ReportCard } from '../ReportCard';
+import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet';
+import { useReport } from '../useReport';
+
+export function SpendingCard() {
+  const categories = useCategories();
+
+  const [isCardHovered, setIsCardHovered] = useState(false);
+
+  const getGraphData = useMemo(() => {
+    return createSpendingSpreadsheet({
+      categories,
+    });
+  }, [categories]);
+
+  const data = useReport('default', getGraphData);
+  const difference =
+    data &&
+    data.intervalData[monthUtils.getDay(monthUtils.currentDay()) - 1].average -
+      data.intervalData[monthUtils.getDay(monthUtils.currentDay()) - 1]
+        .thisMonth;
+
+  return (
+    <ReportCard flex="1" to="/reports/spending">
+      <View
+        style={{ flex: 1 }}
+        onPointerEnter={() => setIsCardHovered(true)}
+        onPointerLeave={() => setIsCardHovered(false)}
+      >
+        <View style={{ flexDirection: 'row', padding: 20 }}>
+          <View style={{ flex: 1 }}>
+            <Block
+              style={{ ...styles.mediumText, fontWeight: 500, marginBottom: 5 }}
+              role="heading"
+            >
+              Monthly Spending
+            </Block>
+            <DateRange
+              start={monthUtils.currentMonth()}
+              end={monthUtils.currentMonth()}
+            />
+          </View>
+          {data && (
+            <View style={{ textAlign: 'right' }}>
+              <Block
+                style={{
+                  ...styles.mediumText,
+                  fontWeight: 500,
+                  marginBottom: 5,
+                }}
+              >
+                <PrivacyFilter activationFilters={[!isCardHovered]}>
+                  {data && amountToCurrency(difference)}
+                </PrivacyFilter>
+              </Block>
+            </View>
+          )}
+        </View>
+
+        {data ? (
+          <SpendingGraph
+            style={{ flexGrow: 1 }}
+            compact={true}
+            data={data}
+            mode="average"
+          />
+        ) : (
+          <LoadingIndicator />
+        )}
+      </View>
+    </ReportCard>
+  );
+}
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
new file mode 100644
index 000000000..4203f18cc
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
@@ -0,0 +1,180 @@
+// @ts-strict-ignore
+
+import { runQuery } from 'loot-core/src/client/query-helpers';
+import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
+import { send } from 'loot-core/src/platform/client/fetch';
+import * as monthUtils from 'loot-core/src/shared/months';
+import { integerToAmount } from 'loot-core/src/shared/util';
+import {
+  type CategoryEntity,
+  type RuleConditionEntity,
+  type CategoryGroupEntity,
+} from 'loot-core/src/types/models';
+import {
+  type SpendingMonthEntity,
+  type SpendingEntity,
+} from 'loot-core/src/types/models/reports';
+
+import { getSpecificRange } from '../reportRanges';
+import { index } from '../util';
+
+import { makeQuery } from './makeQuery';
+
+type createSpendingSpreadsheetProps = {
+  categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] };
+  conditions?: RuleConditionEntity[];
+  conditionsOp?: string;
+  setDataCheck?: (value: boolean) => void;
+};
+
+export function createSpendingSpreadsheet({
+  categories,
+  conditions = [],
+  conditionsOp,
+  setDataCheck,
+}: createSpendingSpreadsheetProps) {
+  const [startDate, endDate] = getSpecificRange(3, null, 'Months');
+  const interval = 'Daily';
+
+  return async (
+    spreadsheet: ReturnType<typeof useSpreadsheet>,
+    setData: (data: SpendingEntity) => void,
+  ) => {
+    const { filters } = await send('make-filters-from-conditions', {
+      conditions: conditions.filter(cond => !cond.customName),
+    });
+    const conditionsOpKey = conditionsOp === 'or' ? '$or' : '$and';
+
+    const [assets, debts] = await Promise.all([
+      runQuery(
+        makeQuery(
+          'assets',
+          startDate,
+          endDate,
+          interval,
+          categories.list,
+          conditionsOpKey,
+          filters,
+        ),
+      ).then(({ data }) => data),
+      runQuery(
+        makeQuery(
+          'debts',
+          startDate,
+          endDate,
+          interval,
+          categories.list,
+          conditionsOpKey,
+          filters,
+        ),
+      ).then(({ data }) => data),
+    ]);
+
+    const intervals = monthUtils.dayRangeInclusive(startDate, endDate);
+    const days = [...Array(29).keys()]
+      .filter(f => f > 0)
+      .map(n => n.toString().padStart(2, '0'));
+
+    let totalAssets = 0;
+    let totalDebts = 0;
+
+    const months = monthUtils
+      .rangeInclusive(startDate, monthUtils.currentMonth() + '-01')
+      .map(month => {
+        return { month, perMonthAssets: 0, perMonthDebts: 0 };
+      });
+
+    const intervalData = days.map(day => {
+      let averageSum = 0;
+      let monthCount = 0;
+      const dayData = months.map(month => {
+        const data = intervals.reduce((arr, intervalItem) => {
+          const offsetDay =
+            Number(intervalItem.substring(8, 10)) >= 28
+              ? '28'
+              : intervalItem.substring(8, 10);
+          let perIntervalAssets = 0;
+          let perIntervalDebts = 0;
+
+          if (
+            month.month === monthUtils.getMonth(intervalItem) &&
+            day === offsetDay
+          ) {
+            const intervalAssets = assets
+              .filter(e => !e.categoryIncome)
+              .filter(asset => asset.date === intervalItem)
+              .reduce((a, v) => (a = a + v.amount), 0);
+            perIntervalAssets += intervalAssets;
+
+            const intervalDebts = debts
+              .filter(e => !e.categoryIncome)
+              .filter(debt => debt.date === intervalItem)
+              .reduce((a, v) => (a = a + v.amount), 0);
+            perIntervalDebts += intervalDebts;
+
+            totalAssets += perIntervalAssets;
+            totalDebts += perIntervalDebts;
+
+            let cumulativeAssets = 0;
+            let cumulativeDebts = 0;
+
+            months.map(m => {
+              if (m.month === month.month) {
+                cumulativeAssets = m.perMonthAssets += perIntervalAssets;
+                cumulativeDebts = m.perMonthDebts += perIntervalDebts;
+              }
+              return null;
+            });
+            if (month.month !== monthUtils.currentMonth()) {
+              averageSum += cumulativeAssets + cumulativeDebts;
+              monthCount += 1;
+            }
+
+            arr.push({
+              date: intervalItem,
+              totalDebts: integerToAmount(perIntervalDebts),
+              totalAssets: integerToAmount(perIntervalAssets),
+              totalTotals: integerToAmount(
+                perIntervalDebts + perIntervalAssets,
+              ),
+              cumulative:
+                intervalItem <= monthUtils.currentDay()
+                  ? integerToAmount(cumulativeDebts + cumulativeAssets)
+                  : null,
+            });
+          }
+
+          return arr;
+        }, []);
+        const maxCumulative = data.reduce((a, b) =>
+          a.cumulative < b.cumulative ? a : b,
+        ).cumulative;
+
+        return {
+          date: data[0].date,
+          cumulative: maxCumulative,
+          daily: data[0].totalTotals,
+          month: month.month,
+        };
+      });
+      const indexedData: SpendingMonthEntity = index(dayData, 'month');
+      return {
+        months: indexedData,
+        day,
+        average: integerToAmount(averageSum) / monthCount,
+        thisMonth: dayData[3].cumulative,
+        lastMonth: dayData[2].cumulative,
+      };
+    });
+
+    setData({
+      intervalData,
+      startDate,
+      endDate,
+      totalDebts: integerToAmount(totalDebts),
+      totalAssets: integerToAmount(totalAssets),
+      totalTotals: integerToAmount(totalAssets + totalDebts),
+    });
+    setDataCheck?.(true);
+  };
+}
diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx
index ef0a99b10..c0917d169 100644
--- a/packages/desktop-client/src/components/settings/Experimental.tsx
+++ b/packages/desktop-client/src/components/settings/Experimental.tsx
@@ -80,6 +80,9 @@ export function ExperimentalFeatures() {
         expanded ? (
           <View style={{ gap: '1em' }}>
             <FeatureToggle flag="customReports">Custom reports</FeatureToggle>
+            <FeatureToggle flag="spendingReport">
+              Monthly spending
+            </FeatureToggle>
 
             <ReportBudgetFeature />
 
diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts
index 467abc9ee..d041c17bf 100644
--- a/packages/desktop-client/src/hooks/useFeatureFlag.ts
+++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts
@@ -7,6 +7,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
   reportBudget: false,
   goalTemplatesEnabled: false,
   customReports: false,
+  spendingReport: false,
   simpleFinSync: false,
   splitsInRules: false,
 };
diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts
index 19789df76..3379aeac3 100644
--- a/packages/loot-core/src/types/models/reports.d.ts
+++ b/packages/loot-core/src/types/models/reports.d.ts
@@ -24,6 +24,39 @@ export interface CustomReportEntity {
   tombstone?: boolean;
 }
 
+export type SpendingMonthEntity = Record<
+  string | number,
+  {
+    cumulative: number;
+    daily: number;
+    date: string;
+    month: string;
+  }
+>;
+
+export interface SpendingDataEntity {
+  date: string;
+  totalAssets: number;
+  totalDebts: number;
+  totalTotals: number;
+  cumulative: number;
+}
+
+export interface SpendingEntity {
+  intervalData: {
+    months: SpendingMonthEntity;
+    day: string;
+    average: number;
+    thisMonth: number;
+    lastMonth: number;
+  }[];
+  startDate?: string;
+  endDate?: string;
+  totalDebts: number;
+  totalAssets: number;
+  totalTotals: number;
+}
+
 export interface GroupedEntity {
   data?: DataEntity[];
   intervalData: DataEntity[];
diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts
index 9989c0fe0..63e20d2cd 100644
--- a/packages/loot-core/src/types/prefs.d.ts
+++ b/packages/loot-core/src/types/prefs.d.ts
@@ -4,6 +4,7 @@ export type FeatureFlag =
   | 'reportBudget'
   | 'goalTemplatesEnabled'
   | 'customReports'
+  | 'spendingReport'
   | 'simpleFinSync'
   | 'splitsInRules';
 
diff --git a/upcoming-release-notes/2622.md b/upcoming-release-notes/2622.md
new file mode 100644
index 000000000..f0595a33b
--- /dev/null
+++ b/upcoming-release-notes/2622.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [carkom]
+---
+
+Add a new monthly spending report to track MTD spending compared to previous months.
-- 
GitLab