From f3cc853e08831f719205aa8d37f88d4ce568ef93 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 11 Apr 2026 20:33:48 +0200 Subject: [PATCH] feat(places): clickable tracking label + full address + browser proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/mana/apps/web/src/lib/geocoding/index.ts | 75 +++++- .../src/lib/modules/places/ListView.svelte | 244 ++++++++++++++++-- .../api/v1/geocode/[...path]/+server.ts | 77 ++++++ 3 files changed, 375 insertions(+), 21 deletions(-) create mode 100644 apps/mana/apps/web/src/routes/api/v1/geocode/[...path]/+server.ts diff --git a/apps/mana/apps/web/src/lib/geocoding/index.ts b/apps/mana/apps/web/src/lib/geocoding/index.ts index 661a8e896..176fb9355 100644 --- a/apps/mana/apps/web/src/lib/geocoding/index.ts +++ b/apps/mana/apps/web/src/lib/geocoding/index.ts @@ -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 = { + 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 || ''; +} 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 1d6223d9e..0c1d7fdf6 100644 --- a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte @@ -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(null); + let currentLocationResult = $state(null); let lastReverseKey = ''; let reverseDebounce: ReturnType | 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([]); + let showLocationSuggestions = $state(false); + let locationDebounce: ReturnType | undefined; + let locationInputEl = $state(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([]); @@ -232,18 +299,62 @@ {#if trackingStore.currentPosition}
- {#if currentLocationLabel} - - - {currentLocationLabel} - + {#if editingLocation} +
+ setTimeout(cancelEditLocation, 200)} + placeholder="Adresse suchen..." + /> + {#if showLocationSuggestions} +
+ {#each locationSuggestions as result} + + {/each} +
+ {/if} +
+ {:else} + {/if} - - {formatCoords( - trackingStore.currentPosition.coords.latitude, - trackingStore.currentPosition.coords.longitude - )} -
{/if} @@ -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; diff --git a/apps/mana/apps/web/src/routes/api/v1/geocode/[...path]/+server.ts b/apps/mana/apps/web/src/routes/api/v1/geocode/[...path]/+server.ts new file mode 100644 index 000000000..affe43163 --- /dev/null +++ b/apps/mana/apps/web/src/routes/api/v1/geocode/[...path]/+server.ts @@ -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 { + 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 ?? ''); +};