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
- The planner (
mobile/app/trip-planner.tsx) reads nearby patches around the device's real location only (never a previewed city — enforced bypreviewSafety.test.ts) viauseNearbyPatches, at a radius chosen from four discrete steps: 5/10/25/50 mi (RADIUS_STEPS_MI). It is split intoTripPlannerScreenViewModelImpl(every hook) and the pureTripPlannerScreenLayout, the house view-model pattern, with the whole derivation in the exportedbuildTripPlannerScreenData— 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 undersrc/components/trips/because it has exactly one consumer; thevariant: 'modal' | 'drawer'prop it used to take had none at all and went with the move. - Two interchangeable UI modes build the same pick set: a Tinder-style swipe deck
(
SwipeCard/StackCardinsideTripDeck.tsx, add/pass/undo) or a grid with checkboxes (TripGrid.tsx). Both read the sameDeckState(deckReducer.ts), so picks made in one mode persist when switching to the other. - "Create plan" (
onCreate) callssetActiveTrip, which writes one newActiveTripinto the store'sdayTripsarray 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. - Adopting a curated trip short-circuits steps 1-3:
useTripActions.startCurated(mobile/src/components/trips/useTripActions.ts) converts the server'sCuratedTriprow into the sameActiveTrip/RoadTripshape 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
RoadPlannerScreen(mobile/src/components/road-trip/RoadPlannerScreen.tsx) is a "where to?" screen: device location as the fixed start, a destination chosen through the sharedLocationPicker, 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).- Choosing a start/destination (or a preset) opens a draft
(
openRoadDraft,road-planner.tsx:41-69) and calls the backendPOST /api/trips/route-corridor(TripsController.routeCorridor,backend/src/trips/trips.controller.ts:59-71). - 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_Intersectsagainst aus_statestable), 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_DWithinon::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.
- finds every U.S. state the route's line actually intersects (
- 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). 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.- Saving (
roadCreate,RoadStopsScreen.tsx:148-160) writes aRoadTripinto 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.
- Route drawing:
POST /api/trips/route(tripRoute, unauthenticated,trips.controller.ts:86-96) routes through the trip's own already-chosen stops as ordered waypoints (capped atMAX_TRIP_WAYPOINTS = 50,backend/src/trips/dto/trip-route.dto.ts:26) and returns a simplified (~11m tolerance) GeoJSON polyline plus turn-by-turnsteps. - Turn-by-turn: maneuvers come from the OpenRouteService response, converted
from vertex indices into a fraction of total route length server-side
(
backend/src/routing/route-steps.ts:1-22) so they stay valid against the client's independently-simplified polyline copy.maneuverAt()(mobile/src/utils/maneuvers.ts:81-107) picks the current/next maneuver from the driver's live progress fraction;ManeuverRow(mobile/src/components/trips/ManeuverBanner.tsx) renders it as the map's one headline card, showing distance-to-turn (feet under ~0.19 mi, otherwise miles) or "Rerouting…" while a rejoin route is in flight. - Rerouting:
useReroute(mobile/src/hooks/useReroute.ts) watches how far off the trip's planned line the live GPS fix sits.rerouteGate.tsgates the network call on four conditions: off by >0.15 mi (OFF_ROUTE_MI), sustained continuously for 20s (OFF_ROUTE_SUSTAIN_MS), at least 60s since the last reroute request (REROUTE_COOLDOWN_MS), and a live fix present. When it fires,POST /api/trips/reroute(throttled 12/min/IP,trips.controller.ts:114-126) returns a separate, throwaway "rejoin" line — the trip's own canonical route, mileage, and bead-rail positions are never replaced (TripsService.reroutedoc comment,trips.service.ts:101-112). - Where the route starts: at the driver, not at the plan — via a separate
approach leg, on both trip kinds (
useApproachLeg,mobile/src/hooks/useTripMapModel.ts). The trip's own route is fetched once and never re-routed; a dashed line joins the driver to the stop they are being sent to, and disappears once they are within 0.25 mi of the route (the road is then the leg). Same separationuseReroutekeeps for rejoin lines. The approach origin is quantized to a 0.25-mile grid (mobile/src/utils/tripApproach.ts) so it is not re-routed on every GPS tick.- A day trip briefly did this differently — the live fix became waypoint zero of the whole trip and the route re-fetched as you moved. It was reverted before shipping for two reasons worth recording: a full N-waypoint re-route per quarter-mile spends roughly 200 of OpenRouteService's 2,000/day on one 50-mile drive (about ten drives a day across all users before every trip map fails to draw), and changing the trip's own polyline mid-drive blanked the map to a loading state every ~20 seconds at speed.
- A day trip's frozen
originstill orders its stops (hydrateDayTrip) and still begins its drawn route. Re-optimizing the order around a moving fix would reshuffle the itinerary under a driver.
- Before you set off: with nothing yet collected on the trip, the driver not
on its road (or no fix at all), and the driver not having pressed "Start
driving", the console is a pre-drive card
rather than a driving instrument — the trip's facts plus one primary
"Start driving" action, with the platform-maps hand-off as a secondary
(
mobile/src/components/trips/TripPreDrive.tsx, decided bypreDrive.ts). The map previously opened straight into mid-drive chrome whatever the situation. - When the position is a guess, it says so. Past 25 miles off route the map
falls back to a derived position (
LIVE_FIX_MAX_OFF_ROUTE_MI);TripMapdetects that as!live— the same term the ETA and the maneuver banner already gate on — so the mile countdown renders—and the rail's "you" marker draws hollow rather than presenting a derived point as a measured one. Deliberately decided in the screen, not the model: the model never sees the live fix, and an earlier attempt to compute it there was wrong for every day trip that had not collected anything yet. - Camera: three framings,
mobile/src/utils/roadMap.ts:255-267(RoadView = 'driver' | 'follow' | 'overview', labelled "Driving" / "Follow" / "Route" in the UI, default'driver'). Driving is heading-up, tilted, and zooms continuously from 16 to 19.5 as you approach the nearest stop (mobile/src/utils/driverCamera.ts); Follow is north-up, flat, fixed zoom, centred on you; Route fits the whole trip's bounds. - Auto-collect on arrival:
useArrivalUnlock(mobile/src/hooks/useArrivalUnlock.ts), called fromTripMap.tsx:400, re-runs the same authoritative polygon-containment unlock check (checkPatches()) the compass and home screen use — on focus, every 25m of travel (ARRIVAL_RECHECK_METERS), and once after 4s stationary (STATIONARY_SETTLE_MS, covering the park-and-walk case). Unlock itself is server-side polygon containment (out of scope for this doc — see the unlock feature doc); this hook is only what triggers the re-check while the trip map is open. - Finishing:
archiveIfCompletein the store moves a trip fromdayTrips/roadTripsintotripArchivethe moment its last stop is collected, and the live screen swaps toTripFinale— a recap with a looping "patches earned" reel, mileage/time totals, and a Share action that renders an off-screen branded card to a PNG and hands it to the native share sheet (captureRef+expo-sharing,mobile/src/components/trips/TripFinale.tsx:118-125).
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:
Trip(trips) — a user-planned trip.id(uuid),userId,name, timestamps. Has manyTripPatch, at most oneUserActiveTrip.TripPatch(trip_patches) — one stop.tripId,patchId,sequenceOrder.@@unique([tripId, patchId])— a patch can appear once per trip. No visited column: per-trip visit state lives only on the device (see below).- Device-side only:
ActiveTrip.visitedIds/RoadTrip.visitedIds(mobile/src/domain/types.ts) — the stops you collected by being at them, recorded against every saved trip that holds the stop. Persisted inside the already-persisteddayTrips/roadTripsarrays (MMKV), optional so trips saved before it read as "nothing collected yet". UserActiveTrip(user_active_trips) — which trip is "live" for a user.userIdis@unique,tripIdis@unique— one active trip per user, full stop, regardless of kind. Since the client can have both a live day trip and a live road trip simultaneously, whichever is flagged active in the last sync push wins this column server-side (mobile/src/domain/tripSync.ts:42-47) — a labelling detail server-side only, since nothing reads this column back to the client (no achievement or UI depends on it).CuratedTrip(curated_trips) — Scout-authored content, explicitly not the same table as userTrips (schema comment, line 850-853: "Mixing the two would put user rows in a published table").kind('day' | 'road'),name,tagline, day-triporiginLat/Lng/Label+radiusMi, roadstartLat/Lng/Label+endLat/Lng/Label,estMinutes,sortOrder. Has manyCuratedTripStop.CuratedTripStop(curated_trip_stops) —tripId,patchId,sequenceOrder.@@unique([tripId, patchId]).
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:
backend/src/trips/trips.controller.ts— the 4 trip-routing HTTP endpoints.backend/src/trips/trips.service.ts— corridor query, trip-route, reroute, display-polyline simplification.backend/src/trips/curated-trips.service.ts— read model for publishedCuratedTripcontent.backend/src/trips/corridor-grouping.ts— pure function grouping raw corridor rows into per-state sections.backend/src/trips/road-presets.ts— the one hand-pinned preset (Route 66) whose historic-alignment waypoints beat a naive fastest-route (comment, lines 26-44: naive routing recovers only 31% of curated stops vs. 100% with pinned waypoints).backend/src/routing/ors.provider.ts— OpenRouteService client (HeiGIT free tier, 2,000/day, 100/min).backend/src/routing/route-steps.ts— converts ORS maneuvers into route-fraction-positionedRouteSteps.backend/scripts/seed-curated-trips.ts— hand-authored curated trip content (local-DB only).backend/src/scripts/generate-city-day-trips.ts— generates 2-3 day trips per city collection.
Mobile — planning:
mobile/app/trip-planner.tsx— Day Trip swipe deck / grid builder. View model + pure layout;buildTripPlannerScreenDatais the exported pure derivation, andstatus('no-location' | 'loading' | 'ready') is its discriminant.mobile/src/components/trips/TripDeck.tsx— the swipe deck and its ✕ / undo / ♥ row, extracted so the planner's layout is hook-free at its own level.mobile/src/dev/mocks/trip-planner.tsx— the planner's 14 gallery states, built from four real candidate rings (Key West 16 stops, Charleston 10, Adrian TX 1, Valentine NE 0) with every distance computed by the app's ownhaversineDistance.mobile/src/components/road-trip/RoadPlannerScreen.tsx— Road Trip "where to?" entry.mobile/src/components/road-trip/RoadStopsScreen.tsx— corridor stop picker with the detour-distance stepper.mobile/src/utils/roadCorridor.ts— pure corridor filtering/ordering/summarizing logic.mobile/src/components/trips/curatedTrip.ts— converts serverCuratedTriprows into local trip shapes.
Mobile — home/board/preview:
mobile/src/components/trips/TripHomeScreen.tsx— shared Day/Road home (hero + trip board + curated shelf). View model + pure layout;buildTripHomeScreenDataandtripHomeEmptyHeroare the exported pure derivations.mobile/src/dev/mocks/trip-home.tsx— the screen's 11 gallery states (both kinds), built from 13 real curated trips and all 124 of their real stops.mobile/src/components/trips/TripBoard.tsx— masonry board of trip cards.mobile/src/components/trips/TripPreviewScreen.tsx— map-first "what is this trip" screen before committing.mobile/src/components/trips/useTripActions.ts— shared start/resume/adopt logic for home + preview.
Mobile — driving:
mobile/src/components/trips/TripMap.tsx— the shared driving map for both trip kinds.mobile/src/utils/tripMapModel.ts— kind-agnostic model (route + ordered stops + progress) both screens consume.mobile/src/utils/maneuvers.ts— active-maneuver selection from route progress.mobile/src/components/trips/ManeuverBanner.tsx— turn-by-turn banner UI.mobile/src/hooks/useReroute.ts/mobile/src/utils/rerouteGate.ts— off-route detection and throttled rejoin-route fetch.mobile/src/utils/driverCamera.ts— Driving-framing zoom/pitch/bearing curves.mobile/src/utils/roadMap.ts— the 3 camera-framing definitions and their frame-fitting logic.mobile/src/hooks/useArrivalUnlock.ts— arrival re-check driving auto-collect.mobile/src/components/trips/TripStopsScreen.tsx— printable-style itinerary.mobile/src/components/trips/TripFinale.tsx/ShareCard.tsx— recap + shareable image.mobile/src/components/trips/MyTripsScreen.tsx— day-trip history with live/saved/done chips.
Mobile — sync:
mobile/src/domain/tripSync.ts— maps the three local trip lists (dayTrips,roadTrips,tripArchive) onto the server's flat payload shape, with client-side caps mirroring the server's.
Configuration and flags
- No feature flag gates any trips surface (see Status).
ORS_API_KEY(backend env) must be set or every trip-routing endpoint throwsRouteProviderError→502 Bad Gateway(ors.provider.ts:47-49,trips.controller.ts's catch blocks). OpenRouteService free tier: 2,000 directions/day, 100/min.CONTENT_EDITING_ENABLED=true(local-only, per project convention) is what letsseed-curated-trips.ts/generate-city-day-trips.tswrite locally; reaching prod still requires a human publish via/admin/publish.- Reroute tuning constants (
mobile/src/utils/rerouteGate.ts:12-17): off-route threshold 0.15 mi, sustain 20s, cooldown 60s. - Detour-distance steps: day trip 5/10/25/50 mi; road trip 5/10/15/25/50/100/150 mi,
default 25 mi, max fetch radius 150 mi (mirrored client/server:
mobile/src/utils/roadCorridor.ts:11-16,backend/src/trips/trips.service.ts:23-26). - Sync payload caps (
mobile/src/domain/tripSync.ts:111-113, mirrored server-side inbackend/src/sync/dto/sync.dto.ts): 250 trips/push, 500 stops/trip, 5,000 total stops/push.
Edge cases and known limits
-
A completed day trip does not stay on Home, and
TripResumeCard'scompletebranch is dormant.archiveIfCompletefreezes a finished trip into the archive, which clearsactiveTrip; Home's slot 3 isactiveTrip ? { kind: 'trip' } : …in the view model (mobile/src/components/home-v3/FieldGuideHome.tsx:613-624, drawn byDefaultSlotat:737), so the ladder falls straight through to the context band or the marquee. The card's completed copy —DAY TRIP COMPLETE,N STOPS COLLECTED, "Every stop collected — open your finale." (mobile/src/components/home-v3/TripResumeCard.tsx:63-85) — therefore renders for a trip state that can no longer exist. Verified on device 2026-09-02: after finishing a two-stop day trip, Home in Sausalito showedfg-context-bandand no resume card. The finale is where a finished trip is celebrated; Home simply goes back to discovery. Whether that is the intent or the completed card should survive until dismissed is a product question, not a code one — but the copy is currently unreachable, andday-trip.yamlasserts the release rather than the card. -
A failed curated fetch is reported to the user as "nothing is here". The trip home has no error branch at all.
useTripActionsexposescuratedError(mobile/src/components/trips/useTripActions.ts), andTripHomeScreenViewModelImplreads it only to RELEASE the loading gate — deliberately, so a user with saved trips is never stranded on placeholders during an outage (mobile/src/components/trips/TripHomeScreen.tsx:282). But for a user with no saved trips of that kind, the release falls straight through to the empty hero, which then makes a confident and false claim: "Nobody has mapped <city> yet." when the truth is that the request failed.statuson the view model is therefore'loading' | 'ready'with no'error'member — the type states what the screen actually renders rather than implying a branch that does not exist. Not fixed; recorded here and inmobile/src/dev/mocks/trip-home.tsx. -
The DAY-TRIP PLANNER has the same shape of the same bug.
useNearbyPatchesreturns anerror, andmobile/app/trip-planner.tsxnever destructures it. It is worse than the home screen's version in one respect: the failure does not even have to reach that flag. A failed content fetch leavescontentSyncStatusat'success'with no cached patches (mobile/src/hooks/useNearbyPatches.ts,nearbyComputeGate.ts), so the nearby computation runs against an empty catalog, returns[], and the planner FREEZES that empty ring for the session. The user is told "Nothing within reach — Widen the search above, or try another location.", and widening cannot help, because the ring is empty for a reason that has nothing to do with distance.statuson its view model is therefore'no-location' | 'loading' | 'ready'with no'error'member, for the same reason as above. Not fixed; recorded here and inmobile/src/dev/mocks/trip-planner.tsx. -
The planner enforces no cap on stops. Every candidate in the ring can be taken — sixteen of them in Key West — and "Create plan · 16 stops" commits them all as one day trip. Nothing on screen suggests that is more than a day. The only stop cap in the system is the backend's, on sync (
sync.trips.db.spec.ts). Thegrid-everythinggallery state exists to put that in front of a person. -
Standing inside a City Challenge suppresses the resume card entirely. Place mode takes slot 3 outright, so a live trip shows no card while you are inside a Challenge — which is most of the time on a city day trip.
-
A road-trip corridor request outside the U.S. still computes a real route (OpenRouteService is a global router), but the stop-finding query joins against a
us_statestable (trips.service.ts:215,234) — a route entirely outside the U.S. would compute mileage/time/polyline correctly but return zero grouped stops, because both the "which states does this cross" and "nearest state" logic assume U.S. states exist to match against. -
A trip over
MAX_TRIP_WAYPOINTS(50) or a corridor request the provider can't snap within its 10km search radius fails with a routing error, not a partial route. -
Narrowing the road-trip detour radius below a previously-picked stop's distance hides it from the visible haul and the final saved trip (it survives in
pickedIdsuntil save, but a save made while it's hidden drops it) — intentional, documented behavior, not a bug, but worth knowing precisely: widening the radius back out does restore it as picked. -
TripPatch/CuratedTripStopare both@@unique([tripId, patchId]); the client dedupes before sending and the server also tolerates a duplicate viaskipDuplicates— a repeated stop in a payload doesn't 500. -
A reroute failure is silent by design (
useReroute.ts:99-107) — the banner simply falls back to the trip's own last-known maneuver rather than showing an error, since a reroute is explicitly "an enhancement on top of a map that already works." -
Two trip kinds can be simultaneously "live" client-side (one day trip + one road trip), but the server's
user_active_tripsrow is unique per user, so only the last-synced one is recorded server-side as the active trip — see Data model. A stop belonging to both live trips is recorded as visited on both, independently. -
A camera-roll import (
collectPatchBatch) records trip visits — including for matches you already owned — against every saved trip holding them, and can therefore finish a trip, preserving the behaviour that predates per-trip state. This sits in tension with the batch's ownsource: 'import'rule — importing is explicitly not being there in person, which is why imports earn no achievements — so a trip can still be completed from the sofa. Left as-is deliberately rather than silently removing a capability users have today. -
The approach leg costs one extra routing call per quantized quarter-mile while the driver is off the route, against OpenRouteService's free tier (2,000/day, 100/min). It stops entirely once they are on it, and the trip's own route is fetched once — so a whole drive costs a handful of calls, not hundreds.
-
A stop you already own is still arrival-tested while its trip is live (
getUncollectedPatches,mobile/src/providers/LocationProvider.tsx), andcollectPatchrecords the visit even when it collects no new badge. Without both, a trip through patches you already held could never advance or finish — the failure mode that per-trip state introduced and that these two close.
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:
- (a) "Trips are stored on the device and do not sync across devices" — FALSE.
Trips sync to the backend for every account, including guests. Every install
gets a real
Profileand JWT (POST /auth/anonymous), and any trip mutation is pushed via the JWT-guardedPOST /api/sync/push, pulled back viaGET /api/sync/user-data, and folded into a member account on sign-up viaPOST /api/sync/merge— the same channel patches use. This exists specifically so two achievements (Trip Advisor, Finish Line) can be computed server-side (mobile/src/domain/tripSync.ts:1-14; test coverage inbackend/src/sync/__tests__/sync.trips.db.spec.ts). Nuance: a guest's anonymous profile is still effectively per-device until they sign in, so a never-signed-in guest's trips will not appear on a second device — but this is a guest-account property, not a trip-specific "device-only" design. For a signed-in member, trips follow the account across devices exactly like patches do. - (b) "No trip editing after creation" — still TRUE. No UI anywhere lets a
user add or remove a stop from an already-saved
Trip/RoadTrip. Stops are only ever picked in the pre-save draft (RoadStopsScreen, the day-trip planner's deck/grid). Tapping an existing saved road trip starts driving it (road-planner.tsx:41-44), it does not reopen the picker. The only way to change a saved trip's stops is to delete it and re-plan. - (c) "No scheduling" — still TRUE. No date/time/calendar concept exists anywhere in the trips code — a trip is either "live," "saved," or "done," never scheduled for a future date.
- (d) "No turn-by-turn navigation inside Scout" — FALSE, and this is the
headline change of the 2026-08-29 work. Scout now renders live turn-by-turn
maneuver instructions (
ManeuverBanner.tsx), a heading-up Driving camera that zooms in on approach (driverCamera.ts), and rerouting when you leave the planned road (useReroute.ts). This is genuinely in-app, on Scout's own map — not a hand-off to Apple/Google Maps (a "hand the whole trip to the platform maps app" feature also exists per the commit log, but it is a separate, additional option, not the primary experience). - (e) "Road trips are US-only and do not reroute if you leave the planned
road" — split verdict. The "does not reroute" half is FALSE — see (d)
and
useReroute.ts/rerouteGate.ts, a fully built off-route detection and rejoin-route system. The "US-only" half is effectively still TRUE in practice: routing itself is global (OpenRouteService), but the corridor stop-finding query is hardwired to aus_statestable (see Edge cases above), so a route outside the U.S. would find zero stops even though it would compute a real route. - (f) "One day trip is live at a time" — still TRUE, with a clarification.
activeDayTripIdis a single pointer — exactly one day trip can be the "live" one you resume onto at a time. But this does not mean only one day trip can exist:dayTripsis an array, and the model explicitly supports one saved trip per city plus an unbounded archive of finished ones (mobile/src/domain/store.ts:587-604: "One LIVE saved trip per city... Which day trip is live. Explicit"). A user can also have one live day trip and one live road trip simultaneously (they're independentactiveDayTripId/activeRoadTripIdpointers) — it's "one live day trip" and "one live road trip," not "one live trip, period." - (g) "Curated day-trip inventory is currently very small" — FALSE as of the
2026-08-28 work, with a publish caveat. A generator script produced curated
day trips for essentially every city collection — commit
6430afcf: "Every city now yields a trip: 99 trips, 0 too thin," on top of 5 hand-authored Key West trips that predate it. The localcurated_tripsseed also carries at least 3 named road trips (Route 66, Blue Ridge Parkway, Pacific Coast Highway;backend/scripts/seed-curated-trips.ts:127-167). Caveat: content like this is authored/generated in the local database only and requires a separate, human-run publish step to reach prod (generate-city-day-trips.ts's own header: "WHAT IT DOES NOT DO: publish... that is a human's call"). I did not query prod and cannot confirm the full 99 have actually been published — but the claim that the inventory is "very small" is false as a description of the current codebase/content pipeline regardless, since it was true only before this work shipped.
Also worth stating plainly, independent of the seven claims above:
- Trips do not include an offline map pack; unlocking still requires a network round trip because containment is answered server-side.
- My Trips only lists day trips — road trips have their own list on the
Road Trip home screen (
RoadPlannerScreen's "YOUR ROAD TRIPS" section), not in/my-trips. - A trip keeps its own unlock state. Each trip stores
visitedIds— the stops you collected by being at them while that trip existed — and that is the only thing that counts as progress on it (tripProgress(),mobile/src/utils/roadTrip.ts; written byrecordTripVisitsinmobile/src/domain/store.ts, called from the two collect funnels every unlock path already passes through). A patch you already owned before planning the trip is shown as context ("already in your album") and is never counted, never skipped past, and never able to finish a trip. (This reverses the previous entry here, which said progress was entirely derived from what you own. That derivation could not tell "I drove here today" from "I have held this badge since March", so a trip planned through patches you already owned opened part collected, navigated to a stop you had never driven to, and — owning every stop — archived itself with a recap for a drive that never happened. Board ticket188bde6f.) visitedIdsis device-only and is NOT synced. It is not in the sync payload (mobile/src/domain/tripSync.ts) and has no column intrip_patches, so a reinstall or a second device starts every trip at 0-of-N. Trips saved before this existed also start at 0-of-N: back-filling them from the album would reintroduce exactly the bug above.- Editing which stops a road trip's "detour" campaign system used
(
anchoredRoads/planRoad/campaign-anchored roads inroadTrip.ts:33-112) is present in the codebase but is not wired into any current screen — a repo-wide import check found no live caller. The shipped road-trip flow is the corridor/route-corridorsystem described above.
Tests that cover it
Backend (backend/src/trips/*.spec.ts, backend/src/routing/*.spec.ts):
corridor-grouping.spec.ts,curated-trips.spec.ts,reroute.spec.ts,road-trip-pairing.spec.ts(+ a.live.spec.tsvariant),trip-route.spec.ts— unit coverage of state grouping, curated-trip filtering, reroute request shaping, and route assembly.backend/src/routing/route-steps.spec.ts,route-cache-key.spec.ts,routing.service.spec.ts— maneuver conversion and route caching.backend/src/sync/__tests__/sync.trips.db.spec.ts— end-to-end: a pushed trip reachestrips/trip_patches, re-pushing upserts rather than duplicating, one account's trip can never be overwritten by another's push, Trip Advisor and Finish Line become earnable (and are proven to be exactly as strict as documented — e.g. not awarded for a one-stop trip, not awarded while a stop is still uncollected), malformed trip IDs are rejected without a partial write, and the total-stops cap is enforced.
Mobile:
-
Screen tests:
mobile/screen-tests/{trips,road-trip,road-planner,trip-planner, trip-map,trip-preview,trip-stops,my-trips}.test.tsx.tripsandroad-tripboth driveTripHomeScreenand are what proves the view-model split preserved behaviour for both drawer destinations. -
mobile/screen-tests/screen-mocks.test.tsx— renders all 11trip-homeand all 14trip-plannergallery states and asserts real content in each. -
Hook tests:
mobile/hook-tests/{useReroute,useArrivalUnlock}.test.tsx. -
Per-trip unlock state:
mobile/src/domain/__tests__/tripVisits.test.ts(visits recorded against the live trip only, and a trip whose stops you merely own is never archived),mobile/src/utils/__tests__/roadProgress.test.ts(tripProgress),mobile/src/components/trips/__tests__/preDrive.test.ts,mobile/src/utils/__tests__/tripApproach.test.ts. -
Maestro E2E (
mobile/maestro/tests/), run on aproduction-simulatorbuild against prod. Two behaviours were established on-device while writing these and are recorded in Edge cases above: a completed day trip releases home's slot 3, and the celebration queue is pumped by a location check. -
Maestro E2E (
mobile/maestro/tests/), run on aproduction-simulatorbuild against prod:day-trip.yaml— the PARKED half (split from the driving half 2026-09-02, mirroring the road trip's own division). It drives both start-card branches side by side: a day trip routes fromtrip.origin, so a trip planned where you stand opens on the driving console withroad-rail-me— then stepping ~4 km off its road turns the same screen intoroad-card-predrivewith "MI TO STOP 1" androad-rail-me-estimated, and "Start driving" must dismiss it (the review-caught defect where the card keyed on the approach polyline and never dismissed), after which the countdown still declines to an em dash because we remain off the road. Also asserts the meta copy0 of 2 collected **on this trip**— the words the fix added so the number says what it means.day-trip-driving.yaml— the MOVING half: build, arrive at the bridge (celebration asserted by name belowBADGE UNLOCKED),travelto Alcatraz, then the finale asserted by patch id from the trip's ownvisitedIds, and finally that a completed trip releases Home's slot 3.road-trip-driving.yaml— the driving half, then a second road over stops the account now owns: St. Louis → Chicago (a different corridor, becauseroadCreateupserts byroad-<corridorKey>and mergesvisitedIds, so re-planning the same road would correctly read 3 of 3 and prove nothing). It must open0 of 3, show no finale, carryroad-hero-owned-beforeand3 already owned— and then advance and FINISH on visits alone, with no celebration, since there is no badge left to award. That last half is the guard on the inverted first attempt, where such a trip could never advance.
-
Unit tests:
mobile/src/utils/__tests__/{maneuvers,rerouteGate,driverCamera, tripMapModel,dayTrip,tripRoute}.test.ts,mobile/src/components/trips/__tests__/{curatedTrip,myTrips,tripHomeModel, tripPreview,tripStopsModel}.test.ts,mobile/src/domain/__tests__/{dayTrips,tripStateMigration,tripSync}.test.ts.
Open questions
- Whether the 99 generated city day trips (and the road trips referenced in the
git log — Great River Road, Overseas Highway, Texas Hill Country, Natchez
Trace Parkway, tied to
backend/data/road-trips/*andbackend/src/scripts/create-road-trip-collections.ts) have actually been published to prod'scurated_tripstable, versus sitting published-locally-only. I did not query the database. If they have not been published, the practical curated-trip inventory a user sees in prod today could still be much smaller than the codebase supports. - Whether the
trips_tabfeature flag is genuinely dead code or is consulted somewhere I didn't find (e.g. a build-time or native-config path outsidemobile/srcandmobile/app). - The exact current list of named/preset road trips beyond Route 66 that a user
can pick as a one-tap shortcut in
RoadPlannerScreen(ROAD_PRESETSinmobile/src/config/roadPresets.tscurrently defines onlyroute-66) versus roads that exist only asCuratedTriprows with a plain start/end (Blue Ridge Parkway, Pacific Coast Highway) versus roads that exist only as patch collections not yet surfaced as aCuratedTripat all. I traced the code paths but did not enumerate every named road end-to-end. - Whether "hand the whole trip to the platform maps app" (referenced in the
git log, commit
8f7e19e1, "feat(trip-map): hand the whole trip to the platform maps app") is still present and where — I did not locate and verify this specific surface in this pass, since it's adjacent to but not squarely inside the CRITICAL claims list.