Mobile running balance (#4809)

* start

* small fix

* clean

* working for regular transactions

* working for schedules

* cleanup

* typing

* cleanup

* cleanup

* vrt

* bunny

* use pref

* use pref right, lint

* more lint

* vrt

* pass hasInitialBalances to isLoading

* remove comment

* Add option to calculate running balances in useTransactions hook

* Fix typecheck error

* Fix lint error

* use the updated hook

* typecheck

* simplify

* don't show balances when searching

* Add runningBalances to usePreviewTransactions and an option to set the starting balance to start running balance calculation from

* Add filter to usePreviewTransactions and set startingBalance to account and category preview transaction hooks

* use runningbalance from preview transactions hook

* lint

* lint;typecheck

* remove initial from preview balances

* remove unneeded type

* Apply suggestions from code review

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>

* typecheck; align right; change color

* types

* add a menu item

* cleanup

* fix for loot-core migrated files

* lint;type

* fix import

* only schedules need fixed

* lint

* it works

* cleanup

* make lint happy

* [autofix.ci] apply automated fixes

* simplify a bit

* fix import

* feedback

---------

Co-authored-by: Joel Jeremy Marquez <joeljeremy.marquez@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
youngcw
2025-06-19 17:54:00 -07:00
committed by GitHub
parent 15beba2ca3
commit 2c87c44168
10 changed files with 138 additions and 40 deletions

View File

@@ -16,6 +16,7 @@ import { View } from '@actual-app/components/view';
import { listen, send } from 'loot-core/platform/client/fetch';
import { type Query } from 'loot-core/shared/query';
import { isPreviewId } from 'loot-core/shared/transactions';
import { type IntegerAmount } from 'loot-core/shared/util';
import {
type AccountEntity,
type TransactionEntity,
@@ -31,8 +32,9 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { accountSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import {
collapseModals,
@@ -148,6 +150,19 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
dispatch(reopenAccount({ id: account.id }));
}, [account.id, dispatch]);
const [showBalances, setBalances] = useSyncedPref(
`show-balances-${account.id}`,
);
const onToggleRunningBalance = useCallback(() => {
const newVal = showBalances === 'true' ? 'false' : 'true';
setBalances(newVal);
dispatch(
collapseModals({
rootModalName: 'account-menu',
}),
);
}, [showBalances, setBalances, dispatch]);
const onClick = useCallback(() => {
dispatch(
pushModal({
@@ -159,6 +174,7 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
onEditNotes,
onCloseAccount,
onReopenAccount,
onToggleRunningBalance,
},
},
}),
@@ -170,6 +186,7 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
onEditNotes,
onReopenAccount,
onSave,
onToggleRunningBalance,
]);
return (
@@ -241,30 +258,6 @@ function TransactionListWithPreviews({
readonly accountName: AccountEntity['name'] | string;
}) {
const { t } = useTranslation();
const baseTransactionsQuery = useCallback(
() =>
queries.transactions(accountId).options({ splits: 'all' }).select('*'),
[accountId],
);
const [transactionsQuery, setTransactionsQuery] = useState<Query>(
baseTransactionsQuery(),
);
const {
transactions,
isLoading: isTransactionsLoading,
reload: reloadTransactions,
isLoadingMore,
loadMore: loadMoreTransactions,
} = useTransactions({
query: transactionsQuery,
});
const { previewTransactions, isLoading: isPreviewTransactionsLoading } =
useAccountPreviewTransactions({
accountId: account?.id,
});
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const dispatch = useDispatch();
const navigate = useNavigate();
@@ -275,6 +268,59 @@ function TransactionListWithPreviews({
}
}, [accountId, dispatch]);
const baseTransactionsQuery = useCallback(
() =>
queries.transactions(accountId).options({ splits: 'all' }).select('*'),
[accountId],
);
const [showBalances] = useSyncedPref(`show-balances-${accountId}`);
const [transactionsQuery, setTransactionsQuery] = useState<Query>(
baseTransactionsQuery(),
);
const {
transactions,
runningBalances,
isLoading: isTransactionsLoading,
reload: reloadTransactions,
isLoadingMore,
loadMore: loadMoreTransactions,
} = useTransactions({
query: transactionsQuery,
options: {
calculateRunningBalances: true,
},
});
const { isSearching, search: onSearch } = useTransactionsSearch({
updateQuery: setTransactionsQuery,
resetQuery: () => setTransactionsQuery(baseTransactionsQuery()),
dateFormat,
});
const {
previewTransactions,
runningBalances: previewRunningBalances,
isLoading: isPreviewTransactionsLoading,
} = useAccountPreviewTransactions({
accountId: account?.id,
getRunningBalances: showBalances === 'true',
});
useEffect(() => {
reloadTransactions();
}, [showBalances, reloadTransactions]);
const allBalances =
showBalances === 'true'
? isSearching
? undefined
: new Map<TransactionEntity['id'], IntegerAmount>([
...runningBalances,
...previewRunningBalances,
])
: undefined;
useEffect(() => {
if (accountId) {
dispatch(markAccountRead({ id: accountId }));
@@ -296,12 +342,6 @@ function TransactionListWithPreviews({
});
}, [dispatch, reloadTransactions]);
const { isSearching, search: onSearch } = useTransactionsSearch({
updateQuery: setTransactionsQuery,
resetQuery: () => setTransactionsQuery(baseTransactionsQuery()),
dateFormat,
});
const onOpenTransaction = useCallback(
(transaction: TransactionEntity) => {
if (!isPreviewId(transaction.id)) {
@@ -370,6 +410,7 @@ function TransactionListWithPreviews({
balance={balanceQueries.balance}
balanceCleared={balanceQueries.cleared}
balanceUncleared={balanceQueries.uncleared}
runningBalances={allBalances}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
searchPlaceholder={t('Search {{accountName}}', { accountName })}

View File

@@ -154,6 +154,7 @@ function TransactionListWithPreviews({
balance={balance}
balanceCleared={balanceCleared}
balanceUncleared={balanceUncleared}
runningBalances={undefined}
searchPlaceholder={`Search ${category.name}`}
onSearch={onSearch}
isLoadingMore={isLoadingMore}

View File

@@ -32,7 +32,11 @@ import { View } from '@actual-app/components/view';
import * as monthUtils from 'loot-core/shared/months';
import { isPreviewId } from 'loot-core/shared/transactions';
import { validForTransfer } from 'loot-core/shared/transfer';
import { groupById, integerToCurrency } from 'loot-core/shared/util';
import {
groupById,
type IntegerAmount,
integerToCurrency,
} from 'loot-core/shared/util';
import {
type AccountEntity,
type TransactionEntity,
@@ -83,6 +87,7 @@ function Loading({ style, 'aria-label': ariaLabel }: LoadingProps) {
type TransactionListProps = {
isLoading: boolean;
transactions: readonly TransactionEntity[];
runningBalances: Map<TransactionEntity['id'], IntegerAmount> | undefined;
onOpenTransaction?: (transaction: TransactionEntity) => void;
isLoadingMore: boolean;
onLoadMore: () => void;
@@ -92,6 +97,7 @@ type TransactionListProps = {
export function TransactionList({
isLoading,
transactions,
runningBalances,
onOpenTransaction,
isLoadingMore,
onLoadMore,
@@ -201,6 +207,7 @@ export function TransactionList({
{transaction => (
<TransactionListItem
key={transaction.id}
balance={runningBalances?.get(transaction.id) || null}
value={transaction}
onPress={trans => onTransactionPress(trans)}
onLongPress={trans => onTransactionPress(trans, true)}

View File

@@ -30,7 +30,7 @@ import {
} from '@react-aria/interactions';
import { isPreviewId } from 'loot-core/shared/transactions';
import { integerToCurrency } from 'loot-core/shared/util';
import { type IntegerAmount, integerToCurrency } from 'loot-core/shared/util';
import {
type AccountEntity,
type TransactionEntity,
@@ -38,7 +38,10 @@ import {
import { lookupName, Status } from './TransactionEdit';
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
import {
makeAmountFullStyle,
makeBalanceAmountStyle,
} from '@desktop-client/components/budget/util';
import { useAccount } from '@desktop-client/hooks/useAccount';
import { useCachedSchedules } from '@desktop-client/hooks/useCachedSchedules';
import { useCategories } from '@desktop-client/hooks/useCategories';
@@ -73,11 +76,13 @@ const getScheduleIconStyle = ({ isPreview }: { isPreview: boolean }) => ({
type TransactionListItemProps = ComponentPropsWithoutRef<
typeof ListBoxItem<TransactionEntity>
> & {
balance: IntegerAmount | null;
onPress: (transaction: TransactionEntity) => void;
onLongPress: (transaction: TransactionEntity) => void;
};
export function TransactionListItem({
balance,
onPress,
onLongPress,
...props
@@ -282,7 +287,7 @@ export function TransactionListItem({
</TextOneLine>
)}
</View>
<View style={{ justifyContent: 'center' }}>
<View style={{ textAlign: 'right' }}>
<Text
style={{
...textStyle,
@@ -291,6 +296,17 @@ export function TransactionListItem({
>
{integerToCurrency(amount)}
</Text>
{balance != null && (
<Text
style={{
fontSize: 11,
fontWeight: '400',
...makeBalanceAmountStyle(balance || 0),
}}
>
{integerToCurrency(balance)}
</Text>
)}
</View>
</View>
</Button>

View File

@@ -86,6 +86,7 @@ type TransactionListWithBalancesProps = {
balanceUncleared?:
| Binding<'category', 'balanceUncleared'>
| Binding<'account', 'balanceUncleared'>;
runningBalances: Map<TransactionEntity['id'], number> | undefined;
searchPlaceholder: string;
onSearch: (searchText: string) => void;
isLoadingMore: boolean;
@@ -101,6 +102,7 @@ export function TransactionListWithBalances({
balance,
balanceCleared,
balanceUncleared,
runningBalances,
searchPlaceholder = 'Search...',
onSearch,
isLoadingMore,
@@ -148,6 +150,7 @@ export function TransactionListWithBalances({
<TransactionList
isLoading={isLoading}
transactions={transactions}
runningBalances={runningBalances}
isLoadingMore={isLoadingMore}
onLoadMore={onLoadMore}
onOpenTransaction={onOpenTransaction}

View File

@@ -33,6 +33,7 @@ import { validateAccountName } from '@desktop-client/components/util/accountVali
import { useAccount } from '@desktop-client/hooks/useAccount';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useNotes } from '@desktop-client/hooks/useNotes';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { type Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type AccountMenuModalProps = Extract<
@@ -47,6 +48,7 @@ export function AccountMenuModal({
onReopenAccount,
onEditNotes,
onClose,
onToggleRunningBalance,
}: AccountMenuModalProps) {
const { t } = useTranslation();
const account = useAccount(accountId);
@@ -124,6 +126,7 @@ export function AccountMenuModal({
account={account}
onClose={onCloseAccount}
onReopen={onReopenAccount}
onToggleRunningBalance={onToggleRunningBalance}
/>
}
title={
@@ -158,7 +161,7 @@ export function AccountMenuModal({
notes={
originalNotes && originalNotes.length > 0
? originalNotes
: 'No notes'
: t('No notes')
}
editable={false}
focused={false}
@@ -201,13 +204,16 @@ type AdditionalAccountMenuProps = {
account: AccountEntity;
onClose?: (accountId: string) => void;
onReopen?: (accountId: string) => void;
onToggleRunningBalance?: () => void;
};
function AdditionalAccountMenu({
account,
onClose,
onReopen,
onToggleRunningBalance,
}: AdditionalAccountMenuProps) {
const { t } = useTranslation();
const triggerRef = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);
const itemStyle: CSSProperties = {
@@ -219,6 +225,7 @@ function AdditionalAccountMenu({
...itemStyle,
...(item.name === 'close' && { color: theme.errorTextMenu }),
});
const [showBalances] = useSyncedPref(`show-balances-${account.id}`);
return (
<View>
@@ -244,16 +251,23 @@ function AdditionalAccountMenu({
<Menu
getItemStyle={getItemStyle}
items={[
{
name: 'balance',
text:
showBalances === 'true'
? t('Hide running balance')
: t('Show running balance'),
},
account.closed
? {
name: 'reopen',
text: 'Reopen account',
text: t('Reopen account'),
icon: SvgLockOpen,
iconSize: 15,
}
: {
name: 'close',
text: 'Close account',
text: t('Close account'),
icon: SvgClose,
iconSize: 15,
},
@@ -267,6 +281,9 @@ function AdditionalAccountMenu({
case 'reopen':
onReopen?.(account.id);
break;
case 'balance':
onToggleRunningBalance?.();
break;
default:
throw new Error(`Unrecognized menu option: ${name}`);
}

View File

@@ -709,6 +709,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
onOpenTransaction={onOpenTransaction}
isLoadingMore={false}
account={undefined}
runningBalances={undefined}
/>
</View>
</animated.div>

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react';
import { groupById } from 'loot-core/shared/util';
import { groupById, type IntegerAmount } from 'loot-core/shared/util';
import {
type ScheduleEntity,
type AccountEntity,
@@ -17,6 +17,7 @@ import { accountBalance } from '@desktop-client/spreadsheet/bindings';
type UseAccountPreviewTransactionsProps = {
accountId?: AccountEntity['id'] | undefined;
getRunningBalances?: boolean;
};
type UseAccountPreviewTransactionsResult = ReturnType<
@@ -29,6 +30,7 @@ type UseAccountPreviewTransactionsResult = ReturnType<
*/
export function useAccountPreviewTransactions({
accountId,
getRunningBalances,
}: UseAccountPreviewTransactionsProps): UseAccountPreviewTransactionsResult {
const accounts = useAccounts();
const accountsById = useMemo(() => groupById(accounts), [accounts]);
@@ -108,11 +110,14 @@ export function useAccountPreviewTransactions({
return {
isLoading,
previewTransactions,
runningBalances,
runningBalances: getRunningBalances
? runningBalances
: new Map<AccountEntity['id'], IntegerAmount>(),
error,
};
}, [
accountId,
getRunningBalances,
allPreviewTransactions,
allRunningBalances,
error,

View File

@@ -282,6 +282,7 @@ export type Modal =
onReopenAccount: (accountId: AccountEntity['id']) => void;
onEditNotes: (id: NoteEntity['id']) => void;
onClose?: () => void;
onToggleRunningBalance?: () => void;
};
}
| {

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [youngcw]
---
Add running balance to mobile