[Mobile] Suggest nearby payees when entering transactions

This commit is contained in:
Joel Jeremy Marquez
2025-05-05 22:47:56 -07:00
parent 63604c1161
commit 86c6cf98be
5 changed files with 269 additions and 8 deletions

View File

@@ -33,6 +33,7 @@ import {
import { ItemHeader } from './ItemHeader';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { useCommonPayees, usePayees } from '@desktop-client/hooks/usePayees';
import {
createPayee,
@@ -45,9 +46,16 @@ export type PayeeAutocompleteItem = PayeeEntity;
const MAX_AUTO_SUGGESTIONS = 5;
function getPayeeSuggestions(
nearbyPayees: PayeeAutocompleteItem[],
commonPayees: PayeeAutocompleteItem[],
payees: PayeeAutocompleteItem[],
): (PayeeAutocompleteItem & PayeeItemType)[] {
const nearbyPayeeSuggestions: (PayeeAutocompleteItem & PayeeItemType)[] =
nearbyPayees.map(payee => ({
...payee,
itemType: determineItemType(payee, false, true),
}));
const favoritePayees = payees
.filter(p => p.favorite)
.map(p => {
@@ -75,14 +83,17 @@ function getPayeeSuggestions(
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
.filter(p => !additionalCommonPayees.find(fp => fp.id === p.id))
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
return { ...p, itemType: determineItemType(p, false) };
return { ...p, itemType: determineItemType(p) };
});
return favoritePayees.concat(additionalCommonPayees).concat(filteredPayees);
return nearbyPayeeSuggestions
.concat(favoritePayees)
.concat(additionalCommonPayees)
.concat(filteredPayees);
}
return payees.map(p => {
return { ...p, itemType: determineItemType(p, false) };
return { ...p, itemType: determineItemType(p) };
});
}
@@ -137,20 +148,23 @@ type PayeeListProps = {
footer: ReactNode;
};
type ItemTypes = 'account' | 'payee' | 'common_payee';
type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
type PayeeItemType = {
itemType: ItemTypes;
};
function determineItemType(
item: PayeeAutocompleteItem,
isCommon: boolean,
isCommon: boolean = false,
isNearby: boolean = false,
): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
if (isCommon) {
return 'common_payee';
} else if (isNearby) {
return 'nearby_payee';
} else {
return 'payee';
}
@@ -211,6 +225,8 @@ function PayeeList({
title = t('Payees');
} else if (itemType === 'account' && lastType !== itemType) {
title = t('Transfer To/From');
} else if (itemType === 'nearby_payee' && lastType !== itemType) {
title = t('Nearby Payees');
}
const showMoreMessage =
idx === items.length - 1 && items.length > 100;
@@ -296,6 +312,7 @@ export function PayeeAutocomplete({
if (!payees) {
payees = retrievedPayees;
}
const { payees: nearbyPayees } = useNearbyPayees();
const cachedAccounts = useAccounts();
if (!accounts) {
@@ -306,7 +323,11 @@ export function PayeeAutocomplete({
const [rawPayee, setRawPayee] = useState('');
const hasPayeeInput = !!rawPayee;
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(commonPayees, payees);
const suggestions = getPayeeSuggestions(
nearbyPayees,
commonPayees,
payees,
);
const filteredSuggestions = filterActivePayees(
suggestions,
focusTransferPayees,
@@ -318,7 +339,14 @@ export function PayeeAutocomplete({
}
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
}, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]);
}, [
nearbyPayees,
commonPayees,
payees,
focusTransferPayees,
accounts,
hasPayeeInput,
]);
const dispatch = useDispatch();

View File

@@ -71,6 +71,7 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
import { usePayees } from '@desktop-client/hooks/usePayees';
import {
SingleActiveEditFormProvider,
@@ -1223,6 +1224,8 @@ function TransactionEditUnconnected({
[dateFormat, transactions],
);
const { assignPayeesToLocation } = useNearbyPayees();
const onSave = useCallback(
async newTransactions => {
if (isDeleted.current) {
@@ -1254,8 +1257,10 @@ function TransactionEditUnconnected({
// about
dispatch(setLastTransaction({ transaction: newTransactions[0] }));
}
assignPayeesToLocation(newTransactions.map(t => t.payee));
},
[dispatch, fetchedTransactions],
[assignPayeesToLocation, dispatch, fetchedTransactions],
);
const onDelete = useCallback(

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
export function useGeolocation() {
const [coordinates, setCoordinates] = useState<GeolocationCoordinates>(null);
const [error, setError] = useState<string>(null);
useEffect(() => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async position => {
setCoordinates(position.coords);
},
error => {
console.log(
`Error occurred while getting geolocation: ${error.message}`,
);
setError(error.message);
},
);
} else {
setError('Geolocation is not supported by this browser.');
}
}, []);
return {
coordinates,
error,
};
}

View File

@@ -0,0 +1,193 @@
import { useCallback, useMemo } from 'react';
import { type PayeeEntity } from 'loot-core/types/models';
import { useGeolocation } from './useGeolocation';
import { usePayees } from './usePayees';
type LatLongCoordinates = Pick<
GeolocationCoordinates,
'latitude' | 'longitude'
>;
const payeeGeolocations: Array<
Pick<PayeeEntity, 'id' | 'name'> & {
geolocation: LatLongCoordinates;
}
> = [
{
// 2 locations for Starline Windows
id: '263714b9-365d-49ae-9829-3d3fbe8d0216',
name: 'Starline Windows',
geolocation: {
latitude: 49.0677664,
longitude: -122.6936539,
},
},
{
id: '263714b9-365d-49ae-9829-3d3fbe8d0216',
name: 'Starline Windows',
geolocation: {
latitude: 49.06827256830727,
longitude: -122.69461576882055,
},
},
{
id: 'cf9d7939-95d1-4118-80b1-e1a4eec6ee03',
name: 'A&W',
geolocation: {
latitude: 49.05258819377441,
longitude: -122.69136086574922,
},
},
{
id: 'a759f467-74fd-4894-8c87-1bb20a13f6a8',
name: 'Sun Processing Ltd',
geolocation: {
latitude: 49.06719263166092,
longitude: -122.6943839011504,
},
},
{
id: '771432ea-7249-4fc7-bead-c2bc7e5e2223',
name: 'Caddydriver',
geolocation: {
latitude: 49.0669089090774,
longitude: -122.69341856431981,
},
},
{
id: '751762bc-61b7-4c77-a75a-d311af8399c5',
name: 'Super Save',
geolocation: {
latitude: 49.06774233955684,
longitude: -122.69761371974859,
},
},
{
id: 'b6349c65-a5ea-4c81-b4f5-b4181e520bf9',
name: 'Starline Windows Parking',
geolocation: {
latitude: 49.06839669373029,
longitude: -122.6922249813429,
},
},
];
export function useNearbyPayees({ thresholdInMeters = 50 } = {}) {
const { coordinates: currentCoordinates, error } = useGeolocation();
const payees = usePayees();
console.log('Payees:', payees);
const payeesWithGeocoordinates = useMemo(
() =>
payees.reduce(
(acc, payee) => {
const payeeCoordinatesList = getPayeeGeocoordinates(payee.id);
for (const payeeCoordinates of payeeCoordinatesList) {
acc.push({
...payee,
coordinates: payeeCoordinates,
});
}
return acc;
},
[] as (PayeeEntity & { coordinates: LatLongCoordinates })[],
),
[payees],
);
const getPayeesWithinThreshold = useCallback(
(coordinates: LatLongCoordinates, thresholdInMeters: number) =>
payeesWithGeocoordinates
.map(payee => ({
...payee,
distance: getDistance(coordinates, payee.coordinates),
}))
.filter(payee => payee.distance >= 0)
.filter(payee => payee.distance <= thresholdInMeters)
.sort((a, b) => a.distance - b.distance),
[payeesWithGeocoordinates],
);
const assignPayeesToLocation = useCallback(
(
payeeIds: Array<PayeeEntity['id']>,
coordinates: LatLongCoordinates = currentCoordinates,
) => {
if (!currentCoordinates) {
console.warn('Location is not available.');
return;
}
const payeesWithinThreshold = new Set(
getPayeesWithinThreshold(coordinates, thresholdInMeters).map(p => p.id),
);
for (const payeeId of payeeIds) {
// If current coordinates is within the threshold of any
// existing payee coordinates, skip
if (payeesWithinThreshold.has(payeeId)) {
continue;
}
payeeGeolocations.push({
id: payeeId,
name: null,
geolocation: {
latitude: currentCoordinates.latitude,
longitude: currentCoordinates.longitude,
},
});
}
},
[currentCoordinates, getPayeesWithinThreshold, thresholdInMeters],
);
return useMemo(
() => ({
payees: getPayeesWithinThreshold(currentCoordinates, thresholdInMeters),
coordinates: currentCoordinates,
assignPayeesToLocation,
error,
}),
[
getPayeesWithinThreshold,
currentCoordinates,
thresholdInMeters,
assignPayeesToLocation,
error,
],
);
}
function getDistance(
currentLatLong: LatLongCoordinates,
referenceLatLong: LatLongCoordinates,
) {
if (!currentLatLong || !referenceLatLong) {
return -1;
}
const R = 6371000; // Earth's radius in meters
const deltaLat =
((currentLatLong.latitude - referenceLatLong.latitude) * Math.PI) / 180;
const deltaLong =
((currentLatLong.longitude - referenceLatLong.longitude) * Math.PI) / 180;
const a =
Math.sin(deltaLat / 2) ** 2 +
Math.cos((referenceLatLong.latitude * Math.PI) / 180) *
Math.cos((currentLatLong.latitude * Math.PI) / 180) *
Math.sin(deltaLong / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function getPayeeGeocoordinates(payeeId: PayeeEntity['id']) {
return payeeGeolocations
.filter(p => p.id === payeeId)
.map(p => p.geolocation);
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
[Mobile] Suggest nearby payees when entering transactions