From 60e2665fcca0b733116e3a3b5095b32172039da7 Mon Sep 17 00:00:00 2001 From: Dustin Brewer Date: Mon, 9 Mar 2026 13:26:48 -0700 Subject: [PATCH] MVP for Payee Locations (#6157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * Update packages/loot-core/src/server/payees/app.ts Co-authored-by: Matiss Janis Aboltins * 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] Co-authored-by: Matiss Janis Aboltins --- .../autocomplete/PayeeAutocomplete.test.tsx | 176 +++++++++- .../autocomplete/PayeeAutocomplete.tsx | 328 +++++++++++++++--- .../src/components/mobile/MobileForms.tsx | 4 +- .../mobile/transactions/TransactionEdit.tsx | 147 +++++++- .../src/components/settings/Experimental.tsx | 6 + .../src/hooks/useFeatureFlag.ts | 1 + .../src/hooks/useLocationPermission.ts | 99 ++++++ .../src/hooks/useNearbyPayees.ts | 14 + .../src/payees/location-adapters.ts | 97 ++++++ .../src/payees/location-integration.test.ts | 206 +++++++++++ .../src/payees/location-service.ts | 92 +++++ .../desktop-client/src/payees/location.ts | 10 + .../desktop-client/src/payees/mutations.ts | 52 +++ packages/desktop-client/src/payees/queries.ts | 22 +- .../1768872504000_add_payee_locations.sql | 21 ++ packages/loot-core/src/server/api.ts | 28 ++ .../loot-core/src/server/aql/schema/index.ts | 8 + .../loot-core/src/server/importers/ynab5.ts | 58 ++++ packages/loot-core/src/server/payees/app.ts | 228 +++++++++++- packages/loot-core/src/shared/constants.ts | 5 + .../src/shared/location-utils.test.ts | 69 ++++ .../loot-core/src/shared/location-utils.ts | 49 +++ packages/loot-core/src/types/api-handlers.ts | 20 ++ packages/loot-core/src/types/models/index.ts | 2 + .../src/types/models/nearby-payee.ts | 7 + .../src/types/models/payee-location.ts | 8 + packages/loot-core/src/types/prefs.ts | 3 +- upcoming-release-notes/6157.md | 6 + 28 files changed, 1704 insertions(+), 62 deletions(-) create mode 100644 packages/desktop-client/src/hooks/useLocationPermission.ts create mode 100644 packages/desktop-client/src/hooks/useNearbyPayees.ts create mode 100644 packages/desktop-client/src/payees/location-adapters.ts create mode 100644 packages/desktop-client/src/payees/location-integration.test.ts create mode 100644 packages/desktop-client/src/payees/location-service.ts create mode 100644 packages/desktop-client/src/payees/location.ts create mode 100644 packages/loot-core/migrations/1768872504000_add_payee_locations.sql create mode 100644 packages/loot-core/src/shared/constants.ts create mode 100644 packages/loot-core/src/shared/location-utils.test.ts create mode 100644 packages/loot-core/src/shared/location-utils.ts create mode 100644 packages/loot-core/src/types/models/nearby-payee.ts create mode 100644 packages/loot-core/src/types/models/payee-location.ts create mode 100644 upcoming-release-notes/6157.md diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx index 897b9ed6b7..965026137d 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx @@ -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 { + 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 = [ diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index 100f79471e..24461c0479 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -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, ) => 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, - payees: [] as Array, - transferPayees: [] as Array, - }, - ); + 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, + suggestedPayees: [] as Array, + payees: [] as Array, + transferPayees: [] as Array, + }, + ); - // 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 => ( + + + + ))} + {suggestedPayees.length > 0 && renderPayeeItemGroupHeader({ title: t('Suggested Payees') })} {suggestedPayees.map(item => ( @@ -324,6 +372,7 @@ export type PayeeAutocompleteProps = ComponentProps< ) => ReactElement; 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) => ( } + onForgetLocation={handleForgetLocation} /> )} {...props} @@ -698,3 +797,126 @@ function defaultRenderPayeeItem( ): ReactElement { return ; } + +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 = ( + + ); + 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 ( +
+ + {locationId && ( + + )} +
+ ); +} diff --git a/packages/desktop-client/src/components/mobile/MobileForms.tsx b/packages/desktop-client/src/components/mobile/MobileForms.tsx index 4266e30739..a2f7907793 100644 --- a/packages/desktop-client/src/components/mobile/MobileForms.tsx +++ b/packages/desktop-client/src/components/mobile/MobileForms.tsx @@ -79,6 +79,7 @@ InputField.displayName = 'InputField'; type TapFieldProps = ComponentPropsWithRef & { 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} )} - {!props.isDisabled && rightContent} + {(!props.isDisabled || alwaysShowRightContent) && rightContent} ); } diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx index 7b87f7aa74..3833ab3c7c 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.tsx @@ -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( @@ -569,6 +580,10 @@ const TransactionEditInner = memo( onDelete, onSplit, onAddSplit, + shouldShowSaveLocation, + onSaveLocation, + onSelectNearestPayee, + nearestPayee, }) { const { t } = useTranslation(); const navigate = useNavigate(); @@ -1090,6 +1105,56 @@ const TransactionEditInner = memo( } onPress={() => onEditFieldInner(transaction.id, 'payee')} data-testid="payee-field" + alwaysShowRightContent={ + !!nearestPayee && !transaction.payee && !shouldShowSaveLocation + } + rightContent={ + shouldShowSaveLocation ? ( + + ) : nearestPayee && !transaction.payee ? ( + + ) : undefined + } /> @@ -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([]); 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)[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 ( ); diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 3149136aeb..5d5709e53c 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -215,6 +215,12 @@ export function ExperimentalFeatures() { > Budget Analysis Report + + Payee Locations + {showServerPrefs && ( = { crossoverReport: false, customThemes: false, budgetAnalysisReport: false, + payeeLocations: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/desktop-client/src/hooks/useLocationPermission.ts b/packages/desktop-client/src/hooks/useLocationPermission.ts new file mode 100644 index 0000000000..5f968fae44 --- /dev/null +++ b/packages/desktop-client/src/hooks/useLocationPermission.ts @@ -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; +} diff --git a/packages/desktop-client/src/hooks/useNearbyPayees.ts b/packages/desktop-client/src/hooks/useNearbyPayees.ts new file mode 100644 index 0000000000..3b5148ebd4 --- /dev/null +++ b/packages/desktop-client/src/hooks/useNearbyPayees.ts @@ -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, + }); +} diff --git a/packages/desktop-client/src/payees/location-adapters.ts b/packages/desktop-client/src/payees/location-adapters.ts new file mode 100644 index 0000000000..ae86ba4468 --- /dev/null +++ b/packages/desktop-client/src/payees/location-adapters.ts @@ -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; +}; + +/** + * Abstraction for location-related API calls + */ +export type LocationApiClient = { + saveLocation( + payeeId: string, + coordinates: LocationCoordinates, + ): Promise; + getLocations(payeeId: string): Promise; + deleteLocation(locationId: string): Promise; + getNearbyPayees( + coordinates: LocationCoordinates, + maxDistance: number, + ): Promise; +}; + +/** + * Browser implementation of geolocation using the Web Geolocation API + */ +export class BrowserGeolocationAdapter implements GeolocationAdapter { + async getCurrentPosition( + options: PositionOptions = {}, + ): Promise { + 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( + (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 { + return await send('payee-location-create', { + payeeId, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + }); + } + + async getLocations(payeeId: string): Promise { + return await send('payee-locations-get', { payeeId }); + } + + async deleteLocation(locationId: string): Promise { + await send('payee-location-delete', { id: locationId }); + } + + async getNearbyPayees( + coordinates: LocationCoordinates, + maxDistance: number, + ): Promise { + const result = await send('payees-get-nearby', { + latitude: coordinates.latitude, + longitude: coordinates.longitude, + maxDistance, + }); + return result || []; + } +} diff --git a/packages/desktop-client/src/payees/location-integration.test.ts b/packages/desktop-client/src/payees/location-integration.test.ts new file mode 100644 index 0000000000..72bbe3e2ab --- /dev/null +++ b/packages/desktop-client/src/payees/location-integration.test.ts @@ -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 { + 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 { + this.saveLocationCalls.push({ payeeId, coordinates }); + if (this.shouldThrowOnSave) { + throw new Error('Save failed'); + } + return this.mockLocationId; + } + + async getLocations(payeeId: string): Promise { + this.getLocationsCalls.push(payeeId); + return this.mockLocations.filter(loc => loc.payee_id === payeeId); + } + + async deleteLocation(locationId: string): Promise { + this.deleteLocationCalls.push(locationId); + } + + async getNearbyPayees(): Promise { + 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 }); + }); + }); +}); diff --git a/packages/desktop-client/src/payees/location-service.ts b/packages/desktop-client/src/payees/location-service.ts new file mode 100644 index 0000000000..8f79851467 --- /dev/null +++ b/packages/desktop-client/src/payees/location-service.ts @@ -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 { + // 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 { + 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 { + try { + return await this.apiClient.getLocations(payeeId); + } catch (error) { + console.error('Failed to get payee locations:', error); + throw error; + } + } + + async deletePayeeLocation(locationId: string): Promise { + 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 { + 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; + } +} diff --git a/packages/desktop-client/src/payees/location.ts b/packages/desktop-client/src/payees/location.ts new file mode 100644 index 0000000000..33ec2b0b69 --- /dev/null +++ b/packages/desktop-client/src/payees/location.ts @@ -0,0 +1,10 @@ +import { + BrowserGeolocationAdapter, + SendApiLocationClient, +} from './location-adapters'; +import { LocationService } from './location-service'; + +export const locationService = new LocationService( + new BrowserGeolocationAdapter(), + new SendApiLocationClient(), +); diff --git a/packages/desktop-client/src/payees/mutations.ts b/packages/desktop-client/src/payees/mutations.ts index 46a3d73b31..23ddca1ad3 100644 --- a/packages/desktop-client/src/payees/mutations.ts +++ b/packages/desktop-client/src/payees/mutations.ts @@ -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(); diff --git a/packages/desktop-client/src/payees/queries.ts b/packages/desktop-client/src/payees/queries.ts index 3b4c497c9e..eeef259bd4 100644 --- a/packages/desktop-client/src/payees/queries.ts +++ b/packages/desktop-client/src/payees/queries.ts @@ -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({ + 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( diff --git a/packages/loot-core/migrations/1768872504000_add_payee_locations.sql b/packages/loot-core/migrations/1768872504000_add_payee_locations.sql new file mode 100644 index 0000000000..06f01a81c5 --- /dev/null +++ b/packages/loot-core/migrations/1768872504000_add_payee_locations.sql @@ -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; diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 4881d7a2a7..f5fa407b47 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -766,6 +766,34 @@ handlers['api/tag-delete'] = withMutation(async function ({ 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 () { checkFileOpen(); return handlers['rules-get'](); diff --git a/packages/loot-core/src/server/aql/schema/index.ts b/packages/loot-core/src/server/aql/schema/index.ts index cf86196bd5..742f600368 100644 --- a/packages/loot-core/src/server/aql/schema/index.ts +++ b/packages/loot-core/src/server/aql/schema/index.ts @@ -198,6 +198,14 @@ export const schema = { meta: f('json'), 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 = { diff --git a/packages/loot-core/src/server/importers/ynab5.ts b/packages/loot-core/src/server/importers/ynab5.ts index e3a98baf48..9fc7676b66 100644 --- a/packages/loot-core/src/server/importers/ynab5.ts +++ b/packages/loot-core/src/server/importers/ynab5.ts @@ -460,6 +460,61 @@ function importPayees(data: Budget, entityIdMap: Map) { ); } +async function importPayeeLocations( + data: Budget, + entityIdMap: Map, +) { + // 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( data: Budget, flagNameConflicts: Set, @@ -1119,6 +1174,9 @@ export async function doImport(data: Budget) { logger.log('Importing Payees...'); await importPayees(data, entityIdMap); + logger.log('Importing Payee Locations...'); + await importPayeeLocations(data, entityIdMap); + logger.log('Importing Tags...'); await importFlagsAsTags(data, flagNameConflicts); diff --git a/packages/loot-core/src/server/payees/app.ts b/packages/loot-core/src/server/payees/app.ts index 8a4303d637..d73c73e70d 100644 --- a/packages/loot-core/src/server/payees/app.ts +++ b/packages/loot-core/src/server/payees/app.ts @@ -1,5 +1,12 @@ +import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants'; + 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 * as db from '../db'; import { payeeModel } from '../models'; @@ -18,6 +25,10 @@ export type PayeesHandlers = { 'payees-batch-change': typeof batchChangePayees; 'payees-check-orphaned': typeof checkOrphanedPayees; '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(); @@ -38,6 +49,10 @@ app.method( app.method('payees-batch-change', mutator(undoable(batchChangePayees))); app.method('payees-check-orphaned', checkOrphanedPayees); 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'] }) { return db.insertPayee({ name }); @@ -124,3 +139,214 @@ async function getPayeeRules({ }): Promise { return rules.getRulesForPayee(id).map(rule => rule.serialize()); } + +async function createPayeeLocation({ + payeeId, + latitude, + longitude, +}: { + payeeId: PayeeEntity['id']; + latitude: number; + longitude: number; +}): Promise { + 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 { + 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(query, params, true); +} + +async function deletePayeeLocation({ + id, +}: { + id: PayeeLocationEntity['id']; +}): Promise { + 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'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 { + 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( + 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; +} diff --git a/packages/loot-core/src/shared/constants.ts b/packages/loot-core/src/shared/constants.ts new file mode 100644 index 0000000000..aaaaa6bc35 --- /dev/null +++ b/packages/loot-core/src/shared/constants.ts @@ -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; diff --git a/packages/loot-core/src/shared/location-utils.test.ts b/packages/loot-core/src/shared/location-utils.test.ts new file mode 100644 index 0000000000..e8ba571486 --- /dev/null +++ b/packages/loot-core/src/shared/location-utils.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/loot-core/src/shared/location-utils.ts b/packages/loot-core/src/shared/location-utils.ts new file mode 100644 index 0000000000..d689634200 --- /dev/null +++ b/packages/loot-core/src/shared/location-utils.ts @@ -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}`; +} diff --git a/packages/loot-core/src/types/api-handlers.ts b/packages/loot-core/src/types/api-handlers.ts index 294e8e2afb..1545b18f10 100644 --- a/packages/loot-core/src/types/api-handlers.ts +++ b/packages/loot-core/src/types/api-handlers.ts @@ -15,7 +15,9 @@ import type { QueryState } from '../shared/query'; import type { ImportTransactionEntity, + NearbyPayeeEntity, NewRuleEntity, + PayeeLocationEntity, RuleEntity, ScheduleEntity, TransactionEntity, @@ -232,6 +234,24 @@ export type ApiHandlers = { 'api/tag-delete': (arg: { id: APITagEntity['id'] }) => Promise; + 'api/payee-location-create': (arg: { + payeeId: string; + latitude: number; + longitude: number; + }) => Promise; + + 'api/payee-locations-get': (arg: { + payeeId: string; + }) => Promise; + + 'api/payee-location-delete': (arg: { id: string }) => Promise; + + 'api/payees-get-nearby': (arg: { + latitude: number; + longitude: number; + maxDistance?: number; + }) => Promise; + 'api/rules-get': () => Promise; 'api/payee-rules-get': (arg: { diff --git a/packages/loot-core/src/types/models/index.ts b/packages/loot-core/src/types/models/index.ts index b08a58c2b7..ff1867ba89 100644 --- a/packages/loot-core/src/types/models/index.ts +++ b/packages/loot-core/src/types/models/index.ts @@ -6,9 +6,11 @@ export type * from './category-group'; export type * from './dashboard'; export type * from './gocardless'; export type * from './import-transaction'; +export type * from './nearby-payee'; export type * from './note'; export type * from './openid'; export type * from './payee'; +export type * from './payee-location'; export type * from './pluggyai'; export type * from './reports'; export type * from './rule'; diff --git a/packages/loot-core/src/types/models/nearby-payee.ts b/packages/loot-core/src/types/models/nearby-payee.ts new file mode 100644 index 0000000000..98a2c4edd8 --- /dev/null +++ b/packages/loot-core/src/types/models/nearby-payee.ts @@ -0,0 +1,7 @@ +import type { PayeeEntity } from './payee'; +import type { PayeeLocationEntity } from './payee-location'; + +export type NearbyPayeeEntity = { + payee: PayeeEntity; + location: PayeeLocationEntity; +}; diff --git a/packages/loot-core/src/types/models/payee-location.ts b/packages/loot-core/src/types/models/payee-location.ts new file mode 100644 index 0000000000..d1055dd405 --- /dev/null +++ b/packages/loot-core/src/types/models/payee-location.ts @@ -0,0 +1,8 @@ +export type PayeeLocationEntity = { + id: string; + payee_id: string; + latitude: number; + longitude: number; + created_at: number; + distance?: number; +}; diff --git a/packages/loot-core/src/types/prefs.ts b/packages/loot-core/src/types/prefs.ts index 6134067991..1969d5f39e 100644 --- a/packages/loot-core/src/types/prefs.ts +++ b/packages/loot-core/src/types/prefs.ts @@ -6,7 +6,8 @@ export type FeatureFlag = | 'currency' | 'crossoverReport' | 'customThemes' - | 'budgetAnalysisReport'; + | 'budgetAnalysisReport' + | 'payeeLocations'; /** * Cross-device preferences. These sync across devices when they are changed. diff --git a/upcoming-release-notes/6157.md b/upcoming-release-notes/6157.md new file mode 100644 index 0000000000..a3d2d3febc --- /dev/null +++ b/upcoming-release-notes/6157.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [mannkind] +--- + +MVP for payee locations support, including YNAB5 import