[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() { async goToUncategorizedPage() {
const button = this.page.getByRole('button', { name: /uncategorized/ }); const button = this.page.getByRole('button', { name: 'Categorize' });
await button.click(); await button.click();
return new MobileAccountPage(this.page); 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 // eslint-disable-next-line import/no-default-export
export default defineConfig({ export default defineConfig({
timeout: 45000, // 45 seconds timeout: 60000, // 60 seconds
retries: 1, retries: 1,
testDir: 'e2e/', testDir: 'e2e/',
reporter: !process.env.CI ? [['html', { open: 'never' }]] : undefined, reporter: !process.env.CI ? [['html', { open: 'never' }]] : undefined,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,14 @@
import React, { memo, useCallback, useRef } from 'react'; import React, {
import { useTranslation } from 'react-i18next'; 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 { Button } from '@actual-app/components/button';
import { Card } from '@actual-app/components/card'; import { Card } from '@actual-app/components/card';
@@ -12,7 +21,11 @@ import {
SvgArrowThickRight, SvgArrowThickRight,
SvgCheveronRight, SvgCheveronRight,
} from '@actual-app/components/icons/v1'; } 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 { Label } from '@actual-app/components/label';
import { styles } from '@actual-app/components/styles'; import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text'; import { Text } from '@actual-app/components/text';
@@ -28,6 +41,7 @@ import {
trackingBudget, trackingBudget,
uncategorizedCount, uncategorizedCount,
} from 'loot-core/client/queries'; } from 'loot-core/client/queries';
import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider';
import * as monthUtils from 'loot-core/shared/months'; import * as monthUtils from 'loot-core/shared/months';
import { groupById, integerToCurrency } from 'loot-core/shared/util'; import { groupById, integerToCurrency } from 'loot-core/shared/util';
@@ -36,15 +50,16 @@ import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
import { useLocale } from '../../../hooks/useLocale'; import { useLocale } from '../../../hooks/useLocale';
import { useLocalPref } from '../../../hooks/useLocalPref'; import { useLocalPref } from '../../../hooks/useLocalPref';
import { useNavigate } from '../../../hooks/useNavigate'; import { useNavigate } from '../../../hooks/useNavigate';
import { usePrevious } from '../../../hooks/usePrevious';
import { useSyncedPref } from '../../../hooks/useSyncedPref'; import { useSyncedPref } from '../../../hooks/useSyncedPref';
import { useUndo } from '../../../hooks/useUndo'; import { useUndo } from '../../../hooks/useUndo';
import { useDispatch } from '../../../redux'; import { useDispatch } from '../../../redux';
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover'; import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util'; import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
import { Link } from '../../common/Link';
import { MobilePageHeader, Page } from '../../Page'; import { MobilePageHeader, Page } from '../../Page';
import { PrivacyFilter } from '../../PrivacyFilter'; import { PrivacyFilter } from '../../PrivacyFilter';
import { CellValue } from '../../spreadsheet/CellValue'; import { CellValue } from '../../spreadsheet/CellValue';
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
import { useFormat } from '../../spreadsheet/useFormat'; import { useFormat } from '../../spreadsheet/useFormat';
import { useSheetValue } from '../../spreadsheet/useSheetValue'; import { useSheetValue } from '../../spreadsheet/useSheetValue';
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs'; import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
@@ -112,7 +127,12 @@ function ToBudget({ toBudget, onPress, show3Cols }) {
style={{ style={{
fontSize: 12, fontSize: 12,
fontWeight: '700', fontWeight: '700',
color: amount < 0 ? theme.errorText : theme.formInputText, color:
amount < 0
? theme.errorText
: amount > 0
? theme.noticeText
: theme.formInputText,
}} }}
> >
{format(value, type)} {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({ function BudgetGroups({
type, type,
categoryGroups, categoryGroups,
@@ -1251,6 +1239,7 @@ export function BudgetTable({
/> />
} }
> >
<Banners month={month} onBudgetAction={onBudgetAction} />
<BudgetTableHeader <BudgetTableHeader
type={type} type={type}
month={month} month={month}
@@ -1267,7 +1256,6 @@ export function BudgetTable({
paddingBottom: MOBILE_NAV_HEIGHT, paddingBottom: MOBILE_NAV_HEIGHT,
}} }}
> >
<UncategorizedButton />
<BudgetGroups <BudgetGroups
type={type} type={type}
categoryGroups={categoryGroups} 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({ function BudgetTableHeader({
show3Cols, show3Cols,
type, type,
@@ -1559,7 +1928,7 @@ function MonthSelector({
}} }}
style={{ ...arrowButtonStyle, opacity: prevEnabled ? 1 : 0.6 }} style={{ ...arrowButtonStyle, opacity: prevEnabled ? 1 : 0.6 }}
> >
<SvgArrowThinLeft width="15" height="15" style={{ margin: -5 }} /> <SvgArrowThinLeft width="15" height="15" />
</Button> </Button>
<Button <Button
variant="bare" variant="bare"
@@ -1567,7 +1936,6 @@ function MonthSelector({
textAlign: 'center', textAlign: 'center',
fontSize: 16, fontSize: 16,
fontWeight: 500, fontWeight: 500,
margin: '0 5px',
}} }}
onPress={() => { onPress={() => {
onOpenMonthMenu?.(month); onOpenMonthMenu?.(month);
@@ -1588,7 +1956,7 @@ function MonthSelector({
}} }}
style={{ ...arrowButtonStyle, opacity: nextEnabled ? 1 : 0.6 }} style={{ ...arrowButtonStyle, opacity: nextEnabled ? 1 : 0.6 }}
> >
<SvgArrowThinRight width="15" height="15" style={{ margin: -5 }} /> <SvgArrowThinRight width="15" height="15" />
</Button> </Button>
</View> </View>
); );

View File

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

View File

@@ -212,10 +212,13 @@ export type Modal =
| { | {
name: 'category-autocomplete'; name: 'category-autocomplete';
options: { options: {
title?: string;
categoryGroups?: CategoryGroupEntity[]; categoryGroups?: CategoryGroupEntity[];
onSelect: (categoryId: string, categoryName: string) => void; onSelect: (categoryId: string, categoryName: string) => void;
month?: string | undefined; month?: string | undefined;
showHiddenCategories?: boolean; showHiddenCategories?: boolean;
closeOnSelect?: boolean;
clearOnSelect?: boolean;
onClose?: () => void; onClose?: () => void;
}; };
} }

View File

@@ -4,6 +4,7 @@ import * as asyncStorage from '../../platform/server/asyncStorage';
import { getLocale } from '../../shared/locale'; import { getLocale } from '../../shared/locale';
import * as monthUtils from '../../shared/months'; import * as monthUtils from '../../shared/months';
import { integerToCurrency, safeNumber } from '../../shared/util'; import { integerToCurrency, safeNumber } from '../../shared/util';
import { CategoryEntity } from '../../types/models';
import * as db from '../db'; import * as db from '../db';
import * as sheet from '../sheet'; import * as sheet from '../sheet';
import { batchMessages } from '../sync'; import { batchMessages } from '../sync';
@@ -113,7 +114,7 @@ export function setBudget({
month, month,
amount, amount,
}: { }: {
category: string; category: CategoryEntity['id'];
month: string; month: string;
amount: unknown; amount: unknown;
}): Promise<void> { }): Promise<void> {
@@ -408,15 +409,15 @@ export async function coverOverspending({
from, from,
}: { }: {
month: string; month: string;
to: string; to: CategoryEntity['id'] | 'to-budget';
from: string; from: CategoryEntity['id'] | 'to-budget' | 'overbudgeted';
}): Promise<void> { }): Promise<void> {
const sheetName = monthUtils.sheetForMonth(month); const sheetName = monthUtils.sheetForMonth(month);
const toBudgeted = await getSheetValue(sheetName, 'budget-' + to); const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
const leftover = await getSheetValue(sheetName, 'leftover-' + to); const leftover = await getSheetValue(sheetName, 'leftover-' + to);
const leftoverFrom = await getSheetValue( const leftoverFrom = await getSheetValue(
sheetName, sheetName,
from === 'to-be-budgeted' ? 'to-budget' : 'leftover-' + from, from === 'to-budget' ? 'to-budget' : 'leftover-' + from,
); );
if (leftover >= 0 || leftoverFrom <= 0) { if (leftover >= 0 || leftoverFrom <= 0) {
@@ -426,7 +427,7 @@ export async function coverOverspending({
const amountCovered = Math.min(-leftover, leftoverFrom); const amountCovered = Math.min(-leftover, leftoverFrom);
// If we are covering it from the to be budgeted amount, ignore this // If we are covering it from the to be budgeted amount, ignore this
if (from !== 'to-be-budgeted') { if (from !== 'to-budget') {
const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from); const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
await setBudget({ await setBudget({
category: from, category: from,
@@ -500,8 +501,8 @@ export async function transferCategory({
}: { }: {
month: string; month: string;
amount: number; amount: number;
to: string; to: CategoryEntity['id'] | 'to-budget';
from: string; from: CategoryEntity['id'] | 'to-budget';
}): Promise<void> { }): Promise<void> {
const sheetName = monthUtils.sheetForMonth(month); const sheetName = monthUtils.sheetForMonth(month);
const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from); const fromBudgeted = await getSheetValue(sheetName, 'budget-' + from);
@@ -511,7 +512,7 @@ export async function transferCategory({
// If we are simply moving it back into available cash to budget, // If we are simply moving it back into available cash to budget,
// don't do anything else // don't do anything else
if (to !== 'to-be-budgeted') { if (to !== 'to-budget') {
const toBudgeted = await getSheetValue(sheetName, 'budget-' + to); const toBudgeted = await getSheetValue(sheetName, 'budget-' + to);
await setBudget({ category: to, month, amount: toBudgeted + amount }); await setBudget({ category: to, month, amount: toBudgeted + amount });
} }
@@ -556,8 +557,8 @@ async function addMovementNotes({
}: { }: {
month: string; month: string;
amount: number; amount: number;
to: 'to-be-budgeted' | 'overbudgeted' | string; to: CategoryEntity['id'] | 'to-budget' | 'overbudgeted';
from: 'to-be-budgeted' | string; from: CategoryEntity['id'] | 'to-budget';
}) { }) {
const displayAmount = integerToCurrency(amount); const displayAmount = integerToCurrency(amount);
@@ -576,16 +577,16 @@ async function addMovementNotes({
locale, locale,
); );
const categories = await db.getCategories( const categories = await db.getCategories(
[from, to].filter(c => c !== 'to-be-budgeted' && c !== 'overbudgeted'), [from, to].filter(c => c !== 'to-budget' && c !== 'overbudgeted'),
); );
const fromCategoryName = const fromCategoryName =
from === 'to-be-budgeted' from === 'to-budget'
? 'To Budget' ? 'To Budget'
: categories.find(c => c.id === from)?.name; : categories.find(c => c.id === from)?.name;
const toCategoryName = const toCategoryName =
to === 'to-be-budgeted' to === 'to-budget'
? 'To Budget' ? 'To Budget'
: to === 'overbudgeted' : to === 'overbudgeted'
? 'Overbudgeted' ? 'Overbudgeted'

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Add budget table banners to alert users of recommended budget actions e.g. when there is available funds to be budgeted, when user has overbudgeted, when there are overspent categories, when there are uncategorized transactions (reworked).