Fix transaction hooks and improve transactions loading experience in mobile (#5415)

* Fix transaction hooks and improve transactions loading experience in mobile

* Allow skipping of running balance calculation on preview transactions + recalculate running balances if there are any inversed transaction amounts

* [autofix.ci] apply automated fixes

* Disable PullToRefresh when transaction list is in loading state (See #5080)

* Cleanup

* Add calculateRunningBalancesTopDown to calculate top down from starting balance

* update balance sheet value

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2025-08-12 13:57:17 -07:00
committed by GitHub
parent ccdde60bfe
commit f54e459e03
10 changed files with 303 additions and 317 deletions

View File

@@ -241,6 +241,12 @@ function TransactionListWithPreviews({
readonly accountName: AccountEntity['name'] | string;
}) {
const { t } = useTranslation();
const balanceQueries = useMemo(
() => queriesFromAccountId(accountId, account),
[accountId, account],
);
const baseTransactionsQuery = useCallback(
() =>
queries.transactions(accountId).options({ splits: 'all' }).select('*'),
@@ -354,11 +360,6 @@ function TransactionListWithPreviews({
[dispatch, navigate],
);
const balanceQueries = useMemo(
() => queriesFromAccountId(accountId, account),
[accountId, account],
);
const transactionsToDisplay = !isSearching
? // Do not render child transactions in the list, unless searching
previewTransactions.concat(transactions.filter(t => !t.is_child))

View File

@@ -90,7 +90,7 @@ function TransactionListWithPreviews({
);
const {
transactions,
isLoading,
isLoading: isTransactionsLoading,
isLoadingMore,
loadMore: loadMoreTransactions,
reload: reloadTransactions,
@@ -138,10 +138,11 @@ function TransactionListWithPreviews({
month,
);
const { previewTransactions } = useCategoryPreviewTransactions({
categoryId: category.id,
month,
});
const { previewTransactions, isLoading: isPreviewTransactionsLoading } =
useCategoryPreviewTransactions({
categoryId: category.id,
month,
});
const transactionsToDisplay = !isSearching
? previewTransactions.concat(transactions)
@@ -149,7 +150,9 @@ function TransactionListWithPreviews({
return (
<TransactionListWithBalances
isLoading={isLoading}
isLoading={
isSearching ? isTransactionsLoading : isPreviewTransactionsLoading
}
transactions={transactionsToDisplay}
balance={balance}
balanceCleared={balanceCleared}
@@ -159,8 +162,6 @@ function TransactionListWithPreviews({
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
onOpenTransaction={onOpenTransaction}
onRefresh={undefined}
account={undefined}
/>
);
}

View File

@@ -38,7 +38,7 @@ import {
type TransactionEntity,
} from 'loot-core/types/models';
import { TransactionListItem } from './TransactionListItem';
import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem';
import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar';
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
@@ -148,30 +148,34 @@ export function TransactionList({
}
});
if (isLoading) {
return <Loading aria-label={t('Loading transactions...')} />;
}
return (
<>
{isLoading && (
<Loading
style={{ paddingBottom: 8 }}
aria-label={t('Loading transactions...')}
/>
)}
<ListBox
aria-label={t('Transaction list')}
selectionMode={selectedTransactions.size > 0 ? 'multiple' : 'single'}
selectedKeys={selectedTransactions}
dependencies={[selectedTransactions]}
renderEmptyState={() => (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text style={{ fontSize: 15 }}>
<Trans>No transactions</Trans>
</Text>
</View>
)}
renderEmptyState={() =>
!isLoading && (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text style={{ fontSize: 15 }}>
<Trans>No transactions</Trans>
</Text>
</View>
)
}
items={sections}
>
{section => (
@@ -217,7 +221,7 @@ export function TransactionList({
aria-label={t('Loading more transactions...')}
style={{
// Same height as transaction list item
height: 60,
height: ROW_HEIGHT,
}}
/>
)}

View File

@@ -47,7 +47,7 @@ import { usePayee } from '@desktop-client/hooks/usePayee';
import { NotesTagFormatter } from '@desktop-client/notes/NotesTagFormatter';
import { useSelector } from '@desktop-client/redux';
const ROW_HEIGHT = 60;
export const ROW_HEIGHT = 60;
const getTextStyle = ({
isPreview,

View File

@@ -142,7 +142,7 @@ export function TransactionListWithBalances({
/>
</View>
<PullToRefresh
isPullable={!!onRefresh}
isPullable={!isLoading && !!onRefresh}
onRefresh={async () => onRefresh?.()}
>
<TransactionList

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,
@@ -12,6 +12,8 @@ import { useAccounts } from './useAccounts';
import { usePayees } from './usePayees';
import { usePreviewTransactions } from './usePreviewTransactions';
import { useSheetValue } from './useSheetValue';
import { useSyncedPref } from './useSyncedPref';
import { calculateRunningBalancesBottomUp } from './useTransactions';
import { accountBalance } from '@desktop-client/spreadsheet/bindings';
@@ -68,6 +70,8 @@ export function useAccountPreviewTransactions({
accountBalance(accountId || ''),
);
const [showBalances] = useSyncedPref(`show-balances-${accountId}`);
const {
previewTransactions: allPreviewTransactions,
runningBalances: allRunningBalances,
@@ -76,6 +80,7 @@ export function useAccountPreviewTransactions({
} = usePreviewTransactions({
filter: accountSchedulesFilter,
options: {
calculateRunningBalances: showBalances === 'true',
startingBalance: accountBalanceValue ?? 0,
},
});
@@ -90,20 +95,24 @@ export function useAccountPreviewTransactions({
};
}
const previewTransactions = accountPreview({
const {
transactions: previewTransactions,
runningBalances: previewRunningBalances,
} = inverseBasedOnAccount({
accountId,
transactions: allPreviewTransactions,
runningBalances: allRunningBalances,
startingBalance: accountBalanceValue ?? 0,
getPayeeByTransferAccount,
getTransferAccountByPayee,
});
const transactionIds = new Set(previewTransactions.map(t => t.id));
const runningBalances = allRunningBalances;
for (const transactionId of runningBalances.keys()) {
if (!transactionIds.has(transactionId)) {
runningBalances.delete(transactionId);
}
}
const runningBalances = new Map(
[...previewRunningBalances.entries()].filter(([id]) =>
transactionIds.has(id),
),
);
return {
isLoading,
@@ -114,17 +123,20 @@ export function useAccountPreviewTransactions({
}, [
accountId,
allPreviewTransactions,
accountBalanceValue,
allRunningBalances,
error,
getPayeeByTransferAccount,
getTransferAccountByPayee,
isLoading,
error,
]);
}
type AccountPreviewProps = {
type InverseBasedOnAccountProps = {
accountId?: AccountEntity['id'];
transactions: readonly TransactionEntity[];
startingBalance: IntegerAmount;
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
getPayeeByTransferAccount: (
transferAccountId?: AccountEntity['id'],
) => PayeeEntity | null;
@@ -133,13 +145,18 @@ type AccountPreviewProps = {
) => AccountEntity | null;
};
function accountPreview({
function inverseBasedOnAccount({
accountId,
transactions,
runningBalances,
startingBalance,
getPayeeByTransferAccount,
getTransferAccountByPayee,
}: AccountPreviewProps): TransactionEntity[] {
return transactions.map(transaction => {
}: InverseBasedOnAccountProps): {
transactions: TransactionEntity[];
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
} {
const mappedTransactions = transactions.map(transaction => {
const inverse = transaction.account !== accountId;
const subtransactions = transaction.subtransactions?.map(st => ({
...st,
@@ -151,6 +168,7 @@ function accountPreview({
: st.account,
}));
return {
inversed: inverse,
...transaction,
amount: inverse ? -transaction.amount : transaction.amount,
payee:
@@ -163,4 +181,22 @@ function accountPreview({
...(subtransactions && { subtransactions }),
};
});
// Recalculate running balances if any transaction was inversed.
// This is necessary because the running balances are calculated based on the
// original transaction amounts and accounts, and we need to adjust them
// based on the inversed transactions.
const anyInversed = mappedTransactions.some(t => t.inversed);
const mappedRunningBalances = anyInversed
? calculateRunningBalancesBottomUp(
mappedTransactions,
'all',
startingBalance ?? 0,
)
: runningBalances;
return {
transactions: mappedTransactions,
runningBalances: mappedRunningBalances,
};
}

View File

@@ -66,8 +66,8 @@ export function useCategoryPreviewTransactions({
return {
previewTransactions: [],
runningBalances: new Map(),
isLoading: false,
error: undefined,
isLoading,
error,
};
}
@@ -77,12 +77,9 @@ export function useCategoryPreviewTransactions({
);
const transactionIds = new Set(previewTransactions.map(t => t.id));
const runningBalances = allRunningBalances;
for (const transactionId of runningBalances.keys()) {
if (!transactionIds.has(transactionId)) {
runningBalances.delete(transactionId);
}
}
const runningBalances = new Map(
[...allRunningBalances].filter(([id]) => transactionIds.has(id)),
);
return {
previewTransactions,

View File

@@ -1,10 +1,9 @@
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { useEffect, useState, useMemo, useRef } from 'react';
import * as d from 'date-fns';
import { send } from 'loot-core/platform/client/fetch';
import { currentDay, addDays, parseDate } from 'loot-core/shared/months';
import { type Query } from 'loot-core/shared/query';
import {
getUpcomingDays,
extractScheduleConds,
@@ -22,203 +21,18 @@ import {
import { useCachedSchedules } from './useCachedSchedules';
import { type ScheduleStatuses } from './useSchedules';
import { useSyncedPref } from './useSyncedPref';
import {
pagedQuery,
type PagedQuery,
} from '@desktop-client/queries/pagedQuery';
// Mirrors the `splits` AQL option from the server
type TransactionSplitsOption = 'all' | 'inline' | 'grouped' | 'none';
type CalculateRunningBalancesOption =
| ((
transactions: TransactionEntity[],
splits: TransactionSplitsOption,
) => Map<TransactionEntity['id'], IntegerAmount>)
| boolean;
type UseTransactionsProps = {
/**
* The Query class is immutable so it is important to memoize the query object
* to prevent unnecessary re-renders i.e. `useMemo`, `useState`, etc.
*/
query?: Query;
/**
* The options to configure the hook behavior.
*/
options?: {
/**
* The number of transactions to load at a time.
* This is used for pagination and should be set to a reasonable number
* to avoid loading too many transactions at once.
* The default is 50.
* @default 50
*/
pageCount?: number;
/**
* Whether to calculate running balances for the transactions returned by the query.
* This can be set to `true` to calculate running balances for all transactions
* (using the default running balance calculation), or a function that takes the
* transactions and the query state and returns a map of transaction IDs to running balances.
* The function will be called with the transactions and the query state
* whenever the transactions are loaded or reloaded.
*
* The default running balance calculation is a simple sum of the transaction amounts
* in reverse order (bottom up). This works well if the transactions are ordered by
* date in descending order. If the query orders the transactions differently,
* a custom `calculateRunningBalances` function should be used instead.
* @default false
*/
calculateRunningBalances?: CalculateRunningBalancesOption;
};
};
type UseTransactionsResult = {
/**
* The transactions returned by the query.
*/
transactions: ReadonlyArray<TransactionEntity>;
/**
* The running balances for the transactions returned by the query.
* This is only populated if `calculateRunningBalances` is either set to `true`
* or a function that implements the calculation in the options.
*/
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
/**
* Whether the transactions are currently being loaded.
*/
isLoading: boolean;
/**
* An error that occurred while loading the transactions.
*/
error?: Error;
/**
* Reload the transactions.
*/
reload: () => void;
/**
* Load more transactions.
*/
loadMore: () => void;
/**
* Whether more transactions are currently being loaded.
*/
isLoadingMore: boolean;
};
export function useTransactions({
query,
options = { pageCount: 50, calculateRunningBalances: false },
}: UseTransactionsProps): UseTransactionsResult {
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [transactions, setTransactions] = useState<
ReadonlyArray<TransactionEntity>
>([]);
const [runningBalances, setRunningBalances] = useState<
Map<TransactionEntity['id'], IntegerAmount>
>(new Map());
const pagedQueryRef = useRef<PagedQuery<TransactionEntity> | null>(null);
// 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;
useEffect(() => {
let isUnmounted = false;
setError(undefined);
if (!query) {
return;
}
function onError(error: Error) {
if (!isUnmounted) {
setError(error);
setIsLoading(false);
}
}
if (query.state.table !== 'transactions') {
onError(new Error('Query must be a transactions query.'));
return;
}
setIsLoading(true);
pagedQueryRef.current = pagedQuery<TransactionEntity>(query, {
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);
}
},
onError,
options: optionsRef.current.pageCount
? { pageCount: optionsRef.current.pageCount }
: {},
});
return () => {
isUnmounted = true;
pagedQueryRef.current?.unsubscribe();
};
}, [query]);
const loadMore = useCallback(async () => {
if (!pagedQueryRef.current) {
return;
}
setIsLoadingMore(true);
await pagedQueryRef.current
.fetchNext()
.catch(setError)
.finally(() => {
setIsLoadingMore(false);
});
}, []);
const reload = useCallback(() => {
pagedQueryRef.current?.run();
}, []);
return {
transactions,
runningBalances,
isLoading,
...(error && { error }),
reload,
loadMore,
isLoadingMore,
};
}
import { calculateRunningBalancesBottomUp } from './useTransactions';
type UsePreviewTransactionsProps = {
filter?: (schedule: ScheduleEntity) => boolean;
options?: {
/**
* Whether to calculate running balances.
*/
calculateRunningBalances?: boolean;
/**
* The starting balance to start the running balance calculation from.
* This is ignored if `calculateRunningBalances` is false.
*/
startingBalance?: IntegerAmount;
};
@@ -372,18 +186,20 @@ 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,
),
);
if (optionsRef.current?.calculateRunningBalances) {
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);
}
@@ -409,61 +225,10 @@ export function usePreviewTransactions({
};
}
export function isForPreview(
schedule: ScheduleEntity,
statuses: ScheduleStatuses,
) {
function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) {
const status = statuses.get(schedule.id);
return (
!schedule.completed &&
['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>())
);
}

View File

@@ -1,35 +1,103 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import type { Query } from 'loot-core/shared/query';
import type { TransactionEntity } from 'loot-core/types/models';
import { type Query } from 'loot-core/shared/query';
import { type IntegerAmount } from 'loot-core/shared/util';
import { type TransactionEntity } from 'loot-core/types/models';
import {
type PagedQuery,
pagedQuery,
type PagedQuery,
} from '@desktop-client/queries/pagedQuery';
// Mirrors the `splits` AQL option from the server
type TransactionSplitsOption = 'all' | 'inline' | 'grouped' | 'none';
type CalculateRunningBalancesOption =
| ((
transactions: TransactionEntity[],
splits: TransactionSplitsOption,
startingBalance?: IntegerAmount,
) => Map<TransactionEntity['id'], IntegerAmount>)
| boolean;
type UseTransactionsProps = {
/**
* The Query class is immutable so it is important to memoize the query object
* to prevent unnecessary re-renders i.e. `useMemo`, `useState`, etc.
*/
query?: Query;
/**
* The options to configure the hook behavior.
*/
options?: {
/**
* The number of transactions to load at a time.
* This is used for pagination and should be set to a reasonable number
* to avoid loading too many transactions at once.
* The default is 50.
* @default 50
*/
pageCount?: number;
/**
* Whether to calculate running balances for the transactions returned by the query.
* This can be set to `true` to calculate running balances for all transactions
* (using the default running balance calculation), or a function that takes the
* transactions and the query state and returns a map of transaction IDs to running balances.
* The function will be called with the transactions and the query state
* whenever the transactions are loaded or reloaded.
*
* The default running balance calculation is a simple sum of the transaction amounts
* in reverse order (bottom up). This works well if the transactions are ordered by
* date in descending order. If the query orders the transactions differently,
* a custom `calculateRunningBalances` function should be used instead.
* @default false
*/
calculateRunningBalances?: CalculateRunningBalancesOption;
/**
* The starting balance to start the running balance calculation from.
* This is ignored if `calculateRunningBalances` is false.
* @default 0
*/
startingBalance?: IntegerAmount;
};
};
type UseTransactionsResult = {
/**
* The transactions returned by the query.
*/
transactions: ReadonlyArray<TransactionEntity>;
/**
* The running balances for the transactions returned by the query.
* This is only populated if `calculateRunningBalances` is either set to `true`
* or a function that implements the calculation in the options.
*/
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
/**
* Whether the transactions are currently being loaded.
*/
isLoading: boolean;
/**
* An error that occurred while loading the transactions.
*/
error?: Error;
/**
* Reload the transactions.
*/
reload: () => void;
/**
* Load more transactions.
*/
loadMore: () => void;
/**
* Whether more transactions are currently being loaded.
*/
isLoadingMore: boolean;
};
export function useTransactions({
query,
options = { pageCount: 50 },
options = { pageCount: 50, calculateRunningBalances: false },
}: UseTransactionsProps): UseTransactionsResult {
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -37,6 +105,9 @@ export function useTransactions({
const [transactions, setTransactions] = useState<
ReadonlyArray<TransactionEntity>
>([]);
const [runningBalances, setRunningBalances] = useState<
Map<TransactionEntity['id'], IntegerAmount>
>(new Map());
const pagedQueryRef = useRef<PagedQuery<TransactionEntity> | null>(null);
@@ -73,6 +144,20 @@ 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,
optionsRef.current?.startingBalance,
),
);
}
setIsLoading(false);
}
},
@@ -109,6 +194,7 @@ export function useTransactions({
return {
transactions,
runningBalances,
isLoading,
...(error && { error }),
reload,
@@ -116,3 +202,93 @@ export function useTransactions({
isLoadingMore,
};
}
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>())
);
}
export function calculateRunningBalancesTopDown(
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;
}
})
.reduce((acc, transaction, index, arr) => {
if (index === 0) {
// This is the first transaction in the list,
// so we set the running balance to the starting balance
acc.set(transaction.id, startingBalance);
return acc;
}
if (index === arr.length - 1) {
// This is the last transaction in the list,
// so we set the running balance to the amount of the transaction
acc.set(transaction.id, transaction.amount);
return acc;
}
const previousTransaction = arr[index - 1];
const previousRunningBalance = acc.get(previousTransaction.id) ?? 0;
const previousAmount = previousTransaction.amount ?? 0;
const currentRunningBalance = previousRunningBalance - previousAmount;
acc.set(transaction.id, currentRunningBalance);
return acc;
}, new Map<TransactionEntity['id'], IntegerAmount>());
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [joel-jeremy]
---
Fix transaction hooks and improve transactions loading experience in mobile