# V3-08 — Road-snapped routing and ETA **Phase** Route planning · **Depends on** V3-07, benefits from V3-01, **blocked on V3-17** · **Size** L · **Status** Deferred ## Goal Resolve the actual shortest path along roads between pins, and estimate how long the ride will take. ## Context This is what Dylan asked for. It is also the ticket with a **decision that cannot be deferred**: road-snapped routing needs a routing engine over OpenStreetMap data, and the choice has ongoing consequences. **Decided, not deferred:** self-hosted OSRM (see the table below). What's actually deferred is standing one up — that's [V3-17](V3-17-osrm-hosting.md), split out on request because provisioning a server is a different kind of work from building the app code that calls it, and this ticket cannot start until V3-17 has something to point `RoutingService` at. ## Design ### Choosing an engine — decide before writing code | Option | Trade-off | |---|---| | **Public OSRM demo** | Free, zero setup, **explicitly not for production**, rate-limited. Prototype only. | | **Self-hosted OSRM** | Fast, well understood. Needs a machine and a regional OSM extract (a province is a few GB). | | **GraphHopper** | Self-hostable, good cycling/motorcycle profiles, friendlier ETAs | | **Valhalla** | Best multi-modal profiles, heaviest to run | | **Mapbox / Google** | No ops, per-request billing, an API key shipped in the app | **Profiles matter more here than usual.** A motorcycle route and a bicycle route between the same pins genuinely differ — cycling engines avoid motorways, and a motorcyclist often wants the twisty road rather than the fast one. This is where V3-01 pays off: the activity selects the profile. Put it behind a `RoutingService` interface with a fake, exactly as `LocationSource` did for GPS. That seam is what made swapping the location engine cheap, and the same argument applies here. ### ETA is a promise, and easy to get wrong Engines estimate from posted speed limits. That is not how long *you* take. Once there is history, the rider's own average moving speed for that activity — already stored on every `Trip` — is a better predictor. Show the engine's estimate, then replace it with a personal one once there is enough history to justify it. Label which is which. ### Caching Cache the returned polyline on `Route.geometry`. A saved plan must open offline and must not re-bill a request every time it is viewed. ## Implementation 1. Decide the engine. Write the decision and its reasoning into this file. 2. `RoutingService` interface + implementation + `FakeRoutingService` 3. Resolve on pin change, debounced — not on every drag frame 4. Persist geometry, distance and duration on `Route` 5. Personal ETA from `Trip` history, once ≥5 rides of that activity exist 6. Graceful offline behaviour: fall back to straight lines and say so ## Acceptance criteria - [ ] Pins resolve to a road-following polyline - [ ] Distance reflects the road path, not the straight line - [ ] Activity changes the profile and can change the route - [ ] A saved route renders offline from cached geometry, with no network call - [ ] Offline with no cache degrades to straight lines with a visible explanation - [ ] No API key is committed to the repository ## Tests - `FakeRoutingService` drives every path: success, failure, offline, empty - Cached geometry means no second request — assert the fake is called once - Personal ETA maths against fixed history - **No live network calls in any test** ## Risks - **Vendor lock-in and cost.** The interface is the mitigation. - Debouncing matters: dragging a pin could otherwise fire dozens of requests. - OSM route quality varies. It will occasionally suggest something daft; that is the data, not a bug to chase. ## Out of scope Turn-by-turn navigation and voice guidance. That is a different product.