diff --git a/AGENTS.md b/AGENTS.md
index ce97a2a..2e173b7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -189,9 +189,19 @@ session with **no other memory of this project**. So:
- **Routing / directions**: `RouteDialog` is the single in-app router, opened
from the map spot sheet, the food cards, and the spot / shop / event detail
pages (MapPage's old external "Open with" Google/OSM/Waze menu was removed).
- Road modes (skate-walk / bike / moto / car) come from keyless **OSRM** via the
- `proxy.shakespeare.diy` CORS proxy (`src/lib/routing.ts`); moto≈car is
- labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`,
+ Road modes (skate-walk / bike / moto / car) come from keyless **OSRM**
+ (`src/lib/routing.ts`). ⚠️ **Use the per-profile FOSSGIS servers, not the demo
+ one:** `router.project-osrm.org` hosts a SINGLE car dataset and **ignores the
+ profile in the URL**, so foot/bike/car all returned identical distance+time
+ (the "every transport option shows the same time" report). We now call
+ `https://routing.openstreetmap.de/routed-{foot|bike|car}/route/v1/{foot|bike|
+ driving}/…` (CORS `*`, direct first, `proxy.shakespeare.diy` proxy only as
+ fallback), request `steps=true` and expose `RouteStep[]` (verified 2026-09-27:
+ same trip = walk 13 min / bike 5 / drive 2). The demo server survives only as a
+ last-resort fallback and its result is flagged `approximate` ("driving
+ estimate") so we never pass driving numbers off as walking. Moto≈car is
+ labelled honestly; the foot summary also shows a ~10 km/h `skateTime` cruise
+ estimate ("~6 min on a board") next to the walking time. **Transit** mode uses **Transitous** (`api.transitous.org`,
MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
modes. **No external map-app handoff anywhere** (product decision) and no
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from
@@ -234,6 +244,20 @@ session with **no other memory of this project**. So:
`isInteractiveSigner` is true, so merely opening the notification bell (which
marks read) no longer pops Amber twice (encrypt + sign). Interactive signers
only prompt for real signed actions.
+ - ⚠️ **`staleTime: Infinity` does NOT stop the FIRST decrypt (fixed
+ 2026-09-27).** That was the "Amber pops on every screen" report: the
+ encrypted `useSyncedAppData` blobs (read-state, preferences) ran their
+ queryFn once on mount for each racing consumer, and relay responses landing
+ during navigation made it look per-page. `useSyncedAppData` now takes
+ **`interactiveNetwork` (default false)**: for an interactive signer the blob
+ is **local-only** — the query is not even enabled and `patch()` is a no-op —
+ so no decrypt on mount and no publish on a nav-tab change. localStorage is
+ the session source of truth. Only set `interactiveNetwork: true` if a feature
+ genuinely cannot work from localStorage. **Diagnostic recipe:** mock an
+ `x-amber` login by writing `localStorage['nostr:login']` before load, then
+ wrap `localStorage.setItem` to log writes of `amber_pending_operation`
+ (captures every prompt + a stack). Beware stale harness profiles — use a
+ fresh `--user-data-dir` per run or the ops array is from a previous run.
- ⚠️ **Don't make read-state mutators (or any hook they flow from) unstable
(fixed 2026-09-27).** `/newsletter` was stuck in an **infinite render loop**
(~100% CPU, React "Maximum update depth exceeded") because: `useSyncedAppData`
diff --git a/release-notes.txt b/release-notes.txt
index 9ea524e..f43fa68 100644
--- a/release-notes.txt
+++ b/release-notes.txt
@@ -10,6 +10,13 @@ v1.5.2
🧭 Directions actually work now
- Fixed the route map never drawing (you'd get an empty dialog and "no route").
Routes now show your position, the spot, and the line between them.
+- Real per-mode routing: Skate/Walk, Bike, Moto and Car now give genuinely
+ different routes and times (walking 13 min vs biking 5 vs driving 2 on the same
+ trip) instead of one number copy-pasted across every mode.
+- Added turn-by-turn route steps for orientation — the actual turns and street
+ names, not just a line on a map.
+- Skating gets its own honest estimate ("~6 min cruise on a board") next to the
+ walking time, and moto is clearly labelled as using driving roads.
- Opens instantly from your last known location; "Near Me" sorts by distance.
📱 Nav fixes
@@ -25,8 +32,11 @@ v1.5.2
the drops.
✨ Smoother everywhere
-- Amber only pops for things that need a signature — no more prompts just for
- reading your notifications.
+- Amber is properly scoped now: it only opens for real signed actions (log in /
+ sign up, messages, your Stash, marketplace trades, invites, publishing).
+ Browsing, reading notifications and moving between sections no longer trigger
+ a signer prompt — the app's encrypted cross-device data stays local-only on
+ Amber instead of decrypting on every screen.
- Logo no longer crowds the nav.
---
diff --git a/src/components/RouteDialog.tsx b/src/components/RouteDialog.tsx
index b47fe1c..fe2869e 100644
--- a/src/components/RouteDialog.tsx
+++ b/src/components/RouteDialog.tsx
@@ -22,6 +22,7 @@ import {
TRANSIT_ATTRIBUTION,
ROUTE_ERR_BUSY,
ROUTE_ERR_NO_ROUTE,
+ skateTime,
type TravelMode,
type RouteResult,
} from '@/lib/routing';
@@ -311,7 +312,7 @@ export function RouteDialog({
{mode === 'transit'
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
- : 'Routed in-app · nothing leaves to Google or Apple Maps'}
+ : 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'}
@@ -436,6 +437,10 @@ export function RouteDialog({
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
· uses driving roads
)}
+ {route.approximate && · driving estimate}
+ {mode === 'foot' && (
+ · {skateTime(route.distanceM / 1000)} on a board
+ )}
@@ -449,6 +454,38 @@ export function RouteDialog({
)}
+
+ {/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */}
+ {mode !== 'transit' && route?.steps && route.steps.length > 1 && (
+
+
+
+ Route steps
+
+ {mode === 'foot' && (
+
walking route — quicker on a board
+ )}
+
+
+ {route.steps.map((s, i) => (
+ -
+
+ {i + 1}
+
+ {s.instruction}
+ {s.distanceM >= 10 && (
+
+ {formatDistance(s.distanceM / 1000)}
+
+ )}
+
+ ))}
+
+
+ Orientation only — no live turn-by-turn or voice guidance yet.
+
+
+ )}
);
diff --git a/src/hooks/useSyncedAppData.ts b/src/hooks/useSyncedAppData.ts
index fa7910a..d3a3cc2 100644
--- a/src/hooks/useSyncedAppData.ts
+++ b/src/hooks/useSyncedAppData.ts
@@ -52,12 +52,28 @@ interface UseSyncedAppDataOptions {
enabled?: boolean;
/** Coalesce writes into at most one publish per this many ms (default 4000). */
debounceMs?: number;
+ /**
+ * Opt in to the encrypted relay copy while using an INTERACTIVE signer
+ * (Amber/NIP-55). Off by default: for an interactive signer the blob is
+ * **local-only** — no decrypt on mount and no publish on patch — because
+ * every decrypt/encrypt/sign is a user-facing Amber approval screen. Only set
+ * this for a feature that truly cannot work from the localStorage copy.
+ */
+ interactiveNetwork?: boolean;
}
const APP_DATA_KIND = 30078; // NIP-78 application-specific data
export function useSyncedAppData(options: UseSyncedAppDataOptions) {
- const { dTag, alt, empty, merge, enabled = true, debounceMs = 4000 } = options;
+ const {
+ dTag,
+ alt,
+ empty,
+ merge,
+ enabled = true,
+ debounceMs = 4000,
+ interactiveNetwork = false,
+ } = options;
const { nostr } = useNostr();
const { user } = useCurrentUser();
@@ -76,6 +92,13 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) {
// they keep normal refetching.)
const interactive = isInteractiveSigner(user, logins);
+ // For an interactive signer the relay copy is OPT-IN. `staleTime: Infinity`
+ // only stops *refetching* — it never stopped the FIRST decrypt, so the app
+ // still popped Amber once per blob (twice: read-state + preferences, and
+ // racing mounts could multiply that). The localStorage copy is the session
+ // source of truth, so we skip the network entirely unless a caller opts in.
+ const canUseNetwork = !interactive || interactiveNetwork;
+
const queryKey = useMemo(
() => ['synced-app-data', dTag, user?.pubkey] as const,
[dTag, user?.pubkey],
@@ -84,7 +107,8 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) {
// ── Fetch + decrypt the single event ──────────────────────────────────────
const { data, isLoading } = useQuery({
queryKey,
- enabled: enabled && canSync,
+ // Interactive signer → no decrypt unless the caller opted in (see above).
+ enabled: enabled && canSync && canUseNetwork,
// For interactive signers: never consider it stale and never evict it from
// memory during the session → one decrypt, then cache hits.
staleTime: interactive ? Infinity : 60_000,
@@ -178,7 +202,9 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) {
*/
const patch = useCallback(
async (producePatch: T | ((current: T) => T)) => {
- if (!canSync) return;
+ // Local-only for an interactive signer: publishing costs an Amber
+ // encrypt + sign, and reading the remote copy first costs a decrypt.
+ if (!canSync || !canUseNetwork) return;
const latest = await fetchLatest();
const asPatch =
@@ -195,7 +221,7 @@ export function useSyncedAppData(options: UseSyncedAppDataOptions) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { void publishNow(); }, debounceMs);
},
- [canSync, fetchLatest, merge, queryClient, queryKey, publishNow, debounceMs],
+ [canSync, canUseNetwork, fetchLatest, merge, queryClient, queryKey, publishNow, debounceMs],
);
// Flush any pending write when the tab is hidden/closed so a quick glance +
diff --git a/src/lib/routing.ts b/src/lib/routing.ts
index b27c461..3f0758b 100644
--- a/src/lib/routing.ts
+++ b/src/lib/routing.ts
@@ -56,6 +56,13 @@ export interface RouteResult {
durationS: number;
/** Which mode the route was requested for. */
mode: TravelMode;
+ /** Turn-by-turn instructions (orientation aid; not live navigation). */
+ steps?: RouteStep[];
+ /**
+ * True when the numbers came from the car-only fallback server, so the
+ * distance/time are a driving approximation for the chosen mode.
+ */
+ approximate?: boolean;
/** Present only for transit routes — the chosen public-transport itinerary. */
transit?: TransitItinerary;
}
@@ -86,18 +93,104 @@ export interface TransitItinerary {
/** Attribution required by Transitous / OpenStreetMap. */
export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors';
-const OSRM_BASE = 'https://router.project-osrm.org/route/v1';
+/**
+ * Real per-profile OSRM endpoints (FOSSGIS / OpenStreetMap Germany).
+ *
+ * ⚠️ The old `router.project-osrm.org` demo server hosts ONE dataset (car) and
+ * **ignores the profile segment of the URL** — foot/bike/car all came back with
+ * the same distance and time, so the mode toggle looked broken and the times
+ * were dishonest. FOSSGIS runs a separate engine per profile, so foot really is
+ * a walking route and bike a cycling one (they differ in distance AND time).
+ * It sends `Access-Control-Allow-Origin: *`, so we can call it directly.
+ */
+const OSRM_PROFILE_BASE: Record<'foot' | 'bike' | 'car', string> = {
+ foot: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
+ bike: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
+ car: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
+};
+
+/** Last-resort fallback: the demo server (car-only, hence approximate). */
+const OSRM_FALLBACK_BASE = 'https://router.project-osrm.org/route/v1';
+
const CORS_PROXY = 'https://proxy.shakespeare.diy/?url=';
+interface OsrmStep {
+ distance?: number;
+ name?: string;
+ maneuver?: { type?: string; modifier?: string; location?: [number, number] };
+}
+
+interface OsrmLeg {
+ steps?: OsrmStep[];
+}
+
interface OsrmResponse {
code: string;
routes?: Array<{
distance: number;
duration: number;
geometry: { coordinates: Array<[number, number]> }; // [lng, lat]
+ legs?: OsrmLeg[];
}>;
}
+/** One turn-by-turn instruction (used for orientation, not live navigation). */
+export interface RouteStep {
+ /** Human instruction, e.g. "Turn left onto Sukhumvit Rd". */
+ instruction: string;
+ /** Length of this step in metres. */
+ distanceM: number;
+ /** Where the manoeuvre happens. */
+ lat: number;
+ lng: number;
+}
+
+/** Turn OSRM's terse maneuver codes into something readable. */
+function maneuverLabel(type: string, modifier: string): string {
+ const t = type.toLowerCase();
+ const m = modifier.toLowerCase();
+ if (t === 'depart') return 'Head out';
+ if (t === 'arrive') return 'Arrive at the spot';
+ if (t === 'new name') return 'Continue';
+ if (t === 'continue') return m.includes('left') ? 'Bear left' : m.includes('right') ? 'Bear right' : 'Continue';
+ if (t === 'turn') {
+ if (m.includes('sharp left')) return 'Sharp left';
+ if (m.includes('sharp right')) return 'Sharp right';
+ if (m.includes('slight left')) return 'Bear left';
+ if (m.includes('slight right')) return 'Bear right';
+ if (m.includes('left')) return 'Turn left';
+ if (m.includes('right')) return 'Turn right';
+ return 'Turn';
+ }
+ if (t === 'fork') return m.includes('left') ? 'Keep left' : m.includes('right') ? 'Keep right' : 'Take the fork';
+ if (t === 'merge') return 'Merge';
+ if (t === 'roundabout' || t === 'rotary') return 'Take the roundabout';
+ if (t === 'end of road') return m.includes('left') ? 'Turn left' : 'Turn right';
+ if (t === 'on ramp') return 'Take the ramp';
+ if (t === 'off ramp') return 'Take the exit';
+ return t.replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function parseSteps(data: OsrmResponse): RouteStep[] {
+ const route = data.routes?.[0];
+ if (!route?.legs) return [];
+ const steps: RouteStep[] = [];
+ for (const leg of route.legs) {
+ for (const s of leg.steps ?? []) {
+ const label = maneuverLabel(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
+ const road = (s.name ?? '').trim();
+ const loc = s.maneuver?.location;
+ steps.push({
+ instruction: road && !label.startsWith('Arrive') ? `${label} onto ${road}` : label,
+ distanceM: s.distance ?? 0,
+ lat: loc?.[1] ?? 0,
+ lng: loc?.[0] ?? 0,
+ });
+ }
+ }
+ return steps;
+}
+
/** Error message constants the UI branches on (kept in sync with RouteDialog). */
export const ROUTE_ERR_NO_ROUTE = 'No route found';
export const ROUTE_ERR_BUSY = 'Routing service busy';
@@ -149,9 +242,26 @@ export async function fetchRoute(
if (!isPoint(from) || !isPoint(to)) throw new Error(ROUTE_ERR_NO_ROUTE);
// OSRM wants lon,lat;lon,lat
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`;
- const target = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson`;
+ const query = `overview=full&geometries=geojson&steps=true`;
+
+ // 1) The real per-profile server (foot ≠ bike ≠ car distances and times).
+ let data: OsrmResponse | null = null;
+ let approximate = false;
+ try {
+ data = await fetchOsrm(`${OSRM_PROFILE_BASE[profile]}/${coords}?${query}`, signal);
+ if (data.code !== 'Ok') data = null;
+ } catch (e) {
+ if ((e as Error)?.name === 'AbortError') throw e;
+ }
+
+ // 2) Fallback: the demo server, which is CAR-ONLY — flag the result as
+ // approximate so we can say so plainly rather than passing driving
+ // numbers off as a walking/cycling route.
+ if (!data) {
+ data = await fetchOsrm(`${OSRM_FALLBACK_BASE}/driving/${coords}?${query}`, signal);
+ approximate = profile !== 'car';
+ }
- const data = await fetchOsrm(target, signal);
if (data.code === 'TooManyRequests') throw new Error(ROUTE_ERR_BUSY);
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error(ROUTE_ERR_NO_ROUTE);
@@ -168,6 +278,8 @@ export async function fetchRoute(
distanceM: route.distance,
durationS: route.duration,
mode,
+ steps: parseSteps(data),
+ approximate: approximate || undefined,
};
}