diff --git a/packages/desktop-client/e2e/page-models/reports-page.js b/packages/desktop-client/e2e/page-models/reports-page.js
index 1eb33f1fa3d6f6f4bed8396c168d708ec428f111..00933b4699a892c9758a4958bf86ec3da4d8f59c 100644
--- a/packages/desktop-client/e2e/page-models/reports-page.js
+++ b/packages/desktop-client/e2e/page-models/reports-page.js
@@ -5,22 +5,22 @@ export class ReportsPage {
   }
 
   async waitToLoad() {
-    return this.pageContent.getByRole('link', { name: /^Net/ }).waitFor();
+    return this.pageContent.getByRole('button', { name: /^Net/ }).waitFor();
   }
 
   async goToNetWorthPage() {
-    await this.pageContent.getByRole('link', { name: /^Net/ }).click();
+    await this.pageContent.getByRole('button', { name: /^Net/ }).click();
     return new ReportsPage(this.page);
   }
 
   async goToCashFlowPage() {
-    await this.pageContent.getByRole('link', { name: /^Cash/ }).click();
+    await this.pageContent.getByRole('button', { name: /^Cash/ }).click();
     return new ReportsPage(this.page);
   }
 
   async getAvailableReportList() {
     return this.pageContent
-      .getByRole('link')
+      .getByRole('button')
       .getByRole('heading')
       .allTextContents();
   }
diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png
index 412fa61b6929eeca4e41486674d345668671f7e9..59e9e95db22991e8b2cf8a181c6672a93bde9bd6 100644
Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png
index 583945c0bd5062de234c6e67d0f870cc7a565160..30ab53a5218001e7687774ff6abc351b2f9a6f88 100644
Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png
index 18aa0dd90b1725191fa09997b9612e9c4300ce8a..1f44789f30e7a26be47fd0e81df8fecd54958f98 100644
Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png differ
diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index 922c6485329cc7045c471b4a4281cf9d8e460165..57a8c952bbcec3a8b4fed8dbb772bbdc983c4e1b 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -24,6 +24,7 @@
     "@types/promise-retry": "^1.1.6",
     "@types/react": "^18.2.0",
     "@types/react-dom": "^18.2.1",
+    "@types/react-grid-layout": "^1",
     "@types/react-modal": "^3.16.0",
     "@types/react-redux": "^7.1.25",
     "@types/uuid": "^9.0.2",
@@ -58,6 +59,7 @@
     "react-dnd-html5-backend": "^16.0.1",
     "react-dom": "18.2.0",
     "react-error-boundary": "^4.0.12",
+    "react-grid-layout": "^1.4.4",
     "react-hotkeys-hook": "^4.5.0",
     "react-i18next": "^14.1.2",
     "react-markdown": "^8.0.7",
diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx
index 317c23a761602c212a2e518d17bfb91063b219aa..161b91d1e8b19e4f6e82b336516c2a432b89b47b 100644
--- a/packages/desktop-client/src/components/common/Input.tsx
+++ b/packages/desktop-client/src/components/common/Input.tsx
@@ -39,6 +39,7 @@ export function Input({
   onChangeValue,
   onUpdate,
   focused,
+  className,
   ...nativeProps
 }: InputProps) {
   const ref = useRef<HTMLInputElement>(null);
@@ -63,7 +64,7 @@ export function Input({
         },
         styles.smallText,
         style,
-      )}`}
+      )} ${className}`}
       {...nativeProps}
       onKeyDown={e => {
         nativeProps.onKeyDown?.(e);
diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx
index 037a110d748457fdbc7891df87a1c89e048d2034..55372c02c9e268d6f3816a5ebdbc6af52ada54af 100644
--- a/packages/desktop-client/src/components/common/Menu.tsx
+++ b/packages/desktop-client/src/components/common/Menu.tsx
@@ -48,6 +48,7 @@ type MenuProps<T extends MenuItem = MenuItem> = {
   items: Array<T | typeof Menu.line>;
   onMenuSelect?: (itemName: T['name']) => void;
   style?: CSSProperties;
+  className?: string;
   getItemStyle?: (item: T) => CSSProperties;
 };
 
@@ -57,6 +58,7 @@ export function Menu<T extends MenuItem>({
   items: allItems,
   onMenuSelect,
   style,
+  className,
   getItemStyle,
 }: MenuProps<T>) {
   const elRef = useRef<HTMLDivElement>(null);
@@ -114,6 +116,7 @@ export function Menu<T extends MenuItem>({
 
   return (
     <View
+      className={className}
       style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }}
       tabIndex={1}
       innerRef={elRef}
diff --git a/packages/desktop-client/src/components/reports/ChooseGraph.tsx b/packages/desktop-client/src/components/reports/ChooseGraph.tsx
index c74da46592c31385cf4534a24c124a97e1dd4bbd..527ed45acb487964e799266a9139e83c093ef77f 100644
--- a/packages/desktop-client/src/components/reports/ChooseGraph.tsx
+++ b/packages/desktop-client/src/components/reports/ChooseGraph.tsx
@@ -146,6 +146,7 @@ export function ChooseGraph({
         viewLabels={viewLabels}
         showHiddenCategories={showHiddenCategories}
         showOffBudget={showOffBudget}
+        showTooltip={showTooltip}
       />
     );
   }
diff --git a/packages/desktop-client/src/components/reports/Overview.tsx b/packages/desktop-client/src/components/reports/Overview.tsx
index 49c708275512bb0a6da8237be2b5b90b5352b086..0a0d5f7455d5df48a5b5ce8f9dfe49640f9c21fb 100644
--- a/packages/desktop-client/src/components/reports/Overview.tsx
+++ b/packages/desktop-client/src/components/reports/Overview.tsx
@@ -1,40 +1,306 @@
-import React from 'react';
+import React, { useMemo, useRef, useState } from 'react';
+import { Responsive, WidthProvider, type Layout } from 'react-grid-layout';
+import { useHotkeys } from 'react-hotkeys-hook';
+import { Trans, useTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
 import { useLocation } from 'react-router-dom';
 
-import { css } from 'glamor';
-
+import {
+  addNotification,
+  removeNotification,
+} from 'loot-core/src/client/actions';
+import { useDashboard } from 'loot-core/src/client/data-hooks/dashboard';
 import { useReports } from 'loot-core/src/client/data-hooks/reports';
+import { send } from 'loot-core/src/platform/client/fetch';
+import {
+  type CustomReportWidget,
+  type ExportImportDashboard,
+  type Widget,
+} from 'loot-core/src/types/models';
 
 import { useAccounts } from '../../hooks/useAccounts';
 import { useFeatureFlag } from '../../hooks/useFeatureFlag';
 import { useNavigate } from '../../hooks/useNavigate';
 import { useResponsive } from '../../ResponsiveProvider';
+import { breakpoints } from '../../tokens';
 import { Button } from '../common/Button2';
+import { Menu } from '../common/Menu';
+import { MenuButton } from '../common/MenuButton';
+import { Popover } from '../common/Popover';
 import { View } from '../common/View';
 import { MOBILE_NAV_HEIGHT } from '../mobile/MobileNavTabs';
 import { MobilePageHeader, Page, PageHeader } from '../Page';
 
+import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
+import { LoadingIndicator } from './LoadingIndicator';
 import { CashFlowCard } from './reports/CashFlowCard';
 import { CustomReportListCards } from './reports/CustomReportListCards';
 import { NetWorthCard } from './reports/NetWorthCard';
 import { SpendingCard } from './reports/SpendingCard';
 
+import './overview.scss';
+
+const ResponsiveGridLayout = WidthProvider(Responsive);
+
+function isCustomReportWidget(widget: Widget): widget is CustomReportWidget {
+  return widget.type === 'custom-report';
+}
+
+function useWidgetLayout(widgets: Widget[]): (Layout & {
+  type: Widget['type'];
+  meta: Widget['meta'];
+})[] {
+  return widgets.map(widget => ({
+    i: widget.id,
+    type: widget.type,
+    x: widget.x,
+    y: widget.y,
+    w: widget.width,
+    h: widget.height,
+    minW: isCustomReportWidget(widget) ? 2 : 3,
+    minH: isCustomReportWidget(widget) ? 1 : 2,
+    meta: widget.meta,
+  }));
+}
+
 export function Overview() {
-  const { data: customReports } = useReports();
+  const { t } = useTranslation();
+  const dispatch = useDispatch();
+
+  const triggerRef = useRef(null);
+  const extraMenuTriggerRef = useRef(null);
+  const [menuOpen, setMenuOpen] = useState(false);
+  const [extraMenuOpen, setExtraMenuOpen] = useState(false);
+  const [isImporting, setIsImporting] = useState(false);
+  const [isEditing, setIsEditing] = useState(false);
+  const [currentBreakpoint, setCurrentBreakpoint] = useState<
+    'mobile' | 'desktop'
+  >('desktop');
+
+  const { data: customReports, isLoading: isCustomReportsLoading } =
+    useReports();
+  const { data: widgets, isLoading: isWidgetsLoading } = useDashboard();
+
+  const customReportMap = useMemo(
+    () => new Map(customReports.map(report => [report.id, report])),
+    [customReports],
+  );
+
+  const isLoading = isCustomReportsLoading || isWidgetsLoading;
+
   const { isNarrowWidth } = useResponsive();
   const navigate = useNavigate();
 
   const location = useLocation();
   sessionStorage.setItem('url', location.pathname);
 
+  const isDashboardsFeatureEnabled = useFeatureFlag('dashboards');
   const spendingReportFeatureFlag = useFeatureFlag('spendingReport');
 
+  const layout = useWidgetLayout(widgets);
+
+  const closeNotifications = () => {
+    dispatch(removeNotification('import'));
+  };
+
+  // Close import notifications when doing "undo" operation
+  useHotkeys(
+    'ctrl+z, cmd+z, meta+z',
+    closeNotifications,
+    {
+      scopes: ['app'],
+    },
+    [closeNotifications],
+  );
+
+  const onDispatchSucessNotification = (message: string) => {
+    dispatch(
+      addNotification({
+        id: 'import',
+        type: 'message',
+        sticky: true,
+        timeout: 30_000, // 30s
+        message,
+        messageActions: {
+          undo: () => {
+            closeNotifications();
+            window.__actionsForMenu.undo();
+          },
+        },
+      }),
+    );
+  };
+
+  const onBreakpointChange = (breakpoint: 'mobile' | 'desktop') => {
+    setCurrentBreakpoint(breakpoint);
+  };
+
+  const onResetDashboard = async () => {
+    setIsImporting(true);
+    await send('dashboard-reset');
+    setIsImporting(false);
+
+    onDispatchSucessNotification(
+      t(
+        'Dashboard has been successfully reset to default state. Don’t like what you see? You can always press [ctrl+z](#undo) to undo.',
+      ),
+    );
+  };
+
+  const onLayoutChange = (newLayout: Layout[]) => {
+    if (!isEditing) {
+      return;
+    }
+
+    send(
+      'dashboard-update',
+      newLayout.map(item => ({
+        id: item.i,
+        width: item.w,
+        height: item.h,
+        x: item.x,
+        y: item.y,
+      })),
+    );
+  };
+
+  const onAddWidget = <T extends Widget>(
+    type: T['type'],
+    meta: T['meta'] = null,
+  ) => {
+    send('dashboard-add-widget', {
+      type,
+      width: 4,
+      height: 2,
+      meta,
+    });
+    setMenuOpen(false);
+  };
+
+  const onRemoveWidget = (widgetId: string) => {
+    send('dashboard-remove-widget', widgetId);
+  };
+
+  const onExport = () => {
+    const widgetMap = new Map(widgets.map(item => [item.id, item]));
+
+    const data = {
+      version: 1,
+      widgets: layout.map(item => {
+        const widget = widgetMap.get(item.i);
+
+        if (!widget) {
+          throw new Error(`Unable to query widget: ${item.i}`);
+        }
+
+        if (isCustomReportWidget(widget)) {
+          const customReport = customReportMap.get(widget.meta.id);
+
+          if (!customReport) {
+            throw new Error(`Custom report not found for widget: ${item.i}`);
+          }
+
+          return {
+            ...widget,
+            meta: customReport,
+            id: undefined,
+            tombstone: undefined,
+          };
+        }
+
+        return { ...widget, id: undefined, tombstone: undefined };
+      }),
+    } satisfies ExportImportDashboard;
+
+    window.Actual?.saveFile(
+      JSON.stringify(data, null, 2),
+      'dashboard.json',
+      'Export Dashboard',
+    );
+  };
+  const onImport = async () => {
+    const openFileDialog = window.Actual?.openFileDialog;
+
+    if (!openFileDialog) {
+      dispatch(
+        addNotification({
+          type: 'error',
+          message: t(
+            'Fatal error occurred: unable to open import file dialog.',
+          ),
+        }),
+      );
+      return;
+    }
+
+    const [filepath] = await openFileDialog({
+      properties: ['openFile'],
+      filters: [
+        {
+          name: 'JSON files',
+          extensions: ['json'],
+        },
+      ],
+    });
+
+    closeNotifications();
+    setIsImporting(true);
+    const res = await send('dashboard-import', { filepath });
+    setIsImporting(false);
+
+    if (res.error) {
+      switch (res.error) {
+        case 'json-parse-error':
+          dispatch(
+            addNotification({
+              id: 'import',
+              type: 'error',
+              message: t('Failed parsing the imported JSON.'),
+            }),
+          );
+          break;
+
+        case 'validation-error':
+          dispatch(
+            addNotification({
+              id: 'import',
+              type: 'error',
+              message: res.message,
+            }),
+          );
+          break;
+
+        default:
+          dispatch(
+            addNotification({
+              id: 'import',
+              type: 'error',
+              message: t('Failed importing the dashboard file.'),
+            }),
+          );
+          break;
+      }
+      return;
+    }
+
+    onDispatchSucessNotification(
+      t(
+        'Dashboard has been successfully imported. Don’t like what you see? You can always press [ctrl+z](#undo) to undo.',
+      ),
+    );
+  };
+
   const accounts = useAccounts();
+
+  if (isLoading) {
+    return <LoadingIndicator message={t('Loading reports...')} />;
+  }
+
   return (
     <Page
       header={
         isNarrowWidth ? (
-          <MobilePageHeader title="Reports" />
+          <MobilePageHeader title={t('Reports')} />
         ) : (
           <View
             style={{
@@ -43,37 +309,219 @@ export function Overview() {
               marginRight: 15,
             }}
           >
-            <PageHeader title="Reports" />
-            {!isNarrowWidth && (
-              <Button
-                variant="primary"
-                onPress={() => navigate('/reports/custom')}
-              >
-                Create new custom report
-              </Button>
-            )}
+            <PageHeader title={t('Reports')} />
+
+            <View
+              style={{
+                flexDirection: 'row',
+                justifyContent: 'space-between',
+                gap: 5,
+              }}
+            >
+              {currentBreakpoint === 'desktop' && (
+                <>
+                  {isEditing ? (
+                    <>
+                      <Button
+                        ref={triggerRef}
+                        variant="primary"
+                        isDisabled={isImporting}
+                        onPress={() => setMenuOpen(true)}
+                      >
+                        <Trans>Add new widget</Trans>
+                      </Button>
+                      <Button
+                        isDisabled={isImporting}
+                        onPress={() => setIsEditing(false)}
+                      >
+                        <Trans>Finish editing dashboard</Trans>
+                      </Button>
+
+                      <Popover
+                        triggerRef={triggerRef}
+                        isOpen={menuOpen}
+                        onOpenChange={() => setMenuOpen(false)}
+                      >
+                        <Menu
+                          onMenuSelect={item => {
+                            if (item === 'custom-report') {
+                              navigate('/reports/custom');
+                              return;
+                            }
+
+                            function isExistingCustomReport(
+                              name: string,
+                            ): name is `custom-report-${string}` {
+                              return name.startsWith('custom-report-');
+                            }
+                            if (isExistingCustomReport(item)) {
+                              const [, reportId] = item.split('custom-report-');
+                              onAddWidget('custom-report', { id: reportId });
+                              return;
+                            }
+
+                            onAddWidget(item);
+                          }}
+                          items={[
+                            {
+                              name: 'cash-flow-card' as const,
+                              text: t('Cash flow graph'),
+                            },
+                            {
+                              name: 'net-worth-card' as const,
+                              text: t('Net worth graph'),
+                            },
+                            ...(spendingReportFeatureFlag
+                              ? [
+                                  {
+                                    name: 'spending-card' as const,
+                                    text: t('Spending analysis'),
+                                  },
+                                ]
+                              : []),
+                            {
+                              name: 'custom-report' as const,
+                              text: t('New custom report'),
+                            },
+                            ...(customReports.length
+                              ? ([Menu.line] satisfies Array<typeof Menu.line>)
+                              : []),
+                            ...customReports.map(report => ({
+                              name: `custom-report-${report.id}` as const,
+                              text: report.name,
+                            })),
+                          ]}
+                        />
+                      </Popover>
+                    </>
+                  ) : (
+                    <>
+                      <Button
+                        variant="primary"
+                        isDisabled={isImporting}
+                        onPress={() => navigate('/reports/custom')}
+                      >
+                        <Trans>Create new custom report</Trans>
+                      </Button>
+                      {isDashboardsFeatureEnabled && (
+                        <Button
+                          isDisabled={isImporting}
+                          onPress={() => setIsEditing(true)}
+                        >
+                          <Trans>Edit dashboard</Trans>
+                        </Button>
+                      )}
+                    </>
+                  )}
+
+                  {isDashboardsFeatureEnabled && (
+                    <>
+                      <MenuButton
+                        ref={extraMenuTriggerRef}
+                        onPress={() => setExtraMenuOpen(true)}
+                      />
+                      <Popover
+                        triggerRef={extraMenuTriggerRef}
+                        isOpen={extraMenuOpen}
+                        onOpenChange={() => setExtraMenuOpen(false)}
+                      >
+                        <Menu
+                          onMenuSelect={item => {
+                            switch (item) {
+                              case 'reset':
+                                onResetDashboard();
+                                break;
+                              case 'export':
+                                onExport();
+                                break;
+                              case 'import':
+                                onImport();
+                                break;
+                            }
+                            setExtraMenuOpen(false);
+                          }}
+                          items={[
+                            {
+                              name: 'reset',
+                              text: t('Reset to default'),
+                              disabled: isImporting,
+                            },
+                            Menu.line,
+                            {
+                              name: 'import',
+                              text: t('Import'),
+                              disabled: isImporting,
+                            },
+                            {
+                              name: 'export',
+                              text: t('Export'),
+                              disabled: isImporting,
+                            },
+                          ]}
+                        />
+                      </Popover>
+                    </>
+                  )}
+                </>
+              )}
+            </View>
           </View>
         )
       }
-      padding={0}
+      padding={10}
       style={{ paddingBottom: MOBILE_NAV_HEIGHT }}
     >
-      <View
-        className={`${css({
-          flex: '0 0 auto',
-          flexDirection: isNarrowWidth ? 'column' : 'row',
-          flexWrap: isNarrowWidth ? 'nowrap' : 'wrap',
-          padding: '10',
-          '> a, > div': {
-            margin: '10',
-          },
-        })}`}
-      >
-        <NetWorthCard accounts={accounts} />
-        <CashFlowCard />
-        {spendingReportFeatureFlag && <SpendingCard />}
-        <CustomReportListCards reports={customReports} />
-      </View>
+      {isImporting ? (
+        <LoadingIndicator message={t('Import is running...')} />
+      ) : (
+        <View style={{ userSelect: 'none' }}>
+          <ResponsiveGridLayout
+            breakpoints={{ desktop: breakpoints.medium, mobile: 1 }}
+            layouts={{ desktop: layout, mobile: layout }}
+            onLayoutChange={
+              currentBreakpoint === 'desktop' ? onLayoutChange : undefined
+            }
+            onBreakpointChange={onBreakpointChange}
+            cols={{ desktop: 12, mobile: 1 }}
+            rowHeight={100}
+            draggableCancel={`.${NON_DRAGGABLE_AREA_CLASS_NAME}`}
+            isDraggable={currentBreakpoint === 'desktop' && isEditing}
+            isResizable={currentBreakpoint === 'desktop' && isEditing}
+          >
+            {layout.map(item => (
+              <div key={item.i}>
+                {item.type === 'net-worth-card' ? (
+                  <NetWorthCard
+                    isEditing={isEditing}
+                    accounts={accounts}
+                    onRemove={() => onRemoveWidget(item.i)}
+                  />
+                ) : item.type === 'cash-flow-card' ? (
+                  <CashFlowCard
+                    isEditing={isEditing}
+                    onRemove={() => onRemoveWidget(item.i)}
+                  />
+                ) : item.type === 'spending-card' ? (
+                  <SpendingCard
+                    isEditing={isEditing}
+                    onRemove={() => onRemoveWidget(item.i)}
+                  />
+                ) : item.type === 'custom-report' ? (
+                  <CustomReportListCards
+                    isEditing={isEditing}
+                    report={
+                      item.meta && 'id' in item.meta
+                        ? customReportMap.get(item.meta.id)
+                        : undefined
+                    }
+                    onRemove={() => onRemoveWidget(item.i)}
+                  />
+                ) : null}
+              </div>
+            ))}
+          </ResponsiveGridLayout>
+        </View>
+      )}
     </Page>
   );
 }
diff --git a/packages/desktop-client/src/components/reports/ReportCard.tsx b/packages/desktop-client/src/components/reports/ReportCard.tsx
index 08e2bcf646bc73f83fb0d1c78e17391d07b7062d..44f32746460cfd3d9ac7691c2be9b12b150f0226 100644
--- a/packages/desktop-client/src/components/reports/ReportCard.tsx
+++ b/packages/desktop-client/src/components/reports/ReportCard.tsx
@@ -1,64 +1,174 @@
-import React, { type ReactNode } from 'react';
+import React, {
+  useRef,
+  useState,
+  type ComponentProps,
+  type ReactNode,
+} from 'react';
 
 import { type CustomReportEntity } from 'loot-core/src/types/models';
 
+import { useIsInViewport } from '../../hooks/useIsInViewport';
+import { useNavigate } from '../../hooks/useNavigate';
 import { useResponsive } from '../../ResponsiveProvider';
 import { type CSSProperties, theme } from '../../style';
-import { Link } from '../common/Link';
+import { Menu } from '../common/Menu';
+import { MenuButton } from '../common/MenuButton';
+import { Popover } from '../common/Popover';
 import { View } from '../common/View';
 
+import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
+
 type ReportCardProps = {
-  to: string;
+  isEditing?: boolean;
+  to?: string;
   children: ReactNode;
   report?: CustomReportEntity;
+  menuItems?: ComponentProps<typeof Menu>['items'];
+  onMenuSelect?: ComponentProps<typeof Menu>['onMenuSelect'];
   size?: number;
   style?: CSSProperties;
 };
 
 export function ReportCard({
+  isEditing,
   to,
   report,
+  menuItems,
+  onMenuSelect,
   children,
   size = 1,
   style,
 }: ReportCardProps) {
+  const ref = useRef(null);
+  const isInViewport = useIsInViewport(ref);
+  const navigate = useNavigate();
   const { isNarrowWidth } = useResponsive();
   const containerProps = {
     flex: isNarrowWidth ? '1 1' : `0 0 calc(${size * 100}% / 3 - 20px)`,
   };
 
+  const layoutProps = {
+    isEditing,
+    menuItems,
+    onMenuSelect,
+  };
+
   const content = (
     <View
+      ref={ref}
       style={{
         backgroundColor: theme.tableBackground,
-        borderRadius: 2,
-        height: 200,
+        borderBottomLeftRadius: 2,
+        borderBottomRightRadius: 2,
+        width: '100%',
+        height: '100%',
         boxShadow: '0 2px 6px rgba(0, 0, 0, .15)',
         transition: 'box-shadow .25s',
-        '& .recharts-surface:hover': {
-          cursor: 'pointer',
-        },
-        ':hover': to && {
-          boxShadow: '0 4px 6px rgba(0, 0, 0, .15)',
+        ...(isEditing
+          ? {
+              '& .recharts-surface:hover': {
+                cursor: 'move',
+                ':active': { cursor: 'grabbing' },
+              },
+              ':active': { cursor: 'grabbing' },
+              filter: 'grayscale(1)',
+            }
+          : {
+              '& .recharts-surface:hover': {
+                cursor: 'pointer',
+              },
+            }),
+        ':hover': {
+          ...(to ? { boxShadow: '0 4px 6px rgba(0, 0, 0, .15)' } : null),
+          ...(isEditing ? { cursor: 'move', filter: 'grayscale(0)' } : null),
         },
         ...(to ? null : containerProps),
         ...style,
       }}
     >
-      {children}
+      {/* we render the content only if it is in the viewport
+      this reduces the amount of concurrent server api calls and thus
+      has a better performance */}
+      {isInViewport ? children : null}
     </View>
   );
 
   if (to) {
     return (
-      <Link
-        to={to}
-        report={report}
-        style={{ textDecoration: 'none', ...containerProps }}
-      >
-        {content}
-      </Link>
+      <Layout {...layoutProps}>
+        <View
+          role="button"
+          onClick={
+            isEditing ? undefined : () => navigate(to, { state: { report } })
+          }
+          style={{
+            height: '100%',
+            width: '100%',
+            ':hover': {
+              cursor: 'pointer',
+            },
+          }}
+        >
+          {content}
+        </View>
+      </Layout>
     );
   }
-  return content;
+
+  return <Layout {...layoutProps}>{content}</Layout>;
+}
+
+type LayoutProps = {
+  children: ReactNode;
+} & Pick<ReportCardProps, 'isEditing' | 'menuItems' | 'onMenuSelect'>;
+
+function Layout({ children, isEditing, menuItems, onMenuSelect }: LayoutProps) {
+  const triggerRef = useRef(null);
+  const [menuOpen, setMenuOpen] = useState(false);
+
+  return (
+    <View
+      style={{
+        display: 'block',
+        height: '100%',
+        '& .hover-visible': {
+          opacity: 0,
+          transition: 'opacity .25s',
+        },
+        '&:hover .hover-visible': {
+          opacity: 1,
+        },
+      }}
+    >
+      {menuItems && isEditing && (
+        <View
+          className={[
+            menuOpen ? undefined : 'hover-visible',
+            NON_DRAGGABLE_AREA_CLASS_NAME,
+          ].join(' ')}
+          style={{
+            position: 'absolute',
+            top: 7,
+            right: 3,
+            zIndex: 1,
+          }}
+        >
+          <MenuButton ref={triggerRef} onPress={() => setMenuOpen(true)} />
+          <Popover
+            triggerRef={triggerRef}
+            isOpen={menuOpen}
+            onOpenChange={() => setMenuOpen(false)}
+          >
+            <Menu
+              className={NON_DRAGGABLE_AREA_CLASS_NAME}
+              onMenuSelect={onMenuSelect}
+              items={menuItems}
+            />
+          </Popover>
+        </View>
+      )}
+
+      {children}
+    </View>
+  );
 }
diff --git a/packages/desktop-client/src/components/reports/SaveReport.tsx b/packages/desktop-client/src/components/reports/SaveReport.tsx
index 09f7a0492c8cd71c771f7a371990bd8a7c10ee70..5c0a454dd17802a65f1229eb3cf6196a48bbc5ed 100644
--- a/packages/desktop-client/src/components/reports/SaveReport.tsx
+++ b/packages/desktop-client/src/components/reports/SaveReport.tsx
@@ -71,6 +71,14 @@ export function SaveReport({
         return;
       }
 
+      // Add to dashboard
+      await send('dashboard-add-widget', {
+        type: 'custom-report',
+        width: 4,
+        height: 2,
+        meta: { id: response.data },
+      });
+
       setNameMenuOpen(false);
       onReportChange({
         savedReport: {
diff --git a/packages/desktop-client/src/components/reports/constants.ts b/packages/desktop-client/src/components/reports/constants.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a106ad0f964b9c39d7ed824ece3f7894f88a576
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/constants.ts
@@ -0,0 +1 @@
+export const NON_DRAGGABLE_AREA_CLASS_NAME = 'non-draggable-area';
diff --git a/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx
index ef9545ded1952455ff21d88e116191175fbe4388..c5186e974d3332724221a1c73918e95292f216ad 100644
--- a/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx
+++ b/packages/desktop-client/src/components/reports/graphs/BarGraph.tsx
@@ -29,7 +29,6 @@ import { useAccounts } from '../../../hooks/useAccounts';
 import { useCategories } from '../../../hooks/useCategories';
 import { useNavigate } from '../../../hooks/useNavigate';
 import { usePrivacyMode } from '../../../hooks/usePrivacyMode';
-import { useResponsive } from '../../../ResponsiveProvider';
 import { type CSSProperties } from '../../../style';
 import { theme } from '../../../style/index';
 import { AlignedText } from '../../common/AlignedText';
@@ -178,7 +177,6 @@ export function BarGraph({
   const categories = useCategories();
   const accounts = useAccounts();
   const privacyMode = usePrivacyMode();
-  const { isNarrowWidth } = useResponsive();
   const [pointer, setPointer] = useState('');
 
   const yAxis = groupBy === 'Interval' ? 'date' : 'name';
@@ -279,7 +277,7 @@ export function BarGraph({
                     setPointer('pointer')
                   }
                   onClick={item =>
-                    !isNarrowWidth &&
+                    ((compact && showTooltip) || !compact) &&
                     !['Group', 'Interval'].includes(groupBy) &&
                     showActivity({
                       navigate,
diff --git a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx
index d6b02490b9f806662159d2deaf45c2eaa61f9bd8..ebde9faf2b4c48308d2e344539677b3c49a33b42 100644
--- a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx
+++ b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx
@@ -13,7 +13,6 @@ import { type RuleConditionEntity } from 'loot-core/types/models/rule';
 import { useAccounts } from '../../../hooks/useAccounts';
 import { useCategories } from '../../../hooks/useCategories';
 import { useNavigate } from '../../../hooks/useNavigate';
-import { useResponsive } from '../../../ResponsiveProvider';
 import { theme, type CSSProperties } from '../../../style';
 import { PrivacyFilter } from '../../PrivacyFilter';
 import { Container } from '../Container';
@@ -189,6 +188,7 @@ type DonutGraphProps = {
   viewLabels: boolean;
   showHiddenCategories?: boolean;
   showOffBudget?: boolean;
+  showTooltip?: boolean;
 };
 
 export function DonutGraph({
@@ -201,6 +201,7 @@ export function DonutGraph({
   viewLabels,
   showHiddenCategories,
   showOffBudget,
+  showTooltip = true,
 }: DonutGraphProps) {
   const yAxis = groupBy === 'Interval' ? 'date' : 'name';
   const splitData = groupBy === 'Interval' ? 'intervalData' : 'data';
@@ -208,7 +209,6 @@ export function DonutGraph({
   const navigate = useNavigate();
   const categories = useCategories();
   const accounts = useAccounts();
-  const { isNarrowWidth } = useResponsive();
   const [pointer, setPointer] = useState('');
 
   const getVal = obj => {
@@ -259,7 +259,7 @@ export function DonutGraph({
                     }
                   }}
                   onClick={item =>
-                    !isNarrowWidth &&
+                    ((compact && showTooltip) || !compact) &&
                     !['Group', 'Interval'].includes(groupBy) &&
                     showActivity({
                       navigate,
diff --git a/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx b/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx
index 162290a3e4bd34cad28e102d4c4d08b3e3edc8fa..5648b9a5e556a51974b44a05d6d7231dfb12b084 100644
--- a/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx
+++ b/packages/desktop-client/src/components/reports/graphs/LineGraph.tsx
@@ -26,7 +26,6 @@ import { useAccounts } from '../../../hooks/useAccounts';
 import { useCategories } from '../../../hooks/useCategories';
 import { useNavigate } from '../../../hooks/useNavigate';
 import { usePrivacyMode } from '../../../hooks/usePrivacyMode';
-import { useResponsive } from '../../../ResponsiveProvider';
 import { theme } from '../../../style';
 import { type CSSProperties } from '../../../style';
 import { AlignedText } from '../../common/AlignedText';
@@ -143,7 +142,6 @@ export function LineGraph({
   const privacyMode = usePrivacyMode();
   const [pointer, setPointer] = useState('');
   const [tooltip, setTooltip] = useState('');
-  const { isNarrowWidth } = useResponsive();
 
   const largestValue = data.intervalData
     .map(c => c[balanceTypeOp])
@@ -239,7 +237,7 @@ export function LineGraph({
                           setTooltip('');
                         },
                         onClick: (e, payload) =>
-                          !isNarrowWidth &&
+                          ((compact && showTooltip) || !compact) &&
                           !['Group', 'Interval'].includes(groupBy) &&
                           onShowActivity(e, entry.id, payload),
                       }}
diff --git a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx
index 3d84d5968f08513a1e70b2b3b8bd5d9de839ca81..5d59623ebc6fea97970172ebd4c341a3992f25dc 100644
--- a/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx
+++ b/packages/desktop-client/src/components/reports/graphs/NetWorthGraph.tsx
@@ -15,7 +15,6 @@ import {
 import { amountToCurrencyNoDecimal } from 'loot-core/shared/util';
 
 import { usePrivacyMode } from '../../../hooks/usePrivacyMode';
-import { useResponsive } from '../../../ResponsiveProvider';
 import { theme } from '../../../style';
 import { type CSSProperties } from '../../../style';
 import { AlignedText } from '../../common/AlignedText';
@@ -26,15 +25,16 @@ type NetWorthGraphProps = {
   style?: CSSProperties;
   graphData;
   compact: boolean;
+  showTooltip?: boolean;
 };
 
 export function NetWorthGraph({
   style,
   graphData,
   compact,
+  showTooltip = true,
 }: NetWorthGraphProps) {
   const privacyMode = usePrivacyMode();
-  const { isNarrowWidth } = useResponsive();
 
   const tickFormatter = tick => {
     const res = privacyMode
@@ -151,7 +151,7 @@ export function NetWorthGraph({
                   tick={{ fill: theme.pageText }}
                   tickLine={{ stroke: theme.pageText }}
                 />
-                {(!isNarrowWidth || !compact) && (
+                {showTooltip && (
                   <Tooltip
                     content={<CustomTooltip />}
                     formatter={numberFormatterTooltip}
diff --git a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx
index 3b9bae5bfc0cd6646c006dc896414b7cce32183a..f83a297c8ffd739b72893a8f2e460df243c413b7 100644
--- a/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx
+++ b/packages/desktop-client/src/components/reports/graphs/StackedBarGraph.tsx
@@ -27,7 +27,6 @@ import { useAccounts } from '../../../hooks/useAccounts';
 import { useCategories } from '../../../hooks/useCategories';
 import { useNavigate } from '../../../hooks/useNavigate';
 import { usePrivacyMode } from '../../../hooks/usePrivacyMode';
-import { useResponsive } from '../../../ResponsiveProvider';
 import { theme } from '../../../style';
 import { type CSSProperties } from '../../../style';
 import { AlignedText } from '../../common/AlignedText';
@@ -171,7 +170,6 @@ export function StackedBarGraph({
   const categories = useCategories();
   const accounts = useAccounts();
   const privacyMode = usePrivacyMode();
-  const { isNarrowWidth } = useResponsive();
   const [pointer, setPointer] = useState('');
   const [tooltip, setTooltip] = useState('');
 
@@ -257,7 +255,7 @@ export function StackedBarGraph({
                         }
                       }}
                       onClick={e =>
-                        !isNarrowWidth &&
+                        ((compact && showTooltip) || !compact) &&
                         !['Group', 'Interval'].includes(groupBy) &&
                         showActivity({
                           navigate,
diff --git a/packages/desktop-client/src/components/reports/overview.scss b/packages/desktop-client/src/components/reports/overview.scss
new file mode 100644
index 0000000000000000000000000000000000000000..4af8585550fd908d86b99b4f0fbb210f2e9af45a
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/overview.scss
@@ -0,0 +1,9 @@
+@use 'react-grid-layout/css/styles.css';
+
+.react-grid-item {
+  transition: none;
+}
+
+.react-grid-item.react-grid-placeholder {
+  background-color: #8719e0;
+}
diff --git a/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx b/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx
index 7d54f045487a9af6007e183be173a94af25f38d5..0c13c2251184052c3522d2e0ae3648bfde54be47 100644
--- a/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx
+++ b/packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx
@@ -11,6 +11,7 @@ import { View } from '../../common/View';
 import { PrivacyFilter } from '../../PrivacyFilter';
 import { Change } from '../Change';
 import { chartTheme } from '../chart-theme';
+import { Container } from '../Container';
 import { DateRange } from '../DateRange';
 import { LoadingIndicator } from '../LoadingIndicator';
 import { ReportCard } from '../ReportCard';
@@ -67,7 +68,7 @@ function CustomLabel({
   );
 }
 
-export function CashFlowCard() {
+export function CashFlowCard({ isEditing, onRemove }) {
   const end = monthUtils.currentDay();
   const start = monthUtils.currentMonth() + '-01';
 
@@ -83,7 +84,25 @@ export function CashFlowCard() {
   const income = graphData?.income || 0;
 
   return (
-    <ReportCard to="/reports/cash-flow">
+    <ReportCard
+      isEditing={isEditing}
+      to="/reports/cash-flow"
+      menuItems={[
+        {
+          name: 'remove',
+          text: 'Remove',
+        },
+      ]}
+      onMenuSelect={item => {
+        switch (item) {
+          case 'remove':
+            onRemove();
+            break;
+          default:
+            throw new Error(`Unrecognized selection: ${item}`);
+        }
+      }}
+    >
       <View
         style={{ flex: 1 }}
         onPointerEnter={onCardHover}
@@ -109,35 +128,50 @@ export function CashFlowCard() {
         </View>
 
         {data ? (
-          <ResponsiveContainer width="100%" height="100%">
-            <BarChart
-              data={[
-                {
-                  income,
-                  expenses,
-                },
-              ]}
-              margin={{
-                top: 10,
-                bottom: 0,
-              }}
-            >
-              <Bar dataKey="income" fill={chartTheme.colors.blue} barSize={14}>
-                <LabelList
-                  dataKey="income"
-                  position="left"
-                  content={<CustomLabel name="Income" />}
-                />
-              </Bar>
-              <Bar dataKey="expenses" fill={chartTheme.colors.red} barSize={14}>
-                <LabelList
-                  dataKey="expenses"
-                  position="right"
-                  content={<CustomLabel name="Expenses" />}
-                />
-              </Bar>
-            </BarChart>
-          </ResponsiveContainer>
+          <Container style={{ height: 'auto', flex: 1 }}>
+            {(width, height) => (
+              <ResponsiveContainer>
+                <BarChart
+                  width={width}
+                  height={height}
+                  data={[
+                    {
+                      income,
+                      expenses,
+                    },
+                  ]}
+                  margin={{
+                    top: 10,
+                    bottom: 0,
+                  }}
+                >
+                  <Bar
+                    dataKey="income"
+                    fill={chartTheme.colors.blue}
+                    barSize={14}
+                  >
+                    <LabelList
+                      dataKey="income"
+                      position="left"
+                      content={<CustomLabel name="Income" />}
+                    />
+                  </Bar>
+
+                  <Bar
+                    dataKey="expenses"
+                    fill={chartTheme.colors.red}
+                    barSize={14}
+                  >
+                    <LabelList
+                      dataKey="expenses"
+                      position="right"
+                      content={<CustomLabel name="Expenses" />}
+                    />
+                  </Bar>
+                </BarChart>
+              </ResponsiveContainer>
+            )}
+          </Container>
         ) : (
           <LoadingIndicator />
         )}
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
index f2869b3e44b720f3ae958fdeb99d14464796e246..678fcbcf271b9f3eded8737237f8eb5f25f47e90 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
 
 import * as d from 'date-fns';
 
+import { calculateHasWarning } from 'loot-core/src/client/reports';
 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';
@@ -23,6 +24,7 @@ import { usePayees } from '../../../hooks/usePayees';
 import { useSyncedPref } from '../../../hooks/useSyncedPref';
 import { useResponsive } from '../../../ResponsiveProvider';
 import { theme, styles } from '../../../style';
+import { Warning } from '../../alerts';
 import { AlignedText } from '../../common/AlignedText';
 import { Block } from '../../common/Block';
 import { Text } from '../../common/Text';
@@ -348,6 +350,12 @@ export function CustomReport() {
   const payees = usePayees();
   const accounts = useAccounts();
 
+  const hasWarning = calculateHasWarning(conditions, {
+    categories: categories.list,
+    payees,
+    accounts,
+  });
+
   const getGroupData = useMemo(() => {
     return createGroupedSpreadsheet({
       startDate,
@@ -712,36 +720,52 @@ export function CustomReport() {
               style={{
                 marginBottom: 10,
                 marginLeft: 5,
-                flexShrink: 0,
-                flexDirection: 'row',
+                marginRight: 5,
+                gap: 10,
                 alignItems: 'flex-start',
-                justifyContent: 'flex-start',
+                flexShrink: 0,
               }}
             >
-              <AppliedFilters
-                conditions={conditions}
-                onUpdate={(oldFilter, newFilter) => {
-                  setSessionReport(
-                    'conditions',
-                    conditions.map(f => (f === oldFilter ? newFilter : f)),
-                  );
-                  onReportChange({ type: 'modify' });
-                  onUpdateFilter(oldFilter, newFilter);
-                }}
-                onDelete={deletedFilter => {
-                  setSessionReport(
-                    'conditions',
-                    conditions.filter(f => f !== deletedFilter),
-                  );
-                  onDeleteFilter(deletedFilter);
-                  onReportChange({ type: 'modify' });
-                }}
-                conditionsOp={conditionsOp}
-                onConditionsOpChange={co => {
-                  onConditionsOpChange(co);
-                  onReportChange({ type: 'modify' });
+              <View
+                style={{
+                  flexShrink: 0,
+                  flexDirection: 'row',
+                  alignItems: 'flex-start',
+                  justifyContent: 'flex-start',
                 }}
-              />
+              >
+                <AppliedFilters
+                  conditions={conditions}
+                  onUpdate={(oldFilter, newFilter) => {
+                    setSessionReport(
+                      'conditions',
+                      conditions.map(f => (f === oldFilter ? newFilter : f)),
+                    );
+                    onReportChange({ type: 'modify' });
+                    onUpdateFilter(oldFilter, newFilter);
+                  }}
+                  onDelete={deletedFilter => {
+                    setSessionReport(
+                      'conditions',
+                      conditions.filter(f => f !== deletedFilter),
+                    );
+                    onDeleteFilter(deletedFilter);
+                    onReportChange({ type: 'modify' });
+                  }}
+                  conditionsOp={conditionsOp}
+                  onConditionsOpChange={co => {
+                    onConditionsOpChange(co);
+                    onReportChange({ type: 'modify' });
+                  }}
+                />
+              </View>
+
+              {hasWarning && (
+                <Warning style={{ paddingTop: 5, paddingBottom: 5 }}>
+                  This report is configured to use a non-existing filter value
+                  (i.e. category/account/payee).
+                </Warning>
+              )}
             </View>
           )}
           <View
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx b/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx
index 73edcca315fbebfd7b42dc8a9b1f081af911f868..d654d17c90e840ca47c2a6654a256f150e164ede 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReportListCards.tsx
@@ -1,6 +1,9 @@
 import React, { useEffect, useState } from 'react';
+import { useDispatch } from 'react-redux';
 
 import { send, sendCatch } from 'loot-core/platform/client/fetch/index';
+import { addNotification } from 'loot-core/src/client/actions';
+import { calculateHasWarning } from 'loot-core/src/client/reports';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { type CustomReportEntity } from 'loot-core/types/models/reports';
 
@@ -8,56 +11,76 @@ import { useAccounts } from '../../../hooks/useAccounts';
 import { useCategories } from '../../../hooks/useCategories';
 import { usePayees } from '../../../hooks/usePayees';
 import { useSyncedPref } from '../../../hooks/useSyncedPref';
-import { useResponsive } from '../../../ResponsiveProvider';
+import { SvgExclamationSolid } from '../../../icons/v1';
 import { styles } from '../../../style/index';
 import { theme } from '../../../style/theme';
 import { Block } from '../../common/Block';
+import { InitialFocus } from '../../common/InitialFocus';
+import { Input } from '../../common/Input';
 import { Text } from '../../common/Text';
+import { Tooltip } from '../../common/Tooltip';
 import { View } from '../../common/View';
+import { NON_DRAGGABLE_AREA_CLASS_NAME } from '../constants';
 import { DateRange } from '../DateRange';
 import { ReportCard } from '../ReportCard';
 
 import { GetCardData } from './GetCardData';
-import { ListCardsPopover } from './ListCardsPopover';
+import { MissingReportCard } from './MissingReportCard';
 
-function index(data: CustomReportEntity[]): { [key: string]: boolean }[] {
-  return data.reduce((carry, report) => {
-    const reportId: string = report.id === undefined ? '' : report.id;
+type CustomReportListCardsProps = {
+  isEditing?: boolean;
+  report?: CustomReportEntity;
+  onRemove: () => void;
+};
 
-    return {
-      ...carry,
-      [reportId]: false,
-    };
-  }, []);
+export function CustomReportListCards({
+  isEditing,
+  report,
+  onRemove,
+}: CustomReportListCardsProps) {
+  // It's possible for a dashboard to reference a non-existing
+  // custom report
+  if (!report) {
+    return (
+      <MissingReportCard isEditing={isEditing} onRemove={onRemove}>
+        This custom report has been deleted.
+      </MissingReportCard>
+    );
+  }
+
+  return (
+    <CustomReportListCardsInner
+      isEditing={isEditing}
+      report={report}
+      onRemove={onRemove}
+    />
+  );
 }
 
-export function CustomReportListCards({
-  reports,
-}: {
-  reports: CustomReportEntity[];
+function CustomReportListCardsInner({
+  isEditing,
+  report,
+  onRemove,
+}: Omit<CustomReportListCardsProps, 'report'> & {
+  report: CustomReportEntity;
 }) {
-  const result: { [key: string]: boolean }[] = index(reports);
-  const [reportMenu, setReportMenu] = useState(result);
-  const [deleteMenuOpen, setDeleteMenuOpen] = useState(result);
-  const [nameMenuOpen, setNameMenuOpen] = useState(result);
-  const [err, setErr] = useState('');
-  const [name, setName] = useState('');
+  const dispatch = useDispatch();
+
+  const [nameMenuOpen, setNameMenuOpen] = useState(false);
   const [earliestTransaction, setEarliestTransaction] = useState('');
 
   const payees = usePayees();
   const accounts = useAccounts();
   const categories = useCategories();
-  const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
-  const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
-  const { isNarrowWidth } = useResponsive();
 
-  const [isCardHovered, setIsCardHovered] = useState('');
+  const hasWarning = calculateHasWarning(report.conditions ?? [], {
+    categories: categories.list,
+    payees,
+    accounts,
+  });
 
-  const onDelete = async (reportData: string) => {
-    setName('');
-    await send('report/delete', reportData);
-    onDeleteMenuOpen(reportData === undefined ? '' : reportData, false);
-  };
+  const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
+  const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
 
   useEffect(() => {
     async function run() {
@@ -67,141 +90,128 @@ export function CustomReportListCards({
     run();
   }, []);
 
-  const onAddUpdate = async ({
-    reportData,
-  }: {
-    reportData?: CustomReportEntity;
-  }) => {
-    if (!reportData) {
-      return null;
-    }
-
+  const onSaveName = async (name: string) => {
     const updatedReport = {
-      ...reportData,
+      ...report,
       name,
     };
 
     const response = await sendCatch('report/update', updatedReport);
 
     if (response.error) {
-      setErr(response.error.message);
-      onNameMenuOpen(reportData.id === undefined ? '' : reportData.id, true);
+      dispatch(
+        addNotification({
+          type: 'error',
+          message: `Failed saving report name: ${response.error.message}`,
+        }),
+      );
+      setNameMenuOpen(true);
       return;
     }
 
-    onNameMenuOpen(reportData.id === undefined ? '' : reportData.id, false);
-  };
-
-  const onMenuSelect = async (item: string, report: CustomReportEntity) => {
-    if (item === 'delete') {
-      onMenuOpen(report.id, false);
-      onDeleteMenuOpen(report.id, true);
-      setErr('');
-    }
-    if (item === 'rename') {
-      onMenuOpen(report.id, false);
-      onNameMenuOpen(report.id, true);
-      setName(report.name);
-      setErr('');
-    }
-  };
-
-  const onMenuOpen = (item: string, state: boolean) => {
-    setReportMenu({ ...reportMenu, [item]: state });
+    setNameMenuOpen(false);
   };
 
-  const onDeleteMenuOpen = (item: string, state: boolean) => {
-    setDeleteMenuOpen({ ...deleteMenuOpen, [item]: state });
-  };
-
-  const onNameMenuOpen = (item: string, state: boolean) => {
-    setNameMenuOpen({ ...nameMenuOpen, [item]: state });
-  };
-
-  if (reports.length === 0) return null;
   return (
-    <>
-      {reports.map((report, id) => (
+    <ReportCard
+      isEditing={isEditing}
+      to="/reports/custom"
+      report={report}
+      menuItems={[
+        {
+          name: 'rename',
+          text: 'Rename',
+        },
+        {
+          name: 'remove',
+          text: 'Remove',
+        },
+      ]}
+      onMenuSelect={item => {
+        switch (item) {
+          case 'remove':
+            onRemove();
+            break;
+          case 'rename':
+            setNameMenuOpen(true);
+            break;
+        }
+      }}
+    >
+      <View style={{ flex: 1, padding: 10 }}>
         <View
-          key={id}
           style={{
-            flex: isNarrowWidth ? '1 1' : `0 0 calc(100% / 3 - 20px)`,
+            flexShrink: 0,
+            paddingBottom: 5,
           }}
         >
-          <ReportCard to="/reports/custom" report={report}>
-            <View
-              style={{ flex: 1, padding: 10 }}
-              onMouseEnter={() =>
-                setIsCardHovered(report.id === undefined ? '' : report.id)
-              }
-              onMouseLeave={() => {
-                setIsCardHovered('');
-                onMenuOpen(report.id === undefined ? '' : report.id, false);
-              }}
-            >
-              <View
+          <View style={{ flex: 1 }}>
+            {nameMenuOpen ? (
+              <InitialFocus>
+                <Input
+                  className={NON_DRAGGABLE_AREA_CLASS_NAME}
+                  defaultValue={report.name}
+                  onEnter={e =>
+                    onSaveName((e.target as HTMLInputElement).value)
+                  }
+                  onBlur={e => onSaveName(e.target.value)}
+                  onEscape={() => setNameMenuOpen(false)}
+                  style={{
+                    fontSize: 15,
+                    fontWeight: 500,
+                    marginTop: -6,
+                    marginBottom: -1,
+                    marginLeft: -6,
+                    width: Math.max(20, report.name.length) + 'ch',
+                  }}
+                />
+              </InitialFocus>
+            ) : (
+              <Block
                 style={{
-                  flexShrink: 0,
-                  paddingBottom: 5,
+                  ...styles.mediumText,
+                  fontWeight: 500,
+                  marginBottom: 5,
                 }}
+                role="heading"
               >
-                <View style={{ flex: 1 }}>
-                  <Block
-                    style={{
-                      ...styles.mediumText,
-                      fontWeight: 500,
-                      marginBottom: 5,
-                    }}
-                    role="heading"
-                  >
-                    {report.name}
-                  </Block>
-                  {report.isDateStatic ? (
-                    <DateRange start={report.startDate} end={report.endDate} />
-                  ) : (
-                    <Text style={{ color: theme.pageTextSubdued }}>
-                      {report.dateRange}
-                    </Text>
-                  )}
-                </View>
-              </View>
-              <GetCardData
-                report={report}
-                payees={payees}
-                accounts={accounts}
-                categories={categories}
-                earliestTransaction={earliestTransaction}
-                firstDayOfWeekIdx={firstDayOfWeekIdx}
-              />
-            </View>
-          </ReportCard>
-          <View
-            style={{
-              textAlign: 'right',
-              position: 'absolute',
-              right: 10,
-              top: 10,
-            }}
+                {report.name}
+              </Block>
+            )}
+            {report.isDateStatic ? (
+              <DateRange start={report.startDate} end={report.endDate} />
+            ) : (
+              <Text style={{ color: theme.pageTextSubdued }}>
+                {report.dateRange}
+              </Text>
+            )}
+          </View>
+        </View>
+        <GetCardData
+          report={report}
+          payees={payees}
+          accounts={accounts}
+          categories={categories}
+          earliestTransaction={earliestTransaction}
+          firstDayOfWeekIdx={firstDayOfWeekIdx}
+          showTooltip={!isEditing}
+        />
+      </View>
+      {hasWarning && (
+        <View style={{ padding: 5, position: 'absolute', bottom: 0 }}>
+          <Tooltip
+            content="The widget is configured to use a non-existing filter value (i.e. category/account/payee). Edit the filters used in this report widget to remove the warning."
+            placement="bottom start"
+            style={{ ...styles.tooltip, maxWidth: 300 }}
           >
-            <ListCardsPopover
-              report={report}
-              onMenuOpen={onMenuOpen}
-              isCardHovered={isCardHovered}
-              reportMenu={reportMenu}
-              onMenuSelect={onMenuSelect}
-              nameMenuOpen={nameMenuOpen}
-              name={name}
-              setName={setName}
-              onAddUpdate={onAddUpdate}
-              err={err}
-              onNameMenuOpen={onNameMenuOpen}
-              deleteMenuOpen={deleteMenuOpen}
-              onDeleteMenuOpen={onDeleteMenuOpen}
-              onDelete={onDelete}
+            <SvgExclamationSolid
+              width={20}
+              height={20}
+              style={{ color: theme.warningText }}
             />
-          </View>
+          </Tooltip>
         </View>
-      ))}
-    </>
+      )}
+    </ReportCard>
   );
 }
diff --git a/packages/desktop-client/src/components/reports/reports/ListCardsMenu.tsx b/packages/desktop-client/src/components/reports/reports/ListCardsMenu.tsx
deleted file mode 100644
index 7ecd3542c364d63de3653ccc69f6b56f84b8eeb3..0000000000000000000000000000000000000000
--- a/packages/desktop-client/src/components/reports/reports/ListCardsMenu.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-
-import { type CustomReportEntity } from 'loot-core/types/models/reports';
-
-import { Menu } from '../../common/Menu';
-
-type ListCardsMenuProps = {
-  onMenuSelect: (item: string, report: CustomReportEntity) => void;
-  report: CustomReportEntity;
-};
-
-export function ListCardsMenu({ onMenuSelect, report }: ListCardsMenuProps) {
-  return (
-    <Menu
-      onMenuSelect={item => {
-        onMenuSelect(item, report);
-      }}
-      items={[
-        {
-          name: 'rename',
-          text: 'Rename report',
-        },
-        {
-          name: 'delete',
-          text: 'Delete report',
-        },
-      ]}
-    />
-  );
-}
diff --git a/packages/desktop-client/src/components/reports/reports/ListCardsPopover.tsx b/packages/desktop-client/src/components/reports/reports/ListCardsPopover.tsx
deleted file mode 100644
index 7b2a2817ba240b28827eabf0571a42227157e600..0000000000000000000000000000000000000000
--- a/packages/desktop-client/src/components/reports/reports/ListCardsPopover.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import React, { createRef, useRef } from 'react';
-
-import { type CustomReportEntity } from 'loot-core/types/models/reports';
-
-import { MenuButton } from '../../common/MenuButton';
-import { Popover } from '../../common/Popover';
-import { SaveReportDelete } from '../SaveReportDelete';
-import { SaveReportName } from '../SaveReportName';
-
-import { ListCardsMenu } from './ListCardsMenu';
-
-type ListCardsPopoverProps = {
-  report: CustomReportEntity;
-  onMenuOpen: (item: string, state: boolean) => void;
-  isCardHovered: string;
-  reportMenu: { [key: string]: boolean }[];
-  onMenuSelect: (item: string, report: CustomReportEntity) => void;
-  nameMenuOpen: { [key: string]: boolean }[];
-  onNameMenuOpen: (item: string, state: boolean) => void;
-  name: string;
-  setName: (name: string) => void;
-  onAddUpdate: ({ reportData }: { reportData?: CustomReportEntity }) => void;
-  err: string;
-  deleteMenuOpen: { [key: string]: boolean }[];
-  onDeleteMenuOpen: (item: string, state: boolean) => void;
-  onDelete: (reportData: string) => void;
-};
-export function ListCardsPopover({
-  report,
-  onMenuOpen,
-  isCardHovered,
-  reportMenu,
-  onMenuSelect,
-  nameMenuOpen,
-  onNameMenuOpen,
-  name,
-  setName,
-  onAddUpdate,
-  err,
-  deleteMenuOpen,
-  onDeleteMenuOpen,
-  onDelete,
-}: ListCardsPopoverProps) {
-  const triggerRef = useRef(null);
-  const inputRef = createRef<HTMLInputElement>();
-
-  return (
-    <>
-      <MenuButton
-        aria-label="Report menu"
-        ref={triggerRef}
-        onPress={() =>
-          onMenuOpen(report.id === undefined ? '' : report.id, true)
-        }
-        style={{
-          color: isCardHovered === report.id ? 'inherit' : 'transparent',
-        }}
-      />
-
-      <Popover
-        triggerRef={triggerRef}
-        isOpen={
-          !!(report.id && reportMenu[report.id as keyof typeof reportMenu])
-        }
-        onOpenChange={() =>
-          onMenuOpen(report.id === undefined ? '' : report.id, false)
-        }
-        style={{ width: 120 }}
-      >
-        <ListCardsMenu onMenuSelect={onMenuSelect} report={report} />
-      </Popover>
-
-      <Popover
-        triggerRef={triggerRef}
-        isOpen={
-          !!(report.id && nameMenuOpen[report.id as keyof typeof nameMenuOpen])
-        }
-        onOpenChange={() =>
-          onNameMenuOpen(report.id === undefined ? '' : report.id, false)
-        }
-        style={{ width: 325 }}
-      >
-        <SaveReportName
-          menuItem="rename"
-          name={name}
-          setName={setName}
-          inputRef={inputRef}
-          onAddUpdate={onAddUpdate}
-          err={err}
-          report={report}
-        />
-      </Popover>
-
-      <Popover
-        triggerRef={triggerRef}
-        isOpen={
-          !!(
-            report.id &&
-            deleteMenuOpen[report.id as keyof typeof deleteMenuOpen]
-          )
-        }
-        onOpenChange={() =>
-          onDeleteMenuOpen(report.id === undefined ? '' : report.id, false)
-        }
-        style={{ width: 275, padding: 15 }}
-      >
-        <SaveReportDelete
-          onDelete={() => onDelete(report.id)}
-          onClose={() =>
-            onDeleteMenuOpen(report.id === undefined ? '' : report.id, false)
-          }
-          name={report.name}
-        />
-      </Popover>
-    </>
-  );
-}
diff --git a/packages/desktop-client/src/components/reports/reports/MissingReportCard.tsx b/packages/desktop-client/src/components/reports/reports/MissingReportCard.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..427fd5ca4acc1a469bb3522ae392aea5b7a25571
--- /dev/null
+++ b/packages/desktop-client/src/components/reports/reports/MissingReportCard.tsx
@@ -0,0 +1,46 @@
+import React, { type ReactNode } from 'react';
+
+import { View } from '../../common/View';
+import { ReportCard } from '../ReportCard';
+
+type MissingReportCardProps = {
+  isEditing?: boolean;
+  onRemove: () => void;
+  children: ReactNode;
+};
+
+export function MissingReportCard({
+  isEditing,
+  onRemove,
+  children,
+}: MissingReportCardProps) {
+  return (
+    <ReportCard
+      isEditing={isEditing}
+      menuItems={[
+        {
+          name: 'remove',
+          text: 'Remove',
+        },
+      ]}
+      onMenuSelect={item => {
+        switch (item) {
+          case 'remove':
+            onRemove();
+            break;
+        }
+      }}
+    >
+      <View
+        style={{
+          flex: 1,
+          alignItems: 'center',
+          justifyContent: 'center',
+          padding: 10,
+        }}
+      >
+        {children}
+      </View>
+    </ReportCard>
+  );
+}
diff --git a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx
index 1340ba4811211da114037389e460d49b1b0a5682..4a3d0f9afbf530eedadc05dec1a5e8e9740e90da 100644
--- a/packages/desktop-client/src/components/reports/reports/NetWorth.jsx
+++ b/packages/desktop-client/src/components/reports/reports/NetWorth.jsx
@@ -151,6 +151,7 @@ export function NetWorth() {
           domain={{
             y: [data.lowestNetWorth * 0.99, data.highestNetWorth * 1.01],
           }}
+          showTooltip={!isNarrowWidth}
         />
 
         <View style={{ marginTop: 30, userSelect: 'none' }}>
diff --git a/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx b/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx
index c318690ec75b4a4298aa552145b9d5e125cd0e65..aab03f09920817c04ee5ddb68281446cadf635f5 100644
--- a/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx
+++ b/packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx
@@ -3,6 +3,7 @@ import React, { useState, useMemo, useCallback } from 'react';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { integerToCurrency } from 'loot-core/src/shared/util';
 
+import { useResponsive } from '../../../ResponsiveProvider';
 import { styles } from '../../../style';
 import { Block } from '../../common/Block';
 import { View } from '../../common/View';
@@ -15,7 +16,9 @@ import { ReportCard } from '../ReportCard';
 import { createSpreadsheet as netWorthSpreadsheet } from '../spreadsheets/net-worth-spreadsheet';
 import { useReport } from '../useReport';
 
-export function NetWorthCard({ accounts }) {
+export function NetWorthCard({ isEditing, accounts, onRemove }) {
+  const { isNarrowWidth } = useResponsive();
+
   const end = monthUtils.currentMonth();
   const start = monthUtils.subMonths(end, 5);
   const [isCardHovered, setIsCardHovered] = useState(false);
@@ -29,7 +32,25 @@ export function NetWorthCard({ accounts }) {
   const data = useReport('net_worth', params);
 
   return (
-    <ReportCard size={2} to="/reports/net-worth">
+    <ReportCard
+      isEditing={isEditing}
+      to="/reports/net-worth"
+      menuItems={[
+        {
+          name: 'remove',
+          text: 'Remove',
+        },
+      ]}
+      onMenuSelect={item => {
+        switch (item) {
+          case 'remove':
+            onRemove();
+            break;
+          default:
+            throw new Error(`Unrecognized selection: ${item}`);
+        }
+      }}
+    >
       <View
         style={{ flex: 1 }}
         onPointerEnter={onCardHover}
@@ -71,6 +92,7 @@ export function NetWorthCard({ accounts }) {
             end={end}
             graphData={data.graphData}
             compact={true}
+            showTooltip={!isEditing && !isNarrowWidth}
             style={{ height: 'auto', flex: 1 }}
           />
         ) : (
diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
index 863157c48acb46db1f299b16ed0a2d617c7b7bdb..9127e3040d2d9ed08acfc838ae6f51e7cf10d549 100644
--- a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
+++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
@@ -3,6 +3,7 @@ import React, { useState, useMemo } from 'react';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { amountToCurrency } from 'loot-core/src/shared/util';
 
+import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
 import { useLocalPref } from '../../../hooks/useLocalPref';
 import { styles } from '../../../style/styles';
 import { theme } from '../../../style/theme';
@@ -16,7 +17,14 @@ import { ReportCard } from '../ReportCard';
 import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet';
 import { useReport } from '../useReport';
 
-export function SpendingCard() {
+import { MissingReportCard } from './MissingReportCard';
+
+type SpendingCardProps = {
+  isEditing?: boolean;
+  onRemove: () => void;
+};
+
+export function SpendingCard({ isEditing, onRemove }: SpendingCardProps) {
   const [isCardHovered, setIsCardHovered] = useState(false);
   const [spendingReportFilter = ''] = useLocalPref('spendingReportFilter');
   const [spendingReportTime = 'lastMonth'] = useLocalPref('spendingReportTime');
@@ -46,8 +54,36 @@ export function SpendingCard() {
       data.intervalData[todayDay][spendingReportCompare];
   const showLastMonth = data && Math.abs(data.intervalData[27].lastMonth) > 0;
 
+  const spendingReportFeatureFlag = useFeatureFlag('spendingReport');
+
+  if (!spendingReportFeatureFlag) {
+    return (
+      <MissingReportCard isEditing={isEditing} onRemove={onRemove}>
+        The experimental spending report feature has not been enabled.
+      </MissingReportCard>
+    );
+  }
+
   return (
-    <ReportCard to="/reports/spending">
+    <ReportCard
+      isEditing={isEditing}
+      to="/reports/spending"
+      menuItems={[
+        {
+          name: 'remove',
+          text: 'Remove',
+        },
+      ]}
+      onMenuSelect={item => {
+        switch (item) {
+          case 'remove':
+            onRemove();
+            break;
+          default:
+            throw new Error(`Unrecognized selection: ${item}`);
+        }
+      }}
+    >
       <View
         style={{ flex: 1 }}
         onPointerEnter={() => setIsCardHovered(true)}
diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx
index ab49f18c426f77ba4ae8d7ca5d3c7f07ef2fcf8e..0fb7cadaa22b91f6d986a2b6b3571d4c3cfff2bf 100644
--- a/packages/desktop-client/src/components/settings/Experimental.tsx
+++ b/packages/desktop-client/src/components/settings/Experimental.tsx
@@ -89,6 +89,9 @@ export function ExperimentalFeatures() {
               Goal templates
             </FeatureToggle>
             <FeatureToggle flag="simpleFinSync">SimpleFIN sync</FeatureToggle>
+            <FeatureToggle flag="dashboards">
+              Customizable reports page (dashboards)
+            </FeatureToggle>
           </View>
         ) : (
           <Link
diff --git a/packages/desktop-client/src/hooks/useFeatureFlag.ts b/packages/desktop-client/src/hooks/useFeatureFlag.ts
index f6756404513f710a1bbf10ff4981fc7238e709f3..168c80d7fac8386af8d55a97c8641d65f0d8cd46 100644
--- a/packages/desktop-client/src/hooks/useFeatureFlag.ts
+++ b/packages/desktop-client/src/hooks/useFeatureFlag.ts
@@ -8,6 +8,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
   goalTemplatesEnabled: false,
   spendingReport: false,
   simpleFinSync: false,
+  dashboards: false,
 };
 
 export function useFeatureFlag(name: FeatureFlag): boolean {
diff --git a/packages/desktop-client/src/hooks/useIsInViewport.ts b/packages/desktop-client/src/hooks/useIsInViewport.ts
new file mode 100644
index 0000000000000000000000000000000000000000..58c2be240f3778b37f802bacf3c79d605466a0b7
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useIsInViewport.ts
@@ -0,0 +1,32 @@
+import React, { useEffect, useMemo, useState, type RefObject } from 'react';
+
+/**
+ * Check if the given element (by ref) is visible in the viewport.
+ */
+export function useIsInViewport(ref: RefObject<Element>) {
+  const [isIntersecting, setIsIntersecting] = useState(false);
+
+  const observer = useMemo(
+    () =>
+      new IntersectionObserver(([entry]) =>
+        setIsIntersecting(entry.isIntersecting),
+      ),
+    [],
+  );
+
+  useEffect(() => {
+    const view = ref.current;
+
+    if (!view) {
+      return;
+    }
+
+    observer.observe(view);
+
+    return () => {
+      observer.disconnect();
+    };
+  }, [ref, observer]);
+
+  return isIntersecting;
+}
diff --git a/packages/loot-core/migrations/1722804019000_create_dashboard_table.js b/packages/loot-core/migrations/1722804019000_create_dashboard_table.js
new file mode 100644
index 0000000000000000000000000000000000000000..228b85ede0d6948a970c699d45fac0f0eec2962f
--- /dev/null
+++ b/packages/loot-core/migrations/1722804019000_create_dashboard_table.js
@@ -0,0 +1,44 @@
+import { v4 as uuidv4 } from 'uuid';
+
+/* eslint-disable rulesdir/typography */
+export default async function runMigration(db) {
+  db.transaction(() => {
+    db.execQuery(`
+      CREATE TABLE dashboard
+        (id TEXT PRIMARY KEY,
+         type TEXT,
+         width INTEGER,
+         height INTEGER,
+         x INTEGER,
+         y INTEGER,
+         meta TEXT,
+         tombstone INTEGER DEFAULT 0);
+
+      INSERT INTO dashboard (id, type, width, height, x, y)
+      VALUES
+        ('${uuidv4()}','net-worth-card', 8, 2, 0, 0),
+        ('${uuidv4()}', 'cash-flow-card', 4, 2, 8, 0);
+    `);
+
+    // Add custom reports to the dashboard
+    const reports = db.runQuery(
+      'SELECT id FROM custom_reports WHERE tombstone = 0 ORDER BY name COLLATE NOCASE ASC',
+      [],
+      true,
+    );
+    reports.forEach((report, id) => {
+      db.runQuery(
+        `INSERT INTO dashboard (id, type, width, height, x, y, meta) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+        [
+          uuidv4(),
+          'custom-report',
+          4,
+          2,
+          (id * 4) % 12,
+          2 + Math.floor(id / 3) * 2,
+          JSON.stringify({ id: report.id }),
+        ],
+      );
+    });
+  });
+}
diff --git a/packages/loot-core/src/client/data-hooks/dashboard.ts b/packages/loot-core/src/client/data-hooks/dashboard.ts
new file mode 100644
index 0000000000000000000000000000000000000000..457f813ac0667a1619dbd8d59ec59a53ed71b5a0
--- /dev/null
+++ b/packages/loot-core/src/client/data-hooks/dashboard.ts
@@ -0,0 +1,20 @@
+import { useMemo } from 'react';
+
+import { q } from '../../shared/query';
+import { type Widget } from '../../types/models';
+import { useLiveQuery } from '../query-hooks';
+
+export function useDashboard() {
+  const queryData = useLiveQuery<Widget[]>(
+    () => q('dashboard').select('*'),
+    [],
+  );
+
+  return useMemo(
+    () => ({
+      isLoading: queryData === null,
+      data: queryData || [],
+    }),
+    [queryData],
+  );
+}
diff --git a/packages/loot-core/src/client/reports.ts b/packages/loot-core/src/client/reports.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8b34fc93d47f29497e162bf0a2a7148acb0d8996
--- /dev/null
+++ b/packages/loot-core/src/client/reports.ts
@@ -0,0 +1,87 @@
+import {
+  type AccountEntity,
+  type CategoryEntity,
+  type RuleConditionEntity,
+  type PayeeEntity,
+} from '../types/models';
+
+/**
+ * Checks if the given conditions have issues
+ * (i.e. non-existing category/payee/account being used).
+ */
+export function calculateHasWarning(
+  conditions: RuleConditionEntity[],
+  {
+    categories,
+    accounts,
+    payees,
+  }: {
+    categories: CategoryEntity[];
+    accounts: AccountEntity[];
+    payees: PayeeEntity[];
+  },
+) {
+  const categoryIds = new Set(categories.map(({ id }) => id));
+  const payeeIds = new Set(payees.map(({ id }) => id));
+  const accountIds = new Set(accounts.map(({ id }) => id));
+
+  if (!conditions) {
+    return false;
+  }
+
+  for (const cond of conditions) {
+    const { field, value, op } = cond;
+    const isMultiCondition = Array.isArray(value);
+    const isSupportedSingleCondition = ['is', 'isNot'].includes(op);
+
+    // Regex and other more complicated operations are not supported
+    if (!isSupportedSingleCondition && !isMultiCondition) {
+      continue;
+    }
+
+    // Empty value.. we can skip
+    if (!isMultiCondition && !value) {
+      continue;
+    }
+
+    switch (field) {
+      case 'account':
+        if (isMultiCondition) {
+          if (value.find(val => !accountIds.has(val))) {
+            return true;
+          }
+          break;
+        }
+
+        if (!accountIds.has(value)) {
+          return true;
+        }
+        break;
+      case 'payee':
+        if (isMultiCondition) {
+          if (value.find(val => !payeeIds.has(val))) {
+            return true;
+          }
+          break;
+        }
+
+        if (!payeeIds.has(value)) {
+          return true;
+        }
+        break;
+      case 'category':
+        if (isMultiCondition) {
+          if (value.find(val => !categoryIds.has(val))) {
+            return true;
+          }
+          break;
+        }
+
+        if (!categoryIds.has(value)) {
+          return true;
+        }
+        break;
+    }
+  }
+  return false;
+}
diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts
index 085912f6abce53184f818571c8d9bd5429c000e5..b35e2a25a5fac4ef9f766d333e6f3ea9387f3e58 100644
--- a/packages/loot-core/src/server/aql/schema/index.ts
+++ b/packages/loot-core/src/server/aql/schema/index.ts
@@ -170,6 +170,16 @@ export const schema = {
     goal: f('integer'),
     long_goal: f('integer'),
   },
+  dashboard: {
+    id: f('id'),
+    type: f('string', { required: true }),
+    width: f('integer', { required: true }),
+    height: f('integer', { required: true }),
+    x: f('integer', { required: true }),
+    y: f('integer', { required: true }),
+    meta: f('json'),
+    tombstone: f('boolean'),
+  },
 };
 
 export const schemaConfig: SchemaConfig = {
diff --git a/packages/loot-core/src/server/dashboard/app.ts b/packages/loot-core/src/server/dashboard/app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..db6267d10655526970c87c663b5ff8fee8bac4fb
--- /dev/null
+++ b/packages/loot-core/src/server/dashboard/app.ts
@@ -0,0 +1,251 @@
+import isMatch from 'lodash/isMatch';
+
+import { captureException } from '../../platform/exceptions';
+import * as fs from '../../platform/server/fs';
+import { DEFAULT_DASHBOARD_STATE } from '../../shared/dashboard';
+import { q } from '../../shared/query';
+import {
+  type CustomReportEntity,
+  type ExportImportDashboard,
+  type ExportImportDashboardWidget,
+  type ExportImportCustomReportWidget,
+  type Widget,
+} from '../../types/models';
+import { type EverythingButIdOptional } from '../../types/util';
+import { createApp } from '../app';
+import { runQuery as aqlQuery } from '../aql';
+import * as db from '../db';
+import { ValidationError } from '../errors';
+import { requiredFields } from '../models';
+import { mutator } from '../mutators';
+import { reportModel } from '../reports/app';
+import { batchMessages } from '../sync';
+import { undoable } from '../undo';
+
+import { DashboardHandlers } from './types/handlers';
+
+function isExportedCustomReportWidget(
+  widget: ExportImportDashboardWidget,
+): widget is ExportImportCustomReportWidget {
+  return widget.type === 'custom-report';
+}
+
+const exportModel = {
+  validate(dashboard: ExportImportDashboard) {
+    requiredFields('Dashboard', dashboard, ['version', 'widgets']);
+
+    if (!Array.isArray(dashboard.widgets)) {
+      throw new ValidationError(
+        'Invalid dashboard.widgets data type: it must be an array of widgets.',
+      );
+    }
+
+    dashboard.widgets.forEach((widget, idx) => {
+      requiredFields(`Dashboard widget #${idx}`, widget, [
+        'type',
+        'x',
+        'y',
+        'width',
+        'height',
+        ...(isExportedCustomReportWidget(widget) ? ['meta' as const] : []),
+      ]);
+
+      if (!Number.isInteger(widget.x)) {
+        throw new ValidationError(
+          `Invalid widget.${idx}.x data-type for value ${widget.x}.`,
+        );
+      }
+
+      if (!Number.isInteger(widget.y)) {
+        throw new ValidationError(
+          `Invalid widget.${idx}.y data-type for value ${widget.y}.`,
+        );
+      }
+
+      if (!Number.isInteger(widget.width)) {
+        throw new ValidationError(
+          `Invalid widget.${idx}.width data-type for value ${widget.width}.`,
+        );
+      }
+
+      if (!Number.isInteger(widget.height)) {
+        throw new ValidationError(
+          `Invalid widget.${idx}.height data-type for value ${widget.height}.`,
+        );
+      }
+
+      if (
+        ![
+          'net-worth-card',
+          'cash-flow-card',
+          'spending-card',
+          'custom-report',
+        ].includes(widget.type)
+      ) {
+        throw new ValidationError(
+          `Invalid widget.${idx}.type value ${widget.type}.`,
+        );
+      }
+
+      if (isExportedCustomReportWidget(widget)) {
+        reportModel.validate(widget.meta);
+      }
+    });
+  },
+};
+
+async function updateDashboard(
+  widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[],
+) {
+  const { data: dbWidgets } = await aqlQuery(
+    q('dashboard')
+      .filter({ id: { $oneof: widgets.map(({ id }) => id) } })
+      .select('*'),
+  );
+  const dbWidgetMap = new Map(
+    (dbWidgets as Widget[]).map(widget => [widget.id, widget]),
+  );
+
+  await Promise.all(
+    widgets
+      // Perform an update query only if the widget actually has changes
+      .filter(widget => !isMatch(dbWidgetMap.get(widget.id) ?? {}, widget))
+      .map(widget => db.update('dashboard', widget)),
+  );
+}
+
+async function updateDashboardWidget(
+  widget: EverythingButIdOptional<Omit<Widget, 'tombstone'>>,
+) {
+  await db.update('dashboard', widget);
+}
+
+async function resetDashboard() {
+  await batchMessages(async () => {
+    await Promise.all([
+      // Delete all widgets
+      db.deleteAll('dashboard'),
+
+      // Insert the default state
+      ...DEFAULT_DASHBOARD_STATE.map(widget =>
+        db.insertWithSchema('dashboard', widget),
+      ),
+    ]);
+  });
+}
+
+async function addDashboardWidget(
+  widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> &
+    Partial<Pick<Widget, 'x' | 'y'>>,
+) {
+  // If no x & y was provided - calculate it dynamically
+  // The new widget should be the very last one in the list of all widgets
+  if (!('x' in widget) && !('y' in widget)) {
+    const data = await db.first(
+      'SELECT x, y, width, height FROM dashboard WHERE tombstone = 0 ORDER BY y DESC, x DESC',
+    );
+
+    if (!data) {
+      widget.x = 0;
+      widget.y = 0;
+    } else {
+      const xBoundaryCheck = data.x + data.width + widget.width;
+      widget.x = xBoundaryCheck > 12 ? 0 : data.x + data.width;
+      widget.y = data.y + (xBoundaryCheck > 12 ? data.height : 0);
+    }
+  }
+
+  await db.insertWithSchema('dashboard', widget);
+}
+
+async function removeDashboardWidget(widgetId: string) {
+  await db.delete_('dashboard', widgetId);
+}
+
+async function importDashboard({ filepath }: { filepath: string }) {
+  try {
+    if (!(await fs.exists(filepath))) {
+      throw new Error(`File not found at the provided path: ${filepath}`);
+    }
+
+    const content = await fs.readFile(filepath);
+    const parsedContent: ExportImportDashboard = JSON.parse(content);
+
+    exportModel.validate(parsedContent);
+
+    const customReportIds: CustomReportEntity[] = await db.all(
+      'SELECT id from custom_reports',
+    );
+    const customReportIdSet = new Set(customReportIds.map(({ id }) => id));
+
+    await batchMessages(async () => {
+      await Promise.all([
+        // Delete all widgets
+        db.deleteAll('dashboard'),
+
+        // Insert new widgets
+        ...parsedContent.widgets.map(widget =>
+          db.insertWithSchema('dashboard', {
+            type: widget.type,
+            width: widget.width,
+            height: widget.height,
+            x: widget.x,
+            y: widget.y,
+            meta: isExportedCustomReportWidget(widget)
+              ? { id: widget.meta.id }
+              : null,
+          }),
+        ),
+
+        // Insert new custom reports
+        ...parsedContent.widgets
+          .filter(isExportedCustomReportWidget)
+          .filter(({ meta }) => !customReportIdSet.has(meta.id))
+          .map(({ meta }) =>
+            db.insertWithSchema('custom_reports', reportModel.fromJS(meta)),
+          ),
+
+        // Update existing reports
+        ...parsedContent.widgets
+          .filter(isExportedCustomReportWidget)
+          .filter(({ meta }) => customReportIdSet.has(meta.id))
+          .map(({ meta }) =>
+            db.updateWithSchema('custom_reports', {
+              // Replace `undefined` values with `null`
+              // (null clears the value in DB; undefined breaks the operation)
+              ...Object.fromEntries(
+                Object.entries(reportModel.fromJS(meta)).map(([key, value]) => [
+                  key,
+                  value ?? null,
+                ]),
+              ),
+              tombstone: false,
+            }),
+          ),
+      ]);
+    });
+
+    return { status: 'ok' as const };
+  } catch (err: unknown) {
+    if (err instanceof Error) {
+      err.message = 'Error importing file: ' + err.message;
+      captureException(err);
+    }
+    if (err instanceof SyntaxError) {
+      return { error: 'json-parse-error' as const };
+    }
+    if (err instanceof ValidationError) {
+      return { error: 'validation-error' as const, message: err.message };
+    }
+    return { error: 'internal-error' as const };
+  }
+}
+
+export const app = createApp<DashboardHandlers>();
+
+app.method('dashboard-update', mutator(undoable(updateDashboard)));
+app.method('dashboard-update-widget', mutator(undoable(updateDashboardWidget)));
+app.method('dashboard-reset', mutator(undoable(resetDashboard)));
+app.method('dashboard-add-widget', mutator(undoable(addDashboardWidget)));
+app.method('dashboard-remove-widget', mutator(undoable(removeDashboardWidget)));
+app.method('dashboard-import', mutator(undoable(importDashboard)));
diff --git a/packages/loot-core/src/server/dashboard/types/handlers.d.ts b/packages/loot-core/src/server/dashboard/types/handlers.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8d6e8319d38ceb00aa1978600780f2e3855c1e64
--- /dev/null
+++ b/packages/loot-core/src/server/dashboard/types/handlers.d.ts
@@ -0,0 +1,24 @@
+import { type Widget } from '../../../types/models';
+import { type EverythingButIdOptional } from '../../../types/util';
+
+export interface DashboardHandlers {
+  'dashboard-update': (
+    widgets: EverythingButIdOptional<Omit<Widget, 'tombstone'>>[],
+  ) => Promise<void>;
+  'dashboard-update-widget': (
+    widget: EverythingButIdOptional<Omit<Widget, 'tombstone'>>,
+  ) => Promise<void>;
+  'dashboard-reset': () => Promise<void>;
+  'dashboard-add-widget': (
+    widget: Omit<Widget, 'id' | 'x' | 'y' | 'tombstone'> &
+      Partial<Pick<Widget, 'x' | 'y'>>,
+  ) => Promise<void>;
+  'dashboard-remove-widget': (widgetId: string) => Promise<void>;
+  'dashboard-import': (args: {
+    filepath: string;
+  }) => Promise<
+    | { status: 'ok' }
+    | { error: 'json-parse-error' | 'internal-error' }
+    | { error: 'validation-error'; message: string }
+  >;
+}
diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts
index 46fa73423619747b02e144f3dbbf36f723cad5cf..9a7fd514b02e856ceaffe3c19fbfe83733c31f88 100644
--- a/packages/loot-core/src/server/db/index.ts
+++ b/packages/loot-core/src/server/db/index.ts
@@ -241,6 +241,13 @@ export async function delete_(table, id) {
   ]);
 }
 
+export async function deleteAll(table: string) {
+  const rows: Array<{ id: string }> = await all(`
+    SELECT id FROM ${table} WHERE tombstone = 0
+  `);
+  await Promise.all(rows.map(({ id }) => delete_(table, id)));
+}
+
 export async function selectWithSchema(table, sql, params) {
   const rows = await runQuery(sql, params, true);
   return rows
@@ -540,7 +547,7 @@ export function getCommonPayees() {
   const threeMonthsAgo = '20240201';
   const limit = 10;
   return all(`
-    SELECT 	p.id as id, p.name as name, p.favorite as favorite, 
+    SELECT     p.id as id, p.name as name, p.favorite as favorite,
       p.category as category, TRUE as common, NULL as transfer_acct,
     count(*) as c, 
     max(t.date) as latest
diff --git a/packages/loot-core/src/server/errors.ts b/packages/loot-core/src/server/errors.ts
index d7228baab8e83a9c3336dec637c8312d7774fbff..09be2e9f62e216b04076ce5387f50f1e14e97139 100644
--- a/packages/loot-core/src/server/errors.ts
+++ b/packages/loot-core/src/server/errors.ts
@@ -51,6 +51,8 @@ export class SyncError extends Error {
   }
 }
 
+export class ValidationError extends Error {}
+
 export class TransactionError extends Error {}
 
 export class RuleError extends Error {
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index 3dee2fcc7e6d89a37456e48fb835aade039fd4c9..26dd02b6282d8aca5a13a9a9f6053daac741b628 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -38,6 +38,7 @@ import {
 import { app as budgetApp } from './budget/app';
 import * as budget from './budget/base';
 import * as cloudStorage from './cloud-storage';
+import { app as dashboardApp } from './dashboard/app';
 import * as db from './db';
 import * as mappings from './db/mappings';
 import * as encryption from './encryption';
@@ -2056,6 +2057,7 @@ app.handlers = handlers;
 app.combine(
   schedulesApp,
   budgetApp,
+  dashboardApp,
   notesApp,
   toolsApp,
   filtersApp,
diff --git a/packages/loot-core/src/server/migrate/migrations.ts b/packages/loot-core/src/server/migrate/migrations.ts
index f7a7f524e0b46498055c105485fc1fda6ebc01b9..4243b6a48357d34de72c0652e103f0e9b09a4d8d 100644
--- a/packages/loot-core/src/server/migrate/migrations.ts
+++ b/packages/loot-core/src/server/migrate/migrations.ts
@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
 
 import m1632571489012 from '../../../migrations/1632571489012_remove_cache';
 import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories';
+import m1722804019000 from '../../../migrations/1722804019000_create_dashboard_table';
 import * as fs from '../../platform/server/fs';
 import * as sqlite from '../../platform/server/sqlite';
 
@@ -15,6 +16,7 @@ let MIGRATIONS_DIR = fs.migrationsPath;
 const javascriptMigrations = {
   1632571489012: m1632571489012,
   1722717601000: m1722717601000,
+  1722804019000: m1722804019000,
 };
 
 export async function withMigrationsDir(
diff --git a/packages/loot-core/src/server/models.ts b/packages/loot-core/src/server/models.ts
index 03ddbeab06063e5058faf2f8f8ce5abe6b1f1314..772ead3f154b3d90db1bc9d720a37c5acc22e47c 100644
--- a/packages/loot-core/src/server/models.ts
+++ b/packages/loot-core/src/server/models.ts
@@ -5,6 +5,8 @@ import {
   PayeeEntity,
 } from '../types/models';
 
+import { ValidationError } from './errors';
+
 export function requiredFields<T extends object, K extends keyof T>(
   name: string,
   row: T,
@@ -14,11 +16,11 @@ export function requiredFields<T extends object, K extends keyof T>(
   fields.forEach(field => {
     if (update) {
       if (row.hasOwnProperty(field) && row[field] == null) {
-        throw new Error(`${name} is missing field ${String(field)}`);
+        throw new ValidationError(`${name} is missing field ${String(field)}`);
       }
     } else {
       if (!row.hasOwnProperty(field) || row[field] == null) {
-        throw new Error(`${name} is missing field ${String(field)}`);
+        throw new ValidationError(`${name} is missing field ${String(field)}`);
       }
     }
   });
diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts
index b56405ed72879b1b7c5d689d4949be61e63db7e3..faa86b1bae07deff08168a811fb85772c5c30c34 100644
--- a/packages/loot-core/src/server/reports/app.ts
+++ b/packages/loot-core/src/server/reports/app.ts
@@ -6,19 +6,25 @@ import {
 } from '../../types/models';
 import { createApp } from '../app';
 import * as db from '../db';
+import { ValidationError } from '../errors';
 import { requiredFields } from '../models';
 import { mutator } from '../mutators';
 import { undoable } from '../undo';
 
 import { ReportsHandlers } from './types/handlers';
 
-const reportModel = {
-  validate(report: CustomReportEntity, { update }: { update?: boolean } = {}) {
+export const reportModel = {
+  validate(
+    report: Omit<CustomReportEntity, 'tombstone'>,
+    { update }: { update?: boolean } = {},
+  ) {
     requiredFields('Report', report, ['conditionsOp'], update);
 
     if (!update || 'conditionsOp' in report) {
       if (!['and', 'or'].includes(report.conditionsOp)) {
-        throw new Error('Invalid filter conditionsOp: ' + report.conditionsOp);
+        throw new ValidationError(
+          'Invalid filter conditionsOp: ' + report.conditionsOp,
+        );
       }
     }
 
diff --git a/packages/loot-core/src/shared/dashboard.ts b/packages/loot-core/src/shared/dashboard.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1479d9b3b4bd5f990ad5e97fc49bb54338cd2b1c
--- /dev/null
+++ b/packages/loot-core/src/shared/dashboard.ts
@@ -0,0 +1,20 @@
+import { type NewWidget } from '../types/models';
+
+export const DEFAULT_DASHBOARD_STATE: NewWidget[] = [
+  {
+    type: 'net-worth-card',
+    width: 8,
+    height: 2,
+    x: 0,
+    y: 0,
+    meta: null,
+  },
+  {
+    type: 'cash-flow-card',
+    width: 4,
+    height: 2,
+    x: 8,
+    y: 0,
+    meta: null,
+  },
+];
diff --git a/packages/loot-core/src/types/handlers.d.ts b/packages/loot-core/src/types/handlers.d.ts
index 5eb8f281af6c24547e72d0b67ab750b2273d7c19..39d69edbd19840436cc966203573ca03e1f49b7f 100644
--- a/packages/loot-core/src/types/handlers.d.ts
+++ b/packages/loot-core/src/types/handlers.d.ts
@@ -1,4 +1,5 @@
 import type { BudgetHandlers } from '../server/budget/types/handlers';
+import type { DashboardHandlers } from '../server/dashboard/types/handlers';
 import type { FiltersHandlers } from '../server/filters/types/handlers';
 import type { NotesHandlers } from '../server/notes/types/handlers';
 import type { ReportsHandlers } from '../server/reports/types/handlers';
@@ -13,6 +14,7 @@ export interface Handlers
   extends ServerHandlers,
     ApiHandlers,
     BudgetHandlers,
+    DashboardHandlers,
     FiltersHandlers,
     NotesHandlers,
     ReportsHandlers,
diff --git a/packages/loot-core/src/types/models/dashboard.d.ts b/packages/loot-core/src/types/models/dashboard.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..87e809a127a8e7f40f12ef6944d0686aba095aff
--- /dev/null
+++ b/packages/loot-core/src/types/models/dashboard.d.ts
@@ -0,0 +1,47 @@
+import { type CustomReportEntity } from './reports';
+
+type AbstractWidget<
+  T extends string,
+  Meta extends Record<string, unknown> = null,
+> = {
+  id: string;
+  type: T;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  meta: Meta;
+  tombstone: boolean;
+};
+
+type NetWorthWidget = AbstractWidget<'net-worth-card'>;
+type CashFlowWidget = AbstractWidget<'cash-flow-card'>;
+type SpendingWidget = AbstractWidget<'spending-card'>;
+export type CustomReportWidget = AbstractWidget<
+  'custom-report',
+  { id: string }
+>;
+
+type SpecializedWidget = NetWorthWidget | CashFlowWidget | SpendingWidget;
+export type Widget = SpecializedWidget | CustomReportWidget;
+export type NewWidget = Omit<Widget, 'id' | 'tombstone'>;
+
+// Exported/imported (json) widget definition
+export type ExportImportCustomReportWidget = Omit<
+  CustomReportWidget,
+  'id' | 'meta' | 'tombstone'
+> & {
+  meta: Omit<CustomReportEntity, 'tombstone'>;
+};
+export type ExportImportDashboardWidget = Omit<
+  ExportImportCustomReportWidget | SpecializedWidget,
+  'tombstone'
+>;
+
+export type ExportImportDashboard = {
+  // Dashboard exports can be versioned; currently we support
+  // only a single version, but lets account for multiple
+  // future versions
+  version: 1;
+  widgets: ExportImportDashboardWidget[];
+};
diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts
index 1e08cb06a26b34cedf3f0555d52c9fb7b657f34d..3e64570feb870bfd8b4e635846be6d639979de6f 100644
--- a/packages/loot-core/src/types/models/index.d.ts
+++ b/packages/loot-core/src/types/models/index.d.ts
@@ -1,6 +1,7 @@
 export type * from './account';
 export type * from './category';
 export type * from './category-group';
+export type * from './dashboard';
 export type * from './gocardless';
 export type * from './simplefin';
 export type * from './note';
diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts
index a348eca04b405ed3f1da0e22dfbac93dac0ddb80..c1d7054ea481f70665ee1edaa01bb2aa3c15801b 100644
--- a/packages/loot-core/src/types/prefs.d.ts
+++ b/packages/loot-core/src/types/prefs.d.ts
@@ -3,6 +3,7 @@ import { type numberFormats } from '../shared/util';
 import { spendingReportTimeType } from './models/reports';
 
 export type FeatureFlag =
+  | 'dashboards'
   | 'reportBudget'
   | 'goalTemplatesEnabled'
   | 'spendingReport'
diff --git a/packages/loot-core/src/types/util.d.ts b/packages/loot-core/src/types/util.d.ts
index f478d90061fd1ff1b75e7307f5197d99b2f38f43..3a7d6881c27475793158452f407e3daf8513bb9d 100644
--- a/packages/loot-core/src/types/util.d.ts
+++ b/packages/loot-core/src/types/util.d.ts
@@ -3,3 +3,7 @@ export type EmptyObject = Record<never, never>;
 export type StripNever<T> = {
   [K in keyof T as T[K] extends never ? never : K]: T[K];
 };
+
+export type EverythingButIdOptional<T> = { id: T['id'] } & Partial<
+  Omit<T, 'id'>
+>;
diff --git a/packages/loot-core/typings/window.d.ts b/packages/loot-core/typings/window.d.ts
index ddb0da659cf326f52b4744ab2233180ab16f98d8..7f9173c6ae7e76a99a8478d1839f286a8bd257c5 100644
--- a/packages/loot-core/typings/window.d.ts
+++ b/packages/loot-core/typings/window.d.ts
@@ -7,7 +7,7 @@ declare global {
       ACTUAL_VERSION: string;
       openURLInBrowser: (url: string) => void;
       saveFile: (
-        contents: Buffer,
+        contents: string | Buffer,
         filename: string,
         dialogTitle: string,
       ) => void;
diff --git a/upcoming-release-notes/3044.md b/upcoming-release-notes/3044.md
index 63158f9c5de9f090a51260460f74edb090189c27..bf910bfb4b7753b98d8b96f8ca2054ea6cb0d891 100644
--- a/upcoming-release-notes/3044.md
+++ b/upcoming-release-notes/3044.md
@@ -1,6 +1,6 @@
 ---
 category: Bugfix
-authors: [youngcw,wdpk]
+authors: [youngcw, wdpk]
 ---
 
-Fix decimal comma parsing for ofx files
\ No newline at end of file
+Fix decimal comma parsing for ofx files
diff --git a/upcoming-release-notes/3140.md b/upcoming-release-notes/3140.md
index 0802097b93cf8ba8a079b6899cf2ec19e9ee4ab5..badd3c96ae201d634c60e4750327d0e1d91c2466 100644
--- a/upcoming-release-notes/3140.md
+++ b/upcoming-release-notes/3140.md
@@ -3,4 +3,4 @@ category: Enhancements
 authors: [rodriguestiago0]
 ---
 
-Add `reset-hold` and `hold-for-next-month` methods to the API
\ No newline at end of file
+Add `reset-hold` and `hold-for-next-month` methods to the API
diff --git a/upcoming-release-notes/3183.md b/upcoming-release-notes/3183.md
index 852936ebd44a4eb2624d8e8cbd3aba75f263dd4d..7d783c71e904b61e243a1d785f79c163e3371a04 100644
--- a/upcoming-release-notes/3183.md
+++ b/upcoming-release-notes/3183.md
@@ -1,6 +1,6 @@
 ---
 category: Maintenance
-authors: [ ACWalker ]
+authors: [ACWalker]
 ---
 
 Add unit tests for the existing goal template types.
diff --git a/upcoming-release-notes/3220.md b/upcoming-release-notes/3220.md
index 4db0be806d4604087560dfad50ecb2523ddb0b6c..2baac8d13986ad0d3e7213c07ddc13c0ce30c36e 100644
--- a/upcoming-release-notes/3220.md
+++ b/upcoming-release-notes/3220.md
@@ -4,4 +4,3 @@ authors: [MikesGlitch]
 ---
 
 Fix electron builds throwing "We had an unknown problem opening file"
-
diff --git a/upcoming-release-notes/3221.md b/upcoming-release-notes/3221.md
index 13d9f0bb790ceb1031b9ae4c56f4d5c9e1dcf359..6328751b22ec44ce9ebd7a429a782c9b34666e58 100644
--- a/upcoming-release-notes/3221.md
+++ b/upcoming-release-notes/3221.md
@@ -1,6 +1,6 @@
 ---
 category: Maintenance
-authors: [ ACWalker ]
+authors: [ACWalker]
 ---
 
 Extract, refactor and test note handling logic from `goaltemplates.ts` file.
diff --git a/upcoming-release-notes/3231.md b/upcoming-release-notes/3231.md
new file mode 100644
index 0000000000000000000000000000000000000000..38245cc9058eed95def0e2a660e81ffba5237e8d
--- /dev/null
+++ b/upcoming-release-notes/3231.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [MatissJanis]
+---
+
+Customizable dashboard for reports page - drag-able and resizable widgets.
diff --git a/yarn.lock b/yarn.lock
index 947d418bec5d42b2739edd73ede8df3303bdf4da..7e0f725013298e7f857902a2f169b477d611f111 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -77,6 +77,7 @@ __metadata:
     "@types/promise-retry": "npm:^1.1.6"
     "@types/react": "npm:^18.2.0"
     "@types/react-dom": "npm:^18.2.1"
+    "@types/react-grid-layout": "npm:^1"
     "@types/react-modal": "npm:^3.16.0"
     "@types/react-redux": "npm:^7.1.25"
     "@types/uuid": "npm:^9.0.2"
@@ -111,6 +112,7 @@ __metadata:
     react-dnd-html5-backend: "npm:^16.0.1"
     react-dom: "npm:18.2.0"
     react-error-boundary: "npm:^4.0.12"
+    react-grid-layout: "npm:^1.4.4"
     react-hotkeys-hook: "npm:^4.5.0"
     react-i18next: "npm:^14.1.2"
     react-markdown: "npm:^8.0.7"
@@ -5576,6 +5578,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@types/react-grid-layout@npm:^1":
+  version: 1.3.5
+  resolution: "@types/react-grid-layout@npm:1.3.5"
+  dependencies:
+    "@types/react": "npm:*"
+  checksum: 10/21599054dfa977ed8445b1ab3198a842531cb36bd620564c3f8a469688cbea051149eb644a99b2f8f6d02f8d9909a6145668f385781b5647601e9663de216946
+  languageName: node
+  linkType: hard
+
 "@types/react-modal@npm:^3.16.0":
   version: 3.16.0
   resolution: "@types/react-modal@npm:3.16.0"
@@ -7734,6 +7745,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"clsx@npm:^1.1.1":
+  version: 1.2.1
+  resolution: "clsx@npm:1.2.1"
+  checksum: 10/5ded6f61f15f1fa0350e691ccec43a28b12fb8e64c8e94715f2a937bc3722d4c3ed41d6e945c971fc4dcc2a7213a43323beaf2e1c28654af63ba70c9968a8643
+  languageName: node
+  linkType: hard
+
 "clsx@npm:^2.0.0":
   version: 2.1.0
   resolution: "clsx@npm:2.1.0"
@@ -10099,6 +10117,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"fast-equals@npm:^4.0.3":
+  version: 4.0.3
+  resolution: "fast-equals@npm:4.0.3"
+  checksum: 10/04c1ff47b79923314e9b63ec6c81beeaa5e3b9588ec230ee6aff7ece725ff834a72abf627055055127bd0f53ae8a92cc04c3a6e187783fd932dbef743f9b13bf
+  languageName: node
+  linkType: hard
+
 "fast-equals@npm:^5.0.0":
   version: 5.0.1
   resolution: "fast-equals@npm:5.0.1"
@@ -15743,7 +15768,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
+"prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
   version: 15.8.1
   resolution: "prop-types@npm:15.8.1"
   dependencies:
@@ -16020,6 +16045,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5":
+  version: 4.4.6
+  resolution: "react-draggable@npm:4.4.6"
+  dependencies:
+    clsx: "npm:^1.1.1"
+    prop-types: "npm:^15.8.1"
+  peerDependencies:
+    react: ">= 16.3.0"
+    react-dom: ">= 16.3.0"
+  checksum: 10/51b9ac7f913797fc1cebc30ae383f346883033c45eb91e9b0b92e9ebd224bb1545b4ae2391825b649b798cc711a38351a5f41be24d949c64c6703ebc24eba661
+  languageName: node
+  linkType: hard
+
 "react-error-boundary@npm:^4.0.12":
   version: 4.0.12
   resolution: "react-error-boundary@npm:4.0.12"
@@ -16031,6 +16069,23 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-grid-layout@npm:^1.4.4":
+  version: 1.4.4
+  resolution: "react-grid-layout@npm:1.4.4"
+  dependencies:
+    clsx: "npm:^2.0.0"
+    fast-equals: "npm:^4.0.3"
+    prop-types: "npm:^15.8.1"
+    react-draggable: "npm:^4.4.5"
+    react-resizable: "npm:^3.0.5"
+    resize-observer-polyfill: "npm:^1.5.1"
+  peerDependencies:
+    react: ">= 16.3.0"
+    react-dom: ">= 16.3.0"
+  checksum: 10/43c9bc7e8ce5a902720ebe559b1551724615c617391b040a0a275597748e1927b293c5ddcb73053b1925909e1c293948b4969a35799d541bf9d8886e07ac0085
+  languageName: node
+  linkType: hard
+
 "react-hotkeys-hook@npm:^4.5.0":
   version: 4.5.0
   resolution: "react-hotkeys-hook@npm:4.5.0"
@@ -16149,6 +16204,18 @@ __metadata:
   languageName: node
   linkType: hard
 
+"react-resizable@npm:^3.0.5":
+  version: 3.0.5
+  resolution: "react-resizable@npm:3.0.5"
+  dependencies:
+    prop-types: "npm:15.x"
+    react-draggable: "npm:^4.0.3"
+  peerDependencies:
+    react: ">= 16.3"
+  checksum: 10/745fad6ac827857b3a80d1d648b8d6723aa72fc17d5410a01707073f3d37b4adf6e0354dfe3cc33dee34d6e546a3fbd5603ef73e385dfc5218a425a39bf96275
+  languageName: node
+  linkType: hard
+
 "react-router-dom@npm:6.21.3":
   version: 6.21.3
   resolution: "react-router-dom@npm:6.21.3"
@@ -16619,6 +16686,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"resize-observer-polyfill@npm:^1.5.1":
+  version: 1.5.1
+  resolution: "resize-observer-polyfill@npm:1.5.1"
+  checksum: 10/e10ee50cd6cf558001de5c6fb03fee15debd011c2f694564b71f81742eef03fb30d6c2596d1d5bf946d9991cb692fcef529b7bd2e4057041377ecc9636c753ce
+  languageName: node
+  linkType: hard
+
 "resolve-alpn@npm:^1.0.0":
   version: 1.2.1
   resolution: "resolve-alpn@npm:1.2.1"