From 63d9547e7c81fd0cd205c8ff823308263131e7c6 Mon Sep 17 00:00:00 2001
From: Matiss Janis Aboltins <matiss@mja.lv>
Date: Sun, 4 Aug 2024 20:09:54 +0100
Subject: [PATCH] :recycle: (reports) unify selectedCategories and conditions
 (#3178)

---
 .../src/components/reports/ReportSidebar.tsx  |  37 +++--
 .../reports/reports/CustomReport.tsx          | 126 +++++++++++++++---
 .../reports/reports/GetCardData.tsx           |   2 -
 .../components/reports/reports/Spending.tsx   |   6 +-
 .../reports/reports/SpendingCard.tsx          |   6 +-
 .../spreadsheets/custom-spreadsheet.ts        |  12 --
 .../spreadsheets/grouped-spreadsheet.ts       |  11 --
 .../reports/spreadsheets/makeQuery.ts         |  15 ---
 .../spreadsheets/spending-spreadsheet.ts      |  10 +-
 ...601000_reports_move_selected_categories.js |  55 ++++++++
 .../src/client/data-hooks/reports.ts          |   1 -
 .../loot-core/src/server/aql/schema/index.ts  |   1 -
 .../src/server/migrate/migrations.ts          |   2 +
 packages/loot-core/src/server/reports/app.ts  |   2 -
 packages/loot-core/src/shared/rules.ts        |   6 +
 .../loot-core/src/types/models/reports.d.ts   |   3 -
 upcoming-release-notes/3178.md                |   6 +
 17 files changed, 200 insertions(+), 101 deletions(-)
 create mode 100644 packages/loot-core/migrations/1722717601000_reports_move_selected_categories.js
 create mode 100644 upcoming-release-notes/3178.md

diff --git a/packages/desktop-client/src/components/reports/ReportSidebar.tsx b/packages/desktop-client/src/components/reports/ReportSidebar.tsx
index 5c9971430..6f73e4def 100644
--- a/packages/desktop-client/src/components/reports/ReportSidebar.tsx
+++ b/packages/desktop-client/src/components/reports/ReportSidebar.tsx
@@ -8,6 +8,7 @@ import { type LocalPrefs } from 'loot-core/types/prefs';
 
 import { styles } from '../../style/styles';
 import { theme } from '../../style/theme';
+import { Information } from '../alerts';
 import { Button } from '../common/Button';
 import { Menu } from '../common/Menu';
 import { Popover } from '../common/Popover';
@@ -26,6 +27,7 @@ import { setSessionReport } from './setSessionReport';
 
 type ReportSidebarProps = {
   customReportItems: CustomReportEntity;
+  selectedCategories: CategoryEntity[];
   categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] };
   dateRangeLine: number;
   allIntervals: { name: string; pretty: string }[];
