diff --git a/AGENTS.md b/AGENTS.md index 9a35c62..03a276a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` + + + {/* 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({ )} - {locating && origin && ( -
- - 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)) && ( +
+ {cachedAge !== null && ( +
+ Offline · saved route {formatAge(cachedAge)} +
+ )} + {locating && origin && ( +
+ + Refining location… +
+ )} +
+ )} + + {/* "Choose on map" hint */} + {pickingStart && ( +
+ Tap the map to set your start point +
+ )} + + {/* Follow mode: next turn + distance left */} + {following && nav && ( +
+

+ Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''} +

+

+ {nav.stepIndex >= 0 + ? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line' + : route?.steps?.[0]?.instruction ?? 'Follow the line'} +

+

+ {nav.stepIndex >= 0 && Number.isFinite(nav.stepKm) + ? `${formatDistance(nav.stepKm)} to the turn · ` + : ''} + {formatDistance(nav.toDestKm)} to go +

+
+ )} + + {followError && ( +
+ {followError}
)} @@ -424,7 +650,7 @@ export function RouteDialog({
+ {/* Follow mode: explicit, and honest that there's no voice guidance. */} + {route && !route.transit && ( +
+ {following ? ( + + ) : ( + + )} +

+ {following + ? 'Following your position and the next turn. No voice guidance.' + : 'Follows your position and shows the next turn as you roll.'} +

+
+ )} + {/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */} {mode !== 'transit' && route?.steps && route.steps.length > 1 && (
@@ -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', )} > diff --git a/src/lib/geolocation.ts b/src/lib/geolocation.ts index 5c6d6ba..605a9cb 100644 --- a/src/lib/geolocation.ts +++ b/src/lib/geolocation.ts @@ -171,6 +171,99 @@ export async function getCurrentPosition(): Promise { 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 = (fn: () => Promise) => 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. * diff --git a/src/lib/routeCache.ts b/src/lib/routeCache.ts new file mode 100644 index 0000000..94b5365 --- /dev/null +++ b/src/lib/routeCache.ts @@ -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; + +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`; +}