mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
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:
@@ -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 })}
|
||||
|
||||
@@ -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,
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -282,6 +282,7 @@ export type Modal =
|
||||
onReopenAccount: (accountId: AccountEntity['id']) => void;
|
||||
onEditNotes: (id: NoteEntity['id']) => void;
|
||||
onClose?: () => void;
|
||||
onToggleRunningBalance?: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
6
upcoming-release-notes/4809.md
Normal file
6
upcoming-release-notes/4809.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Add running balance to mobile
|
||||
Reference in New Issue
Block a user