From b1fa55dbca887d8d482e751610a48d873c0b8d3e Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 28 Apr 2026 16:24:15 +0200 Subject: [PATCH] feat(places): surface geocoding privacy notices in autocomplete UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mana-geocoding wrapper now returns `notice: 'fallback_used' | 'sensitive_local_unavailable'` alongside results so the UI can show the user *why* a query had unusual behavior. This commit wires that all the way through the Places module's address-autocomplete inputs. Geocoding client (lib/geocoding/index.ts): - Add `GeocodingNotice` and `SearchOutcome` types - Add `searchAddressDetailed` and `reverseGeocodeDetailed` — same semantics as the existing functions but return the wrapper's provider/notice metadata. Existing `searchAddress`/`reverseGeocode` stay backward-compatible (they call the detailed variants under the hood and discard the metadata). - Extend GeocodingResult with optional `provider` field. Places ListView (the only current consumer that exposes typed addresses to users): - Both autocomplete inputs (tracking-edit + main address-search) now use searchAddressDetailed and surface notices inline. - 'sensitive_local_unavailable' renders an amber explainer block in the dropdown — title + body — so the user knows why their medical query returned 0 hits without leaking the search to a public API. - 'fallback_used' renders a small "≈ ungefähr" footer badge so users understand the result came from public OSM (less precise but still valid). - The dropdown opens when EITHER results exist OR a notice is present — sensitive blocked queries with empty results still surface their explainer. i18n: new `places.geocoding_notice.*` sub-namespace in all 5 locales (de/en/es/fr/it) — 4 strings each. All validators green. Other consumers (places DetailView, events, photos, contacts) keep the existing searchAddress/reverseGeocode calls — they don't need the privacy notices today and would just add noise. They can adopt the detailed variant if/when the use case warrants it. --- apps/mana/apps/web/src/lib/geocoding/index.ts | 90 +++++++++++++++-- .../web/src/lib/i18n/locales/places/de.json | 6 ++ .../web/src/lib/i18n/locales/places/en.json | 6 ++ .../web/src/lib/i18n/locales/places/es.json | 6 ++ .../web/src/lib/i18n/locales/places/fr.json | 6 ++ .../web/src/lib/i18n/locales/places/it.json | 6 ++ .../src/lib/modules/places/ListView.svelte | 99 ++++++++++++++++++- scripts/i18n-hardcoded-baseline.json | 24 ++--- 8 files changed, 218 insertions(+), 25 deletions(-) diff --git a/apps/mana/apps/web/src/lib/geocoding/index.ts b/apps/mana/apps/web/src/lib/geocoding/index.ts index 176fb9355..0993bb9b8 100644 --- a/apps/mana/apps/web/src/lib/geocoding/index.ts +++ b/apps/mana/apps/web/src/lib/geocoding/index.ts @@ -66,20 +66,59 @@ export interface GeocodingResult { longitude: number; address: GeocodingAddress; category: PlaceCategory; - /** Raw Pelias categories (food, retail, transport, …) */ + /** Raw Pelias categories (food, retail, transport, …) — only present + * when the result came from Pelias. */ peliasCategories?: string[]; confidence: number; + /** Which backend served this result. `pelias` is local; `photon` and + * `nominatim` are public APIs (the wrapper applies sensitive-query + * blocking + coord quantization before forwarding to those). */ + provider?: 'pelias' | 'photon' | 'nominatim'; } +/** + * Out-of-band information returned alongside results — the wrapper uses + * this to signal *why* a query had unusual behavior: + * + * - `'fallback_used'`: Pelias was unreachable, so a public-API provider + * served the request. Results are still valid but may be less precise. + * UI should show a subtle "approximate" badge. + * - `'sensitive_local_unavailable'`: the query matched the wrapper's + * sensitive-keyword list (medical / mental-health / crisis service) + * AND the local Pelias was unreachable. The wrapper deliberately did + * NOT forward the query to public APIs. Results are empty by design. + * UI should explain this to the user. + */ +export type GeocodingNotice = 'fallback_used' | 'sensitive_local_unavailable'; + interface GeocodingResponse { results: GeocodingResult[]; cached?: boolean; error?: string; + provider?: 'pelias' | 'photon' | 'nominatim'; + notice?: GeocodingNotice; +} + +/** + * Detailed search outcome — includes provider + notice metadata. + * + * Most call sites use the simpler `searchAddress` (returns just the + * results array). The detailed variant is for places where the UI needs + * to surface a notice — typically the address-autocomplete input where + * a "search blocked for privacy" message has to be shown. + */ +export interface SearchOutcome { + results: GeocodingResult[]; + provider?: 'pelias' | 'photon' | 'nominatim'; + notice?: GeocodingNotice; } /** * Forward geocoding / autocomplete. * Returns places matching the search query, biased towards the focus point. + * + * For UI that needs to display privacy-related notices (e.g. "this + * search stays local"), use {@link searchAddressDetailed} instead. */ export async function searchAddress( query: string, @@ -90,7 +129,29 @@ export async function searchAddress( lang?: string; } ): Promise { - if (!query || query.trim().length < 2) return []; + const outcome = await searchAddressDetailed(query, options); + return outcome.results; +} + +/** + * Detailed forward-search variant that surfaces the wrapper's `provider` + * and `notice` fields. Use this in the address-autocomplete UI so we can: + * - Show a subtle "≈ ungefähr" badge when a public-API fallback served + * the result (`notice: 'fallback_used'`). + * - Explain to the user why a sensitive query (Hausarzt, Klinikum, + * etc.) returned 0 results without leaking it to a public API + * (`notice: 'sensitive_local_unavailable'`). + */ +export async function searchAddressDetailed( + query: string, + options?: { + limit?: number; + focusLat?: number; + focusLon?: number; + lang?: string; + } +): Promise { + if (!query || query.trim().length < 2) return { results: [] }; const params = new URLSearchParams({ q: query.trim() }); if (options?.limit) params.set('limit', String(options.limit)); @@ -100,23 +161,36 @@ export async function searchAddress( try { const res = await fetch(geocodeUrl('search', params)); - if (!res.ok) return []; + if (!res.ok) return { results: [] }; const data: GeocodingResponse = await res.json(); - return data.results; + return { results: data.results, provider: data.provider, notice: data.notice }; } catch { console.warn('Geocoding search failed — service may be offline'); - return []; + return { results: [] }; } } /** * Reverse geocoding — resolve coordinates to an address and place type. + * + * For UI that needs to surface the privacy notice (e.g. "approximate match" + * badge on a coordinate-derived address), use {@link reverseGeocodeDetailed}. */ export async function reverseGeocode( lat: number, lon: number, lang = 'de' ): Promise { + const outcome = await reverseGeocodeDetailed(lat, lon, lang); + return outcome.results[0] ?? null; +} + +/** Detailed reverse-geocode variant — see {@link searchAddressDetailed}. */ +export async function reverseGeocodeDetailed( + lat: number, + lon: number, + lang = 'de' +): Promise { try { const params = new URLSearchParams({ lat: String(lat), @@ -124,12 +198,12 @@ export async function reverseGeocode( lang, }); const res = await fetch(geocodeUrl('reverse', params)); - if (!res.ok) return null; + if (!res.ok) return { results: [] }; const data: GeocodingResponse = await res.json(); - return data.results[0] ?? null; + return { results: data.results, provider: data.provider, notice: data.notice }; } catch { console.warn('Reverse geocoding failed — service may be offline'); - return null; + return { results: [] }; } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/places/de.json b/apps/mana/apps/web/src/lib/i18n/locales/places/de.json index 2c5d4e52a..3db1d3066 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/places/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/places/de.json @@ -32,5 +32,11 @@ "meta_last_visit": "Letzter Besuch: {date}", "meta_created": "Erstellt: {date}", "meta_updated": "Bearbeitet: {date}" + }, + "geocoding_notice": { + "sensitive_local_unavailable_title": "Diese Suche bleibt bewusst lokal", + "sensitive_local_unavailable_body": "Sensible Suchbegriffe (Arzt, Klinik, …) werden nie an externe Dienste geschickt. Der lokale Index ist gerade nicht erreichbar — versuche es später nochmal oder formuliere allgemeiner.", + "fallback_used_badge": "≈ ungefähr", + "fallback_used_title": "Adress-Suche über öffentliches OSM" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/places/en.json b/apps/mana/apps/web/src/lib/i18n/locales/places/en.json index 7dc241446..905f3b1d5 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/places/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/places/en.json @@ -32,5 +32,11 @@ "meta_last_visit": "Last visit: {date}", "meta_created": "Created: {date}", "meta_updated": "Edited: {date}" + }, + "geocoding_notice": { + "sensitive_local_unavailable_title": "This search stays local on purpose", + "sensitive_local_unavailable_body": "Sensitive terms (doctor, clinic, …) are never forwarded to public services. The local index is unavailable right now — try again later or use a more general query.", + "fallback_used_badge": "≈ approximate", + "fallback_used_title": "Address lookup via public OSM" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/places/es.json b/apps/mana/apps/web/src/lib/i18n/locales/places/es.json index 26b4fc1b5..cb826f033 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/places/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/places/es.json @@ -32,5 +32,11 @@ "meta_last_visit": "Última visita: {date}", "meta_created": "Creado: {date}", "meta_updated": "Editado: {date}" + }, + "geocoding_notice": { + "sensitive_local_unavailable_title": "Esta búsqueda permanece local a propósito", + "sensitive_local_unavailable_body": "Los términos sensibles (médico, clínica, …) nunca se envían a servicios externos. El índice local no está disponible — vuelve a intentarlo o usa una búsqueda más general.", + "fallback_used_badge": "≈ aprox.", + "fallback_used_title": "Búsqueda de dirección vía OSM público" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/places/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/places/fr.json index deef01cc8..51272ec2f 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/places/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/places/fr.json @@ -32,5 +32,11 @@ "meta_last_visit": "Dernière visite : {date}", "meta_created": "Créé : {date}", "meta_updated": "Modifié : {date}" + }, + "geocoding_notice": { + "sensitive_local_unavailable_title": "Cette recherche reste locale par principe", + "sensitive_local_unavailable_body": "Les termes sensibles (médecin, clinique, …) ne sont jamais transmis à des services externes. L'index local est indisponible — réessaie plus tard ou utilise une formulation plus générale.", + "fallback_used_badge": "≈ approx.", + "fallback_used_title": "Recherche d'adresse via OSM public" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/places/it.json b/apps/mana/apps/web/src/lib/i18n/locales/places/it.json index 9e8bcc8b6..b3f67ffb7 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/places/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/places/it.json @@ -32,5 +32,11 @@ "meta_last_visit": "Ultima visita: {date}", "meta_created": "Creato: {date}", "meta_updated": "Modificato: {date}" + }, + "geocoding_notice": { + "sensitive_local_unavailable_title": "Questa ricerca resta locale di proposito", + "sensitive_local_unavailable_body": "Termini sensibili (medico, clinica, …) non vengono mai inoltrati a servizi esterni. L'indice locale non è disponibile — riprova più tardi o usa una formulazione più generale.", + "fallback_used_badge": "≈ approssimativo", + "fallback_used_title": "Ricerca indirizzo tramite OSM pubblico" } } diff --git a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte index 36539ccc2..cb1774864 100644 --- a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte @@ -8,13 +8,15 @@ import { placesStore } from './stores/places.svelte'; import { trackingStore } from './stores/tracking.svelte'; import { - searchAddress, + searchAddressDetailed, reverseGeocode, formatAddress, formatLocality, formatFullAddress, + type GeocodingNotice, type GeocodingResult, } from '$lib/geocoding'; + import { _ } from 'svelte-i18n'; import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; @@ -77,6 +79,7 @@ let editingLocation = $state(false); let locationDraft = $state(''); let locationSuggestions = $state([]); + let locationNotice = $state(undefined); let showLocationSuggestions = $state(false); let locationDebounce: ReturnType | undefined; let locationInputEl = $state(null); @@ -129,6 +132,7 @@ editingLocation = false; showLocationSuggestions = false; locationSuggestions = []; + locationNotice = undefined; locationDraft = ''; } @@ -137,17 +141,23 @@ const q = locationDraft.trim(); if (q.length < 2) { locationSuggestions = []; + locationNotice = undefined; showLocationSuggestions = false; return; } locationDebounce = setTimeout(async () => { const pos = trackingStore.currentPosition; - locationSuggestions = await searchAddress(q, { + const outcome = await searchAddressDetailed(q, { limit: 6, focusLat: pos?.coords.latitude, focusLon: pos?.coords.longitude, }); - showLocationSuggestions = locationSuggestions.length > 0; + locationSuggestions = outcome.results; + locationNotice = outcome.notice; + // Show the dropdown when we have results OR when we have a + // notice to display (sensitive-query-blocked queries return + // empty results but should still surface the explainer). + showLocationSuggestions = outcome.results.length > 0 || !!outcome.notice; }, 250); } @@ -165,6 +175,7 @@ // --- Address autocomplete --- let addressQuery = $state(''); let suggestions = $state([]); + let suggestionsNotice = $state(undefined); let showSuggestions = $state(false); let debounceTimer: ReturnType | undefined; @@ -172,18 +183,24 @@ clearTimeout(debounceTimer); if (addressQuery.trim().length < 2) { suggestions = []; + suggestionsNotice = undefined; showSuggestions = false; return; } debounceTimer = setTimeout(async () => { const focusLat = trackingStore.currentPosition?.coords.latitude; const focusLon = trackingStore.currentPosition?.coords.longitude; - suggestions = await searchAddress(addressQuery, { + const outcome = await searchAddressDetailed(addressQuery, { limit: 6, focusLat, focusLon, }); - showSuggestions = suggestions.length > 0; + suggestions = outcome.results; + suggestionsNotice = outcome.notice; + // Same as the tracking-edit input: show the dropdown when we + // have either results OR a notice. A sensitive query with + // empty results still needs to surface the explainer. + showSuggestions = outcome.results.length > 0 || !!outcome.notice; }, 300); } @@ -313,6 +330,23 @@ /> {#if showLocationSuggestions}
+ {#if locationNotice === 'sensitive_local_unavailable'} +
+
+ {$_('places.geocoding_notice.sensitive_local_unavailable_title')} +
+
+ {$_('places.geocoding_notice.sensitive_local_unavailable_body')} +
+
+ {:else if locationNotice === 'fallback_used'} +
+ {$_('places.geocoding_notice.fallback_used_badge')} +
+ {/if} {#each locationSuggestions as result}