feat(directions): follow mode, choose-your-start, offline route cache

Follow mode (explicit Start; off by default — a watcher drains battery):
watchPosition in geolocation.ts, live dot, 'NEXT · step N' banner with
distance-to-turn + distance-to-go, current step highlighted, map follows.
A transient watch error (timeout/unavailable) no longer kills it — only a
permission denial does; the watcher recovers on its own.

Choose your start: 'From → My location / Choose on map' — tapping the map sets
the origin, and the route recomputes from there.

Offline: src/lib/routeCache.ts keeps the last route per destination+mode with
the line downsampled to <=250 points. If a fetch fails (no signal / routing
server down) the dialog falls back to it with an 'Offline · saved route {age}'
badge instead of a dead error.

Also tidied the map overlays (offline badge bottom-left, Whole route
bottom-right) so nothing covers Leaflet's zoom control.

Verified with CDP: controls present; follow banner tracks movement (step 1 ->
step 7, distances update); blocking routing shows the cached route + badge;
picking a start on the map changed the route (968 m -> 922 m). Still no voice
guidance or re-routing if you leave the route (documented).
This commit is contained in:
SkateSpots 2026-09-27 19:11:14 +07:00
parent 59e5829b2a
commit 5a819d5a31
5 changed files with 499 additions and 19 deletions

View file

