Scout — Full Product Context → feature documentation

Trips — Day Trip, Road Trip, and driving guidance

Scout has two ways to plan a drive and one shared engine to drive it.

Screen recordings

Short spans cut from real sessions on a real device. Silent, no narration, no editing beyond the trim.

Build a day trip 9.4s · recorded 2026-09-09
Day trip in O'Fallon 2.43s · recorded 2026-09-09
Next stop 7.5s · recorded 2026-09-09
Cathedral Basilica 4.63s · recorded 2026-09-09
Curated road trips 10s · recorded 2026-09-09
Route 66 5s · recorded 2026-09-09
Plan your own 13.1s · recorded 2026-09-09
Eyes on the road 9.5s · recorded 2026-09-09
Lemp Mansion 1.3s · recorded 2026-09-09

Full sessions

The complete, unedited recordings the clips above were cut from — every tap, including the dead ends. These are the raw captures, reframed for the web and otherwise untouched.

Full recording · 09-09 12:46 55.5s · recorded 2026-09-09 · unedited
Full recording · 09-09 12:48 122.3s · recorded 2026-09-09 · unedited

Researched against the codebase as of commit 852f2722 ("feat(trips): turn-by-turn guidance, the trip board, and a map-first trip preview", 2026-08-29) and the route-registry fix at 7c2b6947 (2026-08-31). All line numbers refer to files at that point in history.

Summary

Scout has two ways to plan a drive and one shared engine to drive it. A Day Trip is a short, radius-bounded itinerary around one city, built either by swiping a deck of nearby patches, picking from a grid, or adopting a Scout-authored curated trip. A Road Trip is a long-lived plan between any start and destination: the backend asks a real routing provider (OpenRouteService) for the driving route, finds every patch within a chosen detour distance of that road, and groups the results by the U.S. states the route crosses. Both trip kinds share one map component with three camera framings (Driving / Follow / Route), turn-by-turn maneuver instructions, live rerouting when the driver leaves the route, and passive auto-collect as stops are physically reached. Trips are not device-only — they sync to the backend for every account, guest or signed-in, over the same authenticated push/pull sync channel patches use. There is no in-app editing of an already-created trip (delete and re-plan is the only path), and there is no scheduling/calendar feature.

Status (shipped / beta-badged / flagged off)

