Files
actual/packages/desktop-client/src/payees/location-adapters.ts
Dustin Brewer 60e2665fcc MVP for Payee Locations (#6157)
* Phase 1: Add payee locations database schema/types

 * Add migration to create payee_locations table with proper indexes
 * Add PayeeLocationEntity type definition
 * Update database schema to include payee_locations table
 * Export PayeeLocationEntity from models index

* Phase 2: Add payee location API/services

  * Add constants for default location behavior
  * Implement location service with geolocation adapters
  * Add new API handlers for payee location operations

* Phase 3: Add location-aware UI components/hooks

 * Update mobile transaction editing with location integration
 * Enhance PayeeAutocomplete with nearby payee suggestions and forget
   functionality
 * Implement location permission and placeholder unit of measurement hooks

* Phase 4: Add YNAB5 payee location import support

 * Extend YNAB5 types to include location data from payees
 * Implement location import logic in YNAB5 importer

* Phase 5: Add unit of measurement support

 * Add unit of measurement preference setting in Format.tsx
 * Implement distance formatting utilities for imperial/metric units
 * Add useUnitOfMeasurementFormat hook for accessing preferences

* Required release note about the PR

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #6157

* Actually get syncing working

This was not obvious to me, esp. with 13 years of data, but the
locations I've been inserting were local only.

Everything appeared to work.

What I failed to notice is that the locations did not sync
across devices. Of course all the location data that was imported worked
fine, but nothing new unless it was created on device.

This changes the schema and uses the proper insert/delete methods such
that syncing works.

* Remove unit of measurement preference

Display feet and meters automatically, and don't bother to format based on miles/kilometers.

* Add payeeLocations feature flag

Place the location permissions check and thus user-facing functionality behind the feature flag

* Missed adding tombstone to payee location query

* Adjust migration name to pass CI

Adjust the indexes as well

* Unify location.ts

If CodeRabbit complains again, reply that we are actively choosing a unified file

* Add bounds testing

The validation is straightforward range-checking — if it's wrong, it'll be obvious quickly. Unless there's a plan to start adding broader test coverage for that file, I'd leave it untested for now

* Prefer camelCase for the method params

* Fix the nested interactive containers

* Fix the majority of CodeRabbit nits

The remainder seem to not be related to my code change (just a lint), outdated (sql migration comment), or infeasible (sql haversine query)

* More CodeRabbit nits

* Revert unnecessary YNAB5 zip import

Turns out the payee_locations were inside the exported budget all along!

* Additional guards and other CR fixes

* Match the pattern used elsewhere in file

* YNAB5.Budget -> Budget

Missed in the merge conflict

* ci: trigger rerun

* Change import from fetch to connection module

* Correct invalid border property

Ah. I never noticed this property wasn't working. I guess the button
looked OK to me!

* Only hide the button on success

* Update packages/loot-core/src/shared/location-utils.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Update packages/loot-core/src/server/payees/app.ts

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>

* Fully fix typo

Guess I shouldn't commit a suggestion after waking up

* Attempting to address feedback

Manual select nearby payee and save payee location buttons to make the UX more obvi

* Remove stale file that was moved

* Additional cleanup of remnant change

Removed the references to location from a few existing entities

* Additional cleanup of remnant change

* Show the Nearby payees button even when the field is disabled

If there are nearby payees, there's not a payee already selected, and the save button isn't needed

* runQuery is not async

* Add mockNearbyPayeesResult to test

Trying to utilize the real type in the mock

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-03-09 20:26:48 +00:00

98 lines
2.6 KiB
TypeScript

import { send } from 'loot-core/platform/client/connection';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
PayeeLocationEntity,
} from 'loot-core/types/models';
/**
* Abstraction for geolocation functionality
*/
export type GeolocationAdapter = {
getCurrentPosition(options?: PositionOptions): Promise<LocationCoordinates>;
};
/**
* Abstraction for location-related API calls
*/
export type LocationApiClient = {
saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string>;
getLocations(payeeId: string): Promise<PayeeLocationEntity[]>;
deleteLocation(locationId: string): Promise<void>;
getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]>;
};
/**
* Browser implementation of geolocation using the Web Geolocation API
*/
export class BrowserGeolocationAdapter implements GeolocationAdapter {
async getCurrentPosition(
options: PositionOptions = {},
): Promise<LocationCoordinates> {
if (!navigator.geolocation) {
throw new Error('Geolocation is not supported by this browser');
}
const defaultOptions: PositionOptions = {
enableHighAccuracy: true,
timeout: 15000, // 15 second timeout
maximumAge: 60000, // Accept 1-minute-old cached position
};
const position = await new Promise<GeolocationPosition>(
(resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
...defaultOptions,
...options,
});
},
);
return {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
}
}
/**
* Implementation using the existing send function for API calls
*/
export class SendApiLocationClient implements LocationApiClient {
async saveLocation(
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
return await send('payee-location-create', {
payeeId,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
});
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
return await send('payee-locations-get', { payeeId });
}
async deleteLocation(locationId: string): Promise<void> {
await send('payee-location-delete', { id: locationId });
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]> {
const result = await send('payees-get-nearby', {
latitude: coordinates.latitude,
longitude: coordinates.longitude,
maxDistance,
});
return result || [];
}
}