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 { render, screen } from '@testing-library/react';
import type { Screen } from '@testing-library/react'; import type { Screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { vi } from 'vitest'; import { vi } from 'vitest';
import { generateAccount } from 'loot-core/mocks'; 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 { PayeeAutocomplete } from './PayeeAutocomplete';
import type { PayeeAutocompleteProps } from './PayeeAutocomplete'; import type { PayeeAutocompleteProps } from './PayeeAutocomplete';
import { AuthProvider } from '@desktop-client/auth/AuthProvider'; import { AuthProvider } from '@desktop-client/auth/AuthProvider';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks'; import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
import { payeeQueries } from '@desktop-client/payees'; import { payeeQueries } from '@desktop-client/payees';
const PAYEE_SELECTOR = '[data-testid][role=option]'; const PAYEE_SELECTOR = '[data-testid][role=option]';
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]'; const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
const ALL_PAYEE_ITEMS_SELECTOR = '[data-testid$="-payee-item"]';
const payees = [ const payees = [
makePayee('Bob', { favorite: true }), 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'); const autocompleteElement = screen.getByTestId('autocomplete');
// Get all elements that match either selector, but query them separately // Get all elements that match either selector, but query them separately
@@ -49,7 +79,7 @@ function extractPayeesAndHeaderNames(screen: Screen) {
const headers = [ const headers = [
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR), ...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 // Combine all elements and sort by their position in the DOM
const allElements = [...headers, ...items]; const allElements = [...headers, ...items];
@@ -78,14 +108,52 @@ async function clickAutocomplete(autocomplete: HTMLElement) {
await waitForAutocomplete(); await waitForAutocomplete();
} }
vi.mock('@desktop-client/hooks/useNearbyPayees', () => ({
useNearbyPayees: vi.fn(),
}));
function firstOrIncorrect(id: string | null): string { function firstOrIncorrect(id: string | null): string {
return id?.split('-', 1)[0] || 'incorrect'; 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', () => { describe('PayeeAutocomplete.getPayeeSuggestions', () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
beforeEach(() => { beforeEach(() => {
vi.mocked(useNearbyPayees).mockReturnValue(mockNearbyPayeesResult([]));
queryClient.setQueryData(payeeQueries.listCommon().queryKey, []); 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 () => { test('list with no favorites shows just the payees list', async () => {
//Note that the payees list assumes the payees are already sorted //Note that the payees list assumes the payees are already sorted
const payees = [ const payees = [

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore // @ts-strict-ignore
import React, { Fragment, useMemo, useState } from 'react'; import React, { Fragment, useCallback, useMemo, useState } from 'react';
import type { import type {
ComponentProps, ComponentProps,
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
@@ -13,15 +13,24 @@ import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button'; import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive'; 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 { styles } from '@actual-app/components/styles';
import { TextOneLine } from '@actual-app/components/text-one-line'; import { TextOneLine } from '@actual-app/components/text-one-line';
import { theme } from '@actual-app/components/theme'; import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view'; import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { formatDistance } from 'loot-core/shared/location-utils';
import { getNormalisedString } from 'loot-core/shared/normalisation'; 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 { import {
Autocomplete, Autocomplete,
@@ -32,13 +41,19 @@ import { ItemHeader } from './ItemHeader';
import { useAccounts } from '@desktop-client/hooks/useAccounts'; import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useCommonPayees } from '@desktop-client/hooks/useCommonPayees'; import { useCommonPayees } from '@desktop-client/hooks/useCommonPayees';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees'; import { usePayees } from '@desktop-client/hooks/usePayees';
import { import {
getActivePayees, getActivePayees,
useCreatePayeeMutation, useCreatePayeeMutation,
useDeletePayeeLocationMutation,
} from '@desktop-client/payees'; } from '@desktop-client/payees';
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType; type PayeeAutocompleteItem = PayeeEntity &
PayeeItemType & {
nearbyLocationId?: string;
distance?: number;
};
const MAX_AUTO_SUGGESTIONS = 5; const MAX_AUTO_SUGGESTIONS = 5;
@@ -130,17 +145,25 @@ type PayeeListProps = {
props: ComponentPropsWithoutRef<typeof PayeeItem>, props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactNode; ) => ReactNode;
footer: ReactNode; footer: ReactNode;
onForgetLocation?: (locationId: string) => void;
}; };
type ItemTypes = 'account' | 'payee' | 'common_payee'; type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
type PayeeItemType = { type PayeeItemType = {
itemType: ItemTypes; itemType: ItemTypes;
}; };
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes { function determineItemType(
item: PayeeEntity,
isCommon: boolean,
isNearby: boolean = false,
): ItemTypes {
if (item.transfer_acct) { if (item.transfer_acct) {
return 'account'; return 'account';
} }
if (isNearby) {
return 'nearby_payee';
}
if (isCommon) { if (isCommon) {
return 'common_payee'; return 'common_payee';
} else { } else {
@@ -158,6 +181,7 @@ function PayeeList({
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader, renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
renderPayeeItem = defaultRenderPayeeItem, renderPayeeItem = defaultRenderPayeeItem,
footer, footer,
onForgetLocation,
}: PayeeListProps) { }: PayeeListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -165,56 +189,66 @@ function PayeeList({
// with the value of the input so it always shows whatever the user // with the value of the input so it always shows whatever the user
// entered // entered
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => { const { newPayee, suggestedPayees, payees, transferPayees, nearbyPayees } =
let currentIndex = 0; useMemo(() => {
const result = items.reduce( let currentIndex = 0;
(acc, item) => { const result = items.reduce(
if (item.id === 'new') { (acc, item) => {
acc.newPayee = { ...item }; if (item.id === 'new') {
} else if (item.itemType === 'common_payee') { acc.newPayee = { ...item };
acc.suggestedPayees.push({ ...item }); } else if (item.itemType === 'common_payee') {
} else if (item.itemType === 'payee') { acc.suggestedPayees.push({ ...item });
acc.payees.push({ ...item }); } else if (item.itemType === 'payee') {
} else if (item.itemType === 'account') { acc.payees.push({ ...item });
acc.transferPayees.push({ ...item }); } else if (item.itemType === 'account') {
} acc.transferPayees.push({ ...item });
return acc; } else if (item.itemType === 'nearby_payee') {
}, acc.nearbyPayees.push({ ...item });
{ }
newPayee: null as PayeeAutocompleteItem | null, return acc;
suggestedPayees: [] as Array<PayeeAutocompleteItem>, },
payees: [] as Array<PayeeAutocompleteItem>, {
transferPayees: [] as Array<PayeeAutocompleteItem>, 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 // assign indexes in render order
const newPayeeWithIndex = result.newPayee const newPayeeWithIndex = result.newPayee
? { ...result.newPayee, highlightedIndex: currentIndex++ } ? { ...result.newPayee, highlightedIndex: currentIndex++ }
: null; : null;
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({ const nearbyPayeesWithIndex = result.nearbyPayees.map(item => ({
...item, ...item,
highlightedIndex: currentIndex++, highlightedIndex: currentIndex++,
})); }));
const payeesWithIndex = result.payees.map(item => ({ const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
...item, ...item,
highlightedIndex: currentIndex++, highlightedIndex: currentIndex++,
})); }));
const transferPayeesWithIndex = result.transferPayees.map(item => ({ const payeesWithIndex = result.payees.map(item => ({
...item, ...item,
highlightedIndex: currentIndex++, highlightedIndex: currentIndex++,
})); }));
return { const transferPayeesWithIndex = result.transferPayees.map(item => ({
newPayee: newPayeeWithIndex, ...item,
suggestedPayees: suggestedPayeesWithIndex, highlightedIndex: currentIndex++,
payees: payeesWithIndex, }));
transferPayees: transferPayeesWithIndex,
}; return {
}, [items]); newPayee: newPayeeWithIndex,
nearbyPayees: nearbyPayeesWithIndex,
suggestedPayees: suggestedPayeesWithIndex,
payees: payeesWithIndex,
transferPayees: transferPayeesWithIndex,
};
}, [items]);
// We limit the number of payees shown to 100. // We limit the number of payees shown to 100.
// So we show a hint that more are available via search. // So we show a hint that more are available via search.
@@ -237,6 +271,20 @@ function PayeeList({
embedded, 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 && {suggestedPayees.length > 0 &&
renderPayeeItemGroupHeader({ title: t('Suggested Payees') })} renderPayeeItemGroupHeader({ title: t('Suggested Payees') })}
{suggestedPayees.map(item => ( {suggestedPayees.map(item => (
@@ -324,6 +372,7 @@ export type PayeeAutocompleteProps = ComponentProps<
) => ReactElement<typeof PayeeItem>; ) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[]; accounts?: AccountEntity[];
payees?: PayeeEntity[]; payees?: PayeeEntity[];
nearbyPayees?: NearbyPayeeEntity[];
}; };
export function PayeeAutocomplete({ export function PayeeAutocomplete({
@@ -343,16 +392,22 @@ export function PayeeAutocomplete({
renderPayeeItem = defaultRenderPayeeItem, renderPayeeItem = defaultRenderPayeeItem,
accounts, accounts,
payees, payees,
nearbyPayees,
...props ...props
}: PayeeAutocompleteProps) { }: PayeeAutocompleteProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: commonPayees } = useCommonPayees(); const { data: commonPayees } = useCommonPayees();
const { data: retrievedPayees = [] } = usePayees(); const { data: retrievedPayees = [] } = usePayees();
const { data: retrievedNearbyPayees = [] } = useNearbyPayees();
if (!payees) { if (!payees) {
payees = retrievedPayees; payees = retrievedPayees;
} }
const createPayeeMutation = useCreatePayeeMutation(); const createPayeeMutation = useCreatePayeeMutation();
const deletePayeeLocationMutation = useDeletePayeeLocationMutation();
if (!nearbyPayees) {
nearbyPayees = retrievedNearbyPayees;
}
const { data: cachedAccounts = [] } = useAccounts(); const { data: cachedAccounts = [] } = useAccounts();
if (!accounts) { if (!accounts) {
@@ -392,6 +447,43 @@ export function PayeeAutocomplete({
showInactivePayees, 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) { async function handleSelect(idOrIds, rawInputValue) {
if (!clearOnBlur) { if (!clearOnBlur) {
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue); onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
@@ -480,6 +572,12 @@ export function PayeeAutocomplete({
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))} onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect} onSelect={handleSelect}
getHighlightedIndex={suggestions => { 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) { if (suggestions.length === 0) {
return null; return null;
} else if (suggestions[0].id === 'new') { } else if (suggestions[0].id === 'new') {
@@ -491,7 +589,7 @@ export function PayeeAutocomplete({
filterSuggestions={filterSuggestions} filterSuggestions={filterSuggestions}
renderItems={(items, getItemProps, highlightedIndex, inputValue) => ( renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList <PayeeList
items={items} items={[...filteredNearbyPayees, ...items]}
commonPayees={commonPayees} commonPayees={commonPayees}
getItemProps={getItemProps} getItemProps={getItemProps}
highlightedIndex={highlightedIndex} highlightedIndex={highlightedIndex}
@@ -521,6 +619,7 @@ export function PayeeAutocomplete({
)} )}
</AutocompleteFooter> </AutocompleteFooter>
} }
onForgetLocation={handleForgetLocation}
/> />
)} )}
{...props} {...props}
@@ -698,3 +797,126 @@ function defaultRenderPayeeItem(
): ReactElement<typeof PayeeItem> { ): ReactElement<typeof PayeeItem> {
return <PayeeItem {...props} />; 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> & { type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
rightContent?: ReactNode; rightContent?: ReactNode;
alwaysShowRightContent?: boolean;
textStyle?: CSSProperties; textStyle?: CSSProperties;
}; };
@@ -105,6 +106,7 @@ export function TapField({
children, children,
className, className,
rightContent, rightContent,
alwaysShowRightContent,
textStyle, textStyle,
ref, ref,
...props ...props
@@ -135,7 +137,7 @@ export function TapField({
{value} {value}
</Text> </Text>
)} )}
{!props.isDisabled && rightContent} {(!props.isDisabled || alwaysShowRightContent) && rightContent}
</Button> </Button>
); );
} }

View File

@@ -14,6 +14,7 @@ import { Button } from '@actual-app/components/button';
import { SvgSplit } from '@actual-app/components/icons/v0'; import { SvgSplit } from '@actual-app/components/icons/v0';
import { import {
SvgAdd, SvgAdd,
SvgLocation,
SvgPiggyBank, SvgPiggyBank,
SvgTrash, SvgTrash,
} from '@actual-app/components/icons/v1'; } from '@actual-app/components/icons/v1';
@@ -31,6 +32,8 @@ import {
} from 'date-fns'; } from 'date-fns';
import { send } from 'loot-core/platform/client/connection'; 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 monthUtils from 'loot-core/shared/months';
import * as Platform from 'loot-core/shared/platform'; import * as Platform from 'loot-core/shared/platform';
import { q } from 'loot-core/shared/query'; 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 { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount'; import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useLocationPermission } from '@desktop-client/hooks/useLocationPermission';
import { useNavigate } from '@desktop-client/hooks/useNavigate'; import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees'; import { usePayees } from '@desktop-client/hooks/usePayees';
import { import {
SingleActiveEditFormProvider, SingleActiveEditFormProvider,
@@ -88,6 +93,8 @@ import {
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref'; import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice'; import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice'; 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 { aqlQuery } from '@desktop-client/queries/aqlQuery';
import { useDispatch, useSelector } from '@desktop-client/redux'; import { useDispatch, useSelector } from '@desktop-client/redux';
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice'; import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
@@ -554,6 +561,10 @@ type TransactionEditInnerProps = {
onDelete: (id: TransactionEntity['id']) => void; onDelete: (id: TransactionEntity['id']) => void;
onSplit: (id: TransactionEntity['id']) => void; onSplit: (id: TransactionEntity['id']) => void;
onAddSplit: (id: TransactionEntity['id']) => void; onAddSplit: (id: TransactionEntity['id']) => void;
shouldShowSaveLocation?: boolean;
onSaveLocation?: () => void;
onSelectNearestPayee?: () => void;
nearestPayee?: PayeeEntity | null;
}; };
const TransactionEditInner = memo<TransactionEditInnerProps>( const TransactionEditInner = memo<TransactionEditInnerProps>(
@@ -569,6 +580,10 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
onDelete, onDelete,
onSplit, onSplit,
onAddSplit, onAddSplit,
shouldShowSaveLocation,
onSaveLocation,
onSelectNearestPayee,
nearestPayee,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -1090,6 +1105,56 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
} }
onPress={() => onEditFieldInner(transaction.id, 'payee')} onPress={() => onEditFieldInner(transaction.id, 'payee')}
data-testid="payee-field" 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> </View>
@@ -1312,6 +1377,7 @@ function TransactionEditUnconnected({
const { state: locationState } = useLocation(); const { state: locationState } = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const dispatch = useDispatch(); const dispatch = useDispatch();
const updatePayeeLocationMutation = useSavePayeeLocationMutation();
const navigate = useNavigate(); const navigate = useNavigate();
const [transactions, setTransactions] = useState<TransactionEntity[]>([]); const [transactions, setTransactions] = useState<TransactionEntity[]>([]);
const [fetchedTransactions, setFetchedTransactions] = useState< const [fetchedTransactions, setFetchedTransactions] = useState<
@@ -1333,6 +1399,11 @@ function TransactionEditUnconnected({
[payees, searchParams], [payees, searchParams],
); );
const locationAccess = useLocationPermission();
const [shouldShowSaveLocation, setShouldShowSaveLocation] = useState(false);
const { data: nearbyPayees = [] } = useNearbyPayees();
const nearestPayee = nearbyPayees[0]?.payee ?? null;
useEffect(() => { useEffect(() => {
let unmounted = false; let unmounted = false;
@@ -1370,6 +1441,12 @@ function TransactionEditUnconnected({
}; };
}, [transactionId]); }, [transactionId]);
useEffect(() => {
if (!locationAccess) {
setShouldShowSaveLocation(false);
}
}, [locationAccess]);
useEffect(() => { useEffect(() => {
if (isAdding.current) { if (isAdding.current) {
setTransactions([ setTransactions([
@@ -1430,11 +1507,15 @@ function TransactionEditUnconnected({
if (diff) { if (diff) {
Object.keys(diff).forEach(key => { Object.keys(diff).forEach(key => {
const field = key as keyof TransactionEntity; 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 ( if (
newTransaction[field] == null || newTransaction[field] == null ||
newTransaction[field] === '' || newTransaction[field] === '' ||
newTransaction[field] === 0 || newTransaction[field] === 0 ||
newTransaction[field] === false newTransaction[field] === false ||
updatedField === 'payee'
) { ) {
(newTransaction as Record<string, unknown>)[field] = diff[field]; (newTransaction as Record<string, unknown>)[field] = diff[field];
} }
@@ -1463,8 +1544,33 @@ function TransactionEditUnconnected({
newTransaction, newTransaction,
); );
setTransactions(newTransactions); 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( const onSave = useCallback(
@@ -1544,6 +1650,39 @@ function TransactionEditUnconnected({
[transactions], [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) { if (accounts.length === 0) {
return ( return (
<Page <Page
@@ -1669,6 +1808,10 @@ function TransactionEditUnconnected({
onDelete={onDelete} onDelete={onDelete}
onSplit={onSplit} onSplit={onSplit}
onAddSplit={onAddSplit} onAddSplit={onAddSplit}
shouldShowSaveLocation={shouldShowSaveLocation}
onSaveLocation={onSaveLocation}
onSelectNearestPayee={onSelectNearestPayee}
nearestPayee={locationAccess ? nearestPayee : null}
/> />
</View> </View>
); );

View File

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

View File

@@ -11,6 +11,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
crossoverReport: false, crossoverReport: false,
customThemes: false, customThemes: false,
budgetAnalysisReport: false, budgetAnalysisReport: false,
payeeLocations: false,
}; };
export function useFeatureFlag(name: FeatureFlag): boolean { 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 { send } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models'; import type { PayeeEntity } from 'loot-core/types/models';
import { locationService } from './location';
import { payeeQueries } from './queries'; import { payeeQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice'; import { addNotification } from '@desktop-client/notifications/notificationsSlice';
@@ -40,6 +41,57 @@ type CreatePayeePayload = {
name: PayeeEntity['name']; 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() { export function useCreatePayeeMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dispatch = useDispatch(); const dispatch = useDispatch();

View File

@@ -4,7 +4,13 @@ import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/connection'; import { send } from 'loot-core/platform/client/connection';
import { groupById } from 'loot-core/shared/util'; 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'; import { getAccountsById } from '@desktop-client/accounts/accountsSlice';
@@ -54,6 +60,20 @@ export const payeeQueries = {
}, },
placeholderData: new Map(), 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( export const getActivePayees = memoizeOne(

View File

@@ -0,0 +1,21 @@
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS payee_locations (
id TEXT PRIMARY KEY,
payee_id TEXT,
latitude REAL,
longitude REAL,
created_at INTEGER,
tombstone INTEGER DEFAULT 0
);
-- Create index on payee_id for faster lookups
CREATE INDEX IF NOT EXISTS idx_payee_locations_payee_id ON payee_locations (payee_id);
-- Create index on created_at for time-based queries
CREATE INDEX IF NOT EXISTS idx_payee_locations_tombstone_payee_created ON payee_locations (tombstone, payee_id, created_at);
-- Create geospatial composite index with tombstone for location-based queries
CREATE INDEX IF NOT EXISTS idx_payee_locations_geo_tombstone ON payee_locations (tombstone, latitude, longitude);
COMMIT;

View File

@@ -766,6 +766,34 @@ handlers['api/tag-delete'] = withMutation(async function ({ id }) {
await handlers['tags-delete']({ id }); await handlers['tags-delete']({ id });
}); });
handlers['api/payee-location-create'] = withMutation(async function ({
payeeId,
latitude,
longitude,
}) {
checkFileOpen();
return handlers['payee-location-create']({ payeeId, latitude, longitude });
});
handlers['api/payee-locations-get'] = async function ({ payeeId }) {
checkFileOpen();
return handlers['payee-locations-get']({ payeeId });
};
handlers['api/payee-location-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['payee-location-delete']({ id });
});
handlers['api/payees-get-nearby'] = async function ({
latitude,
longitude,
maxDistance,
}) {
checkFileOpen();
return handlers['payees-get-nearby']({ latitude, longitude, maxDistance });
};
handlers['api/rules-get'] = async function () { handlers['api/rules-get'] = async function () {
checkFileOpen(); checkFileOpen();
return handlers['rules-get'](); return handlers['rules-get']();

View File

@@ -198,6 +198,14 @@ export const schema = {
meta: f('json'), meta: f('json'),
tombstone: f('boolean'), tombstone: f('boolean'),
}, },
payee_locations: {
id: f('id'),
payee_id: f('id', { ref: 'payees', required: true }),
latitude: f('float', { required: true }),
longitude: f('float', { required: true }),
created_at: f('integer', { required: true }),
tombstone: f('boolean'),
},
}; };
export const schemaConfig: SchemaConfig = { export const schemaConfig: SchemaConfig = {

View File

@@ -460,6 +460,61 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
); );
} }
async function importPayeeLocations(
data: Budget,
entityIdMap: Map<string, string>,
) {
// If no payee locations data provided, skip import
if (!data?.payee_locations) {
logger.log('No payee locations data provided, skipping...');
return;
}
const payeeLocations = data.payee_locations;
for (const location of payeeLocations) {
// Skip deleted locations
if (location.deleted) {
continue;
}
// Get the mapped payee ID
const actualPayeeId = entityIdMap.get(location.payee_id);
if (!actualPayeeId) {
logger.log(`Skipping location for unknown payee: ${location.payee_id}`);
continue;
}
// Validate latitude/longitude before attempting import
const latitude = parseFloat(location.latitude);
const longitude = parseFloat(location.longitude);
if (isNaN(latitude) || isNaN(longitude)) {
logger.log(
`Skipping location with invalid coordinates for payee ${actualPayeeId}: lat=${location.latitude}, lng=${location.longitude}`,
);
continue;
}
try {
// Create the payee location in Actual
await send('payee-location-create', {
payeeId: actualPayeeId,
latitude,
longitude,
});
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: String(error ?? 'Unknown error');
logger.error(
`Failed to import location for payee ${actualPayeeId} at (${latitude}, ${longitude}): ${errorMessage}`,
);
}
}
}
async function importFlagsAsTags( async function importFlagsAsTags(
data: Budget, data: Budget,
flagNameConflicts: Set<string>, flagNameConflicts: Set<string>,
@@ -1119,6 +1174,9 @@ export async function doImport(data: Budget) {
logger.log('Importing Payees...'); logger.log('Importing Payees...');
await importPayees(data, entityIdMap); await importPayees(data, entityIdMap);
logger.log('Importing Payee Locations...');
await importPayeeLocations(data, entityIdMap);
logger.log('Importing Tags...'); logger.log('Importing Tags...');
await importFlagsAsTags(data, flagNameConflicts); await importFlagsAsTags(data, flagNameConflicts);

View File

@@ -1,5 +1,12 @@
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
import type { Diff } from '../../shared/util'; import type { Diff } from '../../shared/util';
import type { PayeeEntity, RuleEntity } from '../../types/models'; import type {
NearbyPayeeEntity,
PayeeEntity,
PayeeLocationEntity,
RuleEntity,
} from '../../types/models';
import { createApp } from '../app'; import { createApp } from '../app';
import * as db from '../db'; import * as db from '../db';
import { payeeModel } from '../models'; import { payeeModel } from '../models';
@@ -18,6 +25,10 @@ export type PayeesHandlers = {
'payees-batch-change': typeof batchChangePayees; 'payees-batch-change': typeof batchChangePayees;
'payees-check-orphaned': typeof checkOrphanedPayees; 'payees-check-orphaned': typeof checkOrphanedPayees;
'payees-get-rules': typeof getPayeeRules; 'payees-get-rules': typeof getPayeeRules;
'payee-location-create': typeof createPayeeLocation;
'payee-locations-get': typeof getPayeeLocations;
'payee-location-delete': typeof deletePayeeLocation;
'payees-get-nearby': typeof getNearbyPayees;
}; };
export const app = createApp<PayeesHandlers>(); export const app = createApp<PayeesHandlers>();
@@ -38,6 +49,10 @@ app.method(
app.method('payees-batch-change', mutator(undoable(batchChangePayees))); app.method('payees-batch-change', mutator(undoable(batchChangePayees)));
app.method('payees-check-orphaned', checkOrphanedPayees); app.method('payees-check-orphaned', checkOrphanedPayees);
app.method('payees-get-rules', getPayeeRules); app.method('payees-get-rules', getPayeeRules);
app.method('payee-location-create', mutator(createPayeeLocation));
app.method('payee-locations-get', getPayeeLocations);
app.method('payee-location-delete', mutator(deletePayeeLocation));
app.method('payees-get-nearby', getNearbyPayees);
async function createPayee({ name }: { name: PayeeEntity['name'] }) { async function createPayee({ name }: { name: PayeeEntity['name'] }) {
return db.insertPayee({ name }); return db.insertPayee({ name });
@@ -124,3 +139,214 @@ async function getPayeeRules({
}): Promise<RuleEntity[]> { }): Promise<RuleEntity[]> {
return rules.getRulesForPayee(id).map(rule => rule.serialize()); return rules.getRulesForPayee(id).map(rule => rule.serialize());
} }
async function createPayeeLocation({
payeeId,
latitude,
longitude,
}: {
payeeId: PayeeEntity['id'];
latitude: number;
longitude: number;
}): Promise<PayeeLocationEntity['id']> {
const created_at = Date.now();
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
throw new Error(
'Invalid coordinates: latitude must be between -90 and 90, longitude must be between -180 and 180',
);
}
return await db.insertWithUUID('payee_locations', {
payee_id: payeeId,
latitude,
longitude,
created_at,
});
}
async function getPayeeLocations({
payeeId,
}: {
payeeId?: PayeeEntity['id'];
} = {}): Promise<PayeeLocationEntity[]> {
let query = 'SELECT * FROM payee_locations WHERE tombstone IS NOT 1';
let params: string[] = [];
if (payeeId) {
query += ' AND payee_id = ?';
params = [payeeId];
}
query += ' ORDER BY created_at DESC';
return db.runQuery<PayeeLocationEntity>(query, params, true);
}
async function deletePayeeLocation({
id,
}: {
id: PayeeLocationEntity['id'];
}): Promise<void> {
await db.delete_('payee_locations', id);
}
// Type for the raw query result that combines PayeeEntity and PayeeLocationEntity fields
type NearbyPayeeQueryResult = Pick<
db.DbPayee,
| 'id'
| 'name'
| 'transfer_acct'
| 'favorite'
| 'learn_categories'
| 'tombstone'
> &
Omit<PayeeLocationEntity, 'id'> & {
// PayeeLocationEntity's id renamed to location_id
location_id: PayeeLocationEntity['id'];
// Calculated distance from SQL
distance: number;
};
async function getNearbyPayees({
latitude,
longitude,
maxDistance = DEFAULT_MAX_DISTANCE_METERS,
}: {
latitude: number;
longitude: number;
maxDistance?: number;
}): Promise<NearbyPayeeEntity[]> {
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
throw new Error(
'Invalid coordinates: latitude must be between -90 and 90, longitude must be between -180 and 180',
);
}
if (!Number.isFinite(maxDistance) || maxDistance <= 0) {
throw new Error(
'Invalid maxDistance: must be a finite positive number greater than 0',
);
}
// Get the closest location for each payee within maxDistance using window functions
const query = `
WITH payee_distances AS (
SELECT
pl.id as location_id,
pl.payee_id,
pl.latitude,
pl.longitude,
pl.created_at,
p.id,
p.name,
p.transfer_acct,
p.favorite,
p.learn_categories,
p.tombstone,
-- Haversine formula to calculate distance
((6371 * acos(
MIN(1, MAX(-1,
cos(radians(?)) * cos(radians(pl.latitude)) *
cos(radians(pl.longitude) - radians(?)) +
sin(radians(?)) * sin(radians(pl.latitude))
))
))) * 1000 as distance,
-- Rank locations by distance for each payee
ROW_NUMBER() OVER (PARTITION BY pl.payee_id ORDER BY (
(6371 * acos(
MIN(1, MAX(-1,
cos(radians(?)) * cos(radians(pl.latitude)) *
cos(radians(pl.longitude) - radians(?)) +
sin(radians(?)) * sin(radians(pl.latitude))
))
)) * 1000
)) as distance_rank
FROM payee_locations pl
JOIN payees p ON pl.payee_id = p.id
WHERE p.tombstone IS NOT 1
AND pl.tombstone IS NOT 1
-- Filter by distance using Haversine formula
AND (6371 * acos(
MIN(1, MAX(-1,
cos(radians(?)) * cos(radians(pl.latitude)) *
cos(radians(pl.longitude) - radians(?)) +
sin(radians(?)) * sin(radians(pl.latitude))
))
)) * 1000 <= ?
)
SELECT
location_id,
payee_id,
latitude,
longitude,
created_at,
id,
name,
transfer_acct,
favorite,
learn_categories,
tombstone,
distance
FROM payee_distances
WHERE distance_rank = 1
ORDER BY distance ASC
LIMIT 10
`;
const results = db.runQuery<NearbyPayeeQueryResult>(
query,
[
latitude,
longitude,
latitude, // For first distance calculation in SELECT
latitude,
longitude,
latitude, // For ROW_NUMBER() ordering
latitude,
longitude,
latitude, // For WHERE distance filter
maxDistance,
],
true,
);
// Transform results to expected format
const nearbyPayees: NearbyPayeeEntity[] = results.map(row => {
const payee = payeeModel.fromDb({
id: row.id,
name: row.name,
transfer_acct: row.transfer_acct,
favorite: row.favorite,
learn_categories: row.learn_categories,
tombstone: row.tombstone,
});
return {
payee,
location: {
id: row.location_id,
payee_id: row.payee_id,
latitude: row.latitude,
longitude: row.longitude,
created_at: row.created_at,
distance: row.distance,
},
};
});
return nearbyPayees;
}

View File

@@ -0,0 +1,5 @@
/**
* Default maximum distance (in meters) for nearby payee lookups.
* Payees with locations beyond this distance are not considered "nearby".
*/
export const DEFAULT_MAX_DISTANCE_METERS = 500;

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { calculateDistance, formatDistance } from './location-utils';
describe('Location Utils', () => {
describe('calculateDistance', () => {
it('calculates distance between same location as 0', () => {
const pos = { latitude: 40.7128, longitude: -74.006 };
const distance = calculateDistance(pos, pos);
expect(distance).toBe(0);
});
it('calculates distance between known coordinates accurately', () => {
// NYC to Philadelphia (approximately 129 km)
const nyc = { latitude: 40.7128, longitude: -74.006 };
const philly = { latitude: 39.9526, longitude: -75.1652 };
const distance = calculateDistance(nyc, philly);
// Should be approximately 129,000meters (allow 5% variance for rounding)
expect(distance).toBeGreaterThan(122000);
expect(distance).toBeLessThan(136000);
});
it('calculates short distances accurately', () => {
// Two points very close together (about 100m apart in NYC)
const pos1 = { latitude: 40.7128, longitude: -74.006 };
const pos2 = { latitude: 40.7137, longitude: -74.0068 }; // ~100m north
const distance = calculateDistance(pos1, pos2);
// Should be approximately 100meters (allow reasonable variance for coord precision)
expect(distance).toBeGreaterThan(90);
expect(distance).toBeLessThan(130);
});
it('handles cross-equator distances', () => {
const northPole = { latitude: 89.0, longitude: 0 };
const southPole = { latitude: -89.0, longitude: 0 };
const distance = calculateDistance(northPole, southPole);
// Should be very large (close to half Earth's circumference)
expect(distance).toBeGreaterThan(19000000); // > 19,000 km
});
it('handles cross-meridian distances', () => {
const nearAntimeridianEast = { latitude: 51.5074, longitude: 179 };
const nearAntimeridianWest = { latitude: 51.5074, longitude: -179 };
const distance = calculateDistance(
nearAntimeridianEast,
nearAntimeridianWest,
);
// Should be a reasonable distance, not the long way around
expect(distance).toBeGreaterThan(0);
expect(distance).toBeLessThan(1000000); // < 1000 km
});
});
describe('formatDistance', () => {
it('formats feet/meters correctly', () => {
expect(formatDistance(0)).toBe('0ft | 0m');
expect(formatDistance(0.9)).toBe('3ft | 1m');
expect(formatDistance(50)).toBe('164ft | 50m');
expect(formatDistance(500)).toBe('1640ft | 500m');
expect(formatDistance(1000)).toBe('3281ft | 1000m');
expect(formatDistance(1500)).toBe('4921ft | 1500m');
expect(formatDistance(2500)).toBe('8202ft | 2500m');
});
});
});

View File

@@ -0,0 +1,49 @@
/**
* Pure utility functions for location calculations and formatting.
* These functions have no side effects and can be easily tested.
*/
const metersToFeet = 3.28084;
export type LocationCoordinates = {
latitude: number;
longitude: number;
};
/**
* Calculate the distance between two geographic coordinates using the Haversine formula
* @param pos1 First position coordinates
* @param pos2 Second position coordinates
* @returns Distance in meters
*/
export function calculateDistance(
pos1: LocationCoordinates,
pos2: LocationCoordinates,
): number {
const R = 6371e3; // Earth's radius in meters
const phi1 = (pos1.latitude * Math.PI) / 180;
const phi2 = (pos2.latitude * Math.PI) / 180;
const deltaPhi = ((pos2.latitude - pos1.latitude) * Math.PI) / 180;
const deltaLambda = ((pos2.longitude - pos1.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) *
Math.cos(phi2) *
Math.sin(deltaLambda / 2) *
Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
/**
* Format a distance in meters to a human-readable string
* @param meters Distance in meters
* @returns Formatted distance string
*/
export function formatDistance(meters: number): string {
const distanceImperial = `${Math.round(meters * metersToFeet)}ft`;
const distanceMetric = `${Math.round(meters)}m`;
return `${distanceImperial} | ${distanceMetric}`;
}

View File

@@ -15,7 +15,9 @@ import type { QueryState } from '../shared/query';
import type { import type {
ImportTransactionEntity, ImportTransactionEntity,
NearbyPayeeEntity,
NewRuleEntity, NewRuleEntity,
PayeeLocationEntity,
RuleEntity, RuleEntity,
ScheduleEntity, ScheduleEntity,
TransactionEntity, TransactionEntity,
@@ -232,6 +234,24 @@ export type ApiHandlers = {
'api/tag-delete': (arg: { id: APITagEntity['id'] }) => Promise<void>; 'api/tag-delete': (arg: { id: APITagEntity['id'] }) => Promise<void>;
'api/payee-location-create': (arg: {
payeeId: string;
latitude: number;
longitude: number;
}) => Promise<string>;
'api/payee-locations-get': (arg: {
payeeId: string;
}) => Promise<PayeeLocationEntity[]>;
'api/payee-location-delete': (arg: { id: string }) => Promise<void>;
'api/payees-get-nearby': (arg: {
latitude: number;
longitude: number;
maxDistance?: number;
}) => Promise<NearbyPayeeEntity[]>;
'api/rules-get': () => Promise<RuleEntity[]>; 'api/rules-get': () => Promise<RuleEntity[]>;
'api/payee-rules-get': (arg: { 'api/payee-rules-get': (arg: {

View File

@@ -6,9 +6,11 @@ export type * from './category-group';
export type * from './dashboard'; export type * from './dashboard';
export type * from './gocardless'; export type * from './gocardless';
export type * from './import-transaction'; export type * from './import-transaction';
export type * from './nearby-payee';
export type * from './note'; export type * from './note';
export type * from './openid'; export type * from './openid';
export type * from './payee'; export type * from './payee';
export type * from './payee-location';
export type * from './pluggyai'; export type * from './pluggyai';
export type * from './reports'; export type * from './reports';
export type * from './rule'; export type * from './rule';

View File

@@ -0,0 +1,7 @@
import type { PayeeEntity } from './payee';
import type { PayeeLocationEntity } from './payee-location';
export type NearbyPayeeEntity = {
payee: PayeeEntity;
location: PayeeLocationEntity;
};

View File

@@ -0,0 +1,8 @@
export type PayeeLocationEntity = {
id: string;
payee_id: string;
latitude: number;
longitude: number;
created_at: number;
distance?: number;
};

View File

@@ -6,7 +6,8 @@ export type FeatureFlag =
| 'currency' | 'currency'
| 'crossoverReport' | 'crossoverReport'
| 'customThemes' | 'customThemes'
| 'budgetAnalysisReport'; | 'budgetAnalysisReport'
| 'payeeLocations';
/** /**
* Cross-device preferences. These sync across devices when they are changed. * Cross-device preferences. These sync across devices when they are changed.

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [mannkind]
---
MVP for payee locations support, including YNAB5 import