fix(amber,routing): scope Amber prompts to real actions; real per-mode routes + steps

AMBER — 'pops on every screen':
staleTime: Infinity only stops REFETCHING, never the FIRST decrypt. So the
encrypted useSyncedAppData blobs (read-state, preferences) each ran a queryFn on
mount (racing consumers multiplied it) and relay responses landing during
navigation made it look per-page. useSyncedAppData now has interactiveNetwork
(default false): for an interactive signer the blob is LOCAL-ONLY — query not
even enabled, patch() a no-op — no decrypt on mount, no publish on a nav-tab
change. Verified with a mocked x-amber login: network-on => Amber op at startup,
with the fix => 0. (useCrewDismissals/useStash were already gated.)

ROUTING — 'same time for every transport option':
router.project-osrm.org hosts ONE car dataset and ignores the profile in the URL,
so foot/bike/car returned identical numbers. Switched to the FOSSGIS per-profile
servers (routing.openstreetmap.de/routed-{foot,bike,car}); the demo server is now
only a last-resort fallback and its result is flagged approximate ('driving
estimate'). Request steps=true and surface turn-by-turn steps for orientation,
plus a '~X min on a board' skate estimate next to the walking time. Verified:
same trip => walk 13 min / bike 5 / moto+car 2, with 5-9 steps each.
This commit is contained in:
SkateSpots 2026-09-27 18:35:17 +07:00
parent 6364f08ede
commit e69bace784
5 changed files with 222 additions and 13 deletions

View file

@ -189,9 +189,19 @@ session with **no other memory of this project**. So:
- **Routing / directions**: `RouteDialog` is the single in-app router, opened - **Routing / directions**: `RouteDialog` is the single in-app router, opened
from the map spot sheet, the food cards, and the spot / shop / event detail 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). 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 Road modes (skate-walk / bike / moto / car) come from keyless **OSRM**
`proxy.shakespeare.diy` CORS proxy (`src/lib/routing.ts`); moto≈car is (`src/lib/routing.ts`). ⚠️ **Use the per-profile FOSSGIS servers, not the demo
labelled honestly. **Transit** mode uses **Transitous** (`api.transitous.org`, 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 MOTIS 2, open CORS) — note origin/destination go to that service, unlike road
modes. **No external map-app handoff anywhere** (product decision) and no modes. **No external map-app handoff anywhere** (product decision) and no
scooter/moped profile. **Speed (2026-09-27):** `RouteDialog` seeds origin from 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 `isInteractiveSigner` is true, so merely opening the notification bell (which
marks read) no longer pops Amber twice (encrypt + sign). Interactive signers marks read) no longer pops Amber twice (encrypt + sign). Interactive signers
only prompt for real signed actions. 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 - ⚠️ **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** (fixed 2026-09-27).** `/newsletter` was stuck in an **infinite render loop**
(~100% CPU, React "Maximum update depth exceeded") because: `useSyncedAppData` (~100% CPU, React "Maximum update depth exceeded") because: `useSyncedAppData`

View file

