feat(places): clickable tracking label + full address + browser proxy

Three related fixes for the workbench tracking overlay:

1. **Same-origin proxy at /api/v1/geocode/[...path]/+server.ts.**
   mana-geocoding is intentionally NOT exposed via Cloudflare, so the
   browser can't reach it directly — localhost:3018 is unreachable from
   a visitor's device. Same-origin proxy fixes this: the browser talks
   to https://mana.how/api/v1/geocode/*, SvelteKit forwards to
   http://mana-geocoding:3018 over the docker network. Pattern copied
   from the existing /api/v1/who/[...path] proxy.

2. **`formatFullAddress()` in $lib/geocoding** builds a compact line
   with street+housenumber, postal code, city, and 2-letter country
   code (DE/AT/CH) — e.g. "Hafenstraße 2, 78462 Konstanz, DE". Maps
   German and English OSM country names to ISO 3166-1 alpha-2.

3. **Clickable, inline-editable tracking label in ListView.** The
   tracking overlay used to show "47.6630, 9.1750" while tracking was
   active. Now it shows the venue name + full address with ISO country
   code, tapping it switches to an autocomplete input so the user can
   fix the location when GPS snaps to the wrong building. Debounced
   reverse-geocode on position change (1.5 s + 10 m precision), edits
   are kept local — the current tracking position drives the label
   but user corrections override until the next significant move.

The client lib now uses relative URLs in the browser (same-origin
proxy) and absolute URLs only from Node/SSR (via env var or localhost
fallback). geocoding unit tests still pass (42/42 green).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-11 20:33:48 +02:00
parent 0c1eb623bb
commit f3cc853e08
3 changed files with 375 additions and 21 deletions

View file

