Written from code on develop, 2026-10-06. Mobile paths are relative to mobile/,
backend paths to backend/. Design source: mobile/research/trip-planner-lab.html
(round 3) and docs/superpowers/specs/2026-10-06-unified-trips-design.md.
Summary
Trips replaces the separate Day Trip and Road Trip entry points with one screen and one question: "What kind of trip?". There are three answers, always the same three, and each opens a map:
- Plan my day. The planner picks the nearest patches you still need that fit a 3-hour, half-day or full-day budget. It orders them nearest-next and gives every stop an arrival time, plus a total for miles, travel time and when you are done.
- Just drive. No plan. Choosing it on the question starts the drive at once: Trips becomes the live drive, and there is no second "Start driving". Scout follows the road you are on (your heading) and shows the patches within a detour you choose: 5, 15, 30 or 45 minutes there and back. Add puts one in your route in Google Maps or Apple Maps, or just saves it to the drive. With Scout in the background, a notification says when a patch comes up.
- Collect nearby. The rest of the nearest City Challenge, routed, with an arrival time for each stop. With no Challenge to work on, it shows the nearest patch you still need.
Starting any of them makes it the live trip. Trips then opens on that trip: On the day for a planned trip, or the drive itself. Every live screen has Route in Maps, which opens the remaining stops, in order, as one route in your maps app. A planned trip also sends a leave-by reminder 10 minutes before it is time to leave each stop.
Status
Shipped behind trips_v2, default ON in both registries
(mobile/src/config/feature-flags.ts:57, backend/src/admin/feature-flag-definitions.ts:32).
- Flag off:
/tripsrenders the previous Day Trip home (TripHomeScreen), and the Road Trip drawer row comes back (app/(drawer)/trips.tsx:26,src/components/navigation/drawerSections.ts:171). That path is unchanged and documented in trips-and-driving. - Native build required: the iOS Google Maps check needs
LSApplicationQueriesSchemes: ["comgooglemaps"](app.json:31). OTA cannot deliver it. - Backend first: per-leg times need the backend that returns
legsfromPOST /api/trips/routeplus theroutes.legscolumn (migration20261006120000_route_legs). An older backend still works: the app falls back to estimates and says so.
User-facing surfaces
| Surface | Route / deep link | File |
|---|---|---|
| The question | /trips (scout://trips) with no live v2 trip |
src/components/trips-v2/TripsHome.tsx:115 |
| Plan my day | /trip-plan (scout://trip-plan) |
src/components/trips-v2/TripPlanScreen.tsx:390 |
| Just drive | /trip-drive (scout://trip-drive); also /trips while a drive is live |
src/components/trips-v2/TripDriveScreen.tsx:238 |
| Collect nearby | /trip-collect (scout://trip-collect) |
src/components/trips-v2/TripCollectScreen.tsx:117 |
| On the day | /trips while a planned or Collect nearby trip is live |
src/components/trips-v2/TripLive.tsx:204 |
| Trip Calendar | /trip-calendar (scout://trip-calendar), from the Calendar pill on the question (Calendar · N with N upcoming planned days) |
src/components/trips-v2/TripCalendarScreen.tsx:262, pill at TripsHome.tsx:183 |
| A day's sheet ("Sunday, what kind of day?") | /trip-day?date=YYYY-MM-DD, a sheet over the calendar |
src/components/trips-v2/TripDayScreen.tsx:541 |
| Plan my day, date mode | /trip-plan?date=…&leave=… (+ From, kind, tripId): Save to <Day>, and Delete / Re-plan on a saved day |
src/components/trips-v2/TripPlanScreen.tsx:13 |
| Trip reminders switch | Settings → Notifications → On this device | app/notification-settings.tsx:305 |
| Notification taps | Leave-by and patch ahead are plain alerts with no buttons: a tap opens the trip in the app (On the day, or the drive), where Stay longer and Add live | src/trips-v2/notificationActions.ts |
Every screen is a map with a panel under it (TripScreen,
src/components/trips-v2/parts.tsx). The panel is not a bottom sheet:
- it is as tall as what it holds, up to half the screen, and scrolls past that;
- it cannot be dragged or dismissed;
- on Plan my day, On the day and Collect nearby, a Stops button in its button row folds it down to the buttons and back, giving the map the room.
The map always frames above whatever height the panel has. Patches on every
Trips map use the compass screen's pin (PatchPinMedallion,
src/components/compass/PatchPinMedallion.tsx), with the stop number or a
collected tick on its corner. Each stop's unlock area is drawn under the route the
way the compass draws one: a brass fill with a dashed edge, fetched per stop with
useStopGeofences. This covers Plan my day, On the day, Collect nearby, and the
saved and upcoming patches on Just drive. A stop with no boundary simply has
none drawn.
The drawer shows a single Trips row while the flag is on.
Every older "plan a day trip" entry point opens Plan my day while the flag
is on. /trip-planner redirects to /trip-plan (mobile/app/trip-planner.tsx,
default export). That covers the Home card, My Trips, the old trip home's
builder and the Build a day trip mission (deep link scout://trip-planner).
How it works
One plan object. Every screen reads a TripPlan built by
schedule() (src/trips-v2/plan.ts:108): ordered stops, the leg into each one
(walk or drive), minutes at each stop, and an arrival and leave time for each,
starting from a start time. The rest follows from it:
- the totals are the sums of the legs as shown (
displayMiles,plan.ts:69); - the leave-by reminders come from it (
leaveByTriggers,reminders.ts:52); - Route in Maps reads its remaining stops (
routePoints,trip.ts:111).
Choosing stops (Plan my day).
openPatchesNear(trip.ts:30) takes the patches you have not collected, within the length's radius (5, 10 or 25 mi). It skips city, state and region containers and airports (isSuggestable,plan.ts:42).selectStops(plan.ts:146) then picks nearest-next until the next stop would overrun the budget (180, 300 or 480 minutes of travel plus time at stops), up to 8 stops.- A plan never runs past 9 PM (
DAY_END_HOUR,lengthOptionsinplan.ts). The TIME dial offers only the lengths that fit before then. Mid-afternoon, Full day is gone; with under 3 hours left there is one option, "Until 9:00 PM". With under an hour left, Plan my day says "Too late to start today" and offers Plan for tomorrow, and the question shows the same. - Time at a stop comes from its type (
dwellFor,plan.ts:58): a zoo is 2 hr, a roadside attraction 15 min, and anything else 30 min.
Legs.
- A hop of up to 1 mile of path is walked (
legInto,plan.ts:78, which reuseslegForfromsrc/utils/tripRoute.ts). - Otherwise the router's measured leg is used, plus 5 minutes to park and walk in.
It comes from
POST /api/trips/route→legs[i]: the backend maps each OpenRouteService segment to{ miles, driveMin }(backend/src/routing/ors.provider.ts:108) and caches them inroutes.legs. A row cached before that column existed is healed once (backend/src/routing/routing.service.ts:76,backend/src/trips/trips.service.ts:91). - Until the route answers, or if it fails, legs are straight-line estimates. The
sheet says "Measuring the roads" or "Times are estimates"
(
TripPlanScreen.tsx,ROUTE_NOTE).
Starting.
startV2Trip(src/trips-v2/start.ts:11) saves the trip through the existingseedDayTrip, makes it live and remembers the kind.- It also asks for notification permission once (the OS shows its prompt only the first time).
- A v2 trip is an ordinary
ActiveTripwith av2block (src/domain/types.ts,TripV2): kind, start time, per-stop time overrides, detour, and dismissed or notified patch ids.patchIdsis the fixed stop order: nothing reshuffles it.
On the day.
liveState(trip.ts:92) finds the first stop not yet collected on this trip. Within 0.25 mi of it you are at it, and the card shows when to leave. Otherwise you are heading to it, and the card shows when you get there. Either way it says "On time" or "Running late".- Completed state on the map: the route is split at the last stop behind
you. The part already travelled is drawn dim (darker brass, 65%) and the rest
stays bright gold. Each stop is projected only onto the line after the
previous one, so a route that doubles back is split at the right pass
(
splitRouteAtStop,trip.ts). - Stay longer adds 15 minutes at the current stop (
STAY_LONGER_MIN,plan.ts:183), and every later time moves with it. - The screen re-syncs the reminders whenever the plan changes or the app returns
to the foreground (
TripLive.tsx,syncTripReminders).
Just drive.
- Your heading comes from two fixes at least 0.05 mi apart (
headingFrom,ahead.ts:50). aheadOf(ahead.ts:63) keeps the patches within ±75° of that heading and up to 60 mi up the road. Each one must be close enough to the road that getting off and back on (at 22 mph, ×1.25 for real roads) fits the detour.- Parked, with no heading yet, it keeps what you could reach and get back from within the detour.
- Add asks once (
askAddChoice,mapsApp.ts:56):- Save and add a stop in Google Maps or in Apple Maps saves the patch to the
drive and re-opens
directions as you → your saved stops (
addAsStopInMaps,drive.ts:64). - Save to my drive only just saves it.
- Save and add a stop in Google Maps or in Apple Maps saves the patch to the
drive and re-opens
directions as you → your saved stops (
- A saved stop goes where it adds the least travel from the drive's start
(
insertStopinsaveToDrive,drive.ts:24), so stops saved out of order are still visited in road order. - In the background,
runDrivePass(src/trips-v2/drivePass.ts:34) runs on every background location fix (src/services/location/tracker.ts:131). It notifies about the first patch ahead you have not already been told about, dismissed or saved, once per patch.
Collect nearby.
collectTarget(home.ts:41) takes the missing patches of the nearest City Challenge within 50 mi (nearestChallenge,src/components/home-v3/selectors.ts:45) and orders them nearest-next.- With no Challenge to work on, it takes the single nearest patch you need within 150 mi.
The question.
buildTripsHomeData(home.ts:79) runs the planner for real, so "6 patches from now, done by 4:05 PM" is the plan that Plan my day opens.- Which answer leads:
- Collect nearby when you hold at least 70% of the nearest Challenge ("Finish St. Louis");
- otherwise your last trip kind;
- otherwise Plan my day, or Just drive when nothing is close enough for a day.
Route in Maps and the maps app.
chooseMapsApp(mapsApp.ts:28): Android always uses Google Maps. iOS uses your remembered choice; if none, Apple Maps when Google Maps is not installed; otherwise it asks once.- URLs come from
buildRouteUrl(src/utils/maps.ts:124):- Google: the documented
maps/dir/?api=1link withwaypoints; - Apple:
maps://with onedaddrper stop. - Both are capped at 9 stops after you.
- Google: the documented
Trip Calendar
Trips plans today; the Trip Calendar plans any upcoming day at a time you
choose, with the same planner. Ships with the app release after this doc's date;
it is under trips_v2 like the rest of Trips (no flag of its own).
- Entry: the Calendar pill on the question's top bar (
TripsHome.tsx:183, count fromupcomingPlans,src/trips-v2/calendar.ts:39) andscout://trip-calendar. No drawer row. - The month (
TripCalendarScreen.tsx, data frombuildTripCalendarScreenData,:120): Sunday first; today ringed; the selected day filled brass; past days dimmed and not selectable; a planned day carries its first stop's patch art under the date. Under the month: the selected day's plans as Near Me rows ("9:00 AM to 4:50 PM · 6 patches · 5.9 mi"), then Coming up. An empty upcoming day says "Nothing planned for Sunday" with Plan Sunday. It opens on the next planned day, else today. A plan row opens that saved day in Plan my day's date mode (planParams,:167). - The day sheet (
TripDayScreen.tsx,buildTripDayScreenData:116):- Leave at: quick starts 8, 9, 10 AM, Noon, 2 and 5 PM (
QUICK_STARTS,calendar.ts:34), each labelled with the longest day it allows before 9 PM ("up to full day", "up to 3 hr", "too late";allowsLabel:105), plus Another time… (the system time wheel, rounded to 5 minutes). - From: where you are, or a City Challenge city (searchable, nearest
first). Nothing else. A city's day starts at its patch nearest the middle of
its patches (
cityStart,calendar.ts:88), a real place. - Plan my day and Collect nearby, with the planner's real numbers for that day, time and place. Just drive is not offered.
- Leave at: quick starts 8, 9, 10 AM, Noon, 2 and 5 PM (
- Saving (date mode of Plan my day): the pill shows the day and time and is
not pressable; Save to <Day> replaces Start (
saveScheduledTrip,src/trips-v2/start.ts:82). Opened on a saved day it shows that day's stops, with Delete (deleteScheduledTrip,start.ts:53) and Re-plan. A stop collected since the day was saved stays on it, ticked; nothing is swapped without Re-plan. - No patch on two days. Planning a day, today included, leaves out every
patch another upcoming planned day holds (
reservedFor,calendar.ts:51), and says so in one line: "Not on this plan: 4 for Sat Oct 17 and 2 for Sun Oct 18, already planned." (heldNote,:61). Only patches this day could otherwise have had are counted. Re-planning a day keeps its own patches; deleting a day frees them; a day already past holds nothing. - On its day a planned day becomes the trip Trips opens on, unless another
trip is live (
promoteTodaysPlan,start.ts:35, run when Trips opens and when the app returns to the foreground,app/(drawer)/trips.tsx:46). It goes live once: ending it does not bring it back (v2.wentLive). - One reminder per planned day: the existing 10-minute head-out reminder,
scheduled when the day is saved (
syncTripReminders(…, { headOutOnly: true }),reminders.ts:135). iOS keeps at most 64 pending local notifications, so the per-stop leave-by reminders are scheduled only when the day goes live. Reminders are keyed by trip: saving, moving or deleting one day never touches another's (cancelTripReminders,reminders.ts:112).
Data model
- No new server tables. Trips stay on the device (persisted store, MMKV), the
same as Day Trips. The existing trip sync pushes them flat
(
src/domain/tripSync.ts); thev2block is local only and never sent. - New persisted store fields:
tripRemindersEnabled(default true),preferredMapsApp,lastTripKind(src/domain/store.ts). - A planned day is a saved
ActiveTripwhosev2hasscheduled: true, itsstartAton its day, itsoriginthe From, and, for Collect nearby,collectionId(the City Challenge, so Re-plan stays in that city). It sits indayTripswithout being live (saveDayTrip,store.ts:2349).wentLivemarks a planned day that already went live on its date. - Backend:
routes.legs jsonb(nullable), added bybackend/prisma/migrations/20261006120000_route_legs/migration.sql.
API surface
POST /api/trips/route (existing, authenticated as before) now also returns
legs: { miles, driveMin }[] | null, one per waypoint pair. No new endpoints.
Key files
src/trips-v2/plan.ts: the pure planner (budget, dwell, legs, schedule, insertion, labels, start times).src/trips-v2/trip.ts: saved trip → plan, live state, Route in Maps points, names.src/trips-v2/home.ts: the question's answers and Collect nearby's target.src/trips-v2/ahead.ts: what is up the road within the detour.src/trips-v2/drive.ts: Save and Save and add a stop on a drive.src/trips-v2/reminders.ts: leave-by and patch-ahead notifications.src/trips-v2/drivePass.ts: the background patch-ahead check.src/trips-v2/calendar.ts: the Trip Calendar's pure core (dates, planned days, reserved patches, today's plan, the month, a city's start).src/trips-v2/notificationActions.ts,useTripNotificationActions.ts: a tap on a trip notification opens the trip; mounted inapp/_layout.tsx.src/trips-v2/mapsApp.ts,src/utils/maps.ts:124: the maps-app choice and the route URLs.src/components/trips-v2/: the five screens and their shared parts (parts.tsx).app/(drawer)/trips.tsx,app/trip-plan.tsx,app/trip-drive.tsx,app/trip-collect.tsx,app/trip-calendar.tsx,app/trip-day.tsx: the routes.
Configuration and flags
| Setting | Where | Default |
|---|---|---|
trips_v2 |
both flag registries | on |
| Trip reminders | Settings → Notifications (tripRemindersEnabled) |
on |
| Lengths | PLAN_LENGTHS, plan.ts:20 |
3 hr / 5 mi, Half day / 10 mi, Full day / 25 mi |
| Detour | DETOUR_OPTIONS, ahead.ts:16 |
15 min (5, 15, 30, 45) |
| Reminder lead | LEAVE_LEAD_MIN, reminders.ts:22 |
10 min |
Edge cases and known limits
- No location: the question, Plan my day, Just drive and Collect nearby all say so. The question offers Turn on location. None of them shows a zero-patch plan.
- Nothing in reach:
- Plan my day says "No patches within reach" and offers no Start.
- The question marks it unavailable and leads with Just drive.
- Collect nearby says "Nothing nearby yet" past 150 mi.
- Route request fails: times stay as estimates, with a note, never a stuck skeleton.
- Notification permission denied: the trip still runs; reminders and patch-ahead alerts simply are not scheduled.
- A drive is never archived on its own. Collecting every stop saved so far
does not end it; only End drive does (
src/domain/store.ts:2450). A planned or Collect nearby trip archives when its last stop is collected, as Day Trips always have. - A day trip live from before the update (no
v2block) is adopted the first time Trips opens on it. It becomes a Plan my day trip with its stops in nearest-next order from its own origin and times from now (adoptLegacyTrip,src/trips-v2/start.ts). It is never shown as the question. - A Road Trip planned on the old path is not shown anywhere while the flag is on: the Road Trip row is hidden and Trips has no road view. It is still saved. Turning the flag off brings it back unchanged.
- Analytics: starting any v2 trip goes through
seedDayTrip, so it records the existingday_trip_startedfeature event. A drive records it too. - A plan for later (Leave: In an hour / Tomorrow at 9 AM) shows "Leave at …" and marks no stop "Now" until it starts.
- The heading is only as good as the last two fixes. It is unknown when parked, and lags a sharp turn. Patches level with you or behind you are never "ahead".
- Route in Maps carries at most 9 stops after you, the nearest first.
- Planning today on the Trip Calendar never offers a start that has passed:
earlier quick starts read "too late", Leave at opens on the next one still
ahead (
firstLeaveAfter,calendar.ts), and Plan my day in date mode with a past start says "That time has passed" and offers no Save. - Collecting a planned day's stops before its date ticks them; the day stays
on the calendar and is never archived as a finished trip until it has gone
live (
archiveIfComplete,src/domain/store.ts). - Editing a planned day that is live, or already went live, keeps what
happened on the day: it stays ended once ended, Stay longer is kept, and a live
day keeps its per-stop reminders (
saveScheduledTrip,start.ts). - Daylight saving: a leave time is set on the wall clock of its day
(
startAtFor), so 9 AM is 9 AM on the days the clocks change.
What this feature does NOT do
- Trip notifications have no buttons. No Route in Maps, Stay longer, Add or "Not this one" from the lock screen: a tap opens the trip in the app, and everything is done there.
- No in-app turn-by-turn on this path. Directions are handed to Google Maps or Apple Maps. The in-app driving map and maneuver banner belong to the flag-off Day/Road Trip path only.
- It cannot add a stop to navigation already running in another app. No maps app allows that. "Save and add a stop" re-opens directions from you through your saved stops. Scout cannot see whether Google Maps or Apple Maps is open.
- No prices, opening hours, open/closed status or tickets. The planner neither reads nor shows them. A stop's time is a default by type of place, not a promise about the place.
- No curated or Scout-authored trips on this path; those were dropped for v1.
- No multi-day trips. The longest plan is a full day. On the Trip Calendar a weekend is several planned days, each its own plan.
- The Trip Calendar does not export to Apple Calendar or Google Calendar, and reads nothing from them. Planned days live only in Scout, on this phone.
- No morning-of notification for a planned day. Its one reminder is the 10-minute head-out.
- A planned day starts only from where you are or a City Challenge city. No address search, no other places.
- Just drive is not on the calendar.
- A planned day never replaces a trip that is already live. On its date it waits until nothing is live (Trips opening or the app returning to the foreground picks it up).
- A planned day is not re-planned for you. If you collect one of its patches first, it stays on the plan, ticked, until you press Re-plan.
- No destination for Just drive. It follows your heading only; there is no "drive to X and show what is on the way" here (that is the old Road Trip).
- No server push. Every trip notification is a local one scheduled on the phone. Nothing is sent from a server. Nothing fires outside a trip the user started or a day they planned on the Trip Calendar (that day's one head-out reminder).
- Trips do not sync between devices. The
v2details never leave the phone. - No food, restaurant or fuel stops. The catalog has no such patches.
- It does not unlock anything by itself. Collecting still happens through the normal unlock (polygon containment); Trips only records it against the live trip.
Tests that cover it
- Logic (
mobile/src/trips-v2/__tests__/):plan.test.ts: schedule, budget, walking, insertion, labels, start times;trip.test.ts: plan from a saved trip, live state, route points, names;home.test.ts: lead rules, rural, no location, collected excluded;ahead.test.ts: heading cone, detour, road order, no heading, level patches;reminders.test.ts: leave-by triggers, Stay longer, ahead dedupe, message copy;drivePass.test.ts: background only, needs a heading, once per patch;drive.test.ts: save, cheapest insertion, dismiss, Save and add a stop;notificationActions.test.ts: a tap opens the trip and changes nothing, even with an older build's button id;calendar.test.ts: dates, planned days, reserved patches, held note, today's plan (and ended stays ended), month grid, a city's start;store.test.ts: v2 storage, a drive never auto-archives (regression).
mobile/src/utils/__tests__/maps.test.ts: the Google and Apple route URLs.- Screens (
mobile/screen-tests/):trips-v2.test.tsx: the question, live trip, at a stop with Stay longer, heading, no location, flag off;trip-plan.test.tsx(incl. date mode: Save to Sunday, another day's patches left out, editing and Delete, a fully collected city),trip-drive.test.tsx,trip-collect.test.tsx;trip-calendar.test.tsx: the month, a planned day's art, the empty day, a past day not selectable, where rows and Plan <Day> go;trip-day.test.tsx: the planner's numbers for a day and time, what each start allows, From = City Challenge cities, the params Plan my day gets, a city with nothing left;trips-v2.test.tsxalso: the Calendar pill and its count, today's plan going live, never over a live trip, ended stays ended;notification-settings.test.tsx: the Trip reminders switch;onboarding-tutorial.test.tsx: permission copy.
- The flag-off path stays covered by
trips.test.tsx. - Backend:
src/routing/routing.service.spec.ts: legs stored, healed and served;src/routing/ors.provider.spec.ts: segments → legs;src/trips/trip-route.spec.ts.
- Gallery:
src/dev/mocks/trips-home.tsx,trip-plan.tsx,trip-live.tsx,trip-drive.tsx,trip-collect.tsx,trip-calendar.tsxandtrip-day.tsx(rendered byscreen-tests/screen-mocks.test.tsx).
Open questions
- Background location cadence while driving decides how early a patch-ahead notification can fire. It is not measured on a real drive yet.
- Whether a tap on a notification that arrived while the app was killed reaches
getLastNotificationResponseon every iOS version has not been verified on a device.