@ -208,7 +208,20 @@ session with **no other memory of this project**. So:
- ⚠️ `index.css` has a global mobile rule `button, [role=button] { min-height:
44px; min-width: 44px }` for touch targets. Any dense list of `<button>` rows
(like route steps) must add **`min-h-0`** or every row becomes a 44px slab —
a class beats the element selector, so `min-h-0` is enough. **Transit** mode uses **Transitous** (`api.transitous.org`,
a class beats the element selector, so `min-h-0` is enough.
- **Follow mode / start point / offline (2026-09-27):** the dialog also does
**follow mode** (explicit **Start** → `watchPosition` in `geolocation.ts`,
live dot, "NEXT · step N" banner with distance-to-turn + distance-to-go,
current step highlighted), a **start point** choice (My location / Choose on
map — tapping the Leaflet map sets it), and an **offline route cache**
(`src/lib/routeCache.ts`, downsamples the line to ≤250 points, keeps the last
8 destination+mode entries; the failed fetch falls back to it and shows
"Offline · saved route {age}"). Rules: **never auto-start the watcher**
(battery — user action only) and always `stop()` it on close/unmount; a
transient watch error (timeout/temporarily unavailable) must **not** tear down
the watcher — only `denied` does (headless CDP testing made this obvious:
moving the mocked position emits a transient error that recovered).
Still deliberately absent: voice guidance and re-routing if you leave the route. **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

View file

@ -18,6 +18,12 @@ v1.5.2
"Whole route" to zoom back out.
- Shows an arrival time ("arrive ~08:10 PM") and remembers the mode you picked
last time, so you're not sent back to walking on every trip.
- Tap Start and it follows you: your dot moves, the current step highlights, and
it tells you the next turn and how far is left. (No voice guidance yet.)
- Going somewhere else first? "From → Choose on map" lets you set the start
point instead of routing from where you're standing.
- Lose signal mid-route and it keeps showing your last route — clearly marked
as saved, instead of going blank.
- 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.

View file

@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import L from 'leaflet';
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed } from 'lucide-react';
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair } from 'lucide-react';
import {
Dialog,
DialogContent,
@ -10,8 +10,16 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { getCurrentPosition, GeolocationError } from '@/lib/geolocation';
import {
getCurrentPosition,
watchPosition,
GeolocationError,
type GeoCoords,
type GeoWatch,
} from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation';
import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache';
import { calculateDistance } from '@/lib/sortOptions';
import {
fetchRoute,
fetchTransitRoute,
@ -132,6 +140,36 @@ export function RouteDialog({
}, []);
/** Which step the user tapped (highlights + pans the map). */
const [activeStep, setActiveStep] = useState<number | null>(null);
// ── Start point: my location, or a point the user picks on the map ─────────
const [startMode, setStartMode] = useState<'me' | 'picked'>('me');
const [picked, setPicked] = useState<GeoCoords | null>(null);
const [pickingStart, setPickingStart] = useState(false);
// ── Follow mode (explicit; owns a GPS watcher) ─────────────────────────────
const [following, setFollowing] = useState(false);
const [live, setLive] = useState<GeoCoords | null>(null);
const [followError, setFollowError] = useState<string | null>(null);
const watchRef = useRef<GeoWatch | null>(null);
/** Route shown while offline came from the cache (with its age). */
const [cachedAge, setCachedAge] = useState<number | null>(null);
/** Where the route starts: a picked point wins, else the device location. */
const effectiveOrigin = useMemo<GeoCoords | null>(
() => (startMode === 'picked' && picked ? picked : origin),
[startMode, picked, origin],
);
/** Where to draw the "you are here" dot — live position while following. */
const markerPos = useMemo<GeoCoords | null>(
() => (following && live ? live : effectiveOrigin),
[following, live, effectiveOrigin],
);
// Keep the map-click handler current without rebuilding the map.
const pickingStartRef = useRef(false);
pickingStartRef.current = pickingStart;
const [route, setRoute] = useState<RouteResult | null>(null);
const [locating, setLocating] = useState(false);
const [routing, setRouting] = useState(false);
@ -150,6 +188,75 @@ export function RouteDialog({
setActiveStep(null);
}, [route]);
// ── Follow mode ────────────────────────────────────────────────────────────
// Explicit only: the user taps Start, we watch their position, and we stop the
// watcher the moment they stop or the dialog closes. No voice guidance.
const stopFollowing = useCallback(() => {
watchRef.current?.stop();
watchRef.current = null;
setFollowing(false);
}, []);
const startFollowing = useCallback(() => {
if (watchRef.current) return;
setFollowError(null);
setFollowing(true);
watchRef.current = watchPosition(
(coords) => {
setLive(coords);
setFollowError(null);
},
(err) => {
// Only a real permission denial stops following. A timeout / temporary
// loss is normal (tunnels, indoors) and the watcher recovers by itself,
// so we keep running and just say we're waiting.
if (err.kind === 'denied') {
setFollowError('Location permission denied.');
stopFollowing();
} else {
setFollowError('Waiting for a GPS fix…');
}
},
);
}, [stopFollowing]);
// Never leave the GPS running: stop on close and on unmount.
useEffect(() => {
if (!open && watchRef.current) stopFollowing();
}, [open, stopFollowing]);
useEffect(
() => () => {
watchRef.current?.stop();
watchRef.current = null;
},
[],
);
// While following, keep the live dot centred.
useEffect(() => {
if (!following || !live) return;
mapRef.current?.panTo([live.lat, live.lng]);
}, [following, live]);
/** Live guidance: distance left, and which step you're on. */
const nav = useMemo(() => {
if (!live || !destination) return null;
const toDestKm = calculateDistance(live.lat, live.lng, destination.lat, destination.lng);
let bestI = -1;
let bestKm = Infinity;
(route?.steps ?? []).forEach((s, i) => {
if (!s.lat && !s.lng) return;
const km = calculateDistance(live.lat, live.lng, s.lat, s.lng);
if (km < bestKm) {
bestKm = km;
bestI = i;
}
});
return { toDestKm, stepIndex: bestI, stepKm: bestKm };
}, [live, destination, route]);
const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep;
// ─── Get the user's location when the dialog opens ─────────────────────────
const locate = useCallback(async () => {
setLocating(true);
@ -191,6 +298,8 @@ export function RouteDialog({
if (!open) {
setRoute(null);
setError(null);
setPickingStart(false);
setCachedAge(null);
}
}, [open]);
@ -226,9 +335,19 @@ export function RouteDialog({
mapRef.current = map;
// "Choose on map" — the next tap sets the start point.
const onMapClick = (e: L.LeafletMouseEvent) => {
if (!pickingStartRef.current) return;
setPicked({ lat: e.latlng.lat, lng: e.latlng.lng });
setStartMode('picked');
setPickingStart(false);
};
map.on('click', onMapClick);
setTimeout(() => map.invalidateSize(), 120);
return () => {
map.off('click', onMapClick);
map.remove();
mapRef.current = null;
routeLayersRef.current = null;
@ -237,13 +356,13 @@ export function RouteDialog({
};
}, [open, mapDiv, destination]);
// ─── Draw / update the origin marker when we have the user's location ──────
// ─── Draw / update the "from" marker (device location, picked point, live) ──
useEffect(() => {
const map = mapRef.current;
if (!map || !origin) return;
if (!map || !markerPos) return;
if (originMarkerRef.current) {
originMarkerRef.current.setLatLng([origin.lat, origin.lng]);
originMarkerRef.current.setLatLng([markerPos.lat, markerPos.lng]);
} else {
const userIcon = L.divIcon({
className: 'route-origin-icon',
@ -251,15 +370,15 @@ export function RouteDialog({
iconSize: [18, 18],
iconAnchor: [9, 9],
});
originMarkerRef.current = L.marker([origin.lat, origin.lng], {
originMarkerRef.current = L.marker([markerPos.lat, markerPos.lng], {
icon: userIcon,
}).addTo(map);
}
}, [origin]);
}, [markerPos]);
// ─── Fetch + draw the route whenever origin / mode changes ─────────────────
useEffect(() => {
if (!open || !origin || !destination) return;
if (!open || !effectiveOrigin || !destination) return;
const controller = new AbortController();
let cancelled = false;
@ -269,10 +388,12 @@ export function RouteDialog({
setError(null);
try {
const result = mode === 'transit'
? await fetchTransitRoute(origin, destination, controller.signal)
: await fetchRoute(origin, destination, mode, controller.signal);
? await fetchTransitRoute(effectiveOrigin, destination, controller.signal)
: await fetchRoute(effectiveOrigin, destination, mode, controller.signal);
if (cancelled) return;
setCachedAge(null);
setRoute(result);
if (mode !== 'transit') saveRoute(destination, mode, result);
const map = mapRef.current;
if (map) {
@ -329,6 +450,32 @@ export function RouteDialog({
} else {
setError("Couldn't reach the routing service. Check your connection and try again.");
}
// Offline / server down: fall back to the last route we saved for this
// destination + mode, clearly labelled, instead of a dead dialog.
if (mode !== 'transit') {
const hit = loadRoute(destination, mode);
if (hit) {
setRoute(hit.route);
setCachedAge(hit.ageMs);
setError(null);
const map = mapRef.current;
if (map && hit.route.coordinates.length > 1) {
if (routeLayersRef.current) routeLayersRef.current.remove();
const group = L.layerGroup().addTo(map);
L.polyline(hit.route.coordinates, {
color: '#ccff00',
weight: 5,
opacity: 0.7,
dashArray: '8 8',
lineJoin: 'round',
lineCap: 'round',
}).addTo(group);
routeLayersRef.current = group;
map.fitBounds(L.latLngBounds(hit.route.coordinates), { padding: [48, 48], maxZoom: 16 });
}
}
}
} finally {
if (!cancelled) setRouting(false);
}
@ -338,7 +485,7 @@ export function RouteDialog({
cancelled = true;
controller.abort();
};
}, [open, origin, destination, mode]);
}, [open, effectiveOrigin, destination, mode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -376,6 +523,41 @@ export function RouteDialog({
))}
</div>
{/* Start point — default is where you are; you can pick another on the map */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
From
</span>
<button
type="button"
onClick={() => {
setStartMode('me');
setPickingStart(false);
}}
className={cn(
'rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
startMode === 'me'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
My location
</button>
<button
type="button"
onClick={() => setPickingStart((v) => !v)}
className={cn(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
pickingStart || startMode === 'picked'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
<Crosshair className="h-3 w-3" />
{pickingStart ? 'Tap the map…' : 'Choose on map'}
</button>
</div>
{/* Map. The height lives on the WRAPPER: the global
`.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to
@ -399,10 +581,54 @@ export function RouteDialog({
</div>
)}
{locating && origin && (
<div className="pointer-events-none absolute bottom-2 left-2 flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
<Loader2 className="h-3 w-3 animate-spin" />
Refining location…
{/* Bottom-left stack: offline badge + "refining location" chip. Kept
out of the top-left so it never covers Leaflet's zoom control. */}
{(cachedAge !== null || (locating && origin)) && (
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
{cachedAge !== null && (
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
Offline · saved route {formatAge(cachedAge)}
</div>
)}
{locating && origin && (
<div className="flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
<Loader2 className="h-3 w-3 animate-spin" />
Refining location…
</div>
)}
</div>
)}
{/* "Choose on map" hint */}
{pickingStart && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/80 px-3 py-2 text-center text-[11px] font-bold text-[#ccff00] backdrop-blur-sm">
Tap the map to set your start point
</div>
)}
{/* Follow mode: next turn + distance left */}
{following && nav && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
<p className="text-[10px] font-black uppercase tracking-widest text-[#ccff00]">
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
</p>
<p className="truncate text-sm font-bold text-white">
{nav.stepIndex >= 0
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
</p>
<p className="text-[11px] text-gray-400">
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
? `${formatDistance(nav.stepKm)} to the turn · `
: ''}
{formatDistance(nav.toDestKm)} to go
</p>
</div>
)}
{followError && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 text-center text-[11px] font-bold text-amber-300 backdrop-blur-sm">
{followError}
</div>
)}
@ -424,7 +650,7 @@ export function RouteDialog({
<button
type="button"
onClick={fitRoute}
className="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
aria-label="Fit the whole route on the map"
>
<LocateFixed className="h-3.5 w-3.5" />
@ -508,6 +734,37 @@ export function RouteDialog({
)}
</div>
{/* Follow mode: explicit, and honest that there's no voice guidance. */}
{route && !route.transit && (
<div className="flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
{following ? (
<Button
size="sm"
variant="outline"
onClick={stopFollowing}
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
>
<Square className="h-3.5 w-3.5" />
Stop
</Button>
) : (
<Button
size="sm"
onClick={startFollowing}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Play className="h-3.5 w-3.5" />
Start
</Button>
)}
<p className="min-w-0 flex-1 text-[10px] leading-snug text-muted-foreground">
{following
? 'Following your position and the next turn. No voice guidance.'
: 'Follows your position and shows the next turn as you roll.'}
</p>
</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">
@ -534,7 +791,7 @@ export function RouteDialog({
// rule for `button`, which otherwise made every step row a
// giant 44px slab.
'flex min-h-0 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors',
activeStep === i ? 'bg-[#ccff00]/10 text-white' : 'hover:bg-white/5',
currentStepIndex === i ? 'bg-[#ccff00]/10 text-white' : 'hover:bg-white/5',
)}
>
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">

View file

@ -171,6 +171,99 @@ export async function getCurrentPosition(): Promise<GeoCoords> {
return coords;
}
export interface GeoWatch {
/** Stop watching. Safe to call more than once. */
stop: () => void;
}
/**
* Watch the device position (for "follow me" navigation).
*
* EXPLICIT ONLY — never call this without a user action: a watcher runs the GPS
* continuously and drains battery. The caller owns the `GeoWatch` and must
* `stop()` it on unmount / when the user stops following.
*
* Same platform posture as `getCurrentPosition`: on native we use the plugin
* only to make sure the OS permission is granted, then take positions from the
* WebView's `navigator.geolocation` (avoids the Play-Services path that crashes
* de-Googled ROMs).
*/
export function watchPosition(
onUpdate: (coords: GeoCoords) => void,
onError?: (err: GeolocationError) => void,
): GeoWatch {
let watchId: number | null = null;
let stopped = false;
const start = async () => {
const native = getNativeGeo();
if (native) {
try {
const safe = <T>(fn: () => Promise<T>) => Promise.resolve().then(fn);
let status = await safe(() => native.checkPermissions()).catch(() => null);
if (!status || (status.location !== 'granted' && status.coarseLocation !== 'granted')) {
status = await safe(() =>
native.requestPermissions({ permissions: ['location', 'coarseLocation'] }),
).catch(() => null);
}
if (status && status.location !== 'granted' && status.coarseLocation !== 'granted') {
onError?.(new GeolocationError('denied', 'Location permission denied.'));
return;
}
} catch {
// Permission bridge failed — fall through and let the WebView try.
}
}
if (stopped) return;
if (!('geolocation' in navigator)) {
onError?.(new GeolocationError('unavailable', 'Geolocation is not supported.'));
return;
}
try {
watchId = navigator.geolocation.watchPosition(
(pos) => {
const coords = { lat: pos.coords.latitude, lng: pos.coords.longitude };
saveLastLocation(coords.lat, coords.lng);
onUpdate(coords);
},
(err) => {
// Distinguish a real denial from a transient one: a watcher commonly
// emits TIMEOUT while waiting for the first fix (indoors, tunnels),
// then recovers. Callers should NOT tear down the watcher for those.
if (err.code === err.PERMISSION_DENIED) {
onError?.(new GeolocationError('denied', 'Location permission denied.'));
} else if (err.code === err.TIMEOUT) {
onError?.(new GeolocationError('timeout', 'Waiting for a GPS fix.'));
} else {
onError?.(new GeolocationError('unavailable', 'Location temporarily unavailable.'));
}
},
{ enableHighAccuracy: false, maximumAge: 5000, timeout: 30000 },
);
} catch {
onError?.(new GeolocationError('unavailable', 'Could not start location tracking.'));
}
};
void start();
return {
stop: () => {
stopped = true;
if (watchId !== null) {
try {
navigator.geolocation.clearWatch(watchId);
} catch {
// ignore
}
watchId = null;
}
},
};
}
/**
* Native path.
*

111
src/lib/routeCache.ts Normal file
View file

@ -0,0 +1,111 @@
import type { RouteResult } from '@/lib/routing';
/**
* Tiny localStorage cache of the last few routes.
*
* Why: routing needs the network. If you lose signal mid-skate (or the routing
* server is briefly down), Directions would show nothing at all. Keeping the
* last route per destination+mode means we can still show the line, the time and
* the steps — clearly labelled as cached — instead of an error.
*
* The full OSRM geometry can be thousands of points, so the line is
* DOWNSAMPLED before storing (a route line at this zoom doesn't need them all).
* Steps are kept verbatim — they're what you actually read.
*/
const KEY = 'skatespots:route-cache:v1';
const MAX_ENTRIES = 8;
const MAX_POINTS = 250;
interface StoredRoute {
at: number;
distanceM: number;
durationS: number;
steps?: RouteResult['steps'];
coordinates: Array<[number, number]>;
}
type Store = Record<string, StoredRoute>;
function keyFor(to: { lat: number; lng: number }, mode: string): string {
return `${to.lat.toFixed(4)},${to.lng.toFixed(4)}:${mode}`;
}
function read(): Store {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Store) : {};
} catch {
return {};
}
}
function write(store: Store): void {
try {
localStorage.setItem(KEY, JSON.stringify(store));
} catch {
// quota / private mode — a cache is a nicety, never an error
}
}
/** Keep every Nth point so the stored line stays small. */
function downsample(coords: Array<[number, number]>, max = MAX_POINTS): Array<[number, number]> {
if (coords.length <= max) return coords;
const step = Math.ceil(coords.length / max);
const out: Array<[number, number]> = [];
for (let i = 0; i < coords.length; i += step) out.push(coords[i]);
const last = coords[coords.length - 1];
if (out[out.length - 1] !== last) out.push(last);
return out;
}
/** Remember a freshly-fetched route (best effort). */
export function saveRoute(
to: { lat: number; lng: number },
mode: string,
route: RouteResult,
): void {
if (!route.coordinates?.length) return;
const store = read();
store[keyFor(to, mode)] = {
at: Date.now(),
distanceM: route.distanceM,
durationS: route.durationS,
steps: route.steps,
coordinates: downsample(route.coordinates),
};
// Keep only the newest few destinations so this can never grow unbounded.
const newest = Object.entries(store)
.sort((a, b) => b[1].at - a[1].at)
.slice(0, MAX_ENTRIES);
write(Object.fromEntries(newest));
}
/** Read a cached route for this destination+mode, with how old it is. */
export function loadRoute(
to: { lat: number; lng: number },
mode: string,
): { route: RouteResult; ageMs: number } | null {
const stored = read()[keyFor(to, mode)];
if (!stored || !Array.isArray(stored.coordinates) || stored.coordinates.length < 2) return null;
return {
route: {
coordinates: stored.coordinates,
distanceM: stored.distanceM,
durationS: stored.durationS,
steps: stored.steps,
mode: mode as RouteResult['mode'],
approximate: false,
},
ageMs: Date.now() - stored.at,
};
}
/** "just now" | "12 min ago" | "2 h ago" — for the cached-route badge. */
export function formatAge(ageMs: number): string {
const mins = Math.round(ageMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours} h ago`;
return `${Math.round(hours / 24)} d ago`;
}