@ -14,15 +14,42 @@
export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other';
/**
* Where to send geocoding requests:
* - **Browser**: same-origin proxy at `/api/v1/geocode/*` (handled by the
* SvelteKit `+server.ts` under `routes/api/v1/geocode/[...path]`). This
* keeps geocoding off the public internet it's explicitly NOT exposed
* via Cloudflare and saves us an auth round-trip.
* - **Node / SSR**: direct hit on the wrapper via `PUBLIC_MANA_GEOCODING_URL`
* or `MANA_GEOCODING_INTERNAL_URL`, with a localhost fallback for dev.
*
* Because `PlacesListView` is client-only (reads `navigator.geolocation`),
* browser same-origin is by far the common path.
*/
const GEOCODING_URL = () => {
if (typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_GEOCODING_URL__?: string })
.__PUBLIC_MANA_GEOCODING_URL__;
if (injected) return injected;
// Same-origin proxy — nothing to configure, nothing to leak.
return '';
}
return import.meta.env.PUBLIC_MANA_GEOCODING_URL ?? 'http://localhost:3018';
return (
process.env.MANA_GEOCODING_INTERNAL_URL ??
process.env.PUBLIC_MANA_GEOCODING_URL ??
'http://localhost:3018'
);
};
/** Build a request URL that works in both the browser (relative) and Node (absolute). */
function geocodeUrl(path: string, params: URLSearchParams): string {
const base = GEOCODING_URL();
const query = params.toString();
if (base) {
// Node / SSR path — absolute
return `${base}/api/v1/geocode/${path}${query ? '?' + query : ''}`;
}
// Browser path — same-origin proxy
return `/api/v1/geocode/${path}${query ? '?' + query : ''}`;
}
export interface GeocodingAddress {
street?: string;
houseNumber?: string;
@ -72,7 +99,7 @@ export async function searchAddress(
if (options?.focusLon != null) params.set('focus.lon', String(options.focusLon));
try {
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/search?${params}`);
const res = await fetch(geocodeUrl('search', params));
if (!res.ok) return [];
const data: GeocodingResponse = await res.json();
return data.results;
@ -96,7 +123,7 @@ export async function reverseGeocode(
lon: String(lon),
lang,
});
const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/reverse?${params}`);
const res = await fetch(geocodeUrl('reverse', params));
if (!res.ok) return null;
const data: GeocodingResponse = await res.json();
return data.results[0] ?? null;
@ -136,3 +163,39 @@ export function formatLocality(result: GeocodingResult): string {
if (a.city && a.country) return `${a.city}, ${a.country}`;
return a.city ?? a.country ?? result.label ?? '';
}
/** Map OSM country names to 2-letter codes (DE/AT/CH focus). */
const COUNTRY_CODE: Record<string, string> = {
Germany: 'DE',
Deutschland: 'DE',
Austria: 'AT',
Österreich: 'AT',
Switzerland: 'CH',
Schweiz: 'CH',
};
/**
* Compact address label with street, house number, postal code and 2-letter
* country. Used by the places tracking overlay where horizontal space is
* tight.
*
* Examples:
* "Hafenstraße 2, 78462 Konstanz, DE"
* "Marienplatz 26, 80331 München, DE"
* "78462 Konstanz, DE" (when no street is available)
*/
export function formatFullAddress(result: GeocodingResult): string {
const a = result.address;
const countryCode = a.country ? (COUNTRY_CODE[a.country] ?? a.country) : undefined;
const streetLine = a.street ? (a.houseNumber ? `${a.street} ${a.houseNumber}` : a.street) : '';
const cityLine = a.postalCode && a.city ? `${a.postalCode} ${a.city}` : (a.city ?? '');
const parts: string[] = [];
if (streetLine) parts.push(streetLine);
if (cityLine) parts.push(cityLine);
if (countryCode) parts.push(countryCode);
return parts.join(', ') || result.label || '';
}

View file

@ -12,6 +12,7 @@
reverseGeocode,
formatAddress,
formatLocality,
formatFullAddress,
type GeocodingResult,
} from '$lib/geocoding';
import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons';
@ -66,17 +67,37 @@
// the GeolocationPosition object is replaced on every update. We debounce
// by ~1.5 s and round to ~10 m precision so we only hit the geocoding
// service when the user has actually moved, not on every micro-jitter.
let currentLocationLabel = $state<string | null>(null);
let currentLocationResult = $state<GeocodingResult | null>(null);
let lastReverseKey = '';
let reverseDebounce: ReturnType<typeof setTimeout> | undefined;
// Inline editing: user can tap the location label, type a different
// address, and pick an autocomplete suggestion. Useful when GPS snaps
// to a nearby building but the user is actually next door.
let editingLocation = $state(false);
let locationDraft = $state('');
let locationSuggestions = $state<GeocodingResult[]>([]);
let showLocationSuggestions = $state(false);
let locationDebounce: ReturnType<typeof setTimeout> | undefined;
let locationInputEl = $state<HTMLInputElement | null>(null);
const currentLocationFull = $derived(
currentLocationResult ? formatFullAddress(currentLocationResult) : null
);
const currentLocationName = $derived(
currentLocationResult
? currentLocationResult.name || formatLocality(currentLocationResult)
: null
);
$effect(() => {
const pos = trackingStore.currentPosition;
if (!pos) {
currentLocationLabel = null;
currentLocationResult = null;
lastReverseKey = '';
return;
}
if (editingLocation) return; // don't clobber user typing
// Round to ~10 m precision (4 decimal places) so we don't re-fetch
// on every tiny coordinate drift while standing still.
@ -90,11 +111,57 @@
reverseDebounce = setTimeout(async () => {
const result = await reverseGeocode(pos.coords.latitude, pos.coords.longitude);
if (result) {
currentLocationLabel = formatLocality(result);
currentLocationResult = result;
}
}, 1500);
});
function startEditLocation() {
locationDraft = currentLocationFull ?? '';
editingLocation = true;
queueMicrotask(() => {
locationInputEl?.focus();
locationInputEl?.select();
});
}
function cancelEditLocation() {
editingLocation = false;
showLocationSuggestions = false;
locationSuggestions = [];
locationDraft = '';
}
function onLocationDraftInput() {
clearTimeout(locationDebounce);
const q = locationDraft.trim();
if (q.length < 2) {
locationSuggestions = [];
showLocationSuggestions = false;
return;
}
locationDebounce = setTimeout(async () => {
const pos = trackingStore.currentPosition;
locationSuggestions = await searchAddress(q, {
limit: 6,
focusLat: pos?.coords.latitude,
focusLon: pos?.coords.longitude,
});
showLocationSuggestions = locationSuggestions.length > 0;
}, 250);
}
function selectLocationSuggestion(result: GeocodingResult) {
currentLocationResult = result;
editingLocation = false;
showLocationSuggestions = false;
locationSuggestions = [];
}
function onLocationKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancelEditLocation();
}
// --- Address autocomplete ---
let addressQuery = $state('');
let suggestions = $state<GeocodingResult[]>([]);
@ -232,18 +299,62 @@
</button>
{#if trackingStore.currentPosition}
<div class="tracking-location">
{#if currentLocationLabel}
<span class="tracking-label">
<MapPin size={10} />
{currentLocationLabel}
</span>
{#if editingLocation}
<div class="tracking-edit-wrapper">
<input
bind:this={locationInputEl}
class="tracking-edit-input"
type="text"
bind:value={locationDraft}
oninput={onLocationDraftInput}
onkeydown={onLocationKeydown}
onblur={() => setTimeout(cancelEditLocation, 200)}
placeholder="Adresse suchen..."
/>
{#if showLocationSuggestions}
<div class="tracking-suggestions">
{#each locationSuggestions as result}
<button
type="button"
class="tracking-suggestion"
onclick={() => selectLocationSuggestion(result)}
>
<div class="tracking-suggestion-name">
{result.name || formatLocality(result)}
</div>
<div class="tracking-suggestion-full">
{formatFullAddress(result)}
</div>
</button>
{/each}
</div>
{/if}
</div>
{:else}
<button
type="button"
class="tracking-display"
onclick={startEditLocation}
title="Adresse bearbeiten"
>
{#if currentLocationName}
<span class="tracking-label">
<MapPin size={10} weight="fill" />
{currentLocationName}
</span>
{/if}
{#if currentLocationFull}
<span class="tracking-address">{currentLocationFull}</span>
{:else}
<span class="tracking-coords">
{formatCoords(
trackingStore.currentPosition.coords.latitude,
trackingStore.currentPosition.coords.longitude
)}
</span>
{/if}
</button>
{/if}
<span class="tracking-coords">
{formatCoords(
trackingStore.currentPosition.coords.latitude,
trackingStore.currentPosition.coords.longitude
)}
</span>
</div>
{/if}
</div>
@ -449,11 +560,34 @@
}
.tracking-location {
flex: 1;
min-width: 0;
display: flex;
justify-content: flex-end;
position: relative;
}
.tracking-display {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.0625rem;
min-width: 0;
max-width: 100%;
padding: 0.25rem 0.375rem;
border-radius: 0.375rem;
border: 1px solid transparent;
background: transparent;
cursor: pointer;
text-align: right;
transition:
background 0.15s,
border-color 0.15s;
}
.tracking-display:hover {
background: rgba(14, 165, 233, 0.06);
border-color: rgba(14, 165, 233, 0.2);
}
.tracking-label {
@ -462,19 +596,99 @@
gap: 0.1875rem;
font-size: 0.75rem;
color: #0ea5e9;
font-weight: 500;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.tracking-address {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 260px;
}
.tracking-coords {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
}
.tracking-edit-wrapper {
position: relative;
min-width: 240px;
max-width: 320px;
flex: 1;
}
.tracking-edit-input {
width: 100%;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid #0ea5e9;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
outline: none;
}
.tracking-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
z-index: 60;
overflow: hidden;
max-height: 280px;
overflow-y: auto;
}
.tracking-suggestion {
display: flex;
flex-direction: column;
gap: 0.0625rem;
padding: 0.375rem 0.5rem;
width: 100%;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
cursor: pointer;
text-align: left;
}
.tracking-suggestion:hover {
background: hsl(var(--color-muted));
}
.tracking-suggestion + .tracking-suggestion {
border-top: 1px solid hsl(var(--color-border) / 0.5);
}
.tracking-suggestion-name {
font-size: 0.75rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tracking-suggestion-full {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tracking-error {
font-size: 0.6875rem;
color: #ef4444;

View file

@ -0,0 +1,77 @@
/**
* Same-origin proxy for /api/v1/geocode/* mana-geocoding:3018
*
* Why this proxy exists
* ---------------------
* mana-geocoding is intentionally NOT exposed via Cloudflare we decided
* early on that geocoding queries (which leak "where the user is looking")
* should never leave our infrastructure. That decision means the browser
* can't reach the wrapper directly: `http://localhost:3018` is unreachable
* in production and `http://mana-geocoding:3018` is only valid inside the
* docker network.
*
* Same-origin proxy fixes this: the browser calls
* `https://mana.how/api/v1/geocode/search?q=...`, SvelteKit (running in
* the mana-web container on the same docker network as mana-geocoding)
* forwards the request to `http://mana-geocoding:3018/api/v1/geocode/*`,
* and the response comes back through the same path. No new Cloudflare
* route, no geocoding traffic on the public internet.
*
* The shared client lib at `$lib/geocoding` points at `/api/v1/geocode`
* (relative URL) so both server-side rendering and browser-side calls
* land on this handler.
*
* No auth required geocoding is pure lookup and has no per-user data.
* If we ever want to rate-limit by user we can add JWT verification here
* without touching the upstream service.
*
* Also proxies /health and /health/pelias so the SvelteKit status page
* (/status) can check the service from its server side.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const UPSTREAM = process.env.MANA_GEOCODING_INTERNAL_URL || 'http://mana-geocoding:3018';
const PROXY_TIMEOUT_MS = 15_000;
async function forward(request: Request, pathSegments: string): Promise<Response> {
const upstreamUrl = `${UPSTREAM}/api/v1/geocode/${pathSegments}`;
const incomingUrl = new URL(request.url);
const finalUrl = incomingUrl.search ? `${upstreamUrl}${incomingUrl.search}` : upstreamUrl;
const headers = new Headers();
const accept = request.headers.get('accept');
if (accept) headers.set('accept', accept);
const init: RequestInit = {
method: request.method,
headers,
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
};
let upstreamRes: Response;
try {
upstreamRes = await fetch(finalUrl, init);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw error(502, `geocoding proxy: ${message}`);
}
const responseHeaders = new Headers();
const upstreamContentType = upstreamRes.headers.get('content-type');
if (upstreamContentType) responseHeaders.set('content-type', upstreamContentType);
// Allow the browser to cache identical geocoding results for a minute.
// The wrapper also has its own 24h LRU, so this is just a hint.
responseHeaders.set('cache-control', 'private, max-age=60');
const body = await upstreamRes.text();
return new Response(body, {
status: upstreamRes.status,
headers: responseHeaders,
});
}
export const GET: RequestHandler = async ({ request, params }) => {
return forward(request, params.path ?? '');
};