From 89a8f102dc941a39abcfd4b7d22f46728cb1b522 Mon Sep 17 00:00:00 2001 From: Ryan Bianchi <1435081+qedi-r@users.noreply.github.com> Date: Fri, 19 Jul 2024 21:44:29 -0400 Subject: [PATCH] Recently used and favorite payees (#2814) * add idea of common payee, a top 10 frequently used payee * add button in payee to mark as favorite * cleanup * minor fixes * add release notes and make favorite optional * fix TransactionsTable test * lint and release notes * rename section, resort list to ensure both are sorted * don't show common, move bookmarked to menu * add a limit on adding common payees * linting * reduce to 5 commonly used payees by default * linting * more linting * update migrate timestamp * more linting * fix api name, bump migrate timestamp * Add star to payee dropdown and rename section to 'Suggested Payees' --------- Co-authored-by: youngcw --- packages/api/methods.ts | 4 + .../autocomplete/PayeeAutocomplete.tsx | 113 +++++++++++++++--- .../src/components/payees/ManagePayees.jsx | 17 +++ .../src/components/payees/PayeeMenu.tsx | 13 ++ .../src/components/payees/PayeeTableRow.tsx | 28 ++++- .../transactions/TransactionsTable.test.jsx | 5 +- .../desktop-client/src/hooks/usePayees.ts | 17 ++- .../1720664867241_add_payee_favorite.sql | 5 + .../loot-core/src/client/actions/queries.ts | 11 ++ packages/loot-core/src/client/constants.ts | 1 + .../loot-core/src/client/reducers/queries.ts | 8 ++ .../src/client/state-types/queries.d.ts | 8 ++ packages/loot-core/src/server/api.ts | 6 + .../loot-core/src/server/aql/schema/index.ts | 1 + packages/loot-core/src/server/db/index.ts | 19 +++ packages/loot-core/src/server/main.ts | 4 + .../loot-core/src/types/api-handlers.d.ts | 2 + .../loot-core/src/types/models/payee.d.ts | 1 + .../loot-core/src/types/server-handlers.d.ts | 2 + upcoming-release-notes/2814.md | 6 + 20 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 packages/loot-core/migrations/1720664867241_add_payee_favorite.sql create mode 100644 upcoming-release-notes/2814.md diff --git a/packages/api/methods.ts b/packages/api/methods.ts index 9770e9b75b3..06109bf8374 100644 --- a/packages/api/methods.ts +++ b/packages/api/methods.ts @@ -165,6 +165,10 @@ export function deleteCategory(id, transferCategoryId?) { return send('api/category-delete', { id, transferCategoryId }); } +export function getCommonPayees() { + return send('api/common-payees-get'); +} + export function getPayees() { return send('api/payees-get'); } diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index ea3f58f197b..36343bc046a 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -22,8 +22,8 @@ import { } from 'loot-core/src/types/models'; import { useAccounts } from '../../hooks/useAccounts'; -import { usePayees } from '../../hooks/usePayees'; -import { SvgAdd } from '../../icons/v1'; +import { useCommonPayees, usePayees } from '../../hooks/usePayees'; +import { SvgAdd, SvgBookmark } from '../../icons/v1'; import { useResponsive } from '../../ResponsiveProvider'; import { type CSSProperties, theme, styles } from '../../style'; import { Button } from '../common/Button'; @@ -39,11 +39,48 @@ import { ItemHeader } from './ItemHeader'; type PayeeAutocompleteItem = PayeeEntity; +const MAX_AUTO_SUGGESTIONS = 5; + function getPayeeSuggestions( + commonPayees: PayeeAutocompleteItem[], + payees: PayeeAutocompleteItem[], +): (PayeeAutocompleteItem & PayeeItemType)[] { + if (commonPayees?.length > 0) { + const favoritePayees = payees.filter(p => p.favorite); + let additionalCommonPayees: PayeeAutocompleteItem[] = []; + if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) { + additionalCommonPayees = commonPayees + .filter( + p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)), + ) + .slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length); + } + const frequentPayees: (PayeeAutocompleteItem & PayeeItemType)[] = + favoritePayees.concat(additionalCommonPayees).map(p => { + return { ...p, itemType: 'common_payee' }; + }); + + const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees + .filter(p => !frequentPayees.find(fp => fp.id === p.id)) + .map(p => { + return { ...p, itemType: determineItemType(p, false) }; + }); + + return frequentPayees + .sort((a, b) => a.name.localeCompare(b.name)) + .concat(filteredPayees); + } + + return payees.map(p => { + return { ...p, itemType: determineItemType(p, false) }; + }); +} + +function filterActivePayees( payees: PayeeAutocompleteItem[], focusTransferPayees: boolean, accounts: AccountEntity[], -): PayeeAutocompleteItem[] { +) { let activePayees = accounts ? getActivePayees(payees, accounts) : payees; if (focusTransferPayees && activePayees) { @@ -70,7 +107,8 @@ function stripNew(value) { } type PayeeListProps = { - items: PayeeAutocompleteItem[]; + items: (PayeeAutocompleteItem & PayeeItemType)[]; + commonPayees: PayeeEntity[]; getItemProps: (arg: { item: PayeeAutocompleteItem; }) => ComponentProps; @@ -89,6 +127,25 @@ type PayeeListProps = { footer: ReactNode; }; +type ItemTypes = 'account' | 'payee' | 'common_payee'; +type PayeeItemType = { + itemType: ItemTypes; +}; + +function determineItemType( + item: PayeeAutocompleteItem, + isCommon: boolean, +): ItemTypes { + if (item.transfer_acct) { + return 'account'; + } + if (isCommon) { + return 'common_payee'; + } else { + return 'payee'; + } +} + function PayeeList({ items, getItemProps, @@ -133,16 +190,19 @@ function PayeeList({ })} {items.map((item, idx) => { - const type = item.transfer_acct ? 'account' : 'payee'; + const itemType = item.itemType; let title; - if (type === 'payee' && lastType !== type) { + + if (itemType === 'common_payee' && lastType !== itemType) { + title = 'Suggested Payees'; + } else if (itemType === 'payee' && lastType !== itemType) { title = 'Payees'; - } else if (type === 'account' && lastType !== type) { + } else if (itemType === 'account' && lastType !== itemType) { title = 'Transfer To/From'; } const showMoreMessage = idx === items.length - 1 && items.length > 100; - lastType = type; + lastType = itemType; return ( @@ -219,6 +279,7 @@ export function PayeeAutocomplete({ payees, ...props }: PayeeAutocompleteProps) { + const commonPayees = useCommonPayees(); const retrievedPayees = usePayees(); if (!payees) { payees = retrievedPayees; @@ -233,17 +294,21 @@ export function PayeeAutocomplete({ const [rawPayee, setRawPayee] = useState(''); const hasPayeeInput = !!rawPayee; const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => { - const suggestions = getPayeeSuggestions( - payees, + const suggestions = getPayeeSuggestions(commonPayees, payees); + const filteredSuggestions = filterActivePayees( + suggestions, focusTransferPayees, accounts, ); if (!hasPayeeInput) { - return suggestions; + return filteredSuggestions; } - return [{ id: 'new', name: '' }, ...suggestions]; - }, [payees, focusTransferPayees, accounts, hasPayeeInput]); + filteredSuggestions.forEach(s => { + console.log(s.name + ' ' + s.id); + }); + return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions]; + }, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]); const dispatch = useDispatch(); @@ -356,6 +421,7 @@ export function PayeeAutocomplete({ renderItems={(items, getItemProps, highlightedIndex, inputValue) => ( + ); + paddingLeftOverFromIcon -= iconSize + 5; + } return (
- {item.name} + + {itemIcon} + {item.name} +
); } diff --git a/packages/desktop-client/src/components/payees/ManagePayees.jsx b/packages/desktop-client/src/components/payees/ManagePayees.jsx index 83f5bada3e3..1ec7fd9b545 100644 --- a/packages/desktop-client/src/components/payees/ManagePayees.jsx +++ b/packages/desktop-client/src/components/payees/ManagePayees.jsx @@ -198,6 +198,22 @@ export const ManagePayees = forwardRef( selected.dispatch({ type: 'select-none' }); } + function onFavorite() { + const allFavorited = [...selected.items] + .map(id => payeesById[id].favorite) + .every(f => f === 1); + if (allFavorited) { + onBatchChange({ + updated: [...selected.items].map(id => ({ id, favorite: 0 })), + }); + } else { + onBatchChange({ + updated: [...selected.items].map(id => ({ id, favorite: 1 })), + }); + } + selected.dispatch({ type: 'select-none' }); + } + async function onMerge() { const ids = [...selected.items]; await props.onMerge(ids); @@ -262,6 +278,7 @@ export const ManagePayees = forwardRef( onClose={() => setMenuOpen(false)} onDelete={onDelete} onMerge={onMerge} + onFavorite={onFavorite} /> diff --git a/packages/desktop-client/src/components/payees/PayeeMenu.tsx b/packages/desktop-client/src/components/payees/PayeeMenu.tsx index 380f13ccaa5..a2c3c12c66e 100644 --- a/packages/desktop-client/src/components/payees/PayeeMenu.tsx +++ b/packages/desktop-client/src/components/payees/PayeeMenu.tsx @@ -1,6 +1,7 @@ import { type PayeeEntity } from 'loot-core/src/types/models'; import { SvgDelete, SvgMerge } from '../../icons/v0'; +import { SvgBookmark } from '../../icons/v1'; import { theme } from '../../style'; import { Menu } from '../common/Menu'; import { View } from '../common/View'; @@ -10,6 +11,7 @@ type PayeeMenuProps = { selectedPayees: Set; onDelete: () => void; onMerge: () => Promise; + onFavorite: () => Promise; onClose: () => void; }; @@ -18,6 +20,7 @@ export function PayeeMenu({ selectedPayees, onDelete, onMerge, + onFavorite, onClose, }: PayeeMenuProps) { // Transfer accounts are never editable @@ -36,6 +39,9 @@ export function PayeeMenu({ case 'merge': onMerge(); break; + case 'favorite': + onFavorite(); + break; default: } }} @@ -61,6 +67,13 @@ export function PayeeMenu({ text: 'Delete', disabled: isDisabled, }, + { + icon: SvgBookmark, + iconSize: 9, + name: 'favorite', + text: 'Favorite', + disabled: isDisabled, + }, { icon: SvgMerge, iconSize: 9, diff --git a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx index b7d2920ad11..657385c4873 100644 --- a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx +++ b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx @@ -4,10 +4,17 @@ import { memo } from 'react'; import { type PayeeEntity } from 'loot-core/src/types/models'; import { useSelectedDispatch } from '../../hooks/useSelected'; -import { SvgArrowThinRight } from '../../icons/v1'; +import { SvgArrowThinRight, SvgBookmark } from '../../icons/v1'; import { type CSSProperties, theme } from '../../style'; import { Text } from '../common/Text'; -import { Cell, CellButton, InputCell, Row, SelectCell } from '../table'; +import { + Cell, + CellButton, + CustomCell, + InputCell, + Row, + SelectCell, +} from '../table'; type RuleButtonProps = { ruleCount: number; @@ -52,7 +59,7 @@ function RuleButton({ ruleCount, focused, onEdit, onClick }: RuleButtonProps) { ); } -type EditablePayeeFields = keyof Pick; +type EditablePayeeFields = keyof Pick; type PayeeTableRowProps = { payee: PayeeEntity; @@ -126,6 +133,21 @@ export const PayeeTableRow = memo( dispatchSelected({ type: 'select', id: payee.id, event: e }); }} /> + {}} + onUpdate={() => {}} + onClick={() => {}} + > + {() => { + if (payee.favorite) { + return ; + } else { + return; + } + }} + vi.fn().mockReturnValue(false)); const accounts = [generateAccount('Bank of America')]; const payees = [ - { id: 'payed-to', name: 'Payed To' }, - { id: 'guy', name: 'This guy on the side of the road' }, + { id: 'payed-to', favorite: true, name: 'Payed To' }, + { id: 'guy', favorite: false, name: 'This guy on the side of the road' }, ]; const categoryGroups = generateCategoryGroups([ { @@ -130,6 +130,7 @@ function LiveTransactionTable(props) { {...props} transactions={transactions} loadMoreTransactions={() => {}} + commonPayees={[]} payees={payees} addNotification={n => console.log(n)} onSave={onSave} diff --git a/packages/desktop-client/src/hooks/usePayees.ts b/packages/desktop-client/src/hooks/usePayees.ts index cf51d6b9a7f..cffa9df0673 100644 --- a/packages/desktop-client/src/hooks/usePayees.ts +++ b/packages/desktop-client/src/hooks/usePayees.ts @@ -1,9 +1,24 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { getPayees } from 'loot-core/src/client/actions'; +import { getCommonPayees, getPayees } from 'loot-core/src/client/actions'; import { type State } from 'loot-core/src/client/state-types'; +export function useCommonPayees() { + const dispatch = useDispatch(); + const commonPayeesLoaded = useSelector( + (state: State) => state.queries.commonPayeesLoaded, + ); + + useEffect(() => { + if (!commonPayeesLoaded) { + dispatch(getCommonPayees()); + } + }, []); + + return useSelector(state => state.queries.commonPayees); +} + export function usePayees() { const dispatch = useDispatch(); const payeesLoaded = useSelector( diff --git a/packages/loot-core/migrations/1720664867241_add_payee_favorite.sql b/packages/loot-core/migrations/1720664867241_add_payee_favorite.sql new file mode 100644 index 00000000000..0819ba0039e --- /dev/null +++ b/packages/loot-core/migrations/1720664867241_add_payee_favorite.sql @@ -0,0 +1,5 @@ +BEGIN TRANSACTION; + +ALTER TABLE payees ADD COLUMN favorite INTEGER DEFAULT 0 DEFAULT FALSE; + +COMMIT; \ No newline at end of file diff --git a/packages/loot-core/src/client/actions/queries.ts b/packages/loot-core/src/client/actions/queries.ts index badb36f37cb..6e4c2111e8c 100644 --- a/packages/loot-core/src/client/actions/queries.ts +++ b/packages/loot-core/src/client/actions/queries.ts @@ -246,6 +246,17 @@ export function getPayees() { }; } +export function getCommonPayees() { + return async (dispatch: Dispatch) => { + const payees = await send('common-payees-get'); + dispatch({ + type: constants.LOAD_COMMON_PAYEES, + payees, + }); + return payees; + }; +} + export function initiallyLoadPayees() { return async (dispatch: Dispatch, getState: GetState) => { if (getState().queries.payees.length === 0) { diff --git a/packages/loot-core/src/client/constants.ts b/packages/loot-core/src/client/constants.ts index 03f0e578630..272ca0a9e27 100644 --- a/packages/loot-core/src/client/constants.ts +++ b/packages/loot-core/src/client/constants.ts @@ -5,6 +5,7 @@ export const MARK_ACCOUNT_READ = 'MARK_ACCOUNT_READ'; export const LOAD_ACCOUNTS = 'LOAD_ACCOUNTS'; export const UPDATE_ACCOUNT = 'UPDATE_ACCOUNT'; export const LOAD_CATEGORIES = 'LOAD_CATEGORIES'; +export const LOAD_COMMON_PAYEES = 'LOAD_COMMON_PAYEES'; export const LOAD_PAYEES = 'LOAD_PAYEES'; export const SET_PREFS = 'SET_PREFS'; export const MERGE_LOCAL_PREFS = 'MERGE_LOCAL_PREFS'; diff --git a/packages/loot-core/src/client/reducers/queries.ts b/packages/loot-core/src/client/reducers/queries.ts index 84528cc12b8..eb858f82491 100644 --- a/packages/loot-core/src/client/reducers/queries.ts +++ b/packages/loot-core/src/client/reducers/queries.ts @@ -19,6 +19,8 @@ const initialState: QueriesState = { list: [], }, categoriesLoaded: false, + commonPayees: [], + commonPayeesLoaded: false, payees: [], payeesLoaded: false, earliestTransaction: null, @@ -84,6 +86,12 @@ export function update(state = initialState, action: Action): QueriesState { categories: action.categories, categoriesLoaded: true, }; + case constants.LOAD_COMMON_PAYEES: + return { + ...state, + commonPayees: action.payees, + commonPayeesLoaded: true, + }; case constants.LOAD_PAYEES: return { ...state, diff --git a/packages/loot-core/src/client/state-types/queries.d.ts b/packages/loot-core/src/client/state-types/queries.d.ts index 0d511868b39..2b5138f4167 100644 --- a/packages/loot-core/src/client/state-types/queries.d.ts +++ b/packages/loot-core/src/client/state-types/queries.d.ts @@ -11,6 +11,8 @@ export type QueriesState = { accountsLoaded: boolean; categories: Awaited>; categoriesLoaded: boolean; + commonPayeesLoaded: boolean; + commonPayees: Awaited>; payees: Awaited>; payeesLoaded: boolean; earliestTransaction: unknown | null; @@ -58,6 +60,11 @@ type LoadPayeesAction = { payees: State['payees']; }; +type LoadCommonPayeesAction = { + type: typeof constants.LOAD_COMMON_PAYEES; + payees: State['common_payees']; +}; + export type QueriesActions = | SetNewTransactionsAction | UpdateNewTransactionsAction @@ -66,4 +73,5 @@ export type QueriesActions = | LoadAccountsAction | UpdateAccountAction | LoadCategoriesAction + | LoadCommonPayeesAction | LoadPayeesAction; diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index e99624982a9..84e53840501 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -630,6 +630,12 @@ handlers['api/category-delete'] = withMutation(async function ({ }); }); +handlers['api/common-payees-get'] = async function () { + checkFileOpen(); + const payees = await handlers['common-payees-get'](); + return payees.map(payeeModel.toExternal); +}; + handlers['api/payees-get'] = async function () { checkFileOpen(); const payees = await handlers['payees-get'](); diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index 490d243b54e..5fa4f519aa0 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -61,6 +61,7 @@ export const schema = { name: f('string', { required: true }), transfer_acct: f('id', { ref: 'accounts' }), tombstone: f('boolean'), + favorite: f('boolean'), }, accounts: { id: f('id'), diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index a578b408c0d..46fa7342361 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -536,6 +536,25 @@ export function getPayees() { `); } +export function getCommonPayees() { + const threeMonthsAgo = '20240201'; + const limit = 10; + return all(` + SELECT p.id as id, p.name as name, p.favorite as favorite, + p.category as category, TRUE as common, NULL as transfer_acct, + count(*) as c, + max(t.date) as latest + FROM payees p + LEFT JOIN v_transactions t on t.payee == p.id + WHERE LENGTH(p.name) > 0 + GROUP BY p.id + HAVING latest > ${threeMonthsAgo} + ORDER BY c DESC ,p.transfer_acct IS NULL DESC, p.name + COLLATE NOCASE + LIMIT ${limit} + `); +} + export function syncGetOrphanedPayees() { return all(` SELECT p.id FROM payees p diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index f6c98e88b4f..10441970929 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -446,6 +446,10 @@ handlers['payee-create'] = mutator(async function ({ name }) { }); }); +handlers['common-payees-get'] = async function () { + return db.getCommonPayees(); +}; + handlers['payees-get'] = async function () { return db.getPayees(); }; diff --git a/packages/loot-core/src/types/api-handlers.d.ts b/packages/loot-core/src/types/api-handlers.d.ts index db221089c76..2b9bd088081 100644 --- a/packages/loot-core/src/types/api-handlers.d.ts +++ b/packages/loot-core/src/types/api-handlers.d.ts @@ -148,6 +148,8 @@ export interface ApiHandlers { 'api/payees-get': () => Promise; + 'api/common-payees-get': () => Promise; + 'api/payee-create': (arg: { payee }) => Promise; 'api/payee-update': (arg: { id; fields }) => Promise; diff --git a/packages/loot-core/src/types/models/payee.d.ts b/packages/loot-core/src/types/models/payee.d.ts index 5d84bc16bcf..1d95d00c93d 100644 --- a/packages/loot-core/src/types/models/payee.d.ts +++ b/packages/loot-core/src/types/models/payee.d.ts @@ -2,6 +2,7 @@ export interface NewPayeeEntity { id?: string; name: string; transfer_acct?: string; + favorite?: boolean; tombstone?: boolean; } diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index 9e3b91fb60f..3bea94101c1 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -105,6 +105,8 @@ export interface ServerHandlers { 'payee-create': (arg: { name }) => Promise; + 'common-payees-get': () => Promise; + 'payees-get': () => Promise; 'payees-get-rule-counts': () => Promise; diff --git a/upcoming-release-notes/2814.md b/upcoming-release-notes/2814.md new file mode 100644 index 00000000000..1057298df2b --- /dev/null +++ b/upcoming-release-notes/2814.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [qedi-r] +--- + +Shows favourite and up to the top 5 most frequently used payees in the payee dropdown menu in a section at the top.