@@ -55,10 +57,12 @@ type ReportSidebarProps = {
   defaultModeItems: (graph: string, item: string) => void;
   earliestTransaction: string;
   firstDayOfWeekIdx: LocalPrefs['firstDayOfWeekIdx'];
+  isComplexCategoryCondition?: boolean;
 };
 
 export function ReportSidebar({
   customReportItems,
+  selectedCategories,
   categories,
   dateRangeLine,
   allIntervals,
@@ -82,6 +86,7 @@ export function ReportSidebar({
   defaultModeItems,
   earliestTransaction,
   firstDayOfWeekIdx,
+  isComplexCategoryCondition = false,
 }: ReportSidebarProps) {
   const [menuOpen, setMenuOpen] = useState(false);
   const triggerRef = useRef(null);
@@ -536,19 +541,25 @@ export function ReportSidebar({
           minHeight: 200,
         }}
       >
-        <CategorySelector
-          categoryGroups={categories.grouped.filter(f => {
-            return customReportItems.showHiddenCategories || !f.hidden
-              ? true
-              : false;
-          })}
-          selectedCategories={customReportItems.selectedCategories || []}
-          setSelectedCategories={e => {
-            setSelectedCategories(e);
-            onReportChange({ type: 'modify' });
-          }}
-          showHiddenCategories={customReportItems.showHiddenCategories}
-        />
+        {isComplexCategoryCondition ? (
+          <Information>
+            Remove active category filters to show the category selector.
+          </Information>
+        ) : (
+          <CategorySelector
+            categoryGroups={categories.grouped.filter(f => {
+              return customReportItems.showHiddenCategories || !f.hidden
+                ? true
+                : false;
+            })}
+            selectedCategories={selectedCategories || []}
+            setSelectedCategories={e => {
+              setSelectedCategories(e);
+              onReportChange({ type: 'modify' });
+            }}
+            showHiddenCategories={customReportItems.showHiddenCategories}
+          />
+        )}
       </View>
     </View>
   );
diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
index edeba3fbf..12fec89e3 100644
--- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
+++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx
@@ -55,6 +55,49 @@ import { createGroupedSpreadsheet } from '../spreadsheets/grouped-spreadsheet';
 import { useReport } from '../useReport';
 import { fromDateRepr } from '../util';
 
+/**
+ * Transform `selectedCategories` into `conditions`.
+ */
+function useSelectedCategories(
+  conditions: RuleConditionEntity[],
+  categories: CategoryEntity[],
+): CategoryEntity[] {
+  const existingCategoryCondition = useMemo(
+    () => conditions.find(({ field }) => field === 'category'),
+    [conditions],
+  );
+
+  return useMemo(() => {
+    if (!existingCategoryCondition) {
+      return categories;
+    }
+
+    switch (existingCategoryCondition.op) {
+      case 'is':
+        return categories.filter(
+          ({ id }) => id === existingCategoryCondition.value,
+        );
+
+      case 'isNot':
+        return categories.filter(
+          ({ id }) => existingCategoryCondition.value !== id,
+        );
+
+      case 'oneOf':
+        return categories.filter(({ id }) =>
+          existingCategoryCondition.value.includes(id),
+        );
+
+      case 'notOneOf':
+        return categories.filter(
+          ({ id }) => !existingCategoryCondition.value.includes(id),
+        );
+    }
+
+    return categories;
+  }, [existingCategoryCondition, categories]);
+}
+
 export function CustomReport() {
   const categories = useCategories();
   const { isNarrowWidth } = useResponsive();
@@ -102,9 +145,65 @@ export function CustomReport() {
     }>
   >([]);
 
-  const [selectedCategories, setSelectedCategories] = useState(
-    loadReport.selectedCategories,
-  );
+  // Complex category conditions are:
+  // - conditions with multiple "category" fields
+  // - conditions with "category" field that use "contains", "doesNotContain" or "matches" operations
+  const isComplexCategoryCondition =
+    !!conditions.find(
+      ({ field, op }) =>
+        field === 'category' &&
+        ['contains', 'doesNotContain', 'matches'].includes(op),
+    ) || conditions.filter(({ field }) => field === 'category').length >= 2;
+
+  const setSelectedCategories = (newCategories: CategoryEntity[]) => {
+    const newCategoryIdSet = new Set(newCategories.map(({ id }) => id));
+    const allCategoryIds = categories.list.map(({ id }) => id);
+    const allCategoriesSelected = !allCategoryIds.find(
+      id => !newCategoryIdSet.has(id),
+    );
+    const newCondition = {
+      field: 'category',
+      op: 'oneOf',
+      value: newCategories.map(({ id }) => id),
+      type: 'id',
+    } satisfies RuleConditionEntity;
+
+    const existingCategoryCondition = conditions.find(
+      ({ field }) => field === 'category',
+    );
+
+    // If the existing conditions already have one for "category" - replace it
+    if (existingCategoryCondition) {
+      // If we selected all categories - remove the filter (default state)
+      if (allCategoriesSelected) {
+        onDeleteFilter(existingCategoryCondition);
+        return;
+      }
+
+      // Update the "notOneOf" condition if it's already set
+      if (existingCategoryCondition.op === 'notOneOf') {
+        onUpdateFilter(existingCategoryCondition, {
+          ...existingCategoryCondition,
+          value: allCategoryIds.filter(id => !newCategoryIdSet.has(id)),
+        });
+        return;
+      }
+
+      // Otherwise use `oneOf` condition
+      onUpdateFilter(existingCategoryCondition, newCondition);
+      return;
+    }
+
+    // Don't add a new filter if all categories are selected (default state)
+    if (allCategoriesSelected) {
+      return;
+    }
+
+    // If the existing conditions does not have a "category" - append a new one
+    onApplyFilter(newCondition);
+  };
+
+  const selectedCategories = useSelectedCategories(conditions, categories.list);
   const [startDate, setStartDate] = useState(loadReport.startDate);
   const [endDate, setEndDate] = useState(loadReport.endDate);
   const [mode, setMode] = useState(loadReport.mode);
@@ -146,12 +245,6 @@ export function CustomReport() {
       : loadReport.savedStatus ?? 'new',
   );
 
-  useEffect(() => {
-    if (selectedCategories === undefined && categories.list.length !== 0) {
-      setSelectedCategories(categories.list);
-    }
-  }, [categories, selectedCategories]);
-
   useEffect(() => {
     async function run() {
       onApplyFilter(null);
@@ -260,7 +353,6 @@ export function CustomReport() {
       endDate,
       interval,
       categories,
-      selectedCategories,
       conditions,
       conditionsOp,
       showEmpty,
@@ -276,7 +368,6 @@ export function CustomReport() {
     interval,
     balanceTypeOp,
     categories,
-    selectedCategories,
     conditions,
     conditionsOp,
     showEmpty,
@@ -293,7 +384,6 @@ export function CustomReport() {
       endDate,
       interval,
       categories,
-      selectedCategories,
       conditions,
       conditionsOp,
       showEmpty,
@@ -315,7 +405,6 @@ export function CustomReport() {
     groupBy,
     balanceTypeOp,
     categories,
-    selectedCategories,
     payees,
     accounts,
     conditions,
@@ -348,7 +437,6 @@ export function CustomReport() {
     showHiddenCategories,
     includeCurrentInterval,
     showUncategorized,
-    selectedCategories,
     graphType,
     conditions,
     conditionsOp,
@@ -471,13 +559,6 @@ export function CustomReport() {
   };
 
   const setReportData = (input: CustomReportEntity) => {
-    const selectAll: CategoryEntity[] = [];
-    categories.grouped.map(categoryGroup =>
-      (categoryGroup.categories || []).map(category =>
-        selectAll.push(category),
-      ),
-    );
-
     setStartDate(input.startDate);
     setEndDate(input.endDate);
     setIsDateStatic(input.isDateStatic);
@@ -491,7 +572,6 @@ export function CustomReport() {
     setShowHiddenCategories(input.showHiddenCategories);
     setIncludeCurrentInterval(input.includeCurrentInterval);
     setShowUncategorized(input.showUncategorized);
-    setSelectedCategories(input.selectedCategories || selectAll);
     setGraphType(input.graphType);
     onApplyFilter(null);
     (input.conditions || []).forEach(condition => onApplyFilter(condition));
@@ -578,6 +658,7 @@ export function CustomReport() {
         {!isNarrowWidth && (
           <ReportSidebar
             customReportItems={customReportItems}
+            selectedCategories={selectedCategories}
             categories={categories}
             dateRangeLine={dateRangeLine}
             allIntervals={allIntervals}
@@ -601,6 +682,7 @@ export function CustomReport() {
             defaultModeItems={defaultModeItems}
             earliestTransaction={earliestTransaction}
             firstDayOfWeekIdx={firstDayOfWeekIdx}
+            isComplexCategoryCondition={isComplexCategoryCondition}
           />
         )}
         <View
diff --git a/packages/desktop-client/src/components/reports/reports/GetCardData.tsx b/packages/desktop-client/src/components/reports/reports/GetCardData.tsx
index 5004f19c0..56331559a 100644
--- a/packages/desktop-client/src/components/reports/reports/GetCardData.tsx
+++ b/packages/desktop-client/src/components/reports/reports/GetCardData.tsx
@@ -114,7 +114,6 @@ export function GetCardData({
       endDate,
       interval: report.interval,
       categories,
-      selectedCategories: report.selectedCategories ?? categories.list,
       conditions: report.conditions ?? [],
       conditionsOp: report.conditionsOp,
       showEmpty: report.showEmpty,
@@ -131,7 +130,6 @@ export function GetCardData({
       endDate,
       interval: report.interval,
       categories,
-      selectedCategories: report.selectedCategories ?? categories.list,
       conditions: report.conditions ?? [],
       conditionsOp: report.conditionsOp,
       showEmpty: report.showEmpty,
diff --git a/packages/desktop-client/src/components/reports/reports/Spending.tsx b/packages/desktop-client/src/components/reports/reports/Spending.tsx
index 4f7391376..1182eec4a 100644
--- a/packages/desktop-client/src/components/reports/reports/Spending.tsx
+++ b/packages/desktop-client/src/components/reports/reports/Spending.tsx
@@ -4,7 +4,6 @@ import * as monthUtils from 'loot-core/src/shared/months';
 import { amountToCurrency } from 'loot-core/src/shared/util';
 import { type RuleConditionEntity } from 'loot-core/types/models/rule';
 
-import { useCategories } from '../../../hooks/useCategories';
 import { useFilters } from '../../../hooks/useFilters';
 import { useLocalPref } from '../../../hooks/useLocalPref';
 import { useNavigate } from '../../../hooks/useNavigate';
@@ -30,8 +29,6 @@ import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'
 import { useReport } from '../useReport';
 
 export function Spending() {
-  const categories = useCategories();
-
   const {
     conditions,
     conditionsOp,
@@ -71,13 +68,12 @@ export function Spending() {
   const getGraphData = useMemo(() => {
     setDataCheck(false);
     return createSpendingSpreadsheet({
-      categories,
       conditions,
       conditionsOp,
       setDataCheck,
       compare,
     });
-  }, [categories, conditions, conditionsOp, compare]);
+  }, [conditions, conditionsOp, compare]);
 
   const data = useReport('default', getGraphData);
   const navigate = useNavigate();
diff --git a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
index 83a313bca..863157c48 100644
--- a/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
+++ b/packages/desktop-client/src/components/reports/reports/SpendingCard.tsx
@@ -3,7 +3,6 @@ import React, { useState, useMemo } from 'react';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { amountToCurrency } from 'loot-core/src/shared/util';
 
-import { useCategories } from '../../../hooks/useCategories';
 import { useLocalPref } from '../../../hooks/useLocalPref';
 import { styles } from '../../../style/styles';
 import { theme } from '../../../style/theme';
@@ -18,8 +17,6 @@ import { createSpendingSpreadsheet } from '../spreadsheets/spending-spreadsheet'
 import { useReport } from '../useReport';
 
 export function SpendingCard() {
-  const categories = useCategories();
-
   const [isCardHovered, setIsCardHovered] = useState(false);
   const [spendingReportFilter = ''] = useLocalPref('spendingReportFilter');
   const [spendingReportTime = 'lastMonth'] = useLocalPref('spendingReportTime');
@@ -30,12 +27,11 @@ export function SpendingCard() {
   const parseFilter = spendingReportFilter && JSON.parse(spendingReportFilter);
   const getGraphData = useMemo(() => {
     return createSpendingSpreadsheet({
-      categories,
       conditions: parseFilter.conditions,
       conditionsOp: parseFilter.conditionsOp,
       compare: spendingReportCompare,
     });
-  }, [categories, parseFilter, spendingReportCompare]);
+  }, [parseFilter, spendingReportCompare]);
 
   const data = useReport('default', getGraphData);
   const todayDay =
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts
index 86a2fd6eb..194a3fd3e 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/custom-spreadsheet.ts
@@ -39,7 +39,6 @@ export type createCustomSpreadsheetProps = {
   endDate: string;
   interval: string;
   categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] };
-  selectedCategories: CategoryEntity[];
   conditions: RuleConditionEntity[];
   conditionsOp: string;
   showEmpty: boolean;
@@ -60,7 +59,6 @@ export function createCustomSpreadsheet({
   endDate,
   interval,
   categories,
-  selectedCategories,
   conditions = [],
   conditionsOp,
   showEmpty,
@@ -77,14 +75,6 @@ export function createCustomSpreadsheet({
 }: createCustomSpreadsheetProps) {
   const [categoryList, categoryGroup] = categoryLists(categories);
 
-  const categoryFilter = (categories.list || []).filter(
-    category =>
-      selectedCategories &&
-      selectedCategories.some(
-        selectedCategory => selectedCategory.id === category.id,
-      ),
-  );
-
   const [groupByList, groupByLabel]: [
     groupByList: UncategorizedEntity[],
     groupByLabel: 'category' | 'categoryGroup' | 'payee' | 'account',
@@ -112,7 +102,6 @@ export function createCustomSpreadsheet({
           startDate,
           endDate,
           interval,
-          categoryFilter,
           conditionsOpKey,
           filters,
         ),
@@ -123,7 +112,6 @@ export function createCustomSpreadsheet({
           startDate,
           endDate,
           interval,
-          categoryFilter,
           conditionsOpKey,
           filters,
         ),
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts
index 414bb93a8..26b7e9439 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/grouped-spreadsheet.ts
@@ -25,7 +25,6 @@ export function createGroupedSpreadsheet({
   endDate,
   interval,
   categories,
-  selectedCategories,
   conditions = [],
   conditionsOp,
   showEmpty,
@@ -37,14 +36,6 @@ export function createGroupedSpreadsheet({
 }: createCustomSpreadsheetProps) {
   const [categoryList, categoryGroup] = categoryLists(categories);
 
-  const categoryFilter = (categories.list || []).filter(
-    category =>
-      selectedCategories &&
-      selectedCategories.some(
-        selectedCategory => selectedCategory.id === category.id,
-      ),
-  );
-
   return async (
     spreadsheet: ReturnType<typeof useSpreadsheet>,
     setData: (data: GroupedEntity[]) => void,
@@ -67,7 +58,6 @@ export function createGroupedSpreadsheet({
           startDate,
           endDate,
           interval,
-          categoryFilter,
           conditionsOpKey,
           filters,
         ),
@@ -78,7 +68,6 @@ export function createGroupedSpreadsheet({
           startDate,
           endDate,
           interval,
-          categoryFilter,
           conditionsOpKey,
           filters,
         ),
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts b/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts
index f72964f35..0fa28e1f8 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/makeQuery.ts
@@ -1,5 +1,4 @@
 import { q } from 'loot-core/src/shared/query';
-import { type CategoryEntity } from 'loot-core/src/types/models';
 
 import { ReportOptions } from '../ReportOptions';
 
@@ -8,7 +7,6 @@ export function makeQuery(
   startDate: string,
   endDate: string,
   interval: string,
-  categoryFilter: CategoryEntity[],
   conditionsOpKey: string,
   filters: unknown[],
 ) {
@@ -24,19 +22,6 @@ export function makeQuery(
       : '$' + ReportOptions.intervalMap.get(interval)?.toLowerCase() || 'month';
 
   const query = q('transactions')
-    //Apply Category_Selector
-    .filter(
-      categoryFilter && {
-        $or: [
-          {
-            category: null,
-            $or: categoryFilter.map(category => ({
-              category: category.id,
-            })),
-          },
-        ],
-      },
-    )
     //Apply filters and split by "Group By"
     .filter({
       [conditionsOpKey]: filters,
diff --git a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
index 718203c73..7324405d2 100644
--- a/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
+++ b/packages/desktop-client/src/components/reports/spreadsheets/spending-spreadsheet.ts
@@ -6,11 +6,7 @@ import { type useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
 import { send } from 'loot-core/src/platform/client/fetch';
 import * as monthUtils from 'loot-core/src/shared/months';
 import { integerToAmount } from 'loot-core/src/shared/util';
-import {
-  type CategoryEntity,
-  type RuleConditionEntity,
-  type CategoryGroupEntity,
-} from 'loot-core/src/types/models';
+import { type RuleConditionEntity } from 'loot-core/src/types/models';
 import {
   type SpendingMonthEntity,
   type SpendingEntity,
@@ -21,7 +17,6 @@ import { getSpecificRange } from '../reportRanges';
 import { makeQuery } from './makeQuery';
 
 type createSpendingSpreadsheetProps = {
-  categories: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] };
   conditions?: RuleConditionEntity[];
   conditionsOp?: string;
   setDataCheck?: (value: boolean) => void;
@@ -29,7 +24,6 @@ type createSpendingSpreadsheetProps = {
 };
 
 export function createSpendingSpreadsheet({
-  categories,
   conditions = [],
   conditionsOp,
   setDataCheck,
@@ -67,7 +61,6 @@ export function createSpendingSpreadsheet({
           lastYearStartDate,
           endDate,
           interval,
-          categories.list,
           conditionsOpKey,
           filters,
         ),
@@ -78,7 +71,6 @@ export function createSpendingSpreadsheet({
           lastYearStartDate,
           endDate,
           interval,
-          categories.list,
           conditionsOpKey,
           filters,
         ),
diff --git a/packages/loot-core/migrations/1722717601000_reports_move_selected_categories.js b/packages/loot-core/migrations/1722717601000_reports_move_selected_categories.js
new file mode 100644
index 000000000..e3333cc1d
--- /dev/null
+++ b/packages/loot-core/migrations/1722717601000_reports_move_selected_categories.js
@@ -0,0 +1,55 @@
+export default async function runMigration(db) {
+  const categories = await db.runQuery(
+    'SELECT id FROM categories WHERE tombstone = 0',
+    [],
+    true,
+  );
+
+  const customReports = await db.runQuery(
+    'SELECT id, selected_categories, conditions FROM custom_reports WHERE tombstone = 0 AND selected_categories IS NOT NULL',
+    [],
+    true,
+  );
+
+  // Move all `selected_categories` to `conditions` if possible.. otherwise skip
+  for (const report of customReports) {
+    const conditions = report.conditions ? JSON.parse(report.conditions) : [];
+    const selectedCategories = report.selected_categories
+      ? JSON.parse(report.selected_categories)
+      : [];
+    const selectedCategoryIds = selectedCategories.map(({ id }) => id);
+
+    const areAllCategoriesSelected = !categories.find(
+      ({ id }) => !selectedCategoryIds.includes(id),
+    );
+
+    // Do nothing if all categories are selected.. we don't need to add a new condition for that
+    if (areAllCategoriesSelected) {
+      continue;
+    }
+
+    // If `conditions` already has a "category" filter - skip the entry
+    if (conditions.find(({ field }) => field === 'category')) {
+      continue;
+    }
+
+    // Append a new condition with the selected category IDs
+    await db.runQuery('UPDATE custom_reports SET conditions = ? WHERE id = ?', [
+      JSON.stringify([
+        ...conditions,
+        {
+          field: 'category',
+          op: 'oneOf',
+          value: selectedCategoryIds,
+          type: 'id',
+        },
+      ]),
+      report.id,
+    ]);
+  }
+
+  // Remove all the `selectedCategories` values - we don't need them anymore
+  await db.runQuery(
+    'UPDATE custom_reports SET selected_categories = NULL WHERE tombstone = 0',
+  );
+}
diff --git a/packages/loot-core/src/client/data-hooks/reports.ts b/packages/loot-core/src/client/data-hooks/reports.ts
index 8153d68ee..693086146 100644
--- a/packages/loot-core/src/client/data-hooks/reports.ts
+++ b/packages/loot-core/src/client/data-hooks/reports.ts
@@ -25,7 +25,6 @@ function toJS(rows: CustomReportData[]) {
       showHiddenCategories: row.show_hidden === 1,
       includeCurrentInterval: row.include_current === 1,
       showUncategorized: row.show_uncategorized === 1,
-      selectedCategories: row.selected_categories,
       graphType: row.graph_type,
       conditions: row.conditions,
       conditionsOp: row.conditions_op ?? 'and',
diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts
index 05cf0968b..085912f6a 100644
--- a/packages/loot-core/src/server/aql/schema/index.ts
+++ b/packages/loot-core/src/server/aql/schema/index.ts
@@ -144,7 +144,6 @@ export const schema = {
     show_hidden: f('integer', { default: 0 }),
     show_uncategorized: f('integer', { default: 0 }),
     include_current: f('integer', { default: 0 }),
-    selected_categories: f('json'),
     graph_type: f('string', { default: 'BarGraph' }),
     conditions: f('json'),
     conditions_op: f('string'),
diff --git a/packages/loot-core/src/server/migrate/migrations.ts b/packages/loot-core/src/server/migrate/migrations.ts
index 0726ec893..f7a7f524e 100644
--- a/packages/loot-core/src/server/migrate/migrations.ts
+++ b/packages/loot-core/src/server/migrate/migrations.ts
@@ -6,6 +6,7 @@ import { Database } from '@jlongster/sql.js';
 import { v4 as uuidv4 } from 'uuid';
 
 import m1632571489012 from '../../../migrations/1632571489012_remove_cache';
+import m1722717601000 from '../../../migrations/1722717601000_reports_move_selected_categories';
 import * as fs from '../../platform/server/fs';
 import * as sqlite from '../../platform/server/sqlite';
 
@@ -13,6 +14,7 @@ let MIGRATIONS_DIR = fs.migrationsPath;
 
 const javascriptMigrations = {
   1632571489012: m1632571489012,
+  1722717601000: m1722717601000,
 };
 
 export async function withMigrationsDir(
diff --git a/packages/loot-core/src/server/reports/app.ts b/packages/loot-core/src/server/reports/app.ts
index 42eb09142..b56405ed7 100644
--- a/packages/loot-core/src/server/reports/app.ts
+++ b/packages/loot-core/src/server/reports/app.ts
@@ -42,7 +42,6 @@ const reportModel = {
       showHiddenCategories: row.show_hidden === 1,
       showUncategorized: row.show_uncategorized === 1,
       includeCurrentInterval: row.include_current === 1,
-      selectedCategories: row.selected_categories,
       graphType: row.graph_type,
       conditions: row.conditions,
       conditionsOp: row.conditions_op,
@@ -66,7 +65,6 @@ const reportModel = {
       show_hidden: report.showHiddenCategories ? 1 : 0,
       show_uncategorized: report.showUncategorized ? 1 : 0,
       include_current: report.includeCurrentInterval ? 1 : 0,
-      selected_categories: report.selectedCategories,
       graph_type: report.graphType,
       conditions: report.conditions,
       conditions_op: report.conditionsOp,
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index bf673ed73..4913a58cd 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -273,6 +273,12 @@ export function makeValue(value, cond) {
     default:
   }
 
+  const isMulti = ['oneOf', 'notOneOf'].includes(cond.op);
+
+  if (isMulti) {
+    return { ...cond, error: null, value: value || [] };
+  }
+
   return { ...cond, error: null, value };
 }
 
diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts
index c8123137b..db9a798f1 100644
--- a/packages/loot-core/src/types/models/reports.d.ts
+++ b/packages/loot-core/src/types/models/reports.d.ts
@@ -1,4 +1,3 @@
-import { CategoryEntity } from './category';
 import { type RuleConditionEntity } from './rule';
 
 export interface CustomReportEntity {
@@ -17,7 +16,6 @@ export interface CustomReportEntity {
   showHiddenCategories: boolean;
   includeCurrentInterval: boolean;
   showUncategorized: boolean;
-  selectedCategories?: CategoryEntity[];
   graphType: string;
   conditions?: RuleConditionEntity[];
   conditionsOp: 'and' | 'or';
@@ -140,7 +138,6 @@ export interface CustomReportData {
   show_hidden: number;
   include_current: number;
   show_uncategorized: number;
-  selected_categories?: CategoryEntity[];
   graph_type: string;
   conditions?: RuleConditionEntity[];
   conditions_op: 'and' | 'or';
diff --git a/upcoming-release-notes/3178.md b/upcoming-release-notes/3178.md
new file mode 100644
index 000000000..7a5c23de4
--- /dev/null
+++ b/upcoming-release-notes/3178.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+Custom reports: unify `selectedCategories` and `conditions` data source.
-- 
GitLab