mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-29 19:14:22 -05:00
Mobile running balance (#5219)
* 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 * fixed regular transaction balance calculation * fix numbers not showing * fix schedule running balance * note * attempt to update properly * type * remove the useEffect that I don't think should be requred * remove old note * cleanup * typeing * I FINALLY FOUND THE PROBLEM * cleaner balance calculation * fixes * fix zeros --------- 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:
@@ -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,64 @@ function TransactionListWithPreviews({
|
||||
}
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
const baseTransactionsQuery = useCallback(
|
||||
() =>
|
||||
queries.transactions(accountId).options({ splits: 'all' }).select('*'),
|
||||
[accountId],
|
||||
);
|
||||
|
||||
const runningBalancesQuery = useCallback(
|
||||
() =>
|
||||
queries
|
||||
.transactions(accountId)
|
||||
.options({ splits: 'none' })
|
||||
.select({ balance: { $sumOver: '$amount' } }),
|
||||
[accountId],
|
||||
);
|
||||
|
||||
const [showBalances] = useSyncedPref(`show-balances-${accountId}`);
|
||||
const [transactionsQuery, setTransactionsQuery] = useState<Query>(
|
||||
baseTransactionsQuery(),
|
||||
);
|
||||
const [balancesQuery] = useState<Query>(runningBalancesQuery);
|
||||
const {
|
||||
transactions,
|
||||
runningBalances,
|
||||
isLoading: isTransactionsLoading,
|
||||
reload: reloadTransactions,
|
||||
isLoadingMore,
|
||||
loadMore: loadMoreTransactions,
|
||||
} = useTransactions({
|
||||
query: transactionsQuery,
|
||||
runningBalanceQuery: balancesQuery,
|
||||
options: {
|
||||
calculateRunningBalances: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { isSearching, search: onSearch } = useTransactionsSearch({
|
||||
updateQuery: setTransactionsQuery,
|
||||
resetQuery: () => setTransactionsQuery(baseTransactionsQuery()),
|
||||
dateFormat,
|
||||
});
|
||||
|
||||
const {
|
||||
previewTransactions,
|
||||
runningBalances: previewRunningBalances,
|
||||
isLoading: isPreviewTransactionsLoading,
|
||||
} = useAccountPreviewTransactions({
|
||||
accountId: account?.id,
|
||||
});
|
||||
|
||||
const allBalances = useMemo(
|
||||
() =>
|
||||
new Map<TransactionEntity['id'], IntegerAmount>([
|
||||
...previewRunningBalances,
|
||||
...runningBalances,
|
||||
]),
|
||||
[runningBalances, previewRunningBalances],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
dispatch(markAccountRead({ id: accountId }));
|
||||
@@ -296,12 +347,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 +415,8 @@ function TransactionListWithPreviews({
|
||||
balance={balanceQueries.balance}
|
||||
balanceCleared={balanceQueries.cleared}
|
||||
balanceUncleared={balanceQueries.uncleared}
|
||||
runningBalances={allBalances}
|
||||
showBalances={isSearching ? false : showBalances === 'true'}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
searchPlaceholder={t('Search {{accountName}}', { accountName })}
|
||||
|
||||
@@ -154,6 +154,7 @@ function TransactionListWithPreviews({
|
||||
balance={balance}
|
||||
balanceCleared={balanceCleared}
|
||||
balanceUncleared={balanceUncleared}
|
||||
runningBalances={undefined}
|
||||
searchPlaceholder={`Search ${category.name}`}
|
||||
onSearch={onSearch}
|
||||
isLoadingMore={isLoadingMore}
|
||||
|
||||
@@ -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,
|
||||
@@ -84,6 +88,8 @@ function Loading({ style, 'aria-label': ariaLabel }: LoadingProps) {
|
||||
type TransactionListProps = {
|
||||
isLoading: boolean;
|
||||
transactions: readonly TransactionEntity[];
|
||||
showBalances?: boolean;
|
||||
runningBalances?: Map<TransactionEntity['id'], IntegerAmount>;
|
||||
onOpenTransaction?: (transaction: TransactionEntity) => void;
|
||||
isLoadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
@@ -93,6 +99,8 @@ type TransactionListProps = {
|
||||
export function TransactionList({
|
||||
isLoading,
|
||||
transactions,
|
||||
showBalances,
|
||||
runningBalances,
|
||||
onOpenTransaction,
|
||||
isLoadingMore,
|
||||
onLoadMore,
|
||||
@@ -198,10 +206,13 @@ export function TransactionList({
|
||||
t => !isPreviewId(t.id) || !t.is_child,
|
||||
)}
|
||||
addIdAndValue
|
||||
dependencies={[transactions, showBalances, runningBalances]}
|
||||
>
|
||||
{transaction => (
|
||||
<TransactionListItem
|
||||
key={transaction.id}
|
||||
showBalance={showBalances}
|
||||
balance={runningBalances?.get(transaction.id)}
|
||||
value={transaction}
|
||||
onPress={trans => onTransactionPress(trans)}
|
||||
onLongPress={trans => onTransactionPress(trans, true)}
|
||||
|
||||
@@ -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,15 @@ const getScheduleIconStyle = ({ isPreview }: { isPreview: boolean }) => ({
|
||||
type TransactionListItemProps = ComponentPropsWithoutRef<
|
||||
typeof ListBoxItem<TransactionEntity>
|
||||
> & {
|
||||
showBalance?: boolean;
|
||||
balance?: IntegerAmount;
|
||||
onPress: (transaction: TransactionEntity) => void;
|
||||
onLongPress: (transaction: TransactionEntity) => void;
|
||||
};
|
||||
|
||||
export function TransactionListItem({
|
||||
showBalance,
|
||||
balance,
|
||||
onPress,
|
||||
onLongPress,
|
||||
...props
|
||||
@@ -282,7 +289,7 @@ export function TransactionListItem({
|
||||
</TextOneLine>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ justifyContent: 'center' }}>
|
||||
<View style={{ textAlign: 'right' }}>
|
||||
<Text
|
||||
style={{
|
||||
...textStyle,
|
||||
@@ -291,6 +298,17 @@ export function TransactionListItem({
|
||||
>
|
||||
{integerToCurrency(amount)}
|
||||
</Text>
|
||||
{showBalance && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
...makeBalanceAmountStyle(balance || 0),
|
||||
}}
|
||||
>
|
||||
{integerToCurrency(balance || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { type IntegerAmount } from 'loot-core/shared/util';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type TransactionEntity,
|
||||
@@ -86,6 +87,8 @@ type TransactionListWithBalancesProps = {
|
||||
balanceUncleared?:
|
||||
| Binding<'category', 'balanceUncleared'>
|
||||
| Binding<'account', 'balanceUncleared'>;
|
||||
showBalances?: boolean;
|
||||
runningBalances?: Map<TransactionEntity['id'], IntegerAmount>;
|
||||
searchPlaceholder: string;
|
||||
onSearch: (searchText: string) => void;
|
||||
isLoadingMore: boolean;
|
||||
@@ -101,6 +104,8 @@ export function TransactionListWithBalances({
|
||||
balance,
|
||||
balanceCleared,
|
||||
balanceUncleared,
|
||||
showBalances,
|
||||
runningBalances,
|
||||
searchPlaceholder = 'Search...',
|
||||
onSearch,
|
||||
isLoadingMore,
|
||||
@@ -148,6 +153,8 @@ export function TransactionListWithBalances({
|
||||
<TransactionList
|
||||
isLoading={isLoading}
|
||||
transactions={transactions}
|
||||
showBalances={showBalances}
|
||||
runningBalances={runningBalances}
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={onLoadMore}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
|
||||
@@ -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,12 +204,14 @@ 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);
|
||||
@@ -220,6 +225,7 @@ function AdditionalAccountMenu({
|
||||
...itemStyle,
|
||||
...(item.name === 'close' && { color: theme.errorTextMenu }),
|
||||
});
|
||||
const [showBalances] = useSyncedPref(`show-balances-${account.id}`);
|
||||
|
||||
return (
|
||||
<View>
|
||||
@@ -245,6 +251,13 @@ function AdditionalAccountMenu({
|
||||
<Menu
|
||||
getItemStyle={getItemStyle}
|
||||
items={[
|
||||
{
|
||||
name: 'balance',
|
||||
text:
|
||||
showBalances === 'true'
|
||||
? t('Hide running balance')
|
||||
: t('Show running balance'),
|
||||
},
|
||||
account.closed
|
||||
? {
|
||||
name: 'reopen',
|
||||
@@ -268,6 +281,9 @@ function AdditionalAccountMenu({
|
||||
case 'reopen':
|
||||
onReopen?.(account.id);
|
||||
break;
|
||||
case 'balance':
|
||||
onToggleRunningBalance?.();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu option: ${name}`);
|
||||
}
|
||||
|
||||
@@ -709,6 +709,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
isLoadingMore={false}
|
||||
account={undefined}
|
||||
runningBalances={undefined}
|
||||
/>
|
||||
</View>
|
||||
</animated.div>
|
||||
|
||||
@@ -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,
|
||||
@@ -19,9 +19,15 @@ type UseAccountPreviewTransactionsProps = {
|
||||
accountId?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
type UseAccountPreviewTransactionsResult = ReturnType<
|
||||
typeof usePreviewTransactions
|
||||
>;
|
||||
// Mirrors the `splits` AQL option from the server
|
||||
type TransactionSplitsOption = 'all' | 'inline' | 'grouped' | 'none';
|
||||
|
||||
type UseAccountPreviewTransactionsResult = {
|
||||
previewTransactions: ReadonlyArray<TransactionEntity>;
|
||||
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
/**
|
||||
* Preview transactions for a given account. This will invert the payees, accounts,
|
||||
@@ -70,21 +76,17 @@ export function useAccountPreviewTransactions({
|
||||
|
||||
const {
|
||||
previewTransactions: allPreviewTransactions,
|
||||
runningBalances: allRunningBalances,
|
||||
isLoading,
|
||||
error,
|
||||
} = usePreviewTransactions({
|
||||
filter: accountSchedulesFilter,
|
||||
options: {
|
||||
startingBalance: accountBalanceValue ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
if (!accountId) {
|
||||
return {
|
||||
previewTransactions: allPreviewTransactions,
|
||||
runningBalances: allRunningBalances,
|
||||
runningBalances: new Map(),
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
@@ -97,6 +99,11 @@ export function useAccountPreviewTransactions({
|
||||
getTransferAccountByPayee,
|
||||
});
|
||||
|
||||
const allRunningBalances = calculateRunningBalancesBottomUp(
|
||||
previewTransactions,
|
||||
'all',
|
||||
accountBalanceValue ?? 0,
|
||||
);
|
||||
const transactionIds = new Set(previewTransactions.map(t => t.id));
|
||||
const runningBalances = allRunningBalances;
|
||||
for (const transactionId of runningBalances.keys()) {
|
||||
@@ -113,8 +120,8 @@ export function useAccountPreviewTransactions({
|
||||
};
|
||||
}, [
|
||||
accountId,
|
||||
accountBalanceValue,
|
||||
allPreviewTransactions,
|
||||
allRunningBalances,
|
||||
error,
|
||||
getPayeeByTransferAccount,
|
||||
getTransferAccountByPayee,
|
||||
@@ -164,3 +171,41 @@ function accountPreview({
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function calculateRunningBalancesBottomUp(
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
startingBalance: IntegerAmount = 0,
|
||||
) {
|
||||
return (
|
||||
transactions
|
||||
.filter(t => {
|
||||
switch (splits) {
|
||||
case 'all':
|
||||
// Only calculate parent/non-split amounts
|
||||
return !t.parent_id;
|
||||
default:
|
||||
// inline
|
||||
// grouped
|
||||
// none
|
||||
return true;
|
||||
}
|
||||
})
|
||||
// We're using `reduceRight` here to calculate the running balance in reverse order (bottom up).
|
||||
.reduceRight((acc, transaction, index, arr) => {
|
||||
const previousTransactionIndex = index + 1;
|
||||
if (previousTransactionIndex >= arr.length) {
|
||||
// This is the last transaction in the list,
|
||||
// so we set the running balance to the starting balance + the amount of the transaction
|
||||
acc.set(transaction.id, startingBalance + transaction.amount);
|
||||
return acc;
|
||||
}
|
||||
const previousTransaction = arr[previousTransactionIndex];
|
||||
const previousRunningBalance = acc.get(previousTransaction.id) ?? 0;
|
||||
const currentRunningBalance =
|
||||
previousRunningBalance + transaction.amount;
|
||||
acc.set(transaction.id, currentRunningBalance);
|
||||
return acc;
|
||||
}, new Map<TransactionEntity['id'], IntegerAmount>())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type CategoryEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { calculateRunningBalancesBottomUp } from './useAccountPreviewTransactions';
|
||||
import { useCategory } from './useCategory';
|
||||
import { useCategoryScheduleGoalTemplates } from './useCategoryScheduleGoalTemplates';
|
||||
import { usePreviewTransactions } from './usePreviewTransactions';
|
||||
@@ -51,14 +52,10 @@ export function useCategoryPreviewTransactions({
|
||||
|
||||
const {
|
||||
previewTransactions: allPreviewTransactions,
|
||||
runningBalances: allRunningBalances,
|
||||
isLoading,
|
||||
error,
|
||||
} = usePreviewTransactions({
|
||||
filter: categorySchedulesFilter,
|
||||
options: {
|
||||
startingBalance: categoryBalanceValue ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -77,7 +74,11 @@ export function useCategoryPreviewTransactions({
|
||||
);
|
||||
|
||||
const transactionIds = new Set(previewTransactions.map(t => t.id));
|
||||
const runningBalances = allRunningBalances;
|
||||
const runningBalances = calculateRunningBalancesBottomUp(
|
||||
previewTransactions,
|
||||
'all',
|
||||
categoryBalanceValue ?? 0,
|
||||
);
|
||||
for (const transactionId of runningBalances.keys()) {
|
||||
if (!transactionIds.has(transactionId)) {
|
||||
runningBalances.delete(transactionId);
|
||||
@@ -92,8 +93,8 @@ export function useCategoryPreviewTransactions({
|
||||
};
|
||||
}, [
|
||||
allPreviewTransactions,
|
||||
allRunningBalances,
|
||||
category,
|
||||
categoryBalanceValue,
|
||||
error,
|
||||
isLoading,
|
||||
schedulesToPreview,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useCachedSchedules } from './useCachedSchedules';
|
||||
import { type ScheduleStatuses } from './useSchedules';
|
||||
import { useSyncedPref } from './useSyncedPref';
|
||||
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import {
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
@@ -44,6 +45,10 @@ type UseTransactionsProps = {
|
||||
* to prevent unnecessary re-renders i.e. `useMemo`, `useState`, etc.
|
||||
*/
|
||||
query?: Query;
|
||||
/**
|
||||
* Query to use to calculate the running balance
|
||||
*/
|
||||
runningBalanceQuery?: Query;
|
||||
/**
|
||||
* The options to configure the hook behavior.
|
||||
*/
|
||||
@@ -109,6 +114,7 @@ type UseTransactionsResult = {
|
||||
|
||||
export function useTransactions({
|
||||
query,
|
||||
runningBalanceQuery,
|
||||
options = { pageCount: 50, calculateRunningBalances: false },
|
||||
}: UseTransactionsProps): UseTransactionsResult {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -156,19 +162,6 @@ export function useTransactions({
|
||||
onData: data => {
|
||||
if (!isUnmounted) {
|
||||
setTransactions(data);
|
||||
|
||||
const calculateFn = getCalculateRunningBalancesFn(
|
||||
optionsRef.current?.calculateRunningBalances,
|
||||
);
|
||||
if (calculateFn) {
|
||||
setRunningBalances(
|
||||
calculateFn(
|
||||
data,
|
||||
query.state.tableOptions?.splits as TransactionSplitsOption,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
@@ -184,6 +177,20 @@ export function useTransactions({
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.calculateRunningBalances && runningBalanceQuery) {
|
||||
aqlQuery(runningBalanceQuery).then(data => {
|
||||
const map = new Map<TransactionEntity['id'], IntegerAmount>();
|
||||
data.data.forEach((val: { id: string; balance: IntegerAmount }) => {
|
||||
map.set(val.id, val.balance);
|
||||
});
|
||||
setRunningBalances(map);
|
||||
});
|
||||
} else {
|
||||
setRunningBalances(new Map());
|
||||
}
|
||||
}, [runningBalanceQuery, options.calculateRunningBalances]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!pagedQueryRef.current) {
|
||||
return;
|
||||
@@ -216,24 +223,16 @@ export function useTransactions({
|
||||
|
||||
type UsePreviewTransactionsProps = {
|
||||
filter?: (schedule: ScheduleEntity) => boolean;
|
||||
options?: {
|
||||
/**
|
||||
* The starting balance to start the running balance calculation from.
|
||||
*/
|
||||
startingBalance?: IntegerAmount;
|
||||
};
|
||||
};
|
||||
|
||||
type UsePreviewTransactionsResult = {
|
||||
previewTransactions: ReadonlyArray<TransactionEntity>;
|
||||
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export function usePreviewTransactions({
|
||||
filter,
|
||||
options,
|
||||
}: UsePreviewTransactionsProps = {}): UsePreviewTransactionsResult {
|
||||
const [previewTransactions, setPreviewTransactions] = useState<
|
||||
TransactionEntity[]
|
||||
@@ -246,18 +245,9 @@ export function usePreviewTransactions({
|
||||
} = useCachedSchedules();
|
||||
const [isLoading, setIsLoading] = useState(isSchedulesLoading);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const [runningBalances, setRunningBalances] = useState<
|
||||
Map<TransactionEntity['id'], IntegerAmount>
|
||||
>(new Map());
|
||||
|
||||
const [upcomingLength] = useSyncedPref('upcomingScheduledTransactionLength');
|
||||
|
||||
// We don't want to re-render if options changes.
|
||||
// Putting options in a ref will prevent that and
|
||||
// allow us to use the latest options on next render.
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const scheduleTransactions = useMemo(() => {
|
||||
if (isSchedulesLoading) {
|
||||
return [];
|
||||
@@ -372,19 +362,6 @@ export function usePreviewTransactions({
|
||||
const ungroupedTransactions = ungroupTransactions(withDefaults);
|
||||
setPreviewTransactions(ungroupedTransactions);
|
||||
|
||||
setRunningBalances(
|
||||
// We always use the bottom up calculation for preview transactions
|
||||
// because the hook controls the order of the transactions. We don't
|
||||
// need to provide a custom way for consumers to calculate the running
|
||||
// balances, at least as of writing.
|
||||
calculateRunningBalancesBottomUp(
|
||||
ungroupedTransactions,
|
||||
// Preview transactions are behaves like 'all' splits
|
||||
'all',
|
||||
optionsRef.current?.startingBalance,
|
||||
),
|
||||
);
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
@@ -403,7 +380,6 @@ export function usePreviewTransactions({
|
||||
const returnError = error || scheduleQueryError;
|
||||
return {
|
||||
previewTransactions,
|
||||
runningBalances,
|
||||
isLoading: isLoading || isSchedulesLoading,
|
||||
...(returnError && { error: returnError }),
|
||||
};
|
||||
@@ -419,51 +395,3 @@ export function isForPreview(
|
||||
['due', 'upcoming', 'missed', 'paid'].includes(status!)
|
||||
);
|
||||
}
|
||||
|
||||
function getCalculateRunningBalancesFn(
|
||||
calculateRunningBalances: CalculateRunningBalancesOption = false,
|
||||
) {
|
||||
return calculateRunningBalances === true
|
||||
? calculateRunningBalancesBottomUp
|
||||
: typeof calculateRunningBalances === 'function'
|
||||
? calculateRunningBalances
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function calculateRunningBalancesBottomUp(
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
startingBalance: IntegerAmount = 0,
|
||||
) {
|
||||
return (
|
||||
transactions
|
||||
.filter(t => {
|
||||
switch (splits) {
|
||||
case 'all':
|
||||
// Only calculate parent/non-split amounts
|
||||
return !t.parent_id;
|
||||
default:
|
||||
// inline
|
||||
// grouped
|
||||
// none
|
||||
return true;
|
||||
}
|
||||
})
|
||||
// We're using `reduceRight` here to calculate the running balance in reverse order (bottom up).
|
||||
.reduceRight((acc, transaction, index, arr) => {
|
||||
const previousTransactionIndex = index + 1;
|
||||
if (previousTransactionIndex >= arr.length) {
|
||||
// This is the last transaction in the list,
|
||||
// so we set the running balance to the starting balance + the amount of the transaction
|
||||
acc.set(transaction.id, startingBalance + transaction.amount);
|
||||
return acc;
|
||||
}
|
||||
const previousTransaction = arr[previousTransactionIndex];
|
||||
const previousRunningBalance = acc.get(previousTransaction.id) ?? 0;
|
||||
const currentRunningBalance =
|
||||
previousRunningBalance + transaction.amount;
|
||||
acc.set(transaction.id, currentRunningBalance);
|
||||
return acc;
|
||||
}, new Map<TransactionEntity['id'], IntegerAmount>())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,6 +282,7 @@ export type Modal =
|
||||
onReopenAccount: (accountId: AccountEntity['id']) => void;
|
||||
onEditNotes: (id: NoteEntity['id']) => void;
|
||||
onClose?: () => void;
|
||||
onToggleRunningBalance?: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user