diff --git a/packages/loot-core/src/client/data-hooks/transactions.ts b/packages/loot-core/src/client/data-hooks/transactions.ts index 2fc3286774..c5c777017e 100644 --- a/packages/loot-core/src/client/data-hooks/transactions.ts +++ b/packages/loot-core/src/client/data-hooks/transactions.ts @@ -7,7 +7,7 @@ import debounce from 'lodash/debounce'; import { send } from '../../platform/client/fetch'; import { currentDay, addDays, parseDate } from '../../shared/months'; -import { type Query } from '../../shared/query'; +import { type QueryState, type Query } from '../../shared/query'; import { getScheduledAmount, extractScheduleConds, @@ -16,6 +16,7 @@ import { scheduleIsRecurring, } from '../../shared/schedules'; import { ungroupTransactions } from '../../shared/transactions'; +import { type IntegerAmount } from '../../shared/util'; import { type ScheduleEntity, type TransactionEntity, @@ -31,23 +32,77 @@ type UseTransactionsProps = { * 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?: + | (( + transactions: TransactionEntity[], + queryState: QueryState, + ) => Map) + | boolean; }; }; type UseTransactionsResult = { + /** + * The transactions returned by the query. + */ transactions: ReadonlyArray; + /** + * 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; + /** + * 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); @@ -55,6 +110,9 @@ export function useTransactions({ const [transactions, setTransactions] = useState< ReadonlyArray >([]); + const [runningBalances, setRunningBalances] = useState< + Map + >(new Map()); const pagedQueryRef = useRef | null>(null); @@ -91,6 +149,12 @@ export function useTransactions({ onData: data => { if (!isUnmounted) { setTransactions(data); + + const calculateFn = getCalculateRunningBalancesFn(optionsRef.current); + if (calculateFn) { + setRunningBalances(calculateFn(data, query.state)); + } + setIsLoading(false); } }, @@ -127,6 +191,7 @@ export function useTransactions({ return { transactions, + runningBalances, isLoading, ...(error && { error }), reload, @@ -344,3 +409,52 @@ function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) { ['due', 'upcoming', 'missed', 'paid'].includes(status!) ); } + +function getCalculateRunningBalancesFn( + options: UseTransactionsProps['options'], +) { + const calculateRunningBalances = options?.calculateRunningBalances ?? false; + return calculateRunningBalances === true + ? calculateRunningBalancesBottomUp + : typeof calculateRunningBalances === 'function' + ? calculateRunningBalances + : undefined; +} + +function calculateRunningBalancesBottomUp( + transactions: TransactionEntity[], + queryState: QueryState, +) { + return ( + transactions + .filter(t => { + const splits = queryState.tableOptions?.splits; + 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 amount of the transaction + acc.set(transaction.id, 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()) + ); +} diff --git a/packages/loot-core/src/types/models/transaction.ts b/packages/loot-core/src/types/models/transaction.ts index 60c99b673a..3bb0c3e2df 100644 --- a/packages/loot-core/src/types/models/transaction.ts +++ b/packages/loot-core/src/types/models/transaction.ts @@ -1,3 +1,5 @@ +import { IntegerAmount } from '../../shared/util'; + import { AccountEntity } from './account'; import { CategoryEntity } from './category'; import { PayeeEntity } from './payee'; @@ -10,7 +12,7 @@ export interface TransactionEntity { parent_id?: TransactionEntity['id']; account: AccountEntity['id']; category?: CategoryEntity['id']; - amount: number; + amount: IntegerAmount; payee?: PayeeEntity['id']; notes?: string; date: string; diff --git a/upcoming-release-notes/4987.md b/upcoming-release-notes/4987.md new file mode 100644 index 0000000000..138dabf688 --- /dev/null +++ b/upcoming-release-notes/4987.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +Add option to calculate running balances in useTransactions hook