MVP for Payee Locations (#6157)

* Phase 1: Add payee locations database schema/types

 * Add migration to create payee_locations table with proper indexes
 * Add PayeeLocationEntity type definition
 * Update database schema to include payee_locations table
 * Export PayeeLocationEntity from models index

* Phase 2: Add payee location API/services

  * Add constants for default location behavior
  * Implement location service with geolocation adapters
  * Add new API handlers for payee location operations

* Phase 3: Add location-aware UI components/hooks

 * Update mobile transaction editing with location integration
 * Enhance PayeeAutocomplete with nearby payee suggestions and forget
   functionality
 * Implement location permission and placeholder unit of measurement hooks

* Phase 4: Add YNAB5 payee location import support

 * Extend YNAB5 types to include location data from payees
 * Implement location import logic in YNAB5 importer

* Phase 5: Add unit of measurement support

 * Add unit of measurement preference setting in Format.tsx
 * Implement distance formatting utilities for imperial/metric units
 * Add useUnitOfMeasurementFormat hook for accessing preferences

* Required release note about the PR

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6157

* Actually get syncing working

This was not obvious to me, esp. with 13 years of data, but the
locations I've been inserting were local only.

Everything appeared to work.

What I failed to notice is that the locations did not sync
across devices. Of course all the location data that was imported worked
fine, but nothing new unless it was created on device.

This changes the schema and uses the proper insert/delete methods such
that syncing works.

* Remove unit of measurement preference

Display feet and meters automatically, and don't bother to format based on miles/kilometers.

* Add payeeLocations feature flag

Place the location permissions check and thus user-facing functionality behind the feature flag

* Missed adding tombstone to payee location query

* Adjust migration name to pass CI

Adjust the indexes as well

* Unify location.ts

If CodeRabbit complains again, reply that we are actively choosing a unified file

* Add bounds testing

The validation is straightforward range-checking — if it's wrong, it'll be obvious quickly. Unless there's a plan to start adding broader test coverage for that file, I'd leave it untested for now

* Prefer camelCase for the method params

* Fix the nested interactive containers

* Fix the majority of CodeRabbit nits

The remainder seem to not be related to my code change (just a lint), outdated (sql migration comment), or infeasible (sql haversine query)

* More CodeRabbit nits

* Revert unnecessary YNAB5 zip import

Turns out the payee_locations were inside the exported budget all along!

* Additional guards and other CR fixes

* Match the pattern used elsewhere in file

* YNAB5.Budget -> Budget

Missed in the merge conflict

* ci: trigger rerun

* Change import from fetch to connection module

* Correct invalid border property

Ah. I never noticed this property wasn't working. I guess the button
looked OK to me!

* Only hide the button on success

* Update packages/loot-core/src/shared/location-utils.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Update packages/loot-core/src/server/payees/app.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Fully fix typo

Guess I shouldn't commit a suggestion after waking up

* Attempting to address feedback

Manual select nearby payee and save payee location buttons to make the UX more obvi

* Remove stale file that was moved

* Additional cleanup of remnant change

Removed the references to location from a few existing entities

* Additional cleanup of remnant change

* Show the Nearby payees button even when the field is disabled

If there are nearby payees, there's not a payee already selected, and the save button isn't needed

* runQuery is not async

* Add mockNearbyPayeesResult to test

Trying to utilize the real type in the mock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
This commit is contained in:
Dustin Brewer
2026-03-09 13:26:48 -07:00
committed by GitHub
parent 102be1c54d
commit 60e2665fcc
28 changed files with 1704 additions and 62 deletions

View File

@@ -1,20 +1,27 @@
import type { UseQueryResult } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import type { Screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { generateAccount } from 'loot-core/mocks';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import type {
AccountEntity,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import { PayeeAutocomplete } from './PayeeAutocomplete';
import type { PayeeAutocompleteProps } from './PayeeAutocomplete';
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
import { payeeQueries } from '@desktop-client/payees';
const PAYEE_SELECTOR = '[data-testid][role=option]';
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
const ALL_PAYEE_ITEMS_SELECTOR = '[data-testid$="-payee-item"]';
const payees = [
makePayee('Bob', { favorite: true }),
@@ -41,7 +48,30 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
};
}
function extractPayeesAndHeaderNames(screen: Screen) {
function makeNearbyPayee(name: string, distance: number): NearbyPayeeEntity {
const id = name.toLowerCase() + '-id';
return {
payee: {
id,
name,
favorite: false,
transfer_acct: undefined,
},
location: {
id: id + '-loc',
payee_id: id,
latitude: 0,
longitude: 0,
created_at: 0,
distance,
},
};
}
function extractPayeesAndHeaderNames(
screen: Screen,
itemSelector: string = PAYEE_SELECTOR,
) {
const autocompleteElement = screen.getByTestId('autocomplete');
// Get all elements that match either selector, but query them separately
@@ -49,7 +79,7 @@ function extractPayeesAndHeaderNames(screen: Screen) {
const headers = [
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR),
];
const items = [...autocompleteElement.querySelectorAll(PAYEE_SELECTOR)];
const items = [...autocompleteElement.querySelectorAll(itemSelector)];
// Combine all elements and sort by their position in the DOM
const allElements = [...headers, ...items];
@@ -78,14 +108,52 @@ async function clickAutocomplete(autocomplete: HTMLElement) {
await waitForAutocomplete();
}
vi.mock('@desktop-client/hooks/useNearbyPayees', () => ({
useNearbyPayees: vi.fn(),
}));
function firstOrIncorrect(id: string | null): string {
return id?.split('-', 1)[0] || 'incorrect';
}
function mockNearbyPayeesResult(
data: NearbyPayeeEntity[],
): UseQueryResult<NearbyPayeeEntity[], Error> {
return {
data,
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
errorUpdateCount: 0,
failureCount: 0,
failureReason: null,
fetchStatus: 'idle',
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isLoading: false,
isLoadingError: false,
isPaused: false,
isPending: false,
isPlaceholderData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
isEnabled: true,
promise: Promise.resolve(data),
refetch: vi.fn(),
status: 'success',
};
}
describe('PayeeAutocomplete.getPayeeSuggestions', () => {
const queryClient = createTestQueryClient();
beforeEach(() => {
vi.mocked(useNearbyPayees).mockReturnValue(mockNearbyPayeesResult([]));
queryClient.setQueryData(payeeQueries.listCommon().queryKey, []);
});
@@ -207,6 +275,108 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
);
});
test('nearby payees appear in their own section before other payees', async () => {
const nearbyPayees = [
makeNearbyPayee('Coffee Shop', 0.3),
makeNearbyPayee('Grocery Store', 1.2),
];
const payees = [makePayee('Alice'), makePayee('Bob')];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
).toStrictEqual([
'Nearby Payees',
'Coffee Shop',
'Grocery Store',
'Payees',
'Alice',
'Bob',
]);
});
test('nearby payees are filtered by search input', async () => {
const nearbyPayees = [
makeNearbyPayee('Coffee Shop', 0.3),
makeNearbyPayee('Grocery Store', 1.2),
];
const payees = [makePayee('Alice'), makePayee('Bob')];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
const autocomplete = renderPayeeAutocomplete({ payees });
await clickAutocomplete(autocomplete);
const input = autocomplete.querySelector('input')!;
await userEvent.type(input, 'Coffee');
await waitForAutocomplete();
const names = extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR);
expect(names).toContain('Nearby Payees');
expect(names).toContain('Coffee Shop');
expect(names).not.toContain('Grocery Store');
expect(names).not.toContain('Alice');
expect(names).not.toContain('Bob');
});
test('nearby payees coexist with favorites and common payees', async () => {
const nearbyPayees = [makeNearbyPayee('Coffee Shop', 0.3)];
const payees = [
makePayee('Alice'),
makePayee('Bob'),
makePayee('Eve', { favorite: true }),
makePayee('Carol'),
];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
queryClient.setQueryData(payeeQueries.listCommon().queryKey, [
makePayee('Bob'),
makePayee('Carol'),
]);
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
).toStrictEqual([
'Nearby Payees',
'Coffee Shop',
'Suggested Payees',
'Eve',
'Bob',
'Carol',
'Payees',
'Alice',
]);
});
test('a payee appearing in both nearby and favorites shows in both sections', async () => {
const nearbyPayees = [makeNearbyPayee('Eve', 0.5)];
const payees = [makePayee('Alice'), makePayee('Eve', { favorite: true })];
vi.mocked(useNearbyPayees).mockReturnValue(
mockNearbyPayeesResult(nearbyPayees),
);
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
expect(
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
).toStrictEqual([
'Nearby Payees',
'Eve',
'Suggested Payees',
'Eve',
'Payees',
'Alice',
]);
});
test('list with no favorites shows just the payees list', async () => {
//Note that the payees list assumes the payees are already sorted
const payees = [

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { Fragment, useMemo, useState } from 'react';
import React, { Fragment, useCallback, useMemo, useState } from 'react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
@@ -13,15 +13,24 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { SvgAdd, SvgBookmark } from '@actual-app/components/icons/v1';
import {
SvgAdd,
SvgBookmark,
SvgLocation,
} from '@actual-app/components/icons/v1';
import { styles } from '@actual-app/components/styles';
import { TextOneLine } from '@actual-app/components/text-one-line';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import { formatDistance } from 'loot-core/shared/location-utils';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import type {
AccountEntity,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import {
Autocomplete,
@@ -32,13 +41,19 @@ import { ItemHeader } from './ItemHeader';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCommonPayees } from '@desktop-client/hooks/useCommonPayees';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
getActivePayees,
useCreatePayeeMutation,
useDeletePayeeLocationMutation,
} from '@desktop-client/payees';
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
type PayeeAutocompleteItem = PayeeEntity &
PayeeItemType & {
nearbyLocationId?: string;
distance?: number;
};
const MAX_AUTO_SUGGESTIONS = 5;
@@ -130,17 +145,25 @@ type PayeeListProps = {
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactNode;
footer: ReactNode;
onForgetLocation?: (locationId: string) => void;
};
type ItemTypes = 'account' | 'payee' | 'common_payee';
type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
type PayeeItemType = {
itemType: ItemTypes;
};
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
function determineItemType(
item: PayeeEntity,
isCommon: boolean,
isNearby: boolean = false,
): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
if (isNearby) {
return 'nearby_payee';
}
if (isCommon) {
return 'common_payee';
} else {
@@ -158,6 +181,7 @@ function PayeeList({
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
renderPayeeItem = defaultRenderPayeeItem,
footer,
onForgetLocation,
}: PayeeListProps) {
const { t } = useTranslation();
@@ -165,56 +189,66 @@ function PayeeList({
// with the value of the input so it always shows whatever the user
// entered
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
let currentIndex = 0;
const result = items.reduce(
(acc, item) => {
if (item.id === 'new') {
acc.newPayee = { ...item };
} else if (item.itemType === 'common_payee') {
acc.suggestedPayees.push({ ...item });
} else if (item.itemType === 'payee') {
acc.payees.push({ ...item });
} else if (item.itemType === 'account') {
acc.transferPayees.push({ ...item });
}
return acc;
},
{
newPayee: null as PayeeAutocompleteItem | null,
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
payees: [] as Array<PayeeAutocompleteItem>,
transferPayees: [] as Array<PayeeAutocompleteItem>,
},
);
const { newPayee, suggestedPayees, payees, transferPayees, nearbyPayees } =
useMemo(() => {
let currentIndex = 0;
const result = items.reduce(
(acc, item) => {
if (item.id === 'new') {
acc.newPayee = { ...item };
} else if (item.itemType === 'common_payee') {
acc.suggestedPayees.push({ ...item });
} else if (item.itemType === 'payee') {
acc.payees.push({ ...item });
} else if (item.itemType === 'account') {
acc.transferPayees.push({ ...item });
} else if (item.itemType === 'nearby_payee') {
acc.nearbyPayees.push({ ...item });
}
return acc;
},
{
newPayee: null as PayeeAutocompleteItem | null,
nearbyPayees: [] as Array<PayeeAutocompleteItem>,
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
payees: [] as Array<PayeeAutocompleteItem>,
transferPayees: [] as Array<PayeeAutocompleteItem>,
},
);
// assign indexes in render order
const newPayeeWithIndex = result.newPayee
? { ...result.newPayee, highlightedIndex: currentIndex++ }
: null;
// assign indexes in render order
const newPayeeWithIndex = result.newPayee
? { ...result.newPayee, highlightedIndex: currentIndex++ }
: null;
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const nearbyPayeesWithIndex = result.nearbyPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
const payeesWithIndex = result.payees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
return {
newPayee: newPayeeWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
const transferPayeesWithIndex = result.transferPayees.map(item => ({
...item,
highlightedIndex: currentIndex++,
}));
return {
newPayee: newPayeeWithIndex,
nearbyPayees: nearbyPayeesWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
// We limit the number of payees shown to 100.
// So we show a hint that more are available via search.
@@ -237,6 +271,20 @@ function PayeeList({
embedded,
})}
{nearbyPayees.length > 0 &&
renderPayeeItemGroupHeader({ title: t('Nearby Payees') })}
{nearbyPayees.map(item => (
<Fragment key={item.id}>
<NearbyPayeeItem
{...(getItemProps ? getItemProps({ item }) : {})}
item={item}
highlighted={highlightedIndex === item.highlightedIndex}
embedded={embedded}
onForgetLocation={onForgetLocation}
/>
</Fragment>
))}
{suggestedPayees.length > 0 &&
renderPayeeItemGroupHeader({ title: t('Suggested Payees') })}
{suggestedPayees.map(item => (
@@ -324,6 +372,7 @@ export type PayeeAutocompleteProps = ComponentProps<
) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[];
payees?: PayeeEntity[];
nearbyPayees?: NearbyPayeeEntity[];
};
export function PayeeAutocomplete({
@@ -343,16 +392,22 @@ export function PayeeAutocomplete({
renderPayeeItem = defaultRenderPayeeItem,
accounts,
payees,
nearbyPayees,
...props
}: PayeeAutocompleteProps) {
const { t } = useTranslation();
const { data: commonPayees } = useCommonPayees();
const { data: retrievedPayees = [] } = usePayees();
const { data: retrievedNearbyPayees = [] } = useNearbyPayees();
if (!payees) {
payees = retrievedPayees;
}
const createPayeeMutation = useCreatePayeeMutation();
const deletePayeeLocationMutation = useDeletePayeeLocationMutation();
if (!nearbyPayees) {
nearbyPayees = retrievedNearbyPayees;
}
const { data: cachedAccounts = [] } = useAccounts();
if (!accounts) {
@@ -392,6 +447,43 @@ export function PayeeAutocomplete({
showInactivePayees,
]);
// Process nearby payees separately from suggestions
const nearbyPayeesWithType: PayeeAutocompleteItem[] = useMemo(() => {
if (!nearbyPayees?.length) {
return [];
}
const processed: PayeeAutocompleteItem[] = nearbyPayees.map(result => ({
...result.payee,
itemType: 'nearby_payee' as const,
nearbyLocationId: result.location.id,
distance: result.location.distance,
}));
return processed;
}, [nearbyPayees]);
// Filter nearby payees based on input value (similar to regular payees)
const filteredNearbyPayees = useMemo(() => {
if (!nearbyPayeesWithType.length || !rawPayee) {
return nearbyPayeesWithType;
}
return nearbyPayeesWithType.filter(payee => {
return defaultFilterSuggestion(payee, rawPayee);
});
}, [nearbyPayeesWithType, rawPayee]);
const handleForgetLocation = useCallback(
async (locationId: string) => {
try {
await deletePayeeLocationMutation.mutateAsync(locationId);
} catch (error) {
console.error('Failed to delete payee location', { error });
}
},
[deletePayeeLocationMutation],
);
async function handleSelect(idOrIds, rawInputValue) {
if (!clearOnBlur) {
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
@@ -480,6 +572,12 @@ export function PayeeAutocomplete({
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect}
getHighlightedIndex={suggestions => {
// If we have nearby payees, highlight the first nearby payee
if (filteredNearbyPayees.length > 0) {
return 0;
}
// Otherwise use original logic for suggestions
if (suggestions.length === 0) {
return null;
} else if (suggestions[0].id === 'new') {
@@ -491,7 +589,7 @@ export function PayeeAutocomplete({
filterSuggestions={filterSuggestions}
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList
items={items}
items={[...filteredNearbyPayees, ...items]}
commonPayees={commonPayees}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
@@ -521,6 +619,7 @@ export function PayeeAutocomplete({
)}
</AutocompleteFooter>
}
onForgetLocation={handleForgetLocation}
/>
)}
{...props}
@@ -698,3 +797,126 @@ function defaultRenderPayeeItem(
): ReactElement<typeof PayeeItem> {
return <PayeeItem {...props} />;
}
type NearbyPayeeItemProps = PayeeItemProps & {
onForgetLocation?: (locationId: string) => void;
};
function NearbyPayeeItem({
item,
className,
highlighted,
embedded,
onForgetLocation,
...props
}: NearbyPayeeItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
const iconSize = isNarrowWidth ? 14 : 8;
let paddingLeftOverFromIcon = 20;
let itemIcon = undefined;
if (item.favorite) {
itemIcon = (
<SvgBookmark
width={iconSize}
height={iconSize}
style={{ marginRight: 5, display: 'inline-block' }}
/>
);
paddingLeftOverFromIcon -= iconSize + 5;
}
// Extract location ID and distance from the nearby payee item
const locationId = item.nearbyLocationId;
const distance = item.distance;
const distanceText = distance !== undefined ? formatDistance(distance) : '';
const handleForgetClick = () => {
if (locationId && onForgetLocation) {
onForgetLocation(locationId);
}
};
return (
<div
className={cx(
className,
css({
backgroundColor: highlighted
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
color: highlighted
? theme.menuAutoCompleteItemTextHover
: theme.menuAutoCompleteItemText,
borderRadius: embedded ? 4 : 0,
padding: 4,
paddingLeft: paddingLeftOverFromIcon,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
...narrowStyle,
}),
)}
data-testid={`${item.name}-payee-item`}
data-highlighted={highlighted || undefined}
>
<button
type="button"
className={css({
display: 'flex',
flexDirection: 'column',
flex: 1,
background: 'none',
border: 'none',
font: 'inherit',
color: 'inherit',
textAlign: 'left',
padding: 0,
cursor: 'pointer',
})}
{...props}
>
<TextOneLine>
{itemIcon}
{item.name}
</TextOneLine>
{distanceText && (
<div
style={{
fontSize: '10px',
color: highlighted
? theme.menuAutoCompleteItemTextHover
: theme.pageTextSubdued,
marginLeft: itemIcon ? iconSize + 5 : 0,
}}
>
{distanceText}
</div>
)}
</button>
{locationId && (
<Button
variant="menu"
onPress={handleForgetClick}
style={{
backgroundColor: theme.errorBackground,
border: `1px solid ${theme.errorBorder}`,
color: theme.pageText,
fontSize: '11px',
padding: '2px 6px',
borderRadius: 3,
}}
>
<Trans i18nKey="forget">Forget</Trans>
<SvgLocation width={10} height={10} style={{ marginLeft: 4 }} />
</Button>
)}
</div>
);
}

View File

@@ -79,6 +79,7 @@ InputField.displayName = 'InputField';
type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
rightContent?: ReactNode;
alwaysShowRightContent?: boolean;
textStyle?: CSSProperties;
};
@@ -105,6 +106,7 @@ export function TapField({
children,
className,
rightContent,
alwaysShowRightContent,
textStyle,
ref,
...props
@@ -135,7 +137,7 @@ export function TapField({
{value}
</Text>
)}
{!props.isDisabled && rightContent}
{(!props.isDisabled || alwaysShowRightContent) && rightContent}
</Button>
);
}

View File

@@ -14,6 +14,7 @@ import { Button } from '@actual-app/components/button';
import { SvgSplit } from '@actual-app/components/icons/v0';
import {
SvgAdd,
SvgLocation,
SvgPiggyBank,
SvgTrash,
} from '@actual-app/components/icons/v1';
@@ -31,6 +32,8 @@ import {
} from 'date-fns';
import { send } from 'loot-core/platform/client/connection';
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
import { calculateDistance } from 'loot-core/shared/location-utils';
import * as monthUtils from 'loot-core/shared/months';
import * as Platform from 'loot-core/shared/platform';
import { q } from 'loot-core/shared/query';
@@ -79,7 +82,9 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useLocationPermission } from '@desktop-client/hooks/useLocationPermission';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
SingleActiveEditFormProvider,
@@ -88,6 +93,8 @@ import {
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useSavePayeeLocationMutation } from '@desktop-client/payees';
import { locationService } from '@desktop-client/payees/location';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
@@ -554,6 +561,10 @@ type TransactionEditInnerProps = {
onDelete: (id: TransactionEntity['id']) => void;
onSplit: (id: TransactionEntity['id']) => void;
onAddSplit: (id: TransactionEntity['id']) => void;
shouldShowSaveLocation?: boolean;
onSaveLocation?: () => void;
onSelectNearestPayee?: () => void;
nearestPayee?: PayeeEntity | null;
};
const TransactionEditInner = memo<TransactionEditInnerProps>(
@@ -569,6 +580,10 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
onDelete,
onSplit,
onAddSplit,
shouldShowSaveLocation,
onSaveLocation,
onSelectNearestPayee,
nearestPayee,
}) {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -1090,6 +1105,56 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
}
onPress={() => onEditFieldInner(transaction.id, 'payee')}
data-testid="payee-field"
alwaysShowRightContent={
!!nearestPayee && !transaction.payee && !shouldShowSaveLocation
}
rightContent={
shouldShowSaveLocation ? (
<Button
variant="bare"
onPress={onSaveLocation}
style={{
backgroundColor: theme.buttonNormalBackground,
border: `1px solid ${theme.buttonNormalBorder}`,
color: theme.buttonNormalText,
fontSize: '11px',
padding: '4px 8px',
borderRadius: 3,
height: 'auto',
minHeight: 'auto',
}}
>
<Trans>Save</Trans>
<SvgLocation
width={10}
height={10}
style={{ marginLeft: 4 }}
/>
</Button>
) : nearestPayee && !transaction.payee ? (
<Button
variant="bare"
onPress={onSelectNearestPayee}
style={{
backgroundColor: theme.buttonNormalBackground,
border: `1px solid ${theme.buttonNormalBorder}`,
color: theme.buttonNormalText,
fontSize: '11px',
padding: '4px 8px',
borderRadius: 3,
height: 'auto',
minHeight: 'auto',
}}
>
<Trans>Nearby</Trans>
<SvgLocation
width={10}
height={10}
style={{ marginLeft: 4 }}
/>
</Button>
) : undefined
}
/>
</View>
@@ -1312,6 +1377,7 @@ function TransactionEditUnconnected({
const { state: locationState } = useLocation();
const [searchParams] = useSearchParams();
const dispatch = useDispatch();
const updatePayeeLocationMutation = useSavePayeeLocationMutation();
const navigate = useNavigate();
const [transactions, setTransactions] = useState<TransactionEntity[]>([]);
const [fetchedTransactions, setFetchedTransactions] = useState<
@@ -1333,6 +1399,11 @@ function TransactionEditUnconnected({
[payees, searchParams],
);
const locationAccess = useLocationPermission();
const [shouldShowSaveLocation, setShouldShowSaveLocation] = useState(false);
const { data: nearbyPayees = [] } = useNearbyPayees();
const nearestPayee = nearbyPayees[0]?.payee ?? null;
useEffect(() => {
let unmounted = false;
@@ -1370,6 +1441,12 @@ function TransactionEditUnconnected({
};
}, [transactionId]);
useEffect(() => {
if (!locationAccess) {
setShouldShowSaveLocation(false);
}
}, [locationAccess]);
useEffect(() => {
if (isAdding.current) {
setTransactions([
@@ -1430,11 +1507,15 @@ function TransactionEditUnconnected({
if (diff) {
Object.keys(diff).forEach(key => {
const field = key as keyof TransactionEntity;
// Update "empty" fields in general
// Or update all fields if the payee changes (assists location-based entry by
// applying rules to prefill category, notes, etc. based on the selected payee)
if (
newTransaction[field] == null ||
newTransaction[field] === '' ||
newTransaction[field] === 0 ||
newTransaction[field] === false
newTransaction[field] === false ||
updatedField === 'payee'
) {
(newTransaction as Record<string, unknown>)[field] = diff[field];
}
@@ -1463,8 +1544,33 @@ function TransactionEditUnconnected({
newTransaction,
);
setTransactions(newTransactions);
if (updatedField === 'payee') {
setShouldShowSaveLocation(false);
if (newTransaction.payee && locationAccess) {
const payeeLocations = await locationService.getPayeeLocations(
newTransaction.payee,
);
if (payeeLocations.length === 0) {
setShouldShowSaveLocation(true);
} else {
const currentPosition = await locationService.getCurrentPosition();
const hasNearby = payeeLocations.some(
loc =>
calculateDistance(currentPosition, {
latitude: loc.latitude,
longitude: loc.longitude,
}) <= DEFAULT_MAX_DISTANCE_METERS,
);
if (!hasNearby) {
setShouldShowSaveLocation(true);
}
}
}
}
},
[dateFormat, transactions],
[dateFormat, transactions, locationAccess],
);
const onSave = useCallback(
@@ -1544,6 +1650,39 @@ function TransactionEditUnconnected({
[transactions],
);
const onSaveLocation = useCallback(async () => {
try {
const [transaction] = transactions;
if (transaction.payee) {
await updatePayeeLocationMutation.mutateAsync(transaction.payee);
setShouldShowSaveLocation(false);
}
} catch (error) {
console.error('Failed to save location', { error });
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to save location'),
},
}),
);
}
}, [t, transactions, dispatch, updatePayeeLocationMutation]);
const onSelectNearestPayee = useCallback(() => {
const transaction = transactions[0];
if (!nearestPayee || !transaction || transaction.payee) {
return;
}
const updated = {
...serializeTransaction(transaction, dateFormat),
payee: nearestPayee.id,
};
onUpdate(updated, 'payee');
}, [transactions, nearestPayee, onUpdate, dateFormat]);
if (accounts.length === 0) {
return (
<Page
@@ -1669,6 +1808,10 @@ function TransactionEditUnconnected({
onDelete={onDelete}
onSplit={onSplit}
onAddSplit={onAddSplit}
shouldShowSaveLocation={shouldShowSaveLocation}
onSaveLocation={onSaveLocation}
onSelectNearestPayee={onSelectNearestPayee}
nearestPayee={locationAccess ? nearestPayee : null}
/>
</View>
);

View File

@@ -215,6 +215,12 @@ export function ExperimentalFeatures() {
>
<Trans>Budget Analysis Report</Trans>
</FeatureToggle>
<FeatureToggle
flag="payeeLocations"
feedbackLink="https://github.com/actualbudget/actual/issues/6706"
>
<Trans>Payee Locations</Trans>
</FeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
prefName="flags.plugins"

View File

@@ -11,6 +11,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
crossoverReport: false,
customThemes: false,
budgetAnalysisReport: false,
payeeLocations: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {

View File

@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { useFeatureFlag } from './useFeatureFlag';
import { locationService } from '@desktop-client/payees/location';
/**
* Custom hook to manage geolocation permission status
* Currently behind the payeeLocations feature flag
*
* @returns boolean indicating whether geolocation access is granted
*/
export function useLocationPermission(): boolean {
const payeeLocationsEnabled = useFeatureFlag('payeeLocations');
const { isNarrowWidth } = useResponsive();
const [locationAccess, setLocationAccess] = useState(false);
useEffect(() => {
if (!payeeLocationsEnabled || !isNarrowWidth) {
setLocationAccess(false);
return;
}
let permissionStatus: PermissionStatus | null = null;
let handleChange: (() => void) | null = null;
let isMounted = true;
// Check if Permissions API is available
if (
!navigator.permissions ||
typeof navigator.permissions.query !== 'function'
) {
setLocationAccess(false);
return;
}
try {
navigator.permissions
.query({ name: 'geolocation' })
.then(status => {
if (!isMounted) {
return;
}
permissionStatus = status;
// Set initial state
setLocationAccess(status.state === 'granted');
// Listen for permission changes
handleChange = () => {
setLocationAccess(status.state === 'granted');
};
status.addEventListener('change', handleChange);
if (status.state === 'prompt') {
locationService
.getCurrentPosition()
.then(() => {
if (isMounted) {
setLocationAccess(true);
}
})
.catch(() => {
if (isMounted) {
setLocationAccess(false);
}
});
}
})
.catch(() => {
if (!isMounted) {
return;
}
// Permission API not supported, assume no access
setLocationAccess(false);
});
} catch {
if (!isMounted) {
return;
}
// Synchronous error (e.g., TypeError), assume no access
setLocationAccess(false);
}
// Cleanup function
return () => {
isMounted = false;
if (permissionStatus && handleChange) {
permissionStatus.removeEventListener('change', handleChange);
}
};
}, [payeeLocationsEnabled, isNarrowWidth]);
return locationAccess;
}

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { useLocationPermission } from './useLocationPermission';
import { payeeQueries } from '@desktop-client/payees';
export function useNearbyPayees() {
const locationAccess = useLocationPermission();
return useQuery({
...payeeQueries.listNearby(),
enabled: !!locationAccess,
});
}

View File

@@ -0,0 +1,97 @@
import { send } from 'loot-core/platform/client/connection';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
/**
* Abstraction for geolocation functionality
*/
export type GeolocationAdapter = {
getCurrentPosition(options?: PositionOptions): Promise<LocationCoordinates>;
};
/**
* Abstraction for location-related API calls
*/
export type LocationApiClient = {
saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string>;
getLocations(payeeId: string): Promise<PayeeLocationEntity[]>;
deleteLocation(locationId: string): Promise<void>;
getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]>;
};
/**
* Browser implementation of geolocation using the Web Geolocation API
*/
export class BrowserGeolocationAdapter implements GeolocationAdapter {
async getCurrentPosition(
options: PositionOptions = {},
): Promise<LocationCoordinates> {
if (!navigator.geolocation) {
throw new Error('Geolocation is not supported by this browser');
}
const defaultOptions: PositionOptions = {
enableHighAccuracy: true,
timeout: 15000, // 15 second timeout
maximumAge: 60000, // Accept 1-minute-old cached position
};
const position = await new Promise<GeolocationPosition>(
(resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
...defaultOptions,
...options,
});
},
);
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
}
}
/**
* Implementation using the existing send function for API calls
*/
export class SendApiLocationClient implements LocationApiClient {
async saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
return await send('payee-location-create', {
payeeId,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
});
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
return await send('payee-locations-get', { payeeId });
}
async deleteLocation(locationId: string): Promise<void> {
await send('payee-location-delete', { id: locationId });
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]> {
const result = await send('payees-get-nearby', {
latitude: coordinates.latitude,
longitude: coordinates.longitude,
maxDistance,
});
return result || [];
}
}

View File

@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
import type {
GeolocationAdapter,
LocationApiClient,
} from './location-adapters';
import { LocationService } from './location-service';
// Clean test implementations - no complex mocking needed
class TestGeolocationAdapter implements GeolocationAdapter {
private callCount = 0;
constructor(
private mockPosition: LocationCoordinates,
private shouldThrow = false,
) {}
async getCurrentPosition(): Promise<LocationCoordinates> {
this.callCount++;
if (this.shouldThrow) {
throw new Error('Geolocation denied');
}
return { ...this.mockPosition }; // Return copy to avoid mutation
}
getCallCount(): number {
return this.callCount;
}
}
class TestApiClient implements LocationApiClient {
public saveLocationCalls: Array<{
payeeId: string;
coordinates: LocationCoordinates;
}> = [];
public deleteLocationCalls: string[] = [];
public getLocationsCalls: string[] = [];
constructor(
private mockLocations: PayeeLocationEntity[] = [],
private mockNearbyPayees: NearbyPayeeEntity[] = [],
private mockLocationId = 'test-location-id',
private shouldThrowOnSave = false,
) {}
async saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
this.saveLocationCalls.push({ payeeId, coordinates });
if (this.shouldThrowOnSave) {
throw new Error('Save failed');
}
return this.mockLocationId;
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
this.getLocationsCalls.push(payeeId);
return this.mockLocations.filter(loc => loc.payee_id === payeeId);
}
async deleteLocation(locationId: string): Promise<void> {
this.deleteLocationCalls.push(locationId);
}
async getNearbyPayees(): Promise<NearbyPayeeEntity[]> {
return this.mockNearbyPayees;
}
}
describe('LocationService Integration Tests', () => {
let testGeolocation: TestGeolocationAdapter;
let testApiClient: TestApiClient;
let locationService: LocationService;
const defaultPosition = { latitude: 40.7128, longitude: -74.006 }; // NYC
beforeEach(() => {
testGeolocation = new TestGeolocationAdapter(defaultPosition);
testApiClient = new TestApiClient();
locationService = new LocationService(testGeolocation, testApiClient);
});
describe('Position Caching', () => {
it('caches position to avoid repeated geolocation calls', async () => {
const position1 = await locationService.getCurrentPosition();
const position2 = await locationService.getCurrentPosition();
expect(position1).toEqual(defaultPosition);
expect(position2).toEqual(defaultPosition);
expect(testGeolocation.getCallCount()).toBe(1); // Only called once due to caching
});
it('refreshes position after calling reset()', async () => {
await locationService.getCurrentPosition();
expect(testGeolocation.getCallCount()).toBe(1);
locationService.reset();
await locationService.getCurrentPosition();
expect(testGeolocation.getCallCount()).toBe(2); // Called again after reset
});
});
describe('Error Handling', () => {
it('propagates geolocation errors with meaningful messages', async () => {
const errorGeolocation = new TestGeolocationAdapter(
defaultPosition,
true,
);
const serviceWithError = new LocationService(
errorGeolocation,
testApiClient,
);
await expect(serviceWithError.getCurrentPosition()).rejects.toThrow(
'Geolocation denied',
);
});
it('propagates API save errors', async () => {
const errorApiClient = new TestApiClient([], [], 'id', true);
const serviceWithError = new LocationService(
testGeolocation,
errorApiClient,
);
await expect(
serviceWithError.savePayeeLocation('payee-123', defaultPosition),
).rejects.toThrow('Save failed');
});
});
describe('API Integration', () => {
it('calls save location with correct parameters', async () => {
const payeeId = 'payee-456';
const coordinates = { latitude: 41.8781, longitude: -87.6298 }; // Chicago
const result = await locationService.savePayeeLocation(
payeeId,
coordinates,
);
expect(result).toBe('test-location-id');
expect(testApiClient.saveLocationCalls).toEqual([
{ payeeId, coordinates },
]);
});
it('retrieves payee locations correctly', async () => {
const payeeId = 'payee-789';
const mockLocations: PayeeLocationEntity[] = [
{
id: 'loc-1',
payee_id: payeeId,
latitude: 40.7128,
longitude: -74.006,
created_at: Date.now() - 1000,
},
{
id: 'loc-2',
payee_id: 'other-payee',
latitude: 40.75,
longitude: -74.0,
created_at: Date.now(),
},
];
testApiClient = new TestApiClient(mockLocations);
locationService = new LocationService(testGeolocation, testApiClient);
const result = await locationService.getPayeeLocations(payeeId);
expect(result).toHaveLength(1);
expect(result[0].payee_id).toBe(payeeId);
expect(testApiClient.getLocationsCalls).toEqual([payeeId]);
});
it('deletes location correctly', async () => {
const locationId = 'location-to-delete';
await locationService.deletePayeeLocation(locationId);
expect(testApiClient.deleteLocationCalls).toEqual([locationId]);
});
});
describe('Edge Cases', () => {
it('handles zero coordinates', async () => {
testGeolocation = new TestGeolocationAdapter({
latitude: 0,
longitude: 0,
});
locationService = new LocationService(testGeolocation, testApiClient);
const position = await locationService.getCurrentPosition();
expect(position).toEqual({ latitude: 0, longitude: 0 });
});
});
});

View File

@@ -0,0 +1,92 @@
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
import type {
GeolocationAdapter,
LocationApiClient,
} from './location-adapters';
export class LocationService {
private currentPosition: LocationCoordinates | null = null;
private lastLocationTime: number = 0;
private readonly CACHE_DURATION = 60000; // 1 minute cache
constructor(
private geolocation: GeolocationAdapter,
private apiClient: LocationApiClient,
) {}
async getCurrentPosition(): Promise<LocationCoordinates> {
// Return cached position if it's recent
if (
this.currentPosition &&
Date.now() - this.lastLocationTime < this.CACHE_DURATION
) {
return this.currentPosition;
}
try {
this.currentPosition = await this.geolocation.getCurrentPosition();
this.lastLocationTime = Date.now();
return this.currentPosition;
} catch (error) {
console.warn('Geolocation error:', error);
throw error;
}
}
async savePayeeLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
try {
return await this.apiClient.saveLocation(payeeId, coordinates);
} catch (error) {
console.error('Failed to save payee location:', error);
throw error;
}
}
async getPayeeLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
try {
return await this.apiClient.getLocations(payeeId);
} catch (error) {
console.error('Failed to get payee locations:', error);
throw error;
}
}
async deletePayeeLocation(locationId: string): Promise<void> {
try {
await this.apiClient.deleteLocation(locationId);
} catch (error) {
console.error('Failed to delete payee location:', error);
throw error;
}
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number = DEFAULT_MAX_DISTANCE_METERS,
): Promise<NearbyPayeeEntity[]> {
try {
return await this.apiClient.getNearbyPayees(coordinates, maxDistance);
} catch (error) {
console.error('Failed to get nearby payees:', error);
return [];
}
}
/**
* Reset the cached location data
* Useful for testing or when you want to force a fresh location request
*/
reset(): void {
this.currentPosition = null;
this.lastLocationTime = 0;
}
}

View File

@@ -0,0 +1,10 @@
import {
BrowserGeolocationAdapter,
SendApiLocationClient,
} from './location-adapters';
import { LocationService } from './location-service';
export const locationService = new LocationService(
new BrowserGeolocationAdapter(),
new SendApiLocationClient(),
);

View File

@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import { locationService } from './location';
import { payeeQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
@@ -40,6 +41,57 @@ type CreatePayeePayload = {
name: PayeeEntity['name'];
};
export function useDeletePayeeLocationMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (locationId: string) => {
await locationService.deletePayeeLocation(locationId);
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: payeeQueries.listNearby().queryKey,
});
},
onError: error => {
console.error('Error deleting payee location:', error);
dispatchErrorNotification(
dispatch,
t('There was an error forgetting the location. Please try again.'),
error,
);
},
});
}
export function useSavePayeeLocationMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (payeeId: PayeeEntity['id']) => {
const coords = await locationService.getCurrentPosition();
await locationService.savePayeeLocation(payeeId, coords);
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: payeeQueries.listNearby().queryKey,
});
},
onError: error => {
console.error('Error saving payee location:', error);
dispatchErrorNotification(
dispatch,
t('There was an error saving the location. Please try again.'),
error,
);
},
});
}
export function useCreatePayeeMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();

View File

@@ -4,7 +4,13 @@ import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/connection';
import { groupById } from 'loot-core/shared/util';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
import type {
AccountEntity,
NearbyPayeeEntity,
PayeeEntity,
} from 'loot-core/types/models';
import { locationService } from './location';
import { getAccountsById } from '@desktop-client/accounts/accountsSlice';
@@ -54,6 +60,20 @@ export const payeeQueries = {
},
placeholderData: new Map(),
}),
listNearby: () =>
queryOptions<NearbyPayeeEntity[]>({
queryKey: [...payeeQueries.all(), 'nearby'],
queryFn: async () => {
const position = await locationService.getCurrentPosition();
return locationService.getNearbyPayees({
latitude: position.latitude,
longitude: position.longitude,
});
},
placeholderData: [],
// Manually invalidated when payee locations change
staleTime: Infinity,
}),
};
export const getActivePayees = memoizeOne(