Shipped, not flagged. No feature flag in backend/src/admin/feature-flag-definitions.ts gates any trips surface today. The registry does define a trips_tab flag (defaultEnabled: false, dev: true, backend/src/admin/feature-flag-definitions.ts:25-31, described as "Show the Trips tab in the bottom tab bar"), but nothing in mobile/src/** or mobile/app/** reads or checks that key — a repo-wide grep for trips_tab outside this flag's own definition finds no consumer. Historical docs in docs/plans/2026-05-06-admin-controlled-feature-flags.md show it was built for an older bottom-tab-bar navigation concept; the app now ships Trips and Road Trip as unconditional drawer destinations (mobile/app/(drawer)/_layout.tsx:30-31: <Drawer.Screen name="trips" .../>, <Drawer.Screen name="road-trip" .../>). The flag appears to be vestigial rather than an active gate — flagged here rather than asserted with certainty, since I could not query the live flag value in prod.

The turn-by-turn/reroute/camera-framing/trip-board/map-first-preview work landed 2026-08-28–29 (see git log --oneline for the feat(trips)/feat(trip-map) commit run culminating in 852f2722), which is materially newer than the old product-brief prose this document supersedes.

User-facing surfaces (screens, routes, deep links, entry points)

All routes below are registered in mobile/src/dev/deepLinkRoutes.ts per the project's route-registry convention and are reachable as scout://<path>.

Route File Purpose
/(drawer)/trips mobile/app/(drawer)/trips.tsx Day Trip home — TripHomeScreen kind="day"
/(drawer)/road-trip mobile/app/(drawer)/road-trip.tsx Road Trip home — TripHomeScreen kind="road"
/trip-planner mobile/app/trip-planner.tsx Day Trip builder (swipe deck / grid)
/road-planner mobile/app/road-planner.tsx Road Trip "where to?" + stop picker
/trip-preview mobile/app/trip-preview.tsx Map-first preview of a trip before committing
/trip-map mobile/app/trip-map.tsx The live driving map (day or road, via ?kind=)
/trip-stops mobile/app/trip-stops.tsx Printable-style itinerary list
/my-trips mobile/app/my-trips.tsx Day-trip history: live/saved/done, and read-only recaps

The Day Trip and Road Trip drawer destinations both render the same TripHomeScreen component parameterized by kind (mobile/src/components/trips/TripHomeScreen.tsx:524). That component is split into TripHomeScreenViewModelImpl (every hook) and the pure TripHomeScreenLayout, the house view-model pattern, with the whole derivation in the exported buildTripHomeScreenData — which is what lets the DEV Screen mocks gallery render both kinds' faces (mobile/src/dev/mocks/trip-home.tsx, scout://dev-screen-mock/trip-home). Both trip kinds also render through the same TripMap component (mobile/src/components/trips/TripMap.tsx) and the same preview screen (TripPreviewScreen.tsx) — "what differs is narrow and lives in tripHomeModel.ts" (TripHomeScreen.tsx:1-9).

How it works (device → API → DB → response)

Day Trip creation

  1. The planner (mobile/app/trip-planner.tsx) reads nearby patches around the device's real location only (never a previewed city — enforced by previewSafety.test.ts) via useNearbyPatches, at a radius chosen from four discrete steps: 5/10/25/50 mi (RADIUS_STEPS_MI). It is split into TripPlannerScreenViewModelImpl (every hook) and the pure TripPlannerScreenLayout, the house view-model pattern, with the whole derivation in the exported buildTripPlannerScreenData — which is what lets the DEV Screen mocks gallery render its 14 states (mobile/src/dev/mocks/trip-planner.tsx, scout://dev-screen-mock/trip-planner). It lives IN its route rather than under src/components/trips/ because it has exactly one consumer; the variant: 'modal' | 'drawer' prop it used to take had none at all and went with the move.
  2. Two interchangeable UI modes build the same pick set: a Tinder-style swipe deck (SwipeCard/StackCard inside TripDeck.tsx, add/pass/undo) or a grid with checkboxes (TripGrid.tsx). Both read the same DeckState (deckReducer.ts), so picks made in one mode persist when switching to the other.
  3. "Create plan" (onCreate) calls setActiveTrip, which writes one new ActiveTrip into the store's dayTrips array and makes it the live trip. City/state/region container patches are excluded from candidates at the source (filterRealStops) — you can't add "Missouri" as a day-trip stop.
  4. Adopting a curated trip short-circuits steps 1-3: useTripActions.startCurated (mobile/src/components/trips/useTripActions.ts) converts the server's CuratedTrip row into the same ActiveTrip/RoadTrip shape a hand-built plan uses (curatedToActiveTrip/curatedToRoadPlan, mobile/src/components/trips/curatedTrip.ts:26-69) and saves it exactly like a manually built one.

Road Trip creation

  1. RoadPlannerScreen (mobile/src/components/road-trip/RoadPlannerScreen.tsx) is a "where to?" screen: device location as the fixed start, a destination chosen through the shared LocationPicker, plus a shortcut list of prebuilt roads (ROAD_PRESETS). This is a free-form start/destination model — any two points, not a fixed list of named roads — with named presets layered on top as shortcuts (comment, RoadPlannerScreen.tsx:1-6).
  2. Choosing a start/destination (or a preset) opens a draft (openRoadDraft, road-planner.tsx:41-69) and calls the backend POST /api/trips/route-corridor (TripsController.routeCorridor, backend/src/trips/trips.controller.ts:59-71).
  3. The backend resolves the route through the routing provider (OpenRouteService, see below), then runs one PostGIS query (TripsService.queryCorridor, backend/src/trips/trips.service.ts:206-239) that:
    • finds every U.S. state the route's line actually intersects (crossed, ST_Intersects against a us_states table), ordered by where the route first reaches each state (reproduces Route 66's IL→MO→KS→OK→TX→NM→AZ→CA sequence exactly, comment lines 190-193);
    • finds every patch within the requested radius using true-metre geography distance (near, ST_DWithin on ::geography);
    • assigns each nearby patch to its nearest state by K-nearest-neighbour (<->), not polygon containment, because simplified state polygons can miss a coastal/border patch entirely (comment lines 199-204). A patch whose nearest state is not one the route crosses is dropped, not mislabelled.
  4. The response is cached forever per route+radius (routes are content-addressed by coordinates, route-cache-key.ts), so widening/narrowing the detour distance in the picker never re-fetches — it only re-filters the one payload already on the device (withinRadius, mobile/src/utils/roadCorridor.ts:57-62).
  5. RoadStopsScreen (mobile/src/components/road-trip/RoadStopsScreen.tsx) groups the corridor response by state, sorted in drive order (s.stops.sort((a,b) => a.progress - b.progress), line 124), with a detour-distance stepper — 5/10/15/25/50/100/150 mi (RADIUS_STEPS, roadCorridor.ts:16). Narrowing the radius hides stops outside the new radius but does not clear them from the pick set (summarize()'s doc comment, roadCorridor.ts:107-117: "narrowing the corridor hides stops rather than deleting them... they must not be counted, and orderPicked drops them from the trip for the same reason" — i.e. widening the radius back out restores a hidden pick; only saving with a pick still hidden drops it from the final trip). A preset road (e.g. Route 66) arrives with its own campaign's patches pre-checked (preselectedIds, seeded once the corridor loads, RoadStopsScreen.tsx:129-137); a custom road starts with nothing checked.
  6. Saving (roadCreate, RoadStopsScreen.tsx:148-160) writes a RoadTrip into the store and returns you to the road list — it does not start driving. "Saving a road lands you back on the road home holding it, never on a map... a plan can sit unstarted for a month" (road-planner.tsx:3-13). Driving is a separate, explicit "Start driving" action per saved plan.

Driving (the shared trip map)

TripMap.tsx renders whichever trip kind is active, sourced from either hydrateDayTrip (nearest-neighbour re-optimized around your current position, mobile/src/utils/dayTrip.ts:31-46) or hydrateRoadTrip (fixed order set at creation, never re-optimized, mobile/src/utils/roadTrip.ts:181-189). Both feed a common TripMapModel (mobile/src/utils/tripMapModel.ts) so the map, the console, and the maneuver banner never disagree about stop order or progress.

Sync

Every locally created or modified trip is pushed to the backend. See "API surface" and "What this feature does NOT do" below for the specifics — this is the most consequential correction to the old brief.

Data model (Prisma models and key fields)

From backend/prisma/schema.prisma:810-901:

Trips are content-published the same way patches are: authored/generated locally, then a human runs the admin publish flow to push them to prod (backend/scripts/seed-curated-trips.ts:1-8: "this writes... in the LOCAL database, and reaching prod is the publish pipeline's job").

Routing itself is backed by a separate routes table (cache of provider responses, referenced via routeId in TripsService, e.g. trips.service.ts:156-160), not covered by this doc's Prisma model list since it isn't one of the five in scope.

API surface (endpoints, auth requirements)

All under @Controller('api/trips') (backend/src/trips/trips.controller.ts):

Method Path Auth Notes
GET /api/trips/curated none Published curated trips. Day trips filtered to within withinMi (default 60mi) of lat/lng; roads never filtered — "hiding Route 66 from anyone not already standing in Illinois would be absurd" (curated-trips.service.ts:32-40).
POST /api/trips/route-corridor none Patches near a driving route, grouped by state. Unauthenticated by design — "reads only published patch geometry and returns patch IDs" (controller comment, lines 51-58).
POST /api/trips/route none Drawable route through a trip's own chosen stops.
POST /api/trips/reroute none, but throttled (ThrottlerGuard, 12/min/IP) The only trip-routing endpoint with a rate limit, because its input is a moving GPS fix and can't hit the fetch-once route cache the other two rely on (controller comment, lines 98-113).

Trip persistence (as opposed to routing) rides the general sync controller, backend/src/sync/sync.controller.ts, all guarded by @UseGuards(JwtAuthGuard):

Method Path What it does with trips
POST /api/sync/push Upserts every trip in the payload into trips/trip_patches, replaces user_active_trips for any flagged active (SyncService.upsertTrip, backend/src/sync/sync.service.ts:464-547).
GET /api/sync/user-data Returns the signed-in user's trips with their patches (sync.service.ts:338-364).
POST /api/sync/merge Folds a guest's local trips into a member account on sign-up/sign-in, through the same upsert path push uses.

A JWT is required for all three — but every Scout install has one: even a "Continue as guest" flow gets a real backend Profile row and access token via POST /auth/anonymous (backend/src/auth/auth-engine.service.ts:196), not a purely local session. See "What this feature does NOT do" for what that does and doesn't mean for cross-device sync.

Key files (annotated)

Backend:

Mobile — planning:

Mobile — home/board/preview:

Mobile — driving:

Mobile — sync:

Configuration and flags

Edge cases and known limits

What this feature does NOT do

The previous public brief (backend/src/context/content.ts:713,728-729) contained this paragraph, which is now materially wrong in most particulars:

"trips are stored on the device and do not sync across devices; there is no trip editing after creation, no scheduling, and no turn-by-turn navigation inside Scout; road trips are US-only and do not reroute if you leave the planned road; and curated day-trip inventory is currently very small..."

Corrected, claim by claim:

Also worth stating plainly, independent of the seven claims above:

Tests that cover it

Backend (backend/src/trips/*.spec.ts, backend/src/routing/*.spec.ts):

Mobile:

Open questions