feat(places): surface geocoding privacy notices in autocomplete UI

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.
This commit is contained in:
Till JS 2026-04-28 16:24:15 +02:00
parent f20a411fd8
commit b1fa55dbca
8 changed files with 218 additions and 25 deletions

View file

@ -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<GeocodingResult[]> {
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<SearchOutcome> {
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<GeocodingResult | null> {
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<SearchOutcome> {
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: [] };
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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<GeocodingResult[]>([]);
let locationNotice = $state<GeocodingNotice | undefined>(undefined);
let showLocationSuggestions = $state(false);
let locationDebounce: ReturnType<typeof setTimeout> | undefined;
let locationInputEl = $state<HTMLInputElement | null>(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<GeocodingResult[]>([]);
let suggestionsNotice = $state<GeocodingNotice | undefined>(undefined);
let showSuggestions = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | 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}
<div class="tracking-suggestions">
{#if locationNotice === 'sensitive_local_unavailable'}
<div class="suggestion-notice notice-sensitive">
<div class="suggestion-notice-title">
{$_('places.geocoding_notice.sensitive_local_unavailable_title')}
</div>
<div class="suggestion-notice-body">
{$_('places.geocoding_notice.sensitive_local_unavailable_body')}
</div>
</div>
{:else if locationNotice === 'fallback_used'}
<div
class="suggestion-notice notice-fallback"
title={$_('places.geocoding_notice.fallback_used_title')}
>
{$_('places.geocoding_notice.fallback_used_badge')}
</div>
{/if}
{#each locationSuggestions as result}
<button
type="button"
@ -387,6 +421,23 @@
</div>
{#if showSuggestions}
<div class="suggestions">
{#if suggestionsNotice === 'sensitive_local_unavailable'}
<div class="suggestion-notice notice-sensitive">
<div class="suggestion-notice-title">
{$_('places.geocoding_notice.sensitive_local_unavailable_title')}
</div>
<div class="suggestion-notice-body">
{$_('places.geocoding_notice.sensitive_local_unavailable_body')}
</div>
</div>
{:else if suggestionsNotice === 'fallback_used'}
<div
class="suggestion-notice notice-fallback"
title={$_('places.geocoding_notice.fallback_used_title')}
>
{$_('places.geocoding_notice.fallback_used_badge')}
</div>
{/if}
{#each suggestions as result}
<button class="suggestion-item" onclick={() => selectSuggestion(result)}>
<div class="suggestion-icon">
@ -792,6 +843,44 @@
border-top: 1px solid hsl(var(--color-border) / 0.5);
}
/* ── Privacy notices in suggestion dropdown ─────────────────
Two visual styles:
- notice-sensitive: full explainer block (sensitive query was
blocked from public APIs). Soft amber tone.
- notice-fallback: tiny right-aligned badge (results came from
a public-API fallback). Muted footer style. */
.suggestion-notice {
padding: 0.5rem 0.625rem;
font-size: 0.75rem;
}
.notice-sensitive {
background: hsl(40 90% 96%);
color: hsl(40 80% 24%);
border-bottom: 1px solid hsl(40 60% 85%);
}
:global(.dark) .notice-sensitive {
background: hsl(40 30% 16%);
color: hsl(40 80% 78%);
border-bottom-color: hsl(40 30% 28%);
}
.suggestion-notice-title {
font-weight: 600;
margin-bottom: 0.125rem;
}
.suggestion-notice-body {
font-size: 0.6875rem;
line-height: 1.4;
}
.notice-fallback {
text-align: right;
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
padding: 0.25rem 0.625rem;
background: hsl(var(--color-muted) / 0.3);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.suggestion-icon {
color: #0ea5e9;
flex-shrink: 0;