From 35c2623d89b18907c821f6a18f4038ea398c5062 Mon Sep 17 00:00:00 2001 From: Joel Jeremy Marquez Date: Thu, 7 Aug 2025 08:00:23 -0700 Subject: [PATCH] Database --- .../mobile/transactions/TransactionEdit.jsx | 4 +- .../src/hooks/useNearbyPayees.ts | 94 ++++--------------- .../src/hooks/usePayeeGeolocations.ts | 23 +++++ .../src/queries/queriesSlice.ts | 54 +++++++++++ .../1754507423000_payee_geolocations.sql | 11 +++ packages/loot-core/src/server/db/index.ts | 14 +++ .../loot-core/src/server/db/types/index.ts | 7 ++ packages/loot-core/src/server/payees/app.ts | 22 ++++- packages/loot-core/src/types/models/index.ts | 1 + .../src/types/models/payee-geolocation.ts | 5 + 10 files changed, 156 insertions(+), 79 deletions(-) create mode 100644 packages/desktop-client/src/hooks/usePayeeGeolocations.ts create mode 100644 packages/loot-core/migrations/1754507423000_payee_geolocations.sql create mode 100644 packages/loot-core/src/types/models/payee-geolocation.ts diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx index 08bf7c0894..a176f6269e 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx @@ -1224,7 +1224,7 @@ function TransactionEditUnconnected({ [dateFormat, transactions], ); - const { assignPayeesToLocation } = useNearbyPayees(); + const { assignPayeesToGeolocation: assignPayeesToLocation } = useNearbyPayees(); const onSave = useCallback( async newTransactions => { @@ -1258,7 +1258,7 @@ function TransactionEditUnconnected({ dispatch(setLastTransaction({ transaction: newTransactions[0] })); } - assignPayeesToLocation(newTransactions.map(t => t.payee)); + await assignPayeesToLocation(newTransactions.map(t => t.payee)); }, [assignPayeesToLocation, dispatch, fetchedTransactions], ); diff --git a/packages/desktop-client/src/hooks/useNearbyPayees.ts b/packages/desktop-client/src/hooks/useNearbyPayees.ts index 37cd69cc70..b82d632cf1 100644 --- a/packages/desktop-client/src/hooks/useNearbyPayees.ts +++ b/packages/desktop-client/src/hooks/useNearbyPayees.ts @@ -4,78 +4,28 @@ 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' >; -const payeeGeolocations: Array< - Pick & { - geolocation: LatLongCoordinates; - } -> = [ - { - // 2 locations for same payee - id: '263714b9-365d-49ae-9829-3d3fbe8d0216', - geolocation: { - latitude: 49.0677664, - longitude: -122.6936539, - }, - }, - { - id: '263714b9-365d-49ae-9829-3d3fbe8d0216', - geolocation: { - latitude: 49.06827256830727, - longitude: -122.69461576882055, - }, - }, - { - id: 'cf9d7939-95d1-4118-80b1-e1a4eec6ee03', - geolocation: { - latitude: 49.05258819377441, - longitude: -122.69136086574922, - }, - }, - { - id: 'a759f467-74fd-4894-8c87-1bb20a13f6a8', - geolocation: { - latitude: 49.06719263166092, - longitude: -122.6943839011504, - }, - }, - { - id: '771432ea-7249-4fc7-bead-c2bc7e5e2223', - geolocation: { - latitude: 49.0669089090774, - longitude: -122.69341856431981, - }, - }, - { - id: '751762bc-61b7-4c77-a75a-d311af8399c5', - geolocation: { - latitude: 49.06774233955684, - longitude: -122.69761371974859, - }, - }, - { - id: 'b6349c65-a5ea-4c81-b4f5-b4181e520bf9', - geolocation: { - latitude: 49.06839669373029, - longitude: -122.6922249813429, - }, - }, -]; - 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 = getPayeeGeocoordinates(payee.id); + const payeeCoordinatesList = payeeGeolocations.filter(pg => pg.payee_id === payee.id); for (const payeeCoordinates of payeeCoordinatesList) { acc.push({ ...payee, @@ -86,7 +36,7 @@ export function useNearbyPayees({ thresholdInMeters = 50 } = {}) { }, [] as (PayeeEntity & { coordinates: LatLongCoordinates })[], ), - [payees], + [payees, payeeGeolocations], ); const getPayeesWithinThreshold = useCallback( @@ -107,8 +57,8 @@ export function useNearbyPayees({ thresholdInMeters = 50 } = {}) { [currentCoordinates, getPayeesWithinThreshold, thresholdInMeters], ); - const assignPayeesToLocation = useCallback( - ( + const assignPayeesToGeolocation = useCallback( + async ( payeeIds: Array, coordinates: LatLongCoordinates | null = currentCoordinates, ) => { @@ -128,13 +78,11 @@ export function useNearbyPayees({ thresholdInMeters = 50 } = {}) { continue; } - payeeGeolocations.push({ - id: payeeId, - geolocation: { - latitude: coordinates.latitude, - longitude: coordinates.longitude, - }, - }); + dispatch(assignPayeeGeolocation({ + payeeId, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + })); } }, [currentCoordinates, payeesWithinThreshold], @@ -144,10 +92,10 @@ export function useNearbyPayees({ thresholdInMeters = 50 } = {}) { () => ({ payees: payeesWithinThreshold, coordinates: currentCoordinates, - assignPayeesToLocation, + assignPayeesToGeolocation, error, }), - [payeesWithinThreshold, currentCoordinates, assignPayeesToLocation, error], + [payeesWithinThreshold, currentCoordinates, assignPayeesToGeolocation, error], ); } @@ -174,9 +122,3 @@ function getDistance( 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); -} diff --git a/packages/desktop-client/src/hooks/usePayeeGeolocations.ts b/packages/desktop-client/src/hooks/usePayeeGeolocations.ts new file mode 100644 index 0000000000..636eb78383 --- /dev/null +++ b/packages/desktop-client/src/hooks/usePayeeGeolocations.ts @@ -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); +} diff --git a/packages/desktop-client/src/queries/queriesSlice.ts b/packages/desktop-client/src/queries/queriesSlice.ts index b552bedc29..2b403be5d7 100644 --- a/packages/desktop-client/src/queries/queriesSlice.ts +++ b/packages/desktop-client/src/queries/queriesSlice.ts @@ -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 { diff --git a/packages/loot-core/migrations/1754507423000_payee_geolocations.sql b/packages/loot-core/migrations/1754507423000_payee_geolocations.sql new file mode 100644 index 0000000000..d7668402f9 --- /dev/null +++ b/packages/loot-core/migrations/1754507423000_payee_geolocations.sql @@ -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; diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index eabd79effe..d3d322ee74 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -42,6 +42,7 @@ import { DbCategoryMapping, DbClockMessage, DbPayee, + DbPayeeGeolocation, DbPayeeMapping, DbTag, DbTransaction, @@ -585,6 +586,19 @@ export function updatePayee(payee: WithRequired, 'id'>) { return update('payees', payee); } +export async function getPayeeGeolocations(): Promise { + return all( + 'SELECT * FROM payee_geolocations', + ); +} + +export async function insertPayeeGeolocation( + payeeGeolocation: WithRequired, 'payee_id' | 'latitude' | 'longitude'>, +) { + const geolocationId = await insertWithUUID('payee_geolocations', payeeGeolocation); + return geolocationId; +} + export async function mergePayees( target: DbPayee['id'], ids: Array, diff --git a/packages/loot-core/src/server/db/types/index.ts b/packages/loot-core/src/server/db/types/index.ts index a8325a1ec3..eb01baf220 100644 --- a/packages/loot-core/src/server/db/types/index.ts +++ b/packages/loot-core/src/server/db/types/index.ts @@ -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; +}; \ No newline at end of file diff --git a/packages/loot-core/src/server/payees/app.ts b/packages/loot-core/src/server/payees/app.ts index f0fa717598..0a04f8a152 100644 --- a/packages/loot-core/src/server/payees/app.ts +++ b/packages/loot-core/src/server/payees/app.ts @@ -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(); @@ -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 { return rules.getRulesForPayee(id).map(rule => rule.serialize()); } + +async function getPayeeGeolocations(): Promise { + return db.getPayeeGeolocations(); +} + +async function assignPayeeGeolocation({ + payeeId, + latitude, + longitude, +}: { + payeeId: PayeeEntity['id']; + latitude: number; + longitude: number; +}): Promise { + await db.insertPayeeGeolocation({ payee_id: payeeId, latitude, longitude }); +} \ No newline at end of file diff --git a/packages/loot-core/src/types/models/index.ts b/packages/loot-core/src/types/models/index.ts index b08a58c2b7..e29f6b6f80 100644 --- a/packages/loot-core/src/types/models/index.ts +++ b/packages/loot-core/src/types/models/index.ts @@ -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'; diff --git a/packages/loot-core/src/types/models/payee-geolocation.ts b/packages/loot-core/src/types/models/payee-geolocation.ts new file mode 100644 index 0000000000..fc5c9cc333 --- /dev/null +++ b/packages/loot-core/src/types/models/payee-geolocation.ts @@ -0,0 +1,5 @@ +export interface PayeeGeolocationEntity { + payee_id: string; + latitude: number; + longitude: number; +}