From f3451bfc2ea024830b00a86884f732af6abe9dd4 Mon Sep 17 00:00:00 2001
From: Matiss Janis Aboltins <matiss@mja.lv>
Date: Tue, 24 Sep 2024 08:03:04 +0100
Subject: [PATCH] :sparkles: (dashboards) ability to save filters & timeframes
 on spending widgets (#3432)

---
 .../src/components/common/Select.tsx          |   3 +
 .../src/components/reports/DateRange.tsx      |   9 +-
 .../src/components/reports/Header.tsx         |   2 +-
 .../src/components/reports/Overview.tsx       |   1 +
 .../src/components/reports/ReportRouter.tsx   |   1 +
 .../reports/graphs/SpendingGraph.tsx          |  52 +-
 .../src/components/reports/reportRanges.ts    |  44 +-
 .../components/reports/reports/Spending.tsx   | 523 ++++++++++--------
 .../reports/reports/SpendingCard.tsx          |  70 +--
 .../spreadsheets/spending-spreadsheet.ts      |   3 -
 .../loot-core/src/types/models/dashboard.d.ts |  10 +-
 .../loot-core/src/types/models/reports.d.ts   |   2 -
 packages/loot-core/src/types/prefs.d.ts       |   6 -
 upcoming-release-notes/3432.md                |   6 +
 14 files changed, 412 insertions(+), 320 deletions(-)
 create mode 100644 upcoming-release-notes/3432.md

diff --git a/packages/desktop-client/src/components/common/Select.tsx b/packages/desktop-client/src/components/common/Select.tsx
index c9fc00b41..0ecfde802 100644
--- a/packages/desktop-client/src/components/common/Select.tsx
+++ b/packages/desktop-client/src/components/common/Select.tsx
@@ -28,6 +28,7 @@ type SelectProps<Value> = {
   disabled?: boolean;
   disabledKeys?: Value[];
   style?: CSSProperties;
+  popoverStyle?: CSSProperties;
 };
 
 /**
@@ -53,6 +54,7 @@ export function Select<const Value = string>({
   disabled = false,
   disabledKeys = [],
   style = {},
+  popoverStyle = {},
 }: SelectProps<Value>) {
   const targetOption = options
     .filter(isValueOption)
@@ -108,6 +110,7 @@ export function Select<const Value = string>({
         placement="bottom start"
         isOpen={isOpen}
         onOpenChange={() => setIsOpen(false)}
+        style={popoverStyle}
       >
         <Menu
           onMenuSelect={item => {
diff --git a/packages/desktop-client/src/components/reports/DateRange.tsx b/packages/desktop-client/src/components/reports/DateRange.tsx
index faefe46e9..6734a0b8e 100644
--- a/packages/desktop-client/src/components/reports/DateRange.tsx
+++ b/packages/desktop-client/src/components/reports/DateRange.tsx
@@ -40,7 +40,14 @@ export function DateRange({ start, end, type }: DateRangeProps): ReactElement {
   }
 
   let content: string | ReactElement;
-  if (startDate.getFullYear() !== endDate.getFullYear()) {
+  if (['budget', 'average'].includes(type || '')) {
+    content = (
+      <div>
+        Compare {d.format(startDate, 'MMM yyyy')} to{' '}
+        {type === 'budget' ? 'budgeted' : 'average'}
+      </div>
+    );
+  } else if (startDate.getFullYear() !== endDate.getFullYear()) {
     content = (
       <div>
         {type && 'Compare '}
diff --git a/packages/desktop-client/src/components/reports/Header.tsx b/packages/desktop-client/src/components/reports/Header.tsx
index b1aad0805..d9322c227 100644
--- a/packages/desktop-client/src/components/reports/Header.tsx
+++ b/packages/desktop-client/src/components/reports/Header.tsx
@@ -73,7 +73,7 @@ export function Header({
         flexShrink: 0,
       }}
     >
-      {!['/reports/custom', '/reports/spending'].includes(path) && (
+      {!['/reports/custom'].includes(path) && (
         <View
           style={{
             flexDirection: isNarrowWidth ? 'column' : 'row',
diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx
index f6dfb8843..0f3c3b22b 100644
--- a/packages/desktop-client/src/components/reports/Overview.tsx
+++ b/packages/desktop-client/src/components/reports/Overview.tsx
@@ -545,6 +545,7 @@ export function Overview() {
                   />
                 ) : item.type === 'spending-card' ? (
                   <SpendingCard
+                    widgetId={item.i}
                     isEditing={isEditing}
                     meta={item.meta}
                     onMetaChange={newMeta => onMetaChange(item, newMeta)}
diff --git a/packages/desktop-client/src/components/reports/ReportRouter.tsx b/packages/desktop-client/src/components/reports/ReportRouter.tsx
index 08dfa109e..71719624f 100644
--- a/packages/desktop-client/src/components/reports/ReportRouter.tsx
+++ b/packages/desktop-client/src/components/reports/ReportRouter.tsx
@@ -17,6 +17,7 @@ export function ReportRouter() {
       <Route path="/cash-flow/:id" element={<CashFlow />} />
       <Route path="/custom" element={<CustomReport />} />
       <Route path="/spending" element={<Spending />} />
+      <Route path="/spending/:id" 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
index 4c6660e40..b8c035291 100644
--- a/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx
+++ b/packages/desktop-client/src/components/reports/graphs/SpendingGraph.tsx
@@ -1,5 +1,5 @@
 // @ts-strict-ignore
-import React from 'react';
+import React, { type ComponentProps } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { css } from 'glamor';
@@ -32,19 +32,22 @@ type PayloadItem = {
     totalDebts: number | string;
     totalTotals: number | string;
     day: string;
-    months: {
-      date: string;
-      cumulative: number | string;
-    };
+    months: Record<
+      string,
+      {
+        date: string;
+        cumulative: number;
+      }
+    >;
   };
 };
 
 type CustomTooltipProps = {
   active?: boolean;
   payload?: PayloadItem[];
-  balanceTypeOp?: string;
-  selection?: string;
-  compare?: string;
+  balanceTypeOp: 'cumulative';
+  selection: string | 'budget' | 'average';
+  compare: string;
 };
 
 const CustomTooltip = ({
@@ -59,7 +62,7 @@ const CustomTooltip = ({
   if (active && payload && payload.length) {
     const comparison = ['average', 'budget'].includes(selection)
       ? payload[0].payload[selection] * -1
-      : payload[0].payload.months[selection].cumulative * -1;
+      : payload[0].payload.months[selection]?.cumulative * -1;
     return (
       <div
         className={`${css({
@@ -82,11 +85,11 @@ const CustomTooltip = ({
             </strong>
           </div>
           <div style={{ lineHeight: 1.5 }}>
-            {payload[0].payload.months[compare].cumulative ? (
+            {payload[0].payload.months[compare]?.cumulative ? (
               <AlignedText
                 left={t('Compare:')}
                 right={amountToCurrency(
-                  payload[0].payload.months[compare].cumulative * -1,
+                  payload[0].payload.months[compare]?.cumulative * -1,
                 )}
               />
             ) : null}
@@ -102,11 +105,11 @@ const CustomTooltip = ({
                 right={amountToCurrency(comparison)}
               />
             )}
-            {payload[0].payload.months[compare].cumulative ? (
+            {payload[0].payload.months[compare]?.cumulative ? (
               <AlignedText
                 left={t('Difference:')}
                 right={amountToCurrency(
-                  payload[0].payload.months[compare].cumulative * -1 -
+                  payload[0].payload.months[compare]?.cumulative * -1 -
                     comparison,
                 )}
               />
@@ -122,7 +125,7 @@ type SpendingGraphProps = {
   style?: CSSProperties;
   data: SpendingEntity;
   compact?: boolean;
-  mode: string;
+  mode: 'single-month' | 'budget' | 'average';
   compare: string;
   compareTo: string;
 };
@@ -138,27 +141,30 @@ export function SpendingGraph({
   const privacyMode = usePrivacyMode();
   const balanceTypeOp = 'cumulative';
 
-  const selection = mode === 'singleMonth' ? compareTo : mode;
+  const selection = mode === 'single-month' ? compareTo : mode;
 
   const thisMonthMax = data.intervalData.reduce((a, b) =>
-    a.months[compare][balanceTypeOp] < b.months[compare][balanceTypeOp] ? a : b,
-  ).months[compare][balanceTypeOp];
+    a.months[compare]?.[balanceTypeOp] < b.months[compare]?.[balanceTypeOp]
+      ? a
+      : b,
+  ).months[compare]?.[balanceTypeOp];
   const selectionMax = ['average', 'budget'].includes(selection)
     ? data.intervalData[27][selection]
     : data.intervalData.reduce((a, b) =>
-        a.months[selection][balanceTypeOp] < b.months[selection][balanceTypeOp]
+        a.months[selection]?.[balanceTypeOp] <
+        b.months[selection]?.[balanceTypeOp]
           ? a
           : b,
-      ).months[selection][balanceTypeOp];
+      ).months[selection]?.[balanceTypeOp];
   const maxYAxis = selectionMax > thisMonthMax;
   const dataMax = Math.max(
-    ...data.intervalData.map(i => i.months[compare].cumulative),
+    ...data.intervalData.map(i => i.months[compare]?.cumulative),
   );
   const dataMin = Math.min(
-    ...data.intervalData.map(i => i.months[compare].cumulative),
+    ...data.intervalData.map(i => i.months[compare]?.cumulative),
   );
 
-  const tickFormatter = tick => {
+  const tickFormatter: ComponentProps<typeof YAxis>['tickFormatter'] = tick => {
     if (!privacyMode) return `${amountToCurrencyNoDecimal(tick)}`; // Formats the tick values as strings with commas
     return '...';
   };
@@ -179,7 +185,7 @@ export function SpendingGraph({
       return obj[month] && -1 * obj[month];
     } else {
       return (
-        obj.months[month][balanceTypeOp] &&
+        obj.months[month]?.[balanceTypeOp] &&
         -1 * obj.months[month][balanceTypeOp]
       );
     }
diff --git a/packages/desktop-client/src/components/reports/reportRanges.ts b/packages/desktop-client/src/components/reports/reportRanges.ts
index 7c3004894..1fa289b5f 100644
--- a/packages/desktop-client/src/components/reports/reportRanges.ts
+++ b/packages/desktop-client/src/components/reports/reportRanges.ts
@@ -166,7 +166,7 @@ export function getLatestRange(offset: number) {
 }
 
 export function calculateTimeRange(
-  timeFrame?: TimeFrame,
+  timeFrame?: Partial<TimeFrame>,
   defaultTimeFrame?: TimeFrame,
 ) {
   const start =
@@ -181,8 +181,48 @@ export function calculateTimeRange(
     return getFullRange(start);
   }
   if (mode === 'sliding-window') {
-    return getLatestRange(monthUtils.differenceInCalendarMonths(end, start));
+    const offset = monthUtils.differenceInCalendarMonths(end, start);
+
+    if (start > end) {
+      return [
+        monthUtils.currentMonth(),
+        monthUtils.subMonths(monthUtils.currentMonth(), -offset),
+        'sliding-window',
+      ] as const;
+    }
+
+    return getLatestRange(offset);
   }
 
   return [start, end, 'static'] as const;
 }
+
+export function calculateSpendingReportTimeRange({
+  compare,
+  compareTo,
+  isLive = true,
+  mode = 'single-month',
+}: {
+  compare?: string;
+  compareTo?: string;
+  isLive?: boolean;
+  mode?: 'budget' | 'average' | 'single-month';
+}): [string, string] {
+  if (['budget', 'average'].includes(mode) && isLive) {
+    return [monthUtils.currentMonth(), monthUtils.currentMonth()];
+  }
+
+  const [start, end] = calculateTimeRange(
+    {
+      start: compare,
+      end: compareTo,
+      mode: (isLive ?? true) ? 'sliding-window' : 'static',
+    },
+    {
+      start: monthUtils.currentMonth(),
+      end: monthUtils.subMonths(monthUtils.currentMonth(), 1),
+      mode: 'sliding-window',
+    },
+  );
+  return [start, end];
+}
diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx
index ec6c81008..4f5407a4a 100644
--- a/packages/desktop-client/src/components/reports/reports/Spending.tsx
+++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx
@@ -1,14 +1,20 @@
 import React, { useState, useMemo, useEffect } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
+import { useParams } from 'react-router-dom';
 
 import * as d from 'date-fns';
 
+import { addNotification } from 'loot-core/client/actions';
+import { useWidget } from 'loot-core/client/data-hooks/widget';
 import { send } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { amountToCurrency } from 'loot-core/src/shared/util';
+import { type SpendingWidget } from 'loot-core/types/models';
 import { type RuleConditionEntity } from 'loot-core/types/models/rule';
 
+import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
 import { useFilters } from '../../../hooks/useFilters';
-import { useLocalPref } from '../../../hooks/useLocalPref';
 import { useNavigate } from '../../../hooks/useNavigate';
 import { useResponsive } from '../../../ResponsiveProvider';
 import { theme, styles } from '../../../style';
@@ -28,11 +34,34 @@ import { PrivacyFilter } from '../../PrivacyFilter';
 import { SpendingGraph } from '../graphs/SpendingGraph';
 import { LoadingIndicator } from '../LoadingIndicator';
 import { ModeButton } from '../ModeButton';
+import { calculateSpendingReportTimeRange } from '../reportRanges';
 import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet';
 import { useReport } from '../useReport';
 import { fromDateRepr } from '../util';
 
 export function Spending() {
+  const params = useParams();
+  const { data: widget, isLoading } = useWidget<SpendingWidget>(
+    params.id ?? '',
+    'spending-card',
+  );
+
+  if (isLoading) {
+    return <LoadingIndicator />;
+  }
+
+  return <SpendingInternal widget={widget} />;
+}
+
+type SpendingInternalProps = {
+  widget: SpendingWidget;
+};
+
+function SpendingInternal({ widget }: SpendingInternalProps) {
+  const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
+  const dispatch = useDispatch();
+  const { t } = useTranslation();
+
   const {
     conditions,
     conditionsOp,
@@ -40,42 +69,23 @@ export function Spending() {
     onDelete: onDeleteFilter,
     onUpdate: onUpdateFilter,
     onConditionsOpChange,
-  } = useFilters<RuleConditionEntity>();
+  } = useFilters<RuleConditionEntity>(
+    widget?.meta?.conditions,
+    widget?.meta?.conditionsOp,
+  );
 
   const emptyIntervals: { name: string; pretty: string }[] = [];
   const [allIntervals, setAllIntervals] = useState(emptyIntervals);
 
-  const [spendingReportFilter = '', setSpendingReportFilter] = useLocalPref(
-    'spendingReportFilter',
-  );
-  const [spendingReportMode = 'singleMonth', setSpendingReportMode] =
-    useLocalPref('spendingReportMode');
-  const [
-    spendingReportCompare = monthUtils.currentMonth(),
-    setSpendingReportCompare,
-  ] = useLocalPref('spendingReportCompare');
-  const [
-    spendingReportCompareTo = monthUtils.currentMonth(),
-    setSpendingReportCompareTo,
-  ] = useLocalPref('spendingReportCompareTo');
-
-  const isDateValid = monthUtils.parseDate(spendingReportCompare);
-  const [dataCheck, setDataCheck] = useState(false);
-  const [mode, setMode] = useState(spendingReportMode);
-  const [compare, setCompare] = useState(
-    isDateValid.toString() === 'Invalid Date'
-      ? monthUtils.currentMonth()
-      : spendingReportCompare,
+  const initialReportMode = widget?.meta?.mode ?? 'single-month';
+  const [initialCompare, initialCompareTo] = calculateSpendingReportTimeRange(
+    widget?.meta ?? {},
   );
-  const [compareTo, setCompareTo] = useState(spendingReportCompareTo);
+  const [compare, setCompare] = useState(initialCompare);
+  const [compareTo, setCompareTo] = useState(initialCompareTo);
+  const [isLive, setIsLive] = useState(widget?.meta?.isLive ?? true);
 
-  const parseFilter = spendingReportFilter && JSON.parse(spendingReportFilter);
-  const filterSaved =
-    JSON.stringify(parseFilter.conditions) === JSON.stringify(conditions) &&
-    parseFilter.conditionsOp === conditionsOp &&
-    spendingReportMode === mode &&
-    spendingReportCompare === compare &&
-    spendingReportCompareTo === compareTo;
+  const [reportMode, setReportMode] = useState(initialReportMode);
 
   useEffect(() => {
     async function run() {
@@ -104,43 +114,47 @@ export function Spending() {
       setAllIntervals(allMonths);
     }
     run();
-    const checkFilter =
-      spendingReportFilter && JSON.parse(spendingReportFilter);
-    if (checkFilter.conditions) {
-      onApplyFilter(checkFilter);
-    }
-  }, [onApplyFilter, spendingReportFilter]);
-
-  const getGraphData = useMemo(() => {
-    setDataCheck(false);
-    return createSpendingSpreadsheet({
-      conditions,
-      conditionsOp,
-      setDataCheck,
-      compare,
-      compareTo,
-    });
-  }, [conditions, conditionsOp, compare, compareTo]);
+  }, []);
+
+  const getGraphData = useMemo(
+    () =>
+      createSpendingSpreadsheet({
+        conditions,
+        conditionsOp,
+        compare,
+        compareTo,
+      }),
+    [conditions, conditionsOp, compare, compareTo],
+  );
 
   const data = useReport('default', getGraphData);
   const navigate = useNavigate();
   const { isNarrowWidth } = useResponsive();
 
-  if (!data) {
-    return null;
-  }
-
-  const saveFilter = () => {
-    setSpendingReportFilter(
-      JSON.stringify({
-        conditionsOp,
+  async function onSaveWidget() {
+    await send('dashboard-update-widget', {
+      id: widget?.id,
+      meta: {
+        ...(widget.meta ?? {}),
         conditions,
+        conditionsOp,
+        compare,
+        compareTo,
+        isLive,
+        mode: reportMode,
+      },
+    });
+    dispatch(
+      addNotification({
+        type: 'message',
+        message: t('Dashboard widget successfully saved.'),
       }),
     );
-    setSpendingReportMode(mode);
-    setSpendingReportCompare(compare);
-    setSpendingReportCompareTo(compareTo);
-  };
+  }
+
+  if (!data) {
+    return null;
+  }
 
   const showAverage =
     data.intervalData[27].months[monthUtils.subMonths(compare, 3)] &&
@@ -156,169 +170,230 @@ export function Spending() {
         : monthUtils.getDay(monthUtils.currentDay()) - 1;
 
   const showCompareTo = Math.abs(data.intervalData[27].compareTo) > 0;
-  const showCompare =
-    compare === monthUtils.currentMonth() ||
-    Math.abs(data.intervalData[27].compare) > 0;
+
+  const title = widget?.meta?.name ?? t('Monthly Spending');
+
   return (
     <Page
       header={
         isNarrowWidth ? (
           <MobilePageHeader
-            title="Monthly Spending"
+            title={title}
             leftContent={
               <MobileBackButton onPress={() => navigate('/reports')} />
             }
           />
         ) : (
-          <PageHeader title="Monthly Spending" />
+          <PageHeader title={title} />
         )
       }
       padding={0}
     >
       <View
         style={{
-          flexDirection: isNarrowWidth ? 'column' : 'row',
-          alignItems: isNarrowWidth ? 'inherit' : 'center',
-          padding: 20,
-          paddingBottom: 0,
+          paddingLeft: 20,
+          paddingRight: 20,
+          paddingTop: 15,
+          paddingBottom: 20,
           flexShrink: 0,
         }}
       >
-        <View
-          style={{
-            alignItems: 'center',
-            flexDirection: 'row',
-            marginRight: 5,
-            marginBottom: 5,
-            marginTop: 5,
-          }}
-        >
-          <Text
-            style={{
-              paddingRight: 5,
-            }}
-          >
-            Compare
-          </Text>
-          <Select
-            value={compare}
-            onChange={e => {
-              setCompare(e);
-            }}
-            options={allIntervals.map(({ name, pretty }) => [name, pretty])}
-          />
-          <Text
-            style={{
-              paddingRight: 5,
-              paddingLeft: 5,
-            }}
-          >
-            to
-          </Text>
-          <Select
-            value={compareTo}
-            onChange={e => {
-              setCompareTo(e);
-            }}
-            options={allIntervals.map(({ name, pretty }) => [name, pretty])}
-            disabled={mode !== 'singleMonth'}
-          />
-        </View>
         {!isNarrowWidth && (
           <View
             style={{
-              width: 1,
-              height: 30,
-              backgroundColor: theme.pillBorderDark,
-              marginRight: 15,
-              marginLeft: 10,
-            }}
-          />
-        )}
-        <View
-          style={{
-            flexDirection: 'row',
-            marginRight: 5,
-            marginTop: 5,
-            marginBottom: 5,
-          }}
-        >
-          <ModeButton
-            selected={mode === 'singleMonth'}
-            style={{
-              backgroundColor: 'inherit',
-            }}
-            onSelect={() => setMode('singleMonth')}
-          >
-            Single month
-          </ModeButton>
-          <ModeButton
-            selected={mode === 'budget'}
-            onSelect={() => setMode('budget')}
-            style={{
-              backgroundColor: 'inherit',
-            }}
-          >
-            Budgeted
-          </ModeButton>
-          <ModeButton
-            selected={mode === 'average'}
-            onSelect={() => setMode('average')}
-            style={{
-              backgroundColor: 'inherit',
+              flexDirection: 'row',
+              alignItems: 'center',
+              flexShrink: 0,
             }}
           >
-            Average
-          </ModeButton>
-        </View>
-        {!isNarrowWidth && (
-          <View
-            style={{
-              width: 1,
-              height: 30,
-              backgroundColor: theme.pillBorderDark,
-              marginRight: 10,
-            }}
-          />
-        )}
-        <View
-          style={{
-            alignItems: 'center',
-            flexDirection: 'row',
-            marginBottom: 5,
-            marginTop: 5,
-            flex: 1,
-          }}
-        >
-          <FilterButton
-            onApply={onApplyFilter}
-            compact={isNarrowWidth}
-            hover={false}
-            exclude={['date']}
-          />
-          <View style={{ flex: 1 }} />
-          <Tooltip
-            placement="top end"
-            content={<Text>Save compare and filter options</Text>}
-            style={{
-              ...styles.tooltip,
-              lineHeight: 1.5,
-              padding: '6px 10px',
-              marginLeft: 10,
-            }}
-          >
-            <Button
-              variant="primary"
+            {isDashboardsFeatureEnabled && (
+              <>
+                <Button
+                  variant={isLive ? 'primary' : 'normal'}
+                  onPress={() => setIsLive(state => !state)}
+                >
+                  {isLive ? t('Live') : t('Static')}
+                </Button>
+
+                <View
+                  style={{
+                    width: 1,
+                    height: 28,
+                    backgroundColor: theme.pillBorderDark,
+                    marginRight: 10,
+                    marginLeft: 10,
+                  }}
+                />
+              </>
+            )}
+
+            <View
+              style={{
+                alignItems: 'center',
+                flexDirection: 'row',
+                marginRight: 5,
+                gap: 5,
+              }}
+            >
+              <Text>
+                <Trans>Compare</Trans>
+              </Text>
+              <Select
+                value={compare}
+                onChange={setCompare}
+                options={allIntervals.map(
+                  ({ name, pretty }) => [name, pretty] as const,
+                )}
+                style={{ width: 150 }}
+                popoverStyle={{ width: 150 }}
+              />
+              <Text>
+                <Trans>to</Trans>
+              </Text>
+              <Select
+                value={reportMode === 'single-month' ? compareTo : 'label'}
+                onChange={setCompareTo}
+                options={
+                  reportMode === 'single-month'
+                    ? allIntervals.map(({ name, pretty }) => [name, pretty])
+                    : [
+                        [
+                          'label',
+                          reportMode === 'budget'
+                            ? t('Budgeted')
+                            : t('Average spent'),
+                        ],
+                      ]
+                }
+                disabled={reportMode !== 'single-month'}
+                style={{ width: 150 }}
+                popoverStyle={{ width: 150 }}
+              />
+            </View>
+
+            <View
               style={{
+                width: 1,
+                height: 28,
+                backgroundColor: theme.pillBorderDark,
+                marginRight: 15,
                 marginLeft: 10,
               }}
-              onPress={saveFilter}
-              isDisabled={filterSaved}
+            />
+
+            <View
+              style={{
+                flexDirection: 'row',
+                marginRight: 5,
+              }}
             >
-              {filterSaved ? 'Saved' : 'Save'}
-            </Button>
-          </Tooltip>
-        </View>
+              <ModeButton
+                selected={reportMode === 'single-month'}
+                style={{
+                  backgroundColor: 'inherit',
+                }}
+                onSelect={() => {
+                  setReportMode('single-month');
+                }}
+              >
+                <Trans>Single month</Trans>
+              </ModeButton>
+              <ModeButton
+                selected={reportMode === 'budget'}
+                onSelect={() => {
+                  setReportMode('budget');
+                }}
+                style={{
+                  backgroundColor: 'inherit',
+                }}
+              >
+                <Trans>Budgeted</Trans>
+              </ModeButton>
+              <ModeButton
+                selected={reportMode === 'average'}
+                onSelect={() => {
+                  setReportMode('average');
+                }}
+                style={{
+                  backgroundColor: 'inherit',
+                }}
+              >
+                <Trans>Average</Trans>
+              </ModeButton>
+            </View>
+
+            <View
+              style={{
+                width: 1,
+                height: 28,
+                backgroundColor: theme.pillBorderDark,
+                marginRight: 10,
+              }}
+            />
+
+            <View
+              style={{
+                alignItems: 'center',
+                flexDirection: 'row',
+                flex: 1,
+              }}
+            >
+              <FilterButton
+                onApply={onApplyFilter}
+                compact={isNarrowWidth}
+                hover={false}
+                exclude={['date']}
+              />
+              <View style={{ flex: 1 }} />
+
+              {widget && (
+                <Tooltip
+                  placement="top end"
+                  content={
+                    <Text>
+                      <Trans>Save compare and filter options</Trans>
+                    </Text>
+                  }
+                  style={{
+                    ...styles.tooltip,
+                    lineHeight: 1.5,
+                    padding: '6px 10px',
+                    marginLeft: 10,
+                  }}
+                >
+                  <Button
+                    variant="primary"
+                    style={{
+                      marginLeft: 10,
+                    }}
+                    onPress={onSaveWidget}
+                  >
+                    <Trans>Save</Trans>
+                  </Button>
+                </Tooltip>
+              )}
+            </View>
+          </View>
+        )}
+
+        {conditions && conditions.length > 0 && (
+          <View
+            style={{
+              marginTop: 5,
+              flexShrink: 0,
+              flexDirection: 'row',
+              spacing: 2,
+            }}
+          >
+            <AppliedFilters
+              conditions={conditions}
+              onUpdate={onUpdateFilter}
+              onDelete={onDeleteFilter}
+              conditionsOp={conditionsOp}
+              onConditionsOpChange={onConditionsOpChange}
+            />
+          </View>
+        )}
       </View>
       <View
         style={{
@@ -333,25 +408,6 @@ export function Spending() {
             flexGrow: 1,
           }}
         >
-          {conditions && conditions.length > 0 && (
-            <View
-              style={{
-                marginBottom: 10,
-                marginLeft: 20,
-                flexShrink: 0,
-                flexDirection: 'row',
-                spacing: 2,
-              }}
-            >
-              <AppliedFilters
-                conditions={conditions}
-                onUpdate={onUpdateFilter}
-                onDelete={onDeleteFilter}
-                conditionsOp={conditionsOp}
-                onConditionsOpChange={onConditionsOpChange}
-              />
-            </View>
-          )}
           <View
             style={{
               backgroundColor: theme.tableBackground,
@@ -403,7 +459,7 @@ export function Spending() {
                         }
                       />
                     )}
-                    {mode === 'singleMonth' && (
+                    {reportMode === 'single-month' && (
                       <AlignedText
                         style={{ marginBottom: 5, minWidth: 210 }}
                         left={
@@ -463,40 +519,31 @@ export function Spending() {
                   )}
                 </View>
               </View>
-              {!showCompare ||
-              (mode === 'singleMonth' && !showCompareTo) ||
-              (mode === 'average' && !showAverage) ? (
-                <View style={{ marginTop: 20 }}>
-                  <h1>Additional data required to generate graph</h1>
-                  <Paragraph>
-                    Currently, there is insufficient data to display selected
-                    information regarding your spending. Please adjust selection
-                    options to enable graph visualization.
-                  </Paragraph>
-                </View>
-              ) : dataCheck ? (
+              {data ? (
                 <SpendingGraph
                   style={{ flexGrow: 1 }}
                   compact={false}
                   data={data}
-                  mode={mode}
+                  mode={reportMode}
                   compare={compare}
                   compareTo={compareTo}
                 />
               ) : (
-                <LoadingIndicator message="Loading report..." />
+                <LoadingIndicator message={t('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
-                    three months before the selected “compare” month.
-                  </Paragraph>
+                  <Trans>
+                    <Paragraph>
+                      <strong>
+                        How are “Average” and “Spent Average MTD” calculated?
+                      </strong>
+                    </Paragraph>
+                    <Paragraph>
+                      They are both the average cumulative spending by day for
+                      the three months before the selected “compare” month.
+                    </Paragraph>
+                  </Trans>
                 </View>
               )}
             </View>
diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
index dd3cffe06..306d92dca 100644
--- a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
+++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
@@ -6,7 +6,6 @@ import { amountToCurrency } from 'loot-core/src/shared/util';
 import { type SpendingWidget } from 'loot-core/src/types/models';
 
 import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
-import { useLocalPref } from '../../../hooks/useLocalPref';
 import { styles } from '../../../style/styles';
 import { theme } from '../../../style/theme';
 import { Block } from '../../common/Block';
@@ -17,12 +16,14 @@ import { SpendingGraph } from '../graphs/SpendingGraph';
 import { LoadingIndicator } from '../LoadingIndicator';
 import { ReportCard } from '../ReportCard';
 import { ReportCardName } from '../ReportCardName';
+import { calculateSpendingReportTimeRange } from '../reportRanges';
 import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet';
 import { useReport } from '../useReport';
 
 import { MissingReportCard } from './MissingReportCard';
 
 type SpendingCardProps = {
+  widgetId: string;
   isEditing?: boolean;
   meta?: SpendingWidget['meta'];
   onMetaChange: (newMeta: SpendingWidget['meta']) => void;
@@ -30,50 +31,36 @@ type SpendingCardProps = {
 };
 
 export function SpendingCard({
+  widgetId,
   isEditing,
-  meta,
+  meta = {},
   onMetaChange,
   onRemove,
 }: SpendingCardProps) {
+  const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
   const { t } = useTranslation();
 
+  const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
+
   const [isCardHovered, setIsCardHovered] = useState(false);
-  const [spendingReportFilter = ''] = useLocalPref('spendingReportFilter');
-  const [spendingReportMode = 'singleMonth'] =
-    useLocalPref('spendingReportMode');
-  const [spendingReportCompare = monthUtils.currentMonth()] = useLocalPref(
-    'spendingReportCompare',
-  );
-  const [spendingReportCompareTo = monthUtils.currentMonth()] = useLocalPref(
-    'spendingReportCompareTo',
-  );
+  const spendingReportMode = meta?.mode ?? 'single-month';
 
   const [nameMenuOpen, setNameMenuOpen] = useState(false);
 
   const selection =
-    spendingReportMode === 'singleMonth' ? 'compareTo' : spendingReportMode;
-  const parseFilter = spendingReportFilter && JSON.parse(spendingReportFilter);
-  const isDateValid = monthUtils.parseDate(spendingReportCompare);
+    spendingReportMode === 'single-month' ? 'compareTo' : spendingReportMode;
   const getGraphData = useMemo(() => {
     return createSpendingSpreadsheet({
-      conditions: parseFilter.conditions,
-      conditionsOp: parseFilter.conditionsOp,
-      compare:
-        isDateValid.toString() === 'Invalid Date'
-          ? monthUtils.currentMonth()
-          : spendingReportCompare,
-      compareTo: spendingReportCompareTo,
+      conditions: meta?.conditions,
+      conditionsOp: meta?.conditionsOp,
+      compare,
+      compareTo,
     });
-  }, [
-    parseFilter,
-    spendingReportCompare,
-    spendingReportCompareTo,
-    isDateValid,
-  ]);
+  }, [meta?.conditions, meta?.conditionsOp, compare, compareTo]);
 
   const data = useReport('default', getGraphData);
   const todayDay =
-    spendingReportCompare !== monthUtils.currentMonth()
+    compare !== monthUtils.currentMonth()
       ? 27
       : monthUtils.getDay(monthUtils.currentDay()) - 1 >= 28
         ? 27
@@ -82,7 +69,6 @@ export function SpendingCard({
     data &&
     data.intervalData[todayDay][selection] -
       data.intervalData[todayDay].compare;
-  const showCompareTo = data && Math.abs(data.intervalData[27].compareTo) > 0;
 
   const spendingReportFeatureFlag = useFeatureFlag('spendingReport');
 
@@ -98,7 +84,11 @@ export function SpendingCard({
   return (
     <ReportCard
       isEditing={isEditing}
-      to="/reports/spending"
+      to={
+        isDashboardsFeatureEnabled
+          ? `/reports/spending/${widgetId}`
+          : '/reports/spending'
+      }
       menuItems={[
         {
           name: 'rename',
@@ -142,12 +132,12 @@ export function SpendingCard({
               onClose={() => setNameMenuOpen(false)}
             />
             <DateRange
-              start={spendingReportCompare}
-              end={spendingReportCompareTo}
+              start={compare}
+              end={compareTo}
               type={spendingReportMode}
             />
           </View>
-          {data && showCompareTo && (
+          {data && (
             <View style={{ textAlign: 'right' }}>
               <Block
                 style={{
@@ -170,23 +160,17 @@ export function SpendingCard({
             </View>
           )}
         </View>
-        {!showCompareTo || isDateValid.toString() === 'Invalid Date' ? (
-          <View style={{ padding: 5 }}>
-            <p style={{ margin: 0, textAlign: 'center' }}>
-              <Trans>Additional data required to generate graph</Trans>
-            </p>
-          </View>
-        ) : data ? (
+        {data ? (
           <SpendingGraph
             style={{ flex: 1 }}
             compact={true}
             data={data}
             mode={spendingReportMode}
-            compare={spendingReportCompare}
-            compareTo={spendingReportCompareTo}
+            compare={compare}
+            compareTo={compareTo}
           />
         ) : (
-          <LoadingIndicator message={t('Loading report...')} />
+          <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
index 0ffa6d742..70619281f 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
@@ -18,7 +18,6 @@ import { makeQuery } from './makeQuery';
 type createSpendingSpreadsheetProps = {
   conditions?: RuleConditionEntity[];
   conditionsOp?: string;
-  setDataCheck?: (value: boolean) => void;
   compare?: string;
   compareTo?: string;
 };
@@ -26,7 +25,6 @@ type createSpendingSpreadsheetProps = {
 export function createSpendingSpreadsheet({
   conditions = [],
   conditionsOp,
-  setDataCheck,
   compare,
   compareTo,
 }: createSpendingSpreadsheetProps) {
@@ -256,6 +254,5 @@ export function createSpendingSpreadsheet({
       totalAssets: integerToAmount(totalAssets),
       totalTotals: integerToAmount(totalAssets + totalDebts),
     });
-    setDataCheck?.(true);
   };
 }
diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts
index a2d7252b1..0b79913ea 100644
--- a/packages/loot-core/src/types/models/dashboard.d.ts
+++ b/packages/loot-core/src/types/models/dashboard.d.ts
@@ -41,7 +41,15 @@ export type CashFlowWidget = AbstractWidget<
 >;
 export type SpendingWidget = AbstractWidget<
   'spending-card',
-  { name?: string } | null
+  {
+    name?: string;
+    conditions?: RuleConditionEntity[];
+    conditionsOp?: 'and' | 'or';
+    compare?: string;
+    compareTo?: string;
+    isLive?: boolean;
+    mode?: 'single-month' | 'budget' | 'average';
+  } | null
 >;
 export type CustomReportWidget = AbstractWidget<
   'custom-report',
diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts
index 465352a8e..e59e65524 100644
--- a/packages/loot-core/src/types/models/reports.d.ts
+++ b/packages/loot-core/src/types/models/reports.d.ts
@@ -30,8 +30,6 @@ export type balanceTypeOpType =
   | 'netAssets'
   | 'netDebts';
 
-export type spendingReportModeType = 'singleMonth' | 'average' | 'budget';
-
 export type SpendingMonthEntity = Record<
   string | number,
   {
diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts
index 984225da3..f10667467 100644
--- a/packages/loot-core/src/types/prefs.d.ts
+++ b/packages/loot-core/src/types/prefs.d.ts
@@ -1,5 +1,3 @@
-import { spendingReportModeType } from './models/reports';
-
 export type FeatureFlag =
   | 'dashboards'
   | 'reportBudget'
@@ -65,10 +63,6 @@ export type LocalPrefs = Partial<{
   reportsViewLegend: boolean;
   reportsViewSummary: boolean;
   reportsViewLabel: boolean;
-  spendingReportFilter: string;
-  spendingReportMode: spendingReportModeType;
-  spendingReportCompare: string;
-  spendingReportCompareTo: string;
   sidebarWidth: number;
   'mobile.showSpentColumn': boolean;
 }>;
diff --git a/upcoming-release-notes/3432.md b/upcoming-release-notes/3432.md
new file mode 100644
index 000000000..afdbed30a
--- /dev/null
+++ b/upcoming-release-notes/3432.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [MatissJanis]
+---
+
+Dashboards: ability to save filters & time-range on spending widgets.
-- 
GitLab