[Mobile] Add banners to alert users of recommended budget actions (#4643)

* Add banners to alert users of recommended budget actions

* Update wording for consistency

* Release notes

* Fix release notes

* Code review feedback

* Cleanup

* Extend playwright timeout

* Update Categorize button locator in test

* Update VRT

* Dummy commit

* Streamline cover spending flow

* VRT

* Remove category from modal when covered and close modal when all categories are covered

* Coderabbit suggestions

* Update translations

* VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2025-03-21 17:19:09 -07:00
committed by GitHub
parent 00ff2e2522
commit 36c40d90d2
64 changed files with 533 additions and 150 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -144,7 +144,7 @@ export class MobileNavigation {
}
async goToUncategorizedPage() {
const button = this.page.getByRole('button', { name: /uncategorized/ });
const button = this.page.getByRole('button', { name: 'Categorize' });
await button.click();
return new MobileAccountPage(this.page);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
// eslint-disable-next-line import/no-default-export
export default defineConfig({
timeout: 45000, // 45 seconds
timeout: 60000, // 60 seconds
retries: 1,
testDir: 'e2e/',
reporter: !process.env.CI ? [['html', { open: 'never' }]] : undefined,

View File

@@ -92,49 +92,48 @@ export function Modals() {
}, [location]);
const modals = modalStack
.map(modal => {
.map((modal, idx) => {
const { name } = modal;
const key = `${name}-${idx}`;
switch (name) {
case 'goal-templates':
return budgetId ? <GoalTemplateModal key={name} /> : null;
return budgetId ? <GoalTemplateModal key={key} /> : null;
case 'keyboard-shortcuts':
// don't show the hotkey help modal when a budget is not open
return budgetId ? <KeyboardShortcutModal key={name} /> : null;
return budgetId ? <KeyboardShortcutModal key={key} /> : null;
case 'import-transactions':
return <ImportTransactionsModal key={name} {...modal.options} />;
return <ImportTransactionsModal key={key} {...modal.options} />;
case 'add-account':
return <CreateAccountModal key={name} {...modal.options} />;
return <CreateAccountModal key={key} {...modal.options} />;
case 'add-local-account':
return <CreateLocalAccountModal key={name} />;
return <CreateLocalAccountModal key={key} />;
case 'close-account':
return <CloseAccountModal key={name} {...modal.options} />;
return <CloseAccountModal key={key} {...modal.options} />;
case 'select-linked-accounts':
return <SelectLinkedAccountsModal key={name} {...modal.options} />;
return <SelectLinkedAccountsModal key={key} {...modal.options} />;
case 'confirm-category-delete':
return <ConfirmCategoryDeleteModal key={name} {...modal.options} />;
return <ConfirmCategoryDeleteModal key={key} {...modal.options} />;
case 'confirm-unlink-account':
return <ConfirmUnlinkAccountModal key={name} {...modal.options} />;
return <ConfirmUnlinkAccountModal key={key} {...modal.options} />;
case 'confirm-transaction-edit':
return <ConfirmTransactionEditModal key={name} {...modal.options} />;
return <ConfirmTransactionEditModal key={key} {...modal.options} />;
case 'confirm-transaction-delete':
return (
<ConfirmTransactionDeleteModal key={name} {...modal.options} />
);
return <ConfirmTransactionDeleteModal key={key} {...modal.options} />;
case 'load-backup':
return (
<LoadBackupModal
key={name}
key={key}
watchUpdates
{...modal.options}
backupDisabled={false}
@@ -142,27 +141,27 @@ export function Modals() {
);
case 'manage-rules':
return <ManageRulesModal key={name} {...modal.options} />;
return <ManageRulesModal key={key} {...modal.options} />;
case 'edit-rule':
return <EditRuleModal key={name} {...modal.options} />;
return <EditRuleModal key={key} {...modal.options} />;
case 'merge-unused-payees':
return <MergeUnusedPayeesModal key={name} {...modal.options} />;
return <MergeUnusedPayeesModal key={key} {...modal.options} />;
case 'gocardless-init':
return <GoCardlessInitialiseModal key={name} {...modal.options} />;
return <GoCardlessInitialiseModal key={key} {...modal.options} />;
case 'simplefin-init':
return <SimpleFinInitialiseModal key={name} {...modal.options} />;
return <SimpleFinInitialiseModal key={key} {...modal.options} />;
case 'pluggyai-init':
return <PluggyAiInitialiseModal key={name} {...modal.options} />;
return <PluggyAiInitialiseModal key={key} {...modal.options} />;
case 'gocardless-external-msg':
return (
<GoCardlessExternalMsgModal
key={name}
key={key}
{...modal.options}
onClose={() => {
modal.options.onClose?.();
@@ -172,73 +171,73 @@ export function Modals() {
);
case 'create-encryption-key':
return <CreateEncryptionKeyModal key={name} {...modal.options} />;
return <CreateEncryptionKeyModal key={key} {...modal.options} />;
case 'fix-encryption-key':
return <FixEncryptionKeyModal key={name} {...modal.options} />;
return <FixEncryptionKeyModal key={key} {...modal.options} />;
case 'edit-field':
return <EditFieldModal key={name} {...modal.options} />;
return <EditFieldModal key={key} {...modal.options} />;
case 'category-autocomplete':
return <CategoryAutocompleteModal key={name} {...modal.options} />;
return <CategoryAutocompleteModal key={key} {...modal.options} />;
case 'account-autocomplete':
return <AccountAutocompleteModal key={name} {...modal.options} />;
return <AccountAutocompleteModal key={key} {...modal.options} />;
case 'payee-autocomplete':
return <PayeeAutocompleteModal key={name} {...modal.options} />;
return <PayeeAutocompleteModal key={key} {...modal.options} />;
case 'payee-category-learning':
return <CategoryLearning key={name} />;
return <CategoryLearning key={key} />;
case 'new-category':
return <NewCategoryModal key={name} {...modal.options} />;
return <NewCategoryModal key={key} {...modal.options} />;
case 'new-category-group':
return <NewCategoryGroupModal key={name} {...modal.options} />;
return <NewCategoryGroupModal key={key} {...modal.options} />;
case 'envelope-budget-summary':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<EnvelopeBudgetSummaryModal key={name} {...modal.options} />
<EnvelopeBudgetSummaryModal key={key} {...modal.options} />
</NamespaceContext.Provider>
);
case 'tracking-budget-summary':
return <TrackingBudgetSummaryModal key={name} {...modal.options} />;
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
case 'schedule-edit':
return <ScheduleDetails key={name} {...modal.options} />;
return <ScheduleDetails key={key} {...modal.options} />;
case 'schedule-link':
return <ScheduleLink key={name} {...modal.options} />;
return <ScheduleLink key={key} {...modal.options} />;
case 'schedules-discover':
return <DiscoverSchedules key={name} />;
return <DiscoverSchedules key={key} />;
case 'schedules-upcoming-length':
return <UpcomingLength key={name} />;
return <UpcomingLength key={key} />;
case 'schedule-posts-offline-notification':
return <PostsOfflineNotification key={name} />;
return <PostsOfflineNotification key={key} />;
case 'synced-account-edit':
return <EditSyncAccount key={name} {...modal.options} />;
return <EditSyncAccount key={key} {...modal.options} />;
case 'account-menu':
return <AccountMenuModal key={name} {...modal.options} />;
return <AccountMenuModal key={key} {...modal.options} />;
case 'category-menu':
return <CategoryMenuModal key={name} {...modal.options} />;
return <CategoryMenuModal key={key} {...modal.options} />;
case 'envelope-budget-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<EnvelopeBudgetMenuModal {...modal.options} />
@@ -248,7 +247,7 @@ export function Modals() {
case 'tracking-budget-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<TrackingBudgetMenuModal {...modal.options} />
@@ -256,15 +255,15 @@ export function Modals() {
);
case 'category-group-menu':
return <CategoryGroupMenuModal key={name} {...modal.options} />;
return <CategoryGroupMenuModal key={key} {...modal.options} />;
case 'notes':
return <NotesModal key={name} {...modal.options} />;
return <NotesModal key={key} {...modal.options} />;
case 'envelope-balance-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<EnvelopeBalanceMenuModal {...modal.options} />
@@ -274,7 +273,7 @@ export function Modals() {
case 'envelope-summary-to-budget-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<EnvelopeToBudgetMenuModal {...modal.options} />
@@ -284,7 +283,7 @@ export function Modals() {
case 'hold-buffer':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<HoldBufferModal {...modal.options} />
@@ -294,7 +293,7 @@ export function Modals() {
case 'tracking-balance-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<TrackingBalanceMenuModal {...modal.options} />
@@ -302,23 +301,21 @@ export function Modals() {
);
case 'transfer':
return <TransferModal key={name} {...modal.options} />;
return <TransferModal key={key} {...modal.options} />;
case 'cover':
return <CoverModal key={name} {...modal.options} />;
return <CoverModal key={key} {...modal.options} />;
case 'scheduled-transaction-menu':
return (
<ScheduledTransactionMenuModal key={name} {...modal.options} />
);
return <ScheduledTransactionMenuModal key={key} {...modal.options} />;
case 'budget-page-menu':
return <BudgetPageMenuModal key={name} {...modal.options} />;
return <BudgetPageMenuModal key={key} {...modal.options} />;
case 'envelope-budget-month-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<EnvelopeBudgetMonthMenuModal {...modal.options} />
@@ -328,7 +325,7 @@ export function Modals() {
case 'tracking-budget-month-menu':
return (
<NamespaceContext.Provider
key={name}
key={key}
value={monthUtils.sheetForMonth(modal.options.month)}
>
<TrackingBudgetMonthMenuModal {...modal.options} />
@@ -338,48 +335,48 @@ export function Modals() {
case 'budget-file-selection':
return <BudgetFileSelectionModal key={name} />;
case 'delete-budget':
return <DeleteFileModal key={name} {...modal.options} />;
return <DeleteFileModal key={key} {...modal.options} />;
case 'duplicate-budget':
return <DuplicateFileModal key={name} {...modal.options} />;
return <DuplicateFileModal key={key} {...modal.options} />;
case 'import':
return <ImportModal key={name} />;
return <ImportModal key={key} />;
case 'files-settings':
return <FilesSettingsModal key={name} />;
return <FilesSettingsModal key={key} />;
case 'confirm-change-document-dir':
return (
<ConfirmChangeDocumentDirModal key={name} {...modal.options} />
);
return <ConfirmChangeDocumentDirModal key={key} {...modal.options} />;
case 'import-ynab4':
return <ImportYNAB4Modal key={name} />;
return <ImportYNAB4Modal key={key} />;
case 'import-ynab5':
return <ImportYNAB5Modal key={name} />;
return <ImportYNAB5Modal key={key} />;
case 'import-actual':
return <ImportActualModal key={name} />;
return <ImportActualModal key={key} />;
case 'out-of-sync-migrations':
return <OutOfSyncMigrationsModal key={name} />;
return <OutOfSyncMigrationsModal key={key} />;
case 'edit-access':
return <EditUserAccess key={name} {...modal.options} />;
return <EditUserAccess key={key} {...modal.options} />;
case 'edit-user':
return <EditUserFinanceApp key={name} {...modal.options} />;
return <EditUserFinanceApp key={key} {...modal.options} />;
case 'transfer-ownership':
return <TransferOwnership key={name} {...modal.options} />;
return <TransferOwnership key={key} {...modal.options} />;
case 'enable-openid':
return <OpenIDEnableModal key={name} {...modal.options} />;
return <OpenIDEnableModal key={key} {...modal.options} />;
case 'enable-password-auth':
return <PasswordEnableModal key={name} {...modal.options} />;
return <PasswordEnableModal key={key} {...modal.options} />;
default:
throw new Error('Unknown modal');
}
})
.map((modal, idx) => (
<React.Fragment key={modalStack[idx].name}>{modal}</React.Fragment>
<React.Fragment key={`${modalStack[idx].name}-${idx}`}>
{modal}
</React.Fragment>
));
// fragment needed per TS types

View File

@@ -51,6 +51,7 @@ type CommonAutocompleteProps<T extends Item> = {
clearOnBlur?: boolean;
clearOnSelect?: boolean;
closeOnBlur?: boolean;
closeOnSelect?: boolean;
onClose?: () => void;
};
@@ -230,6 +231,7 @@ function SingleAutocomplete<T extends Item>({
clearOnBlur = true,
clearOnSelect = false,
closeOnBlur = true,
closeOnSelect = !clearOnSelect,
onClose,
value: initialValue,
}: SingleAutocompleteProps<T>) {
@@ -306,7 +308,9 @@ function SingleAutocomplete<T extends Item>({
if (clearOnSelect) {
setValue('');
} else {
}
if (closeOnSelect) {
close();
}
@@ -348,6 +352,7 @@ function SingleAutocomplete<T extends Item>({
Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem,
// Do nothing if it is a "touch" selection event
Downshift.stateChangeTypes.touchEnd,
Downshift.stateChangeTypes.mouseUp,
// @ts-expect-error Types say there is no type
].includes(changes.type)
) {
@@ -395,7 +400,8 @@ function SingleAutocomplete<T extends Item>({
onStateChange={changes => {
if (
!clearOnBlur &&
changes.type === Downshift.stateChangeTypes.mouseUp
(changes.type === Downshift.stateChangeTypes.mouseUp ||
changes.type === Downshift.stateChangeTypes.touchEnd)
) {
return;
}

View File

@@ -393,7 +393,7 @@ function CategoryItem({
typeof balanceBinding
>(balanceBinding);
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
const isToBudgetItem = item.id === 'to-budget';
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
return (
@@ -430,16 +430,13 @@ function CategoryItem({
display: !showBalances ? 'none' : undefined,
marginLeft: 5,
flexShrink: 0,
...makeAmountFullStyle(
(isToBeBudgetedItem ? toBudget : balance) || 0,
{
positiveColor: theme.noticeTextMenu,
negativeColor: theme.errorTextMenu,
},
),
...makeAmountFullStyle((isToBudgetItem ? toBudget : balance) || 0, {
positiveColor: theme.noticeTextMenu,
negativeColor: theme.errorTextMenu,
}),
}}
>
{isToBeBudgetedItem
{isToBudgetItem
? toBudget != null
? ` ${integerToCurrency(toBudget || 0)}`
: null

View File

@@ -22,16 +22,16 @@ import { getValidMonthBounds } from './MonthsContext';
export function addToBeBudgetedGroup(groups: CategoryGroupEntity[]) {
return [
{
id: 'to-be-budgeted',
name: t('To Be Budgeted'),
id: 'to-budget',
name: t('To Budget'),
categories: [
{
id: 'to-be-budgeted',
name: t('To Be Budgeted'),
cat_group: 'to-be-budgeted',
id: 'to-budget',
name: t('To Budget'),
cat_group: 'to-budget',
group: {
id: 'to-be-budgeted',
name: t('To Be Budgeted'),
id: 'to-budget',
name: t('To Budget'),
},
},
],

View File

@@ -1,5 +1,14 @@
import React, { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import React, {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { GridList, GridListItem } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { Card } from '@actual-app/components/card';
@@ -12,7 +21,11 @@ import {
SvgArrowThickRight,
SvgCheveronRight,
} from '@actual-app/components/icons/v1';
import { SvgCalendar, SvgViewShow } from '@actual-app/components/icons/v2';
import {
SvgArrowButtonDown1,
SvgCalendar,
SvgViewShow,
} from '@actual-app/components/icons/v2';
import { Label } from '@actual-app/components/label';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
@@ -28,6 +41,7 @@ import {
trackingBudget,
uncategorizedCount,
} from 'loot-core/client/queries';
import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider';
import * as monthUtils from 'loot-core/shared/months';
import { groupById, integerToCurrency } from 'loot-core/shared/util';
@@ -36,15 +50,16 @@ import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
import { useLocale } from '../../../hooks/useLocale';
import { useLocalPref } from '../../../hooks/useLocalPref';
import { useNavigate } from '../../../hooks/useNavigate';
import { usePrevious } from '../../../hooks/usePrevious';
import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { useUndo } from '../../../hooks/useUndo';
import { useDispatch } from '../../../redux';
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
import { Link } from '../../common/Link';
import { MobilePageHeader, Page } from '../../Page';
import { PrivacyFilter } from '../../PrivacyFilter';
import { CellValue } from '../../spreadsheet/CellValue';
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
import { useFormat } from '../../spreadsheet/useFormat';
import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
@@ -112,7 +127,12 @@ function ToBudget({ toBudget, onPress, show3Cols }) {
style={{
fontSize: 12,
fontWeight: '700',
color: amount < 0 ? theme.errorText : theme.formInputText,
color:
amount < 0
? theme.errorText
: amount > 0
? theme.noticeText
: theme.formInputText,
}}
>
{format(value, type)}
@@ -1043,38 +1063,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
);
});
function UncategorizedButton() {
const count = useSheetValue(uncategorizedCount());
if (count === null || count <= 0) {
return null;
}
return (
<View
style={{
padding: 5,
paddingBottom: 2,
}}
>
<Link
variant="button"
type="button"
buttonVariant="primary"
to="/accounts/uncategorized"
style={{
border: 0,
justifyContent: 'flex-start',
padding: '1.25em',
}}
>
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
<View style={{ flex: 1 }} />
<SvgArrowThinRight width="15" height="15" />
</Link>
</View>
);
}
function BudgetGroups({
type,
categoryGroups,
@@ -1251,6 +1239,7 @@ export function BudgetTable({
/>
}
>
<Banners month={month} onBudgetAction={onBudgetAction} />
<BudgetTableHeader
type={type}
month={month}
@@ -1267,7 +1256,6 @@ export function BudgetTable({
paddingBottom: MOBILE_NAV_HEIGHT,
}}
>
<UncategorizedButton />
<BudgetGroups
type={type}
categoryGroups={categoryGroups}
@@ -1294,6 +1282,387 @@ export function BudgetTable({
);
}
function Banner({ type = 'info', children }) {
return (
<Card
style={{
height: 50,
marginTop: 10,
marginBottom: 10,
padding: 10,
justifyContent: 'center',
backgroundColor:
type === 'critical'
? theme.errorBackground
: type === 'warning'
? theme.warningBackground
: theme.noticeBackground,
}}
>
{children}
</Card>
);
}
function UncategorizedTransactionsBanner(props) {
const count = useSheetValue(uncategorizedCount());
const navigate = useNavigate();
if (count === null || count <= 0) {
return null;
}
return (
<GridListItem textValue="Uncategorized transactions banner" {...props}>
<Banner type="warning">
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Trans count={count}>
You have {{ count }} uncategorized transactions
</Trans>
<Button
onPress={() => navigate('/accounts/uncategorized')}
style={PILL_STYLE}
>
<Text>
<Trans>Categorize</Trans>
</Text>
</Button>
</View>
</Banner>
</GridListItem>
);
}
function OverbudgetedBanner({ month, onBudgetAction, ...props }) {
const { t } = useTranslation();
const toBudgetAmount = useSheetValue(envelopeBudget.toBudget);
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const { list: categories } = useCategories();
const categoriesById = groupById(categories);
const openCoverOverbudgetedModal = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'cover',
options: {
title: t('Cover overbudgeted'),
month,
showToBeBudgeted: false,
onSubmit: categoryId => {
onBudgetAction(month, 'cover-overbudgeted', {
category: categoryId,
});
showUndoNotification({
message: t('Covered overbudgeted from {{categoryName}}', {
categoryName: categoriesById[categoryId].name,
}),
});
},
},
},
}),
);
}, [
categoriesById,
dispatch,
month,
onBudgetAction,
showUndoNotification,
t,
]);
if (!toBudgetAmount || toBudgetAmount >= 0) {
return null;
}
return (
<GridListItem textValue="Overbudgeted banner" {...props}>
<Banner type="critical">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
}}
>
<SvgArrowButtonDown1 style={{ width: 15, height: 15 }} />
<Text>
<Trans>You have budgeted more than your available funds</Trans>
</Text>
</View>
</View>
<Button onPress={openCoverOverbudgetedModal} style={PILL_STYLE}>
<Trans>Cover</Trans>
</Button>
</View>
</Banner>
</GridListItem>
);
}
function OverspendingBanner({ month, onBudgetAction, ...props }) {
const { t } = useTranslation();
const { list: categories, grouped: categoryGroups } = useCategories();
const categoriesById = groupById(categories);
const categoryBalanceBindings = useMemo(
() =>
categories.map(category => [
category.id,
envelopeBudget.catBalance(category.id),
]),
[categories],
);
const categoryCarryoverBindings = useMemo(
() =>
categories.map(category => [
category.id,
envelopeBudget.catCarryover(category.id),
]),
[categories],
);
const [overspentByCategory, setOverspentByCategory] = useState({});
const [carryoverFlagByCategory, setCarryoverFlagByCategory] = useState({});
const sheetName = useContext(NamespaceContext);
const spreadsheet = useSpreadsheet();
useEffect(() => {
const unbindList = [];
for (const [categoryId, carryoverBinding] of categoryCarryoverBindings) {
const unbind = spreadsheet.bind(sheetName, carryoverBinding, result => {
const isRolloverEnabled = Boolean(result.value);
if (isRolloverEnabled) {
setCarryoverFlagByCategory(prev => ({
...prev,
[categoryId]: result.value,
}));
} else {
// Update to remove covered category.
setCarryoverFlagByCategory(prev => {
const { [categoryId]: _, ...rest } = prev;
return rest;
});
}
});
unbindList.push(unbind);
}
return () => {
unbindList.forEach(unbind => unbind());
};
}, [categoryCarryoverBindings, sheetName, spreadsheet]);
useEffect(() => {
const unbindList = [];
for (const [categoryId, balanceBinding] of categoryBalanceBindings) {
const unbind = spreadsheet.bind(sheetName, balanceBinding, result => {
if (result.value < 0) {
setOverspentByCategory(prev => ({
...prev,
[categoryId]: result.value,
}));
} else if (result.value === 0) {
// Update to remove covered category.
setOverspentByCategory(prev => {
const { [categoryId]: _, ...rest } = prev;
return rest;
});
}
});
unbindList.push(unbind);
}
return () => {
unbindList.forEach(unbind => unbind());
};
}, [categoryBalanceBindings, sheetName, spreadsheet]);
const dispatch = useDispatch();
// Ignore those that has rollover enabled.
const overspentCategoryIds = Object.keys(overspentByCategory).filter(
id => !carryoverFlagByCategory[id],
);
const categoryGroupsToShow = useMemo(
() =>
categoryGroups
.filter(g =>
g.categories?.some(c => overspentCategoryIds.includes(c.id)),
)
.map(g => ({
...g,
categories:
g.categories?.filter(c => overspentCategoryIds.includes(c.id)) ||
[],
})),
[categoryGroups, overspentCategoryIds],
);
const { showUndoNotification } = useUndo();
const onOpenCoverCategoryModal = useCallback(
categoryId => {
const category = categoriesById[categoryId];
dispatch(
pushModal({
modal: {
name: 'cover',
options: {
title: category.name,
month,
categoryId: category.id,
onSubmit: fromCategoryId => {
onBudgetAction(month, 'cover-overspending', {
to: category.id,
from: fromCategoryId,
});
showUndoNotification({
message: t(
`Covered {{toCategoryName}} overspending from {{fromCategoryName}}.`,
{
toCategoryName: category.name,
fromCategoryName:
fromCategoryId === 'to-budget'
? 'To Budget'
: categoriesById[fromCategoryId].name,
},
),
});
},
},
},
}),
);
},
[categoriesById, dispatch, month, onBudgetAction, showUndoNotification, t],
);
const onOpenCategorySelectionModal = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'category-autocomplete',
options: {
title: t('Cover overspending'),
month,
categoryGroups: categoryGroupsToShow,
onSelect: onOpenCoverCategoryModal,
clearOnSelect: true,
closeOnSelect: false,
},
},
}),
);
}, [categoryGroupsToShow, dispatch, month, onOpenCoverCategoryModal, t]);
const numberOfOverspentCategories = overspentCategoryIds.length;
const previousNumberOfOverspentCategories = usePrevious(
numberOfOverspentCategories,
);
useEffect(() => {
if (numberOfOverspentCategories < previousNumberOfOverspentCategories) {
// Re-render the modal when the overspent categories are covered.
dispatch(collapseModals({ rootModalName: 'category-autocomplete' }));
onOpenCategorySelectionModal();
// All overspent categories have been covered.
if (numberOfOverspentCategories === 0) {
dispatch(collapseModals({ rootModalName: 'category-autocomplete' }));
}
}
}, [
dispatch,
onOpenCategorySelectionModal,
numberOfOverspentCategories,
previousNumberOfOverspentCategories,
]);
if (numberOfOverspentCategories === 0) {
return null;
}
return (
<GridListItem textValue="Overspent banner" {...props}>
<Banner type="critical">
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
}}
>
<Text>
<Trans count={numberOfOverspentCategories}>
You have {{ count: numberOfOverspentCategories }} overspent
categories
</Trans>
</Text>
</View>
<Button onPress={onOpenCategorySelectionModal} style={PILL_STYLE}>
<Trans>Cover</Trans>
</Button>
</View>
</Banner>
</GridListItem>
);
}
function Banners({ month, onBudgetAction }) {
const { t } = useTranslation();
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
// Limit to rollover for now.
if (budgetType !== 'rollover') {
return null;
}
return (
<GridList
aria-label={t('Banners')}
style={{ backgroundColor: theme.mobilePageBackground }}
>
<UncategorizedTransactionsBanner />
<OverspendingBanner month={month} onBudgetAction={onBudgetAction} />
<OverbudgetedBanner month={month} onBudgetAction={onBudgetAction} />
</GridList>
);
}
function BudgetTableHeader({
show3Cols,
type,
@@ -1559,7 +1928,7 @@ function MonthSelector({
}}
style={{ ...arrowButtonStyle, opacity: prevEnabled ? 1 : 0.6 }}
>
<SvgArrowThinLeft width="15" height="15" style={{ margin: -5 }} />
<SvgArrowThinLeft width="15" height="15" />
</Button>
<Button
variant="bare"
@@ -1567,7 +1936,6 @@ function MonthSelector({
textAlign: 'center',
fontSize: 16,
fontWeight: 500,
margin: '0 5px',
}}
onPress={() => {
onOpenMonthMenu?.(month);
@@ -1588,7 +1956,7 @@ function MonthSelector({
}}
style={{ ...arrowButtonStyle, opacity: nextEnabled ? 1 : 0.6 }}
>
<SvgArrowThinRight width="15" height="15" style={{ margin: -5 }} />
<SvgArrowThinRight width="15" height="15" />
</Button>
</View>
);

View File

@@ -24,10 +24,13 @@ type CategoryAutocompleteModalProps = Extract<
>['options'];
export function CategoryAutocompleteModal({
title,
month,
onSelect,
categoryGroups,
showHiddenCategories,
closeOnSelect,
clearOnSelect,
onClose,
}: CategoryAutocompleteModalProps) {
const { t } = useTranslation();
@@ -57,7 +60,7 @@ export function CategoryAutocompleteModal({
<ModalHeader
title={
<ModalTitle
title={t('Category')}
title={title || t('Category')}
getStyle={() => ({ color: theme.menuAutoCompleteText })}
/>
}
@@ -88,6 +91,8 @@ export function CategoryAutocompleteModal({
focused={true}
embedded={true}
closeOnBlur={false}
closeOnSelect={closeOnSelect}
clearOnSelect={clearOnSelect}
showSplitOption={false}
onClose={close}
{...defaultAutocompleteProps}