mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
f20a411fd8
commit
b1fa55dbca
8 changed files with 218 additions and 25 deletions
|
|
@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue