From b101f9989b6c75838879be1e37f33947d03c91b2 Mon Sep 17 00:00:00 2001
From: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Date: Tue, 16 Apr 2024 12:14:20 -0700
Subject: [PATCH] Display balances in category autocomplete (#2551)

* Display balances in category autocomplete

* Release notes

* Fix typecheck error

* Update balance colors

* Show category balances in mobile

* Patch unit tests

* Darket midnight theme autocomplete hover color

* Category autocomplete split transaction highlight

* Update 2551.md

* Extract modals from EditField

* Fix failing tests

* Update variable names

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
---
 .../desktop-client/src/components/Modals.tsx  |  4 +-
 .../src/components/accounts/Account.jsx       | 82 +++++++++++++------
 .../autocomplete/CategoryAutocomplete.tsx     | 64 ++++++++++++---
 .../src/components/budget/util.ts             | 18 +++-
 .../components/mobile/budget/BudgetTable.jsx  |  2 +
 .../mobile/transactions/TransactionEdit.jsx   |  5 ++
 .../modals/CategoryAutocompleteModal.tsx      | 27 ++++--
 .../src/components/modals/CoverModal.tsx      |  5 +-
 .../src/components/modals/EditField.jsx       | 50 -----------
 .../modals/RolloverBudgetSummaryModal.tsx     |  1 +
 .../src/components/modals/TransferModal.tsx   |  3 +
 .../transactions/TransactionsTable.jsx        | 34 +++++---
 .../transactions/TransactionsTable.test.jsx   | 53 ++++++------
 .../desktop-client/src/style/themes/dark.ts   |  2 +-
 .../src/style/themes/midnight.ts              |  8 +-
 .../src/client/state-types/modals.d.ts        |  4 +
 upcoming-release-notes/2551.md                |  6 ++
 17 files changed, 229 insertions(+), 139 deletions(-)
 create mode 100644 upcoming-release-notes/2551.md

diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index 39926082a..689896761 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -300,10 +300,10 @@ export function Modals() {
               modalProps={modalProps}
               autocompleteProps={{
                 value: null,
-                categoryGroups: options.categoryGroups,
                 onSelect: options.onSelect,
                 showHiddenCategories: options.showHiddenCategories,
               }}
+              month={options.month}
               onClose={options.onClose}
             />
           );
@@ -587,6 +587,7 @@ export function Modals() {
               key={name}
               modalProps={modalProps}
               title={options.title}
+              month={options.month}
               amount={options.amount}
               onSubmit={options.onSubmit}
               showToBeBudgeted={options.showToBeBudgeted}
@@ -599,6 +600,7 @@ export function Modals() {
               key={name}
               modalProps={modalProps}
               categoryId={options.categoryId}
+              month={options.month}
               onSubmit={options.onSubmit}
             />
           );
diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx
index 25bef7453..5a4025793 100644
--- a/packages/desktop-client/src/components/accounts/Account.jsx
+++ b/packages/desktop-client/src/components/accounts/Account.jsx
@@ -13,6 +13,7 @@ import * as queries from 'loot-core/src/client/queries';
 import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
 import { send, listen } from 'loot-core/src/platform/client/fetch';
 import { currentDay } from 'loot-core/src/shared/months';
+import * as monthUtils from 'loot-core/src/shared/months';
 import { q } from 'loot-core/src/shared/query';
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
 import {
@@ -801,29 +802,31 @@ class AccountInternal extends PureComponent {
   };
 
   onBatchEdit = async (name, ids) => {
+    const { data } = await runQuery(
+      q('transactions')
+        .filter({ id: { $oneof: ids } })
+        .select('*')
+        .options({ splits: 'grouped' }),
+    );
+    const transactions = ungroupTransactions(data);
+
     const onChange = async (name, value, mode) => {
+      let transactionsToChange = transactions;
+
       const newValue = value === null ? '' : value;
       this.setState({ workingHard: true });
 
-      const { data } = await runQuery(
-        q('transactions')
-          .filter({ id: { $oneof: ids } })
-          .select('*')
-          .options({ splits: 'grouped' }),
-      );
-      let transactions = ungroupTransactions(data);
-
       const changes = { deleted: [], updated: [] };
 
       // Cleared is a special case right now
       if (name === 'cleared') {
         // Clear them if any are uncleared, otherwise unclear them
-        value = !!transactions.find(t => !t.cleared);
+        value = !!transactionsToChange.find(t => !t.cleared);
       }
 
       const idSet = new Set(ids);
 
-      transactions.forEach(trans => {
+      transactionsToChange.forEach(trans => {
         if (name === 'cleared' && trans.reconciled) {
           // Skip transactions that are reconciled. Don't want to set them as
           // uncleared.
@@ -856,13 +859,13 @@ class AccountInternal extends PureComponent {
           transaction.reconciled = false;
         }
 
-        const { diff } = updateTransaction(transactions, transaction);
+        const { diff } = updateTransaction(transactionsToChange, transaction);
 
         // TODO: We need to keep an updated list of transactions so
         // the logic in `updateTransaction`, particularly about
         // updating split transactions, works. This isn't ideal and we
         // should figure something else out
-        transactions = applyChanges(diff, transactions);
+        transactionsToChange = applyChanges(diff, transactionsToChange);
 
         changes.deleted = changes.deleted
           ? changes.deleted.concat(diff.deleted)
@@ -879,28 +882,55 @@ class AccountInternal extends PureComponent {
       await this.refetchTransactions();
 
       if (this.table.current) {
-        this.table.current.edit(transactions[0].id, 'select', false);
+        this.table.current.edit(transactionsToChange[0].id, 'select', false);
       }
     };
 
+    const pushPayeeAutocompleteModal = () => {
+      this.props.pushModal('payee-autocomplete', {
+        onSelect: payeeId => onChange(name, payeeId),
+      });
+    };
+
+    const pushAccountAutocompleteModal = () => {
+      this.props.pushModal('account-autocomplete', {
+        onSelect: accountId => onChange(name, accountId),
+      });
+    };
+
+    const pushCategoryAutocompleteModal = () => {
+      // Only show balances when all selected transaction are in the same month.
+      const transactionMonth = transactions[0]?.date
+        ? monthUtils.monthFromDate(transactions[0]?.date)
+        : null;
+      const transactionsHaveSameMonth =
+        transactionMonth &&
+        transactions.every(
+          t => monthUtils.monthFromDate(t.date) === transactionMonth,
+        );
+      this.props.pushModal('category-autocomplete', {
+        month: transactionsHaveSameMonth ? transactionMonth : undefined,
+        onSelect: categoryId => onChange(name, categoryId),
+      });
+    };
+
     if (
       name === 'amount' ||
       name === 'payee' ||
       name === 'account' ||
       name === 'date'
     ) {
-      const { data } = await runQuery(
-        q('transactions')
-          .filter({ id: { $oneof: ids }, reconciled: true })
-          .select('*')
-          .options({ splits: 'grouped' }),
-      );
-      const transactions = ungroupTransactions(data);
-
-      if (transactions.length > 0) {
+      const reconciledTransactions = transactions.filter(t => t.reconciled);
+      if (reconciledTransactions.length > 0) {
         this.props.pushModal('confirm-transaction-edit', {
           onConfirm: () => {
-            this.props.pushModal('edit-field', { name, onSubmit: onChange });
+            if (name === 'payee') {
+              pushPayeeAutocompleteModal();
+            } else if (name === 'account') {
+              pushAccountAutocompleteModal();
+            } else {
+              this.props.pushModal('edit-field', { name, onSubmit: onChange });
+            }
           },
           confirmReason: 'batchEditWithReconciled',
         });
@@ -912,6 +942,12 @@ class AccountInternal extends PureComponent {
       // Cleared just toggles it on/off and it depends on the data
       // loaded. Need to clean this up in the future.
       onChange('cleared', null);
+    } else if (name === 'category') {
+      pushCategoryAutocompleteModal();
+    } else if (name === 'payee') {
+      pushPayeeAutocompleteModal();
+    } else if (name === 'account') {
+      pushAccountAutocompleteModal();
     } else {
       this.props.pushModal('edit-field', { name, onSubmit: onChange });
     }
diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx
index 419467e2a..a742bcbfc 100644
--- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx
+++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx
@@ -11,17 +11,23 @@ import React, {
 
 import { css } from 'glamor';
 
+import { reportBudget, rolloverBudget } from 'loot-core/client/queries';
+import { integerToCurrency } from 'loot-core/shared/util';
 import {
   type CategoryEntity,
   type CategoryGroupEntity,
 } from 'loot-core/src/types/models';
 
+import { useCategories } from '../../hooks/useCategories';
+import { useLocalPref } from '../../hooks/useLocalPref';
 import { SvgSplit } from '../../icons/v0';
 import { useResponsive } from '../../ResponsiveProvider';
 import { type CSSProperties, theme, styles } from '../../style';
+import { makeAmountFullStyle } from '../budget/util';
 import { Text } from '../common/Text';
 import { TextOneLine } from '../common/TextOneLine';
 import { View } from '../common/View';
+import { useSheetValue } from '../spreadsheet/useSheetValue';
 
 import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
 import { ItemHeader } from './ItemHeader';
@@ -48,6 +54,7 @@ export type CategoryListProps = {
     props: ComponentPropsWithoutRef<typeof CategoryItem>,
   ) => ReactElement<typeof CategoryItem>;
   showHiddenItems?: boolean;
+  showBalances?: boolean;
 };
 function CategoryList({
   items,
@@ -59,6 +66,7 @@ function CategoryList({
   renderCategoryItemGroupHeader = defaultRenderCategoryItemGroupHeader,
   renderCategoryItem = defaultRenderCategoryItem,
   showHiddenItems,
+  showBalances,
 }: CategoryListProps) {
   let lastGroup: string | undefined | null = null;
 
@@ -111,6 +119,7 @@ function CategoryList({
                     ...(showHiddenItems &&
                       item.hidden && { color: theme.pageTextSubdued }),
                   },
+                  showBalances,
                 })}
               </Fragment>
             </Fragment>
@@ -125,7 +134,8 @@ function CategoryList({
 type CategoryAutocompleteProps = ComponentProps<
   typeof Autocomplete<CategoryAutocompleteItem>
 > & {
-  categoryGroups: Array<CategoryGroupEntity>;
+  categoryGroups?: Array<CategoryGroupEntity>;
+  showBalances?: boolean;
   showSplitOption?: boolean;
   renderSplitTransactionButton?: (
     props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
@@ -141,6 +151,7 @@ type CategoryAutocompleteProps = ComponentProps<
 
 export function CategoryAutocomplete({
   categoryGroups,
+  showBalances = true,
   showSplitOption,
   embedded,
   closeOnBlur,
@@ -150,9 +161,10 @@ export function CategoryAutocomplete({
   showHiddenCategories,
   ...props
 }: CategoryAutocompleteProps) {
+  const { grouped: defaultCategoryGroups = [] } = useCategories();
   const categorySuggestions: CategoryAutocompleteItem[] = useMemo(
     () =>
-      categoryGroups.reduce(
+      (categoryGroups || defaultCategoryGroups).reduce(
         (list, group) =>
           list.concat(
             (group.categories || [])
@@ -164,7 +176,7 @@ export function CategoryAutocomplete({
           ),
         showSplitOption ? [{ id: 'split', name: '' } as CategoryEntity] : [],
       ),
-    [showSplitOption, categoryGroups],
+    [defaultCategoryGroups, categoryGroups, showSplitOption],
   );
 
   return (
@@ -200,6 +212,7 @@ export function CategoryAutocomplete({
           renderCategoryItemGroupHeader={renderCategoryItemGroupHeader}
           renderCategoryItem={renderCategoryItem}
           showHiddenItems={showHiddenCategories}
+          showBalances={showBalances}
         />
       )}
       {...props}
@@ -261,9 +274,7 @@ function SplitTransactionButton({
         alignItems: 'center',
         fontSize: 11,
         fontWeight: 500,
-        color: highlighted
-          ? theme.menuAutoCompleteTextHover
-          : theme.noticeTextMenu,
+        color: theme.noticeTextMenu,
         padding: '6px 8px',
         ':active': {
           backgroundColor: 'rgba(100, 100, 100, .25)',
@@ -297,6 +308,7 @@ type CategoryItemProps = {
   style?: CSSProperties;
   highlighted?: boolean;
   embedded?: boolean;
+  showBalances?: boolean;
 };
 
 function CategoryItem({
@@ -305,6 +317,7 @@ function CategoryItem({
   style,
   highlighted,
   embedded,
+  showBalances,
   ...props
 }: CategoryItemProps) {
   const { isNarrowWidth } = useResponsive();
@@ -315,6 +328,16 @@ function CategoryItem({
         borderTop: `1px solid ${theme.pillBorder}`,
       }
     : {};
+  const [budgetType] = useLocalPref('budgetType');
+
+  const balance = useSheetValue(
+    budgetType === 'rollover'
+      ? rolloverBudget.catBalance(item.id)
+      : reportBudget.catBalance(item.id),
+  );
+
+  const isToBeBudgetedItem = item.id === 'to-be-budgeted';
+  const toBudget = useSheetValue(rolloverBudget.toBudget);
 
   return (
     <div
@@ -339,10 +362,31 @@ function CategoryItem({
       data-highlighted={highlighted || undefined}
       {...props}
     >
-      <TextOneLine>
-        {item.name}
-        {item.hidden ? ' (hidden)' : null}
-      </TextOneLine>
+      <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
+        <TextOneLine>
+          {item.name}
+          {item.hidden ? ' (hidden)' : null}
+        </TextOneLine>
+        <TextOneLine
+          style={{
+            display: !showBalances ? 'none' : undefined,
+            marginLeft: 5,
+            flexShrink: 0,
+            ...makeAmountFullStyle(isToBeBudgetedItem ? toBudget : balance, {
+              positiveColor: theme.noticeTextMenu,
+              negativeColor: theme.errorTextMenu,
+            }),
+          }}
+        >
+          {isToBeBudgetedItem
+            ? toBudget != null
+              ? ` ${integerToCurrency(toBudget || 0)}`
+              : null
+            : balance != null
+              ? ` ${integerToCurrency(balance || 0)}`
+              : null}
+        </TextOneLine>
+      </View>
     </div>
   );
 }
diff --git a/packages/desktop-client/src/components/budget/util.ts b/packages/desktop-client/src/components/budget/util.ts
index 3c5bc8fe1..733012c54 100644
--- a/packages/desktop-client/src/components/budget/util.ts
+++ b/packages/desktop-client/src/components/budget/util.ts
@@ -67,14 +67,24 @@ export function makeAmountStyle(
   }
 }
 
-export function makeAmountFullStyle(value: number) {
+export function makeAmountFullStyle(
+  value: number,
+  colors?: {
+    positiveColor?: string;
+    negativeColor?: string;
+    zeroColor?: string;
+  },
+) {
+  const positiveColorToUse = colors.positiveColor || theme.noticeText;
+  const negativeColorToUse = colors.negativeColor || theme.errorText;
+  const zeroColorToUse = colors.zeroColor || theme.tableTextSubdued;
   return {
     color:
       value < 0
-        ? theme.errorText
+        ? negativeColorToUse
         : value === 0
-          ? theme.tableTextSubdued
-          : theme.noticeText,
+          ? zeroColorToUse
+          : positiveColorToUse,
   };
 }
 
diff --git a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
index c7fcdd68f..c3db205c3 100644
--- a/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
+++ b/packages/desktop-client/src/components/mobile/budget/BudgetTable.jsx
@@ -263,6 +263,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
     dispatch(
       pushModal('transfer', {
         title: `Transfer: ${category.name}`,
+        month,
         amount: catBalance,
         onSubmit: (amount, toCategoryId) => {
           onBudgetAction(month, 'transfer-category', {
@@ -281,6 +282,7 @@ const ExpenseCategory = memo(function ExpenseCategory({
     dispatch(
       pushModal('cover', {
         categoryId: category.id,
+        month,
         onSubmit: fromCategoryId => {
           onBudgetAction(month, 'cover', {
             to: category.id,
diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx
index 02864a9b5..a48918513 100644
--- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx
+++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx
@@ -545,11 +545,15 @@ const TransactionEditInner = memo(function TransactionEditInner({
   const onClick = (transactionId, name) => {
     onRequestActiveEdit?.(getFieldName(transaction.id, name), () => {
       const transactionToEdit = transactions.find(t => t.id === transactionId);
+      const unserializedTransaction = unserializedTransactions.find(
+        t => t.id === transactionId,
+      );
       switch (name) {
         case 'category':
           dispatch(
             pushModal('category-autocomplete', {
               categoryGroups,
+              month: monthUtils.monthFromDate(unserializedTransaction.date),
               onSelect: categoryId => {
                 onEdit(transactionToEdit, name, categoryId);
               },
@@ -587,6 +591,7 @@ const TransactionEditInner = memo(function TransactionEditInner({
           dispatch(
             pushModal('edit-field', {
               name,
+              month: monthUtils.monthFromDate(unserializedTransaction.date),
               onSubmit: (name, value) => {
                 onEdit(transactionToEdit, name, value);
               },
diff --git a/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx b/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx
index 971269655..b561d27ca 100644
--- a/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx
+++ b/packages/desktop-client/src/components/modals/CategoryAutocompleteModal.tsx
@@ -1,5 +1,7 @@
 import React, { type ComponentPropsWithoutRef } from 'react';
 
+import * as monthUtils from 'loot-core/src/shared/months';
+
 import { useResponsive } from '../../ResponsiveProvider';
 import { theme } from '../../style';
 import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete';
@@ -7,16 +9,19 @@ import { ModalCloseButton, Modal, ModalTitle } from '../common/Modal';
 import { View } from '../common/View';
 import { SectionLabel } from '../forms';
 import { type CommonModalProps } from '../Modals';
+import { NamespaceContext } from '../spreadsheet/NamespaceContext';
 
 type CategoryAutocompleteModalProps = {
   modalProps: CommonModalProps;
   autocompleteProps: ComponentPropsWithoutRef<typeof CategoryAutocomplete>;
   onClose: () => void;
+  month?: string;
 };
 
 export function CategoryAutocompleteModal({
   modalProps,
   autocompleteProps,
+  month,
   onClose,
 }: CategoryAutocompleteModalProps) {
   const _onClose = () => {
@@ -71,15 +76,19 @@ export function CategoryAutocompleteModal({
             />
           )}
           <View style={{ flex: 1 }}>
-            <CategoryAutocomplete
-              focused={true}
-              embedded={true}
-              closeOnBlur={false}
-              showSplitOption={false}
-              onClose={_onClose}
-              {...defaultAutocompleteProps}
-              {...autocompleteProps}
-            />
+            <NamespaceContext.Provider
+              value={month ? monthUtils.sheetForMonth(month) : ''}
+            >
+              <CategoryAutocomplete
+                focused={true}
+                embedded={true}
+                closeOnBlur={false}
+                showSplitOption={false}
+                onClose={_onClose}
+                {...defaultAutocompleteProps}
+                {...autocompleteProps}
+              />
+            </NamespaceContext.Provider>
           </View>
         </View>
       )}
diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx
index 90f4369da..e11baeb6e 100644
--- a/packages/desktop-client/src/components/modals/CoverModal.tsx
+++ b/packages/desktop-client/src/components/modals/CoverModal.tsx
@@ -16,12 +16,14 @@ import { type CommonModalProps } from '../Modals';
 type CoverModalProps = {
   modalProps: CommonModalProps;
   categoryId: string;
+  month: string;
   onSubmit: (categoryId: string) => void;
 };
 
 export function CoverModal({
   modalProps,
   categoryId,
+  month,
   onSubmit,
 }: CoverModalProps) {
   const { grouped: originalCategoryGroups, list: categories } = useCategories();
@@ -36,12 +38,13 @@ export function CoverModal({
     dispatch(
       pushModal('category-autocomplete', {
         categoryGroups,
+        month,
         onSelect: categoryId => {
           setFromCategoryId(categoryId);
         },
       }),
     );
-  }, [categoryGroups, dispatch]);
+  }, [categoryGroups, dispatch, month]);
 
   const _onSubmit = (categoryId: string | null) => {
     if (categoryId) {
diff --git a/packages/desktop-client/src/components/modals/EditField.jsx b/packages/desktop-client/src/components/modals/EditField.jsx
index 4d0b06465..bdcabb487 100644
--- a/packages/desktop-client/src/components/modals/EditField.jsx
+++ b/packages/desktop-client/src/components/modals/EditField.jsx
@@ -5,7 +5,6 @@ import { parseISO, format as formatDate, parse as parseDate } from 'date-fns';
 import { currentDay, dayFromDate } from 'loot-core/src/shared/months';
 import { amountToInteger } from 'loot-core/src/shared/util';
 
-import { useCategories } from '../../hooks/useCategories';
 import { useDateFormat } from '../../hooks/useDateFormat';
 import { useResponsive } from '../../ResponsiveProvider';
 import { theme } from '../../style';
@@ -16,13 +15,8 @@ import { View } from '../common/View';
 import { SectionLabel } from '../forms';
 import { DateSelect } from '../select/DateSelect';
 
-import { AccountAutocompleteModal } from './AccountAutocompleteModal';
-import { CategoryAutocompleteModal } from './CategoryAutocompleteModal';
-import { PayeeAutocompleteModal } from './PayeeAutocompleteModal';
-
 export function EditField({ modalProps, name, onSubmit, onClose }) {
   const dateFormat = useDateFormat() || 'MM/dd/yyyy';
-  const { grouped: categoryGroups } = useCategories();
   const onCloseInner = () => {
     modalProps.onClose();
     onClose?.();
@@ -82,50 +76,6 @@ export function EditField({ modalProps, name, onSubmit, onClose }) {
       );
       break;
 
-    case 'category':
-      return (
-        <CategoryAutocompleteModal
-          modalProps={modalProps}
-          autocompleteProps={{
-            categoryGroups,
-            showHiddenCategories: false,
-            value: null,
-            onSelect: categoryId => {
-              onSelect(categoryId);
-            },
-          }}
-          onClose={onClose}
-        />
-      );
-
-    case 'payee':
-      return (
-        <PayeeAutocompleteModal
-          modalProps={modalProps}
-          autocompleteProps={{
-            value: null,
-            onSelect: payeeId => {
-              onSelect(payeeId);
-            },
-          }}
-          onClose={onClose}
-        />
-      );
-
-    case 'account':
-      return (
-        <AccountAutocompleteModal
-          modalProps={modalProps}
-          autocompleteProps={{
-            value: null,
-            onSelect: accountId => {
-              onSelect(accountId);
-            },
-          }}
-          onClose={onClose}
-        />
-      );
-
     case 'notes':
       label = 'Notes';
       editor = (
diff --git a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
index f0d55b7ab..cd2f12c90 100644
--- a/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
+++ b/packages/desktop-client/src/components/modals/RolloverBudgetSummaryModal.tsx
@@ -35,6 +35,7 @@ export function RolloverBudgetSummaryModal({
     dispatch(
       pushModal('transfer', {
         title: 'Transfer',
+        month,
         amount: sheetValue,
         onSubmit: (amount, toCategoryId) => {
           onBudgetAction?.(month, 'transfer-available', {
diff --git a/packages/desktop-client/src/components/modals/TransferModal.tsx b/packages/desktop-client/src/components/modals/TransferModal.tsx
index 7def9a004..0e5577f46 100644
--- a/packages/desktop-client/src/components/modals/TransferModal.tsx
+++ b/packages/desktop-client/src/components/modals/TransferModal.tsx
@@ -18,6 +18,7 @@ import { type CommonModalProps } from '../Modals';
 type TransferModalProps = {
   modalProps: CommonModalProps;
   title: string;
+  month: string;
   amount: number;
   showToBeBudgeted: boolean;
   onSubmit: (amount: number, toCategoryId: string) => void;
@@ -26,6 +27,7 @@ type TransferModalProps = {
 export function TransferModal({
   modalProps,
   title,
+  month,
   amount: initialAmount,
   showToBeBudgeted,
   onSubmit,
@@ -45,6 +47,7 @@ export function TransferModal({
     dispatch(
       pushModal('category-autocomplete', {
         categoryGroups,
+        month,
         showHiddenCategories: true,
         onSelect: categoryId => {
           setToCategoryId(categoryId);
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index d05689f40..e02e39cf3 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -25,6 +25,7 @@ import {
 } from 'loot-core/src/client/reducers/queries';
 import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
 import { currentDay } from 'loot-core/src/shared/months';
+import * as monthUtils from 'loot-core/src/shared/months';
 import { getScheduledAmount } from 'loot-core/src/shared/schedules';
 import {
   splitTransaction,
@@ -62,6 +63,7 @@ import { Text } from '../common/Text';
 import { View } from '../common/View';
 import { getStatusProps } from '../schedules/StatusBadge';
 import { DateSelect } from '../select/DateSelect';
+import { NamespaceContext } from '../spreadsheet/NamespaceContext';
 import {
   Cell,
   Field,
@@ -1171,19 +1173,25 @@ const Transaction = memo(function Transaction(props) {
             shouldSaveFromKey,
             inputStyle,
           }) => (
-            <CategoryAutocomplete
-              categoryGroups={categoryGroups}
-              value={categoryId}
-              focused={true}
-              clearOnBlur={false}
-              showSplitOption={!isChild && !isParent}
-              shouldSaveFromKey={shouldSaveFromKey}
-              inputProps={{ onBlur, onKeyDown, style: inputStyle }}
-              onUpdate={onUpdate}
-              onSelect={onSave}
-              menuPortalTarget={undefined}
-              showHiddenCategories={false}
-            />
+            <NamespaceContext.Provider
+              value={monthUtils.sheetForMonth(
+                monthUtils.monthFromDate(transaction.date),
+              )}
+            >
+              <CategoryAutocomplete
+                categoryGroups={categoryGroups}
+                value={categoryId}
+                focused={true}
+                clearOnBlur={false}
+                showSplitOption={!isChild && !isParent}
+                shouldSaveFromKey={shouldSaveFromKey}
+                inputProps={{ onBlur, onKeyDown, style: inputStyle }}
+                onUpdate={onUpdate}
+                onSelect={onSave}
+                menuPortalTarget={undefined}
+                showHiddenCategories={false}
+              />
+            </NamespaceContext.Provider>
           )}
         </CustomCell>
       )}
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx
index 3b03b5b4f..d06dbd2fd 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
 import { format as formatDate, parse as parseDate } from 'date-fns';
 import { v4 as uuidv4 } from 'uuid';
 
+import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
 import {
   generateTransaction,
   generateAccount,
@@ -118,26 +119,28 @@ function LiveTransactionTable(props) {
   return (
     <TestProvider>
       <ResponsiveProvider>
-        <SelectedProviderWithItems
-          name="transactions"
-          items={transactions}
-          fetchAllIds={() => transactions.map(t => t.id)}
-        >
-          <SplitsExpandedProvider>
-            <TransactionTable
-              {...props}
-              transactions={transactions}
-              loadMoreTransactions={() => {}}
-              payees={payees}
-              addNotification={n => console.log(n)}
-              onSave={onSave}
-              onSplit={onSplit}
-              onAdd={onAdd}
-              onAddSplit={onAddSplit}
-              onCreatePayee={onCreatePayee}
-            />
-          </SplitsExpandedProvider>
-        </SelectedProviderWithItems>
+        <SpreadsheetProvider>
+          <SelectedProviderWithItems
+            name="transactions"
+            items={transactions}
+            fetchAllIds={() => transactions.map(t => t.id)}
+          >
+            <SplitsExpandedProvider>
+              <TransactionTable
+                {...props}
+                transactions={transactions}
+                loadMoreTransactions={() => {}}
+                payees={payees}
+                addNotification={n => console.log(n)}
+                onSave={onSave}
+                onSplit={onSplit}
+                onAdd={onAdd}
+                onAddSplit={onAddSplit}
+                onCreatePayee={onCreatePayee}
+              />
+            </SplitsExpandedProvider>
+          </SelectedProviderWithItems>
+        </SpreadsheetProvider>
       </ResponsiveProvider>
     </TestProvider>
   );
@@ -155,6 +158,10 @@ function initBasicServer() {
           throw new Error(`queried unknown table: ${query.table}`);
       }
     },
+    getCell: () => ({
+      value: 129_87,
+    }),
+    'get-categories': () => ({ grouped: categoryGroups, list: categories }),
   });
 }
 
@@ -447,7 +454,7 @@ describe('Transactions', () => {
     let items = tooltip.querySelectorAll('[data-testid*="category-item"]');
     expect(items.length).toBe(2);
     expect(items[0].textContent).toBe('Usual Expenses');
-    expect(items[1].textContent).toBe('General');
+    expect(items[1].textContent).toBe('General 129.87');
     expect(items[1].dataset['highlighted']).toBeDefined();
 
     // It should not allow filtering on group names
@@ -473,7 +480,7 @@ describe('Transactions', () => {
     // The right item should be highlighted
     highlighted = tooltip.querySelector('[data-highlighted]');
     expect(highlighted).not.toBeNull();
-    expect(highlighted.textContent).toBe('General');
+    expect(highlighted.textContent).toBe('General 129.87');
 
     expect(getTransactions()[2].category).toBe(
       categories.find(category => category.name === 'Food').id,
@@ -515,7 +522,7 @@ describe('Transactions', () => {
     // Make sure the expected category is highlighted
     highlighted = tooltip.querySelector('[data-highlighted]');
     expect(highlighted).not.toBeNull();
-    expect(highlighted.textContent).toBe('General');
+    expect(highlighted.textContent).toBe('General 129.87');
 
     // Click the item and check the before/after values
     expect(getTransactions()[2].category).toBe(
diff --git a/packages/desktop-client/src/style/themes/dark.ts b/packages/desktop-client/src/style/themes/dark.ts
index 81b756b8d..185b042e4 100644
--- a/packages/desktop-client/src/style/themes/dark.ts
+++ b/packages/desktop-client/src/style/themes/dark.ts
@@ -149,7 +149,7 @@ export const errorBackground = colorPalette.red800;
 export const errorText = colorPalette.red200;
 export const errorTextDark = colorPalette.red150;
 export const errorTextDarker = errorTextDark;
-export const errorTextMenu = colorPalette.red500;
+export const errorTextMenu = colorPalette.red200;
 export const errorBorder = colorPalette.red500;
 export const upcomingBackground = colorPalette.purple700;
 export const upcomingText = colorPalette.purple100;
diff --git a/packages/desktop-client/src/style/themes/midnight.ts b/packages/desktop-client/src/style/themes/midnight.ts
index cb6a41e72..135194af8 100644
--- a/packages/desktop-client/src/style/themes/midnight.ts
+++ b/packages/desktop-client/src/style/themes/midnight.ts
@@ -50,7 +50,7 @@ export const sidebarItemTextSelected = colorPalette.purple200;
 export const menuBackground = colorPalette.gray700;
 export const menuItemBackground = colorPalette.gray200;
 export const menuItemBackgroundHover = colorPalette.gray500;
-export const menuItemText = colorPalette.gray100; /* controls all dropdowns */
+export const menuItemText = colorPalette.gray100;
 export const menuItemTextHover = colorPalette.gray50;
 export const menuItemTextSelected = colorPalette.purple400;
 export const menuItemTextHeader = colorPalette.purple200;
@@ -58,11 +58,11 @@ export const menuBorder = colorPalette.gray800;
 export const menuBorderHover = colorPalette.purple300;
 export const menuKeybindingText = colorPalette.gray500;
 export const menuAutoCompleteBackground = colorPalette.gray600;
-export const menuAutoCompleteBackgroundHover = colorPalette.gray50;
+export const menuAutoCompleteBackgroundHover = colorPalette.gray500;
 export const menuAutoCompleteText = colorPalette.gray100;
 export const menuAutoCompleteTextHover = colorPalette.green900;
 export const menuAutoCompleteTextHeader = colorPalette.purple200;
-export const menuAutoCompleteItemTextHover = colorPalette.gray700;
+export const menuAutoCompleteItemTextHover = colorPalette.gray50;
 export const menuAutoCompleteItemText = menuItemText;
 export const modalBackground = colorPalette.gray700;
 export const modalBorder = colorPalette.gray200;
@@ -151,7 +151,7 @@ export const errorBackground = colorPalette.red800;
 export const errorText = colorPalette.red200;
 export const errorTextDark = colorPalette.red150;
 export const errorTextDarker = errorTextDark;
-export const errorTextMenu = colorPalette.red500;
+export const errorTextMenu = colorPalette.red200;
 export const errorBorder = colorPalette.red500;
 export const upcomingBackground = colorPalette.purple800;
 export const upcomingText = colorPalette.purple200;
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index 96c3b65cc..88583c8fc 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -99,6 +99,7 @@ type FinanceModals = {
 
   'edit-field': {
     name: string;
+    month: string;
     onSubmit: (name: string, value: string) => void;
     onClose: () => void;
   };
@@ -106,6 +107,7 @@ type FinanceModals = {
   'category-autocomplete': {
     categoryGroups: CategoryGroupEntity[];
     onSelect: (categoryId: string, categoryName: string) => void;
+    month?: string;
     showHiddenCategories?: boolean;
     onClose?: () => void;
   };
@@ -215,12 +217,14 @@ type FinanceModals = {
   };
   transfer: {
     title: string;
+    month: string;
     amount: number;
     onSubmit: (amount: number, toCategoryId: string) => void;
     showToBeBudgeted?: boolean;
   };
   cover: {
     categoryId: string;
+    month: string;
     onSubmit: (fromCategoryId: string) => void;
   };
   'hold-buffer': {
diff --git a/upcoming-release-notes/2551.md b/upcoming-release-notes/2551.md
new file mode 100644
index 000000000..7044cc016
--- /dev/null
+++ b/upcoming-release-notes/2551.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [joel-jeremy,MatissJanis]
+---
+
+Display category balances in category autocomplete.
-- 
GitLab