Compare commits

...

6 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
35c2623d89 Database 2025-08-07 08:00:23 -07:00
Joel Jeremy Marquez
cbb07ae0af Fix typecheck error 2025-08-06 15:22:41 -07:00
Joel Jeremy Marquez
bcc1f04ee6 Fix typecheck error 2025-08-06 15:22:41 -07:00
Joel Jeremy Marquez
3c8fb3447b isUnmounted flag 2025-08-06 15:22:40 -07:00
Joel Jeremy Marquez
8518ab10ec Fix lint error 2025-08-06 15:22:40 -07:00
Joel Jeremy Marquez
86c6cf98be [Mobile] Suggest nearby payees when entering transactions 2025-08-06 15:22:40 -07:00
13 changed files with 345 additions and 9 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,7 @@ 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 +335,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 { assignPayeesToGeolocation: assignPayeesToLocation } = useNearbyPayees();
const onSave = useCallback(
async newTransactions => {
if (isDeleted.current) {
@@ -1254,8 +1257,10 @@ function TransactionEditUnconnected({
// about
dispatch(setLastTransaction({ transaction: newTransactions[0] }));
}
await assignPayeesToLocation(newTransactions.map(t => t.payee));
},
[dispatch, fetchedTransactions],
[assignPayeesToLocation, dispatch, fetchedTransactions],
);
const onDelete = useCallback(

View File

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

View File

@@ -0,0 +1,124 @@
import { useCallback, useMemo } from 'react';
import { type PayeeEntity } from 'loot-core/types/models';
import { useGeolocation } from './useGeolocation';
import { usePayees } from './usePayees';
import { usePayeeGeolocations } from './usePayeeGeolocations';
import { assignPayeeGeolocation } from '@desktop-client/queries/queriesSlice';
import { useDispatch } from '@desktop-client/redux';
type LatLongCoordinates = Pick<
GeolocationCoordinates,
'latitude' | 'longitude'
>;
export function useNearbyPayees({ thresholdInMeters = 50 } = {}) {
const dispatch = useDispatch();
const { coordinates: currentCoordinates, error } = useGeolocation();
const payees = usePayees();
const payeeGeolocations = usePayeeGeolocations();
console.log('payeeGeolocations', payeeGeolocations);
const payeesWithGeocoordinates = useMemo(
() =>
payees.reduce(
(acc, payee) => {
const payeeCoordinatesList = payeeGeolocations.filter(pg => pg.payee_id === payee.id);
for (const payeeCoordinates of payeeCoordinatesList) {
acc.push({
...payee,
coordinates: payeeCoordinates,
});
}
return acc;
},
[] as (PayeeEntity & { coordinates: LatLongCoordinates })[],
),
[payees, payeeGeolocations],
);
const getPayeesWithinThreshold = useCallback(
(coordinates: LatLongCoordinates | null, 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 payeesWithinThreshold = useMemo(
() => getPayeesWithinThreshold(currentCoordinates, thresholdInMeters),
[currentCoordinates, getPayeesWithinThreshold, thresholdInMeters],
);
const assignPayeesToGeolocation = useCallback(
async (
payeeIds: Array<PayeeEntity['id']>,
coordinates: LatLongCoordinates | null = currentCoordinates,
) => {
if (!coordinates) {
console.warn('Location is not available.');
return;
}
const payeesWithinThresholdSet = new Set(
payeesWithinThreshold.map(p => p.id),
);
for (const payeeId of payeeIds) {
// If current coordinates is within the threshold of any
// existing payee coordinates, skip
if (payeesWithinThresholdSet.has(payeeId)) {
continue;
}
dispatch(assignPayeeGeolocation({
payeeId,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
}));
}
},
[currentCoordinates, payeesWithinThreshold],
);
return useMemo(
() => ({
payees: payeesWithinThreshold,
coordinates: currentCoordinates,
assignPayeesToGeolocation,
error,
}),
[payeesWithinThreshold, currentCoordinates, assignPayeesToGeolocation, error],
);
}
function getDistance(
currentLatLong: LatLongCoordinates | null,
referenceLatLong: LatLongCoordinates | null,
) {
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;
}

View File

@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useInitialMount } from './useInitialMount';
import {
getPayeeGeolocations,
} from '@desktop-client/queries/queriesSlice';
import { useSelector, useDispatch } from '@desktop-client/redux';
export function usePayeeGeolocations() {
const dispatch = useDispatch();
const payeeGeolocationsLoaded = useSelector(state => state.queries.payeeGeolocationsLoaded);
const isInitialMount = useInitialMount();
useEffect(() => {
if (isInitialMount && !payeeGeolocationsLoaded) {
dispatch(getPayeeGeolocations());
}
}, [dispatch, isInitialMount, payeeGeolocationsLoaded]);
return useSelector(state => state.queries.payeeGeolocations);
}

View File

@@ -12,6 +12,7 @@ import {
type AccountEntity,
type PayeeEntity,
type Tag,
type PayeeGeolocationEntity,
} from 'loot-core/types/models';
import { resetApp } from '@desktop-client/app/appSlice';
@@ -43,6 +44,8 @@ type QueriesState = {
payeesLoaded: boolean;
tags: Tag[];
tagsLoaded: boolean;
payeeGeolocations: PayeeGeolocationEntity[];
payeeGeolocationsLoaded: boolean;
};
const initialState: QueriesState = {
@@ -63,6 +66,8 @@ const initialState: QueriesState = {
payeesLoaded: false,
tags: [],
tagsLoaded: false,
payeeGeolocations: [],
payeeGeolocationsLoaded: false,
};
type SetNewTransactionsPayload = {
@@ -193,6 +198,31 @@ const queriesSlice = createSlice({
builder.addCase(findTags.fulfilled, (state, action) => {
state.tags = action.payload;
});
// Payee geolocations
builder.addCase(getPayeeGeolocations.fulfilled, (state, action) => {
state.payeeGeolocations = action.payload;
state.payeeGeolocationsLoaded = true;
});
builder.addCase(assignPayeeGeolocation.fulfilled, (state, action) => {
const index = state.payeeGeolocations.findIndex(
p => p.payee_id === action.meta.arg.payeeId,
);
if (index !== -1) {
state.payeeGeolocations[index] = {
payee_id: action.meta.arg.payeeId,
latitude: action.meta.arg.latitude,
longitude: action.meta.arg.longitude,
};
} else {
state.payeeGeolocations.push({
payee_id: action.meta.arg.payeeId,
latitude: action.meta.arg.latitude,
longitude: action.meta.arg.longitude,
});
}
});
},
});
@@ -452,6 +482,28 @@ export const getPayees = createAppAsyncThunk(
},
);
export const getPayeeGeolocations = createAppAsyncThunk(
`${sliceName}/getPayeeGeolocations`,
async () => {
const payeeGeolocations: PayeeGeolocationEntity[] = await send('payees-geolocation-get');
return payeeGeolocations;
},
);
type AssignPayeeGeolocationPayload = {
payeeId: PayeeEntity['id'];
latitude: number;
longitude: number;
}
export const assignPayeeGeolocation = createAppAsyncThunk(
`${sliceName}/assignPayeeGeolocation`,
async ({ payeeId, latitude, longitude }: AssignPayeeGeolocationPayload) => {
const geolocationId = await send('payees-geolocation-assign', { payeeId, latitude, longitude });
return geolocationId;
},
);
export const getTags = createAppAsyncThunk(`${sliceName}/getTags`, async () => {
const tags: Tag[] = await send('tags-get');
return tags;
@@ -961,6 +1013,8 @@ export const actions = {
moveCategoryGroup,
initiallyLoadPayees,
getTags,
getPayeeGeolocations,
assignPayeeGeolocation,
};
export const {

View File

@@ -0,0 +1,11 @@
BEGIN TRANSACTION;
CREATE TABLE payee_geolocations(
id TEXT PRIMARY KEY,
payee_id TEXT,
latitude REAL,
longitude REAL,
FOREIGN KEY (payee_id) REFERENCES payees(id)
);
COMMIT;

View File

@@ -42,6 +42,7 @@ import {
DbCategoryMapping,
DbClockMessage,
DbPayee,
DbPayeeGeolocation,
DbPayeeMapping,
DbTag,
DbTransaction,
@@ -585,6 +586,19 @@ export function updatePayee(payee: WithRequired<Partial<DbPayee>, 'id'>) {
return update('payees', payee);
}
export async function getPayeeGeolocations(): Promise<DbPayeeGeolocation[]> {
return all<DbPayeeGeolocation>(
'SELECT * FROM payee_geolocations',
);
}
export async function insertPayeeGeolocation(
payeeGeolocation: WithRequired<Partial<DbPayeeGeolocation>, 'payee_id' | 'latitude' | 'longitude'>,
) {
const geolocationId = await insertWithUUID('payee_geolocations', payeeGeolocation);
return geolocationId;
}
export async function mergePayees(
target: DbPayee['id'],
ids: Array<DbPayee['id']>,

View File

@@ -332,3 +332,10 @@ export type DbTag = {
description?: string | null;
tombstone: 1 | 0;
};
export type DbPayeeGeolocation = {
id: string;
payee_id: DbPayee['id'];
latitude: number;
longitude: number;
};

View File

@@ -1,5 +1,5 @@
import { Diff } from '../../shared/util';
import { PayeeEntity, RuleEntity } from '../../types/models';
import { PayeeEntity, PayeeGeolocationEntity, RuleEntity } from '../../types/models';
import { createApp } from '../app';
import * as db from '../db';
import { payeeModel } from '../models';
@@ -18,6 +18,8 @@ export type PayeesHandlers = {
'payees-batch-change': typeof batchChangePayees;
'payees-check-orphaned': typeof checkOrphanedPayees;
'payees-get-rules': typeof getPayeeRules;
'payees-geolocation-get': typeof getPayeeGeolocations;
'payees-geolocation-assign': typeof assignPayeeGeolocation;
};
export const app = createApp<PayeesHandlers>();
@@ -38,6 +40,8 @@ app.method(
app.method('payees-batch-change', mutator(undoable(batchChangePayees)));
app.method('payees-check-orphaned', checkOrphanedPayees);
app.method('payees-get-rules', getPayeeRules);
app.method('payees-geolocation-assign', mutator(undoable(assignPayeeGeolocation)));
app.method('payees-geolocation-get', getPayeeGeolocations);
async function createPayee({ name }: { name: PayeeEntity['name'] }) {
return db.insertPayee({ name });
@@ -124,3 +128,19 @@ async function getPayeeRules({
}): Promise<RuleEntity[]> {
return rules.getRulesForPayee(id).map(rule => rule.serialize());
}
async function getPayeeGeolocations(): Promise<PayeeGeolocationEntity[]> {
return db.getPayeeGeolocations();
}
async function assignPayeeGeolocation({
payeeId,
latitude,
longitude,
}: {
payeeId: PayeeEntity['id'];
latitude: number;
longitude: number;
}): Promise<void> {
await db.insertPayeeGeolocation({ payee_id: payeeId, latitude, longitude });
}

View File

@@ -19,3 +19,4 @@ export type * from './transaction-filter';
export type * from './user';
export type * from './user-access';
export type * from './tags';
export type * from './payee-geolocation';

View File

@@ -0,0 +1,5 @@
export interface PayeeGeolocationEntity {
payee_id: string;
latitude: number;
longitude: number;
}

View File

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