mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
packages/desktop-client/src/hooks/useLocationPermission.ts
Normal file
99
packages/desktop-client/src/hooks/useLocationPermission.ts
Normal 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;
|
||||
}
|
||||
14
packages/desktop-client/src/hooks/useNearbyPayees.ts
Normal file
14
packages/desktop-client/src/hooks/useNearbyPayees.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
97
packages/desktop-client/src/payees/location-adapters.ts
Normal file
97
packages/desktop-client/src/payees/location-adapters.ts
Normal 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 || [];
|
||||
}
|
||||
}
|
||||
206
packages/desktop-client/src/payees/location-integration.test.ts
Normal file
206
packages/desktop-client/src/payees/location-integration.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
92
packages/desktop-client/src/payees/location-service.ts
Normal file
92
packages/desktop-client/src/payees/location-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
packages/desktop-client/src/payees/location.ts
Normal file
10
packages/desktop-client/src/payees/location.ts
Normal 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(),
|
||||
);
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user