@ -10,6 +10,13 @@ v1.5.2
🧭 Directions actually work now 🧭 Directions actually work now
- Fixed the route map never drawing (you'd get an empty dialog and "no route"). - 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. 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. - Opens instantly from your last known location; "Near Me" sorts by distance.
📱 Nav fixes 📱 Nav fixes
@ -25,8 +32,11 @@ v1.5.2
the drops. the drops.
✨ Smoother everywhere ✨ Smoother everywhere
- Amber only pops for things that need a signature — no more prompts just for - Amber is properly scoped now: it only opens for real signed actions (log in /
reading your notifications. 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. - Logo no longer crowds the nav.
--- ---

View file

@ -22,6 +22,7 @@ import {
TRANSIT_ATTRIBUTION, TRANSIT_ATTRIBUTION,
ROUTE_ERR_BUSY, ROUTE_ERR_BUSY,
ROUTE_ERR_NO_ROUTE, ROUTE_ERR_NO_ROUTE,
skateTime,
type TravelMode, type TravelMode,
type RouteResult, type RouteResult,
} from '@/lib/routing'; } from '@/lib/routing';
@ -311,7 +312,7 @@ export function RouteDialog({
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
{mode === 'transit' {mode === 'transit'
? 'Public transport via Transitous (open timetables + OpenStreetMap)' ? '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'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -436,6 +437,10 @@ export function RouteDialog({
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( {TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span className="ml-1">· uses driving roads</span> <span className="ml-1">· uses driving roads</span>
)} )}
{route.approximate && <span className="ml-1">· driving estimate</span>}
{mode === 'foot' && (
<span className="ml-1">· {skateTime(route.distanceM / 1000)} on a board</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -449,6 +454,38 @@ export function RouteDialog({
</div> </div>
)} )}
</div> </div>
{/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */}
{mode !== 'transit' && route?.steps && route.steps.length > 1 && (
<div className="border-t bg-muted/20">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
Route steps
</p>
{mode === 'foot' && (
<p className="text-[10px] text-muted-foreground/70">walking route — quicker on a board</p>
)}
</div>
<ol className="max-h-56 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
{route.steps.map((s, i) => (
<li key={`${i}-${s.instruction}`} className="flex items-start gap-2 text-xs">
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
{i + 1}
</span>
<span className="min-w-0 flex-1 leading-snug text-gray-300">{s.instruction}</span>
{s.distanceM >= 10 && (
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{formatDistance(s.distanceM / 1000)}
</span>
)}
</li>
))}
</ol>
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
Orientation only — no live turn-by-turn or voice guidance yet.
</p>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View file

@ -52,12 +52,28 @@ interface UseSyncedAppDataOptions<T> {
enabled?: boolean; enabled?: boolean;
/** Coalesce writes into at most one publish per this many ms (default 4000). */ /** Coalesce writes into at most one publish per this many ms (default 4000). */
debounceMs?: number; 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 const APP_DATA_KIND = 30078; // NIP-78 application-specific data
export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) { export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
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 { nostr } = useNostr();
const { user } = useCurrentUser(); const { user } = useCurrentUser();
@ -76,6 +92,13 @@ export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
// they keep normal refetching.) // they keep normal refetching.)
const interactive = isInteractiveSigner(user, logins); 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( const queryKey = useMemo(
() => ['synced-app-data', dTag, user?.pubkey] as const, () => ['synced-app-data', dTag, user?.pubkey] as const,
[dTag, user?.pubkey], [dTag, user?.pubkey],
@ -84,7 +107,8 @@ export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
// ── Fetch + decrypt the single event ────────────────────────────────────── // ── Fetch + decrypt the single event ──────────────────────────────────────
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey, 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 // For interactive signers: never consider it stale and never evict it from
// memory during the session → one decrypt, then cache hits. // memory during the session → one decrypt, then cache hits.
staleTime: interactive ? Infinity : 60_000, staleTime: interactive ? Infinity : 60_000,
@ -178,7 +202,9 @@ export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
*/ */
const patch = useCallback( const patch = useCallback(
async (producePatch: T | ((current: T) => T)) => { 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 latest = await fetchLatest();
const asPatch = const asPatch =
@ -195,7 +221,7 @@ export function useSyncedAppData<T>(options: UseSyncedAppDataOptions<T>) {
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { void publishNow(); }, debounceMs); 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 + // Flush any pending write when the tab is hidden/closed so a quick glance +

View file

@ -56,6 +56,13 @@ export interface RouteResult {
durationS: number; durationS: number;
/** Which mode the route was requested for. */ /** Which mode the route was requested for. */
mode: TravelMode; 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. */ /** Present only for transit routes — the chosen public-transport itinerary. */
transit?: TransitItinerary; transit?: TransitItinerary;
} }
@ -86,18 +93,104 @@ export interface TransitItinerary {
/** Attribution required by Transitous / OpenStreetMap. */ /** Attribution required by Transitous / OpenStreetMap. */
export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors'; 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='; 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 { interface OsrmResponse {
code: string; code: string;
routes?: Array<{ routes?: Array<{
distance: number; distance: number;
duration: number; duration: number;
geometry: { coordinates: Array<[number, number]> }; // [lng, lat] 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). */ /** 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_NO_ROUTE = 'No route found';
export const ROUTE_ERR_BUSY = 'Routing service busy'; 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); if (!isPoint(from) || !isPoint(to)) throw new Error(ROUTE_ERR_NO_ROUTE);
// OSRM wants lon,lat;lon,lat // OSRM wants lon,lat;lon,lat
const coords = `${from.lng},${from.lat};${to.lng},${to.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 === 'TooManyRequests') throw new Error(ROUTE_ERR_BUSY);
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error(ROUTE_ERR_NO_ROUTE); throw new Error(ROUTE_ERR_NO_ROUTE);
@ -168,6 +278,8 @@ export async function fetchRoute(
distanceM: route.distance, distanceM: route.distance,
durationS: route.duration, durationS: route.duration,
mode, mode,
steps: parseSteps(data),
approximate: approximate || undefined,
}; };
} }