feat(places): enrich list-imported places via the Places API (#886) (#1161)

* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
This commit is contained in:
Maurice
2026-06-14 00:54:11 +02:00
committed by GitHub
parent 3398da633b
commit 3e9626fce9
29 changed files with 331 additions and 18 deletions
@@ -0,0 +1,49 @@
/**
* Unit tests for the import-enrichment match selector (#886).
* Covers PENRICH-001 to PENRICH-004 — the coordinate-validation guard that
* prevents a name search from overwriting an imported place with the wrong POI.
*/
import { describe, it, expect, vi } from 'vitest';
// placeEnrichment pulls in the DB, websocket and maps service at import time;
// stub them so the pure match selector can be tested in isolation.
vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null }));
vi.mock('../../../src/websocket', () => ({ broadcast: () => {} }));
vi.mock('../../../src/services/mapsService', () => ({
getMapsKey: () => null,
searchPlaces: async () => ({ places: [], source: 'none' }),
getPlacePhoto: async () => ({ photoUrl: '', attribution: null }),
}));
import { pickEnrichmentMatch } from '../../../src/services/placeEnrichment';
const target = { lat: 48.85, lng: 2.35 };
describe('pickEnrichmentMatch', () => {
it('PENRICH-001: picks the closest Google candidate within the radius', () => {
const candidates = [
{ google_place_id: 'far', lat: 48.8512, lng: 2.3512 }, // ~170 m
{ google_place_id: 'near', lat: 48.85, lng: 2.35 }, // exact
];
const match = pickEnrichmentMatch(candidates, target);
expect(match?.google_place_id).toBe('near');
});
it('PENRICH-002: returns null when every candidate is beyond the radius', () => {
const candidates = [{ google_place_id: 'A', lat: 48.86, lng: 2.36 }]; // ~1.2 km
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
});
it('PENRICH-003: ignores candidates without a google_place_id (e.g. OSM results)', () => {
const candidates = [
{ google_place_id: null, lat: 48.85, lng: 2.35 },
{ name: 'no id', lat: 48.85, lng: 2.35 },
];
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
});
it('PENRICH-004: ignores candidates with non-numeric coordinates', () => {
const candidates = [{ google_place_id: 'A', lat: 'x', lng: 'y' }];
expect(pickEnrichmentMatch(candidates as never, target)).toBeNull();
});
});