Add option to calculate running balances in useTransactions hook (#4987)

* Add option to calculate running balances in useTransactions hook

* Fix typecheck error

* Fix lint error

* Cleanup

* retrigger checks
This commit is contained in:
Joel Jeremy Marquez
2025-05-14 15:27:38 -07:00
committed by GitHub
parent 45ff94590b
commit d9dd96d0de
3 changed files with 125 additions and 3 deletions

View File

@@ -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<TransactionEntity['id'], IntegerAmount>)
| boolean;
};
};
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);
@@ -55,6 +110,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);
@@ -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<TransactionEntity['id'], IntegerAmount>())
);
}

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Add option to calculate running balances in useTransactions hook