feat(directions): make the route dialog usable as guidance, not just a preview

Answering 'are we giving the best navigation experience?': no, it was a preview
with dead ends. Fixes the concrete gaps:

- ETA: 'arrive ~08:10 PM' alongside the duration.
- Mode memory: the last travel mode is remembered (skatespots:route-mode), so
  cyclists/drivers aren't reset to Skate/Walk every trip.
- Tappable steps: each step pans the map and highlights; added a 'Whole route'
  re-fit button to zoom back out.
- Taller route map on desktop (h-64 sm:h-80).
- min-h-0 on step rows: the app-wide 44px mobile touch-target rule was making
  every step a 44px slab (rows now 29px).

Still out of scope (documented, not faked): live turn-by-turn following, voice
guidance and offline routing.
This commit is contained in:
SkateSpots 2026-09-27 18:47:29 +07:00
parent e69bace784
commit 59e5829b2a
3 changed files with 93 additions and 14 deletions

View file

@ -201,7 +201,14 @@ session with **no other memory of this project**. So:
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`,
estimate ("~6 min on a board") next to the walking time. The dialog also
remembers the last mode (`localStorage: skatespots:route-mode`), shows an ETA
("arrive ~08:10 PM"), and each step row is **tappable** (pans the map + highlights)
with a "Whole route" re-fit button.
- ⚠️ `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`,
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

@ -14,7 +14,10 @@ v1.5.2
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.
names, not just a line on a map. Tap any step to jump to it on the map, and use
"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.
- 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

@ -86,6 +86,14 @@ interface RouteDialogProps {
* maps app is opened — the route comes from the keyless public OSRM server and
* is drawn here. See src/lib/routing.ts for the honest scope notes.
*/
/** Local clock time you'd arrive if you set off now. */
function etaClock(durationS: number): string {
return new Date(Date.now() + durationS * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
export function RouteDialog({
open,
onOpenChange,
@ -104,13 +112,44 @@ export function RouteDialog({
const destMarkerRef = useRef<L.Marker | null>(null);
const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null);
const [mode, setMode] = useState<TravelMode>('foot');
// Remember the last mode used — a cyclist or driver shouldn't be reset to
// "Skate / Walk" every time they open Directions.
const [mode, setMode] = useState<TravelMode>(() => {
try {
const saved = localStorage.getItem('skatespots:route-mode') as TravelMode | null;
return saved && TRAVEL_MODES.some((m) => m.mode === saved) ? saved : 'foot';
} catch {
return 'foot';
}
});
const chooseMode = useCallback((next: TravelMode) => {
setMode(next);
try {
localStorage.setItem('skatespots:route-mode', next);
} catch {
// ignore (private mode / quota)
}
}, []);
/** Which step the user tapped (highlights + pans the map). */
const [activeStep, setActiveStep] = useState<number | null>(null);
const [route, setRoute] = useState<RouteResult | null>(null);
const [locating, setLocating] = useState(false);
const [routing, setRouting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [locError, setLocError] = useState<string | null>(null);
/** Re-fit the map to the whole route (after tapping a step pans it away). */
const fitRoute = useCallback(() => {
const map = mapRef.current;
if (!map || !route || route.coordinates.length < 2) return;
map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 });
}, [route]);
// A new route (mode/origin change) invalidates the tapped-step focus.
useEffect(() => {
setActiveStep(null);
}, [route]);
// ─── Get the user's location when the dialog opens ─────────────────────────
const locate = useCallback(async () => {
setLocating(true);
@ -322,7 +361,7 @@ export function RouteDialog({
<button
key={m.mode}
type="button"
onClick={() => setMode(m.mode)}
onClick={() => chooseMode(m.mode)}
className={cn(
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
mode === m.mode
@ -341,7 +380,7 @@ export function RouteDialog({
`.leaflet-container { height: 100% }` override beats a `h-64` on the
map element itself, and against a height-less parent that resolved to
0 — a fully invisible map with no route line. */}
<div className="relative h-64">
<div className="relative h-64 sm:h-80">
<div
ref={setMapDiv}
className="h-full w-full bg-muted"
@ -379,6 +418,19 @@ export function RouteDialog({
</div>
</div>
)}
{/* Re-fit the whole route — handy after tapping a step pans the map. */}
{route && route.coordinates.length > 1 && (
<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"
aria-label="Fit the whole route on the map"
>
<LocateFixed className="h-3.5 w-3.5" />
Whole route
</button>
)}
</div>
{/* Summary bar */}
@ -434,6 +486,7 @@ export function RouteDialog({
</div>
<div className="text-xs text-muted-foreground">
{formatDuration(route.durationS)}
<span className="ml-1">· arrive ~{etaClock(route.durationS)}</span>
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span className="ml-1">· uses driving roads</span>
)}
@ -468,16 +521,32 @@ export function RouteDialog({
</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)}
<li key={`${i}-${s.instruction}`}>
<button
type="button"
onClick={() => {
setActiveStep(i);
const map = mapRef.current;
if (map && s.lat && s.lng) map.panTo([s.lat, s.lng]);
}}
className={cn(
// `min-h-0` overrides the app-wide 44px mobile touch-target
// 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',
)}
>
<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>
)}
</button>
</li>
))}
</ol>