Summary
Missions are tasks Scout gives a signed-in user (guests included, because they have a session), shown on their own screen with a count of the open ones on a drawer row. There are exactly four authored missions:
- Scan your camera roll — "Find patches you've already earned in photos from past trips." Tied to the Automator achievement.
- Build a day trip — "Pick a few patches near you and Scout plans the route." Tied to the Trip Advisor achievement.
- Visit a park (added 2026-10-07) — "Go stand in a park near you. Scout unlocks its patch from your location." Tied to the Grass Act achievement (any
park-type patch, collected in person). - Find something weird (added 2026-10-07) — "Giant statues, tiny churches, monster sightings. Go see one in person." Tied to the Weird Science achievement (any
roadside_americanapatch, collected in person).
The last two are the first visit missions: they finish on a place-type achievement, so only travelling there finishes them.
A mission is tied to exactly one achievement and is complete if and only if that achievement is earned. Steps on a mission are progress hints only; they never decide completion. The system is built so later kinds of mission are additions of data, not a rewrite, but none of those later kinds exist yet (see "What this feature does NOT do").
Status
Live. All four missions rows are approved (is_approved) and published in prod (build-day-trip and
scan-camera-roll on 2026-10-02, find-something-weird and visit-a-park on 2026-10-09; read-only prod query,
2026-10-14), and the screen has shipped in every store build since v1.1.98 (mobile/app/(drawer)/missions.tsx is in
the v1.1.98 and v1.1.107 tags). It is content-gated by is_approved; there is no feature flag. Nothing shows
until a missions row is approved and published to prod, and the app build that carries the screen has shipped. Every row is seeded locally unapproved (backend/scripts/seed-missions.ts), and publishing is a
manual step in /admin/publish. Order: deploy the backend, publish the rows, then ship the mobile build (npm run deploy; the new route and deep link need a fresh build, OTA is emergency-only). The backend must deploy before a
publish: the publish manifest compares table shapes and refuses a publish against a prod without the missions table.
Unapproving every mission row is the kill switch: the endpoint then lists nothing, and the drawer row disappears. There is no admin screen for missions, and prod's admin is read-only for content, so the switch is: unapprove the missions rows in the local database, then publish (/admin/publish; is_approved is a published column, backend/src/content-publish/content-entities.ts:581).
User-facing surfaces
- Drawer row "Missions", second under Home in EXPLORE; the drawer's In-progress block also shows the open mission closest to done (navigation-drawer). It appears only when the user has a session AND at least one approved mission is listed by
GET /api/missions(mobile/src/components/navigation/drawerSections.ts:191-197,hasMissionsfilled fromuseMissions().missions.length > 0inmobile/src/components/navigation/CustomDrawer.tsx). It is hidden until the first successful load, and unapproving every mission (locally, then publishing) removes it (on the next successful fetch). A brass count badge shows how many missions are still open and disappears at zero. The badge reads 0 until the first successful load; after that, a failed refresh keeps showing the last known count, and because the query cache is persisted across launches (mobile/src/query/persistOptions.ts) that count also shows on a cold start before the refetch lands, so it can be stale until the next successful fetch. - The Missions screen,
scout://missions(mobile/app/(drawer)/missions.tsx,mobile/src/screens/MissionsScreen.tsx, registered inmobile/src/dev/deepLinkRoutes.ts:40), on a solid black background (colors.surface.void). Redesigned 2026-10-06 to design A ofmobile/research/missions-redesign-lab.html, in the trip calendar's grammar — no cards, no stamps, no seal:- The bar (
Scout.TopBar inline): the glass menu button, the title "Missions", and the step count on the right ("4 of 6 steps", read out as "4 of 6 steps done"; absent when there are no missions). The drawer's floating hamburger is off for this route (showHamburger={false}); the bar's button is the samedrawer-hamburger. - The mission you're on, unboxed (the first open mission): a section header Up next, or Keep going once one of its steps is done; its badge (108pt); its name and "Earns <badge>"; its one-line description; its steps top to bottom (done = gold check, struck through; the one you're on = ringed number; the rest quiet —
MissionSteps); and the gold button naming the step you're on ("Continue: Pick 2 or more stops"). A mission with no deep link shows no button at all, rather than a dead one. - Every other mission is a flat row (
MissionRow) under All missions (or Completed once every mission is done), with "<done> / <total>" on the right: its badge (72pt), its name, and "1 of 3 steps · earns Trip Advisor" or "Automator earned". An open mission with a deep link is a tap target with a chevron that opens it; a finished one, or one with no link, is not. - The badge (
MissionBadge): the achievement's art with nothing behind it — no disc, no tile — sized to fit wholly inside a dashed track (the largest square inside the ring's inner edge, less 4pt of air, so no art can cross it); a brass arc over the steps done (none at 0 steps); full colour and a gold check once earned, 38% opacity before (opacity only, never greyscale). - Every mission done: "Every mission done" and "New missions show up here when there are more." (generic on purpose: true for any mission set), then the Completed rows.
- The old screen's step ring, badge case, illustrated cards (location-photo prints, the patch-art route), the "Up next" travelling border, the ghost "Start mission" button and the DONE-stamped rows are no longer rendered; their components are still in the repo, unused.
- The bar (
- States: loading uses the house page-loader skeleton (
MissionsSkeleton); an error shows "Couldn't load missions." and Try again, but only when nothing is loaded; an empty board showsmissions-empty("No missions right now."). - The "Mission complete" overlay, a full-screen moment played when a mission's achievement is earned (
mobile/src/components/missions/MissionCompleteOverlay.tsx): the badge stamps in over the legendary unlock celebration's own background (CelebrationBackdropwith the legendary tone and its reveal beat: gold burst, shockwave rings, flash, then drifting embers, halo and god-rays — the sameuseEmberField+TIER_FX.legendarythe legendary reveal uses; static and settled under reduced motion), "<Badge> earned", "You finished <mission title>.", the deck's unlock sound and a success haptic. Its primary button is Next mission: <title> (that mission's deep link), secondary Back to missions; with nothing left, See your missions and Close. "Next" is only ever an open mission that has a deep link and whose own achievement is not also still queued (that one is done and plays its own overlay next); if there is none, the overlay shows the "See your missions" variant (mobile/src/components/achievements/AchievementDeck.tsx:115-122). The overlay is modal: Android's hardware back closes it (the same path as Close: the achievement leaves the queue) and does not navigate the screen underneath; VoiceOver is confined to it (accessibilityViewIsModal), and screen readers hear "Mission complete. <Badge> earned." once when it appears (mobile/src/components/missions/MissionCompleteOverlay.tsx:47-57,:74).
How it works
- Content. Missions are authored rows in a
missionstable, published like any other content (entitymission,backend/src/content-publish/content-entities.ts:560; it publishes after achievements,order: 14at:570, and references its achievement by slug). - Shared evaluation, extracted not copied.
metricSatisfied(backend/src/achievements/achievement-evaluator.service.ts:35) is the single reader of an app-family metric (a bare token checks a recorded event;key>=Nchecks a count; a missing key reads as 0; an empty metric is never satisfied). The evaluator'squalifiesAppcalls it (:90).AchievementsService.buildEarnedContext(backend/src/achievements/achievements.service.ts:21) assembles the user's evaluation context (in-person patches, feature counts, recorded events) and is called by bothaward()and missions, so missions read exactly what achievements are judged against. deriveMissions(backend/src/missions/derive-missions.ts:42) is the pure core. A mission whose achievement is missing, retired or unapproved is hidden.completeis whether the user has that achievement'suser_achievementsrow. A step isdoneif the mission is complete, or its own metric hint holds, or any later step is done (so a step with no metric fills in behind its successor).parseSteps(:24) tolerates malformed authored JSON: a non-array, or a step with no label, is dropped; an empty metric means "no hint".openCountis the number of listed missions not complete.MissionsService.list(backend/src/missions/missions.service.ts:18) loads active + approved missions, their achievements, the user's earned ids andbuildEarnedContext, and hands them toderiveMissions. It is read-only and never callsaward().- Mobile data.
useMissions(mobile/src/hooks/useMissions.ts) wrapsmissionsQuery(mobile/src/query/queries/missions.ts, 30-secondstaleTime), gated on the access token. Freshness is driven by invalidation. The missions invalidation lives in one helper,invalidateMissions(mobile/src/query/invalidateAfterAward.ts:10), and is triggered two ways: any successful feature-event POST marks the board stale, whether or not it awarded anything, because a recorded event can complete a step on its own (mobile/src/api/featureEvents.ts:112); and every award goes throughinvalidateAfterAward(:19), which invalidates the achievements query and callsinvalidateMissions. Awards reach it frommobile/src/api/featureEvents.ts:124and the sync push (mobile/src/query/queries/userData.ts:403). A step event that awards nothing invalidates missions only, never the achievements grid. The Missions screen refetches on focus only when the board is stale, and never cancels a fetch already in flight (refetch({ cancelRefetch: false })); its first focus is skipped because mounting already fetches (mobile/src/screens/MissionsScreen.tsx:49-67). So mounting issues at most one request, and coming back to a fresh board issues none. A failed refetch keeps the already-loaded board (the error page appears only when nothing is loaded). - Step events. Three mission-only feature events light up step hints (and the visit missions reuse the existing
opened_near_me, which the Near Me screen already records for the Close By achievement):photo_access_granted(mobile/src/hooks/usePhotoImport.ts:309,:509),photo_import_scanned(:411) andopened_trip_planner(the swipe deck,mobile/app/trip-planner.tsx, withtrips_v2off; Plan my day,mobile/app/trip-plan.tsx, with it on — the mission'sscout://trip-plannerlink redirects there, seetrips-v2.md).photo_access_grantedfollows one rule everywhere: iOS "Limited" library access does NOT count as "Allow photo access"; hand-picked photos DO. A camera-roll or album scan records it only when the source opened with full access; a hand-picked selection always records it; the album picker records it only on full access (:509).photo_import_scannedis recorded once the photos have been read, whether the scan ran to the end or the user stopped it, and whatever it found: zero matches, and also a scan that found no photo with GPS data at all (it still ends on the "none had location data" error or, under limited access, the limited-access prompt, but "Scan your photos" counts as done). It is not recorded when the scan is cancelled or the screen unmounts mid-scan, or when the scan throws. - The completion moment is the achievement deck's. Nothing new is enqueued: a mission completes because its achievement is awarded, and that is already queued.
AchievementDeck(mobile/src/components/achievements/AchievementDeck.tsx:98,missionsprop passed frommobile/app/_layout.tsx) looks the queued achievement up in the cached missions; a mission achievement renders the overlay instead of a card, and plays before other queued achievements (the rest then play as today). Same gate and invariant as the deck: never over a patch celebration or the onboarding flow, and dequeued only on a button press, never on mount. If the missions cache is empty or not loaded, the item plays as an ordinary deck card, so no celebration is lost. Retroactive awards are not announced (announce: false), so a long-time holder of Automator simply sees the mission done, with no overlay.
Data model
Table missions (backend/prisma/schema.prisma:2046, hand-written migration backend/prisma/migrations/20261002120000_missions/migration.sql). Columns: id (per-database uuid, never published), slug (unique, the cross-database key), title, body, achievement_slug (required and unique: one mission per achievement), steps (JSON array of { label, metric | null }), deep_link, sort_order, is_active, is_approved (default false), timestamps. There is no per-user progress table: progress is derived on every read.
Seed (backend/scripts/seed-missions.ts, local DB only, isApproved: false; npm run seed:missions):
| slug | achievement | deep link | steps |
|---|---|---|---|
scan-camera-roll |
automator |
scout://import-modal |
Allow photo access, Scan your photos, Add a patch you found |
build-day-trip |
trip-advisor |
scout://trip-planner |
Open the planner, Pick 2 or more stops, Save your trip |
visit-a-park |
grass-act |
scout://near-me |
Look for a park in Near Me, Go there in person, Collect the park's patch |
find-something-weird |
weird-science |
scout://near-me |
Look for an oddity in Near Me, Go see it in person, Collect its patch |
The seed stores full scout:// links (backend/scripts/seed-missions.ts:18, :31); the app turns each into an in-app route (/import-modal, /trip-planner) with deepLinkToPath when a mission's button is pressed (mobile/src/screens/MissionsScreen.tsx:60-62).
API surface
GET /api/missions(backend/src/missions/missions.controller.ts), JWT-guarded, guests included. Returns{ missions: [{ id, slug, title, body, deepLink, complete, steps: [{ label, done }], achievement: { id, name, artUrl } }], openCount }. Raw metric strings are never sent to the client.- Three new allow-listed feature events on
POST /api/feature-events:photo_access_granted,photo_import_scanned,opened_trip_planner(backend/src/achievements/feature-events.controller.ts:44-48). No achievement consumes them; they exist only to light up step hints.
Key files
- Backend:
backend/src/missions/{derive-missions,missions.service,missions.controller,missions.module}.ts,backend/src/achievements/achievement-evaluator.service.ts(metricSatisfied),backend/src/achievements/achievements.service.ts(buildEarnedContext),backend/src/content-publish/content-entities.ts(entitymission),backend/scripts/seed-missions.ts. - Mobile:
mobile/src/screens/MissionsScreen.tsx,mobile/src/components/missions/*,mobile/src/hooks/useMissions.ts,mobile/src/query/queries/missions.ts,mobile/src/query/invalidateAfterAward.ts,mobile/src/components/achievements/AchievementDeck.tsx,mobile/src/components/navigation/drawerSections.ts,mobile/src/dev/mocks/missions.tsx.
Configuration and flags
None. There is no feature flag and no app-config key. The gate is content: missions.is_approved and is_active, plus the mission's achievement being active and approved. Nothing to set in the admin besides publishing the rows.
Edge cases and known limits
- A scan that matches nothing cannot finish "Scan your camera roll". Automator needs at least one patch unlocked by the import. The step track shows "Scan your photos" done and stops there (also when none of the photos had GPS data). The mission copy does not promise a match.
- Limited photo access leaves "Allow photo access" open, even though the scan runs on the photos the user shared. Granting full access, or hand-picking photos, ticks it.
- Any trip with 2 or more stops finishes "Build a day trip", including a road trip, because Trip Advisor's metric does not distinguish trip kinds. The mission follows its achievement.
- Imported patches never count toward patch-based achievements (only in-person patches do), so a camera-roll import cannot finish Visit a park or Find something weird; the mission stays open with no step lit.
- A visit mission's first step lights on opening Near Me, whatever Near Me shows. "Look for a park in Near Me" and "Look for an oddity in Near Me" both read
opened_near_me; nothing checks that a park or a roadside place was actually nearby. Near Me is also the button's destination: there is no type-filtered deep link (Browse takescollectionId,categoryId,status,search, not a place type). - The middle step of a visit mission ("Go there in person") has no hint. It turns done only when the patch is collected, so a visit mission reads 1 of 3 until it completes.
- The last step of a visit mission lights only on completion. Its metric is the achievement's own
count(collection_type=…)>=1, whichmetricSatisfieddoes not read (it fails closed); the step is done because the mission is complete. - Visiting a national park finishes Visit a park. National parks are
park-type patches, so they earn Grass Act. There is no national-park mission: no achievement counts national parks specifically (Park Ranger is earned by opening the National Parks map, not by visiting). - Not every roadside place can be unlocked. Unlock is polygon containment, and some
roadside_americanapatches have no geofence yet; Find something weird is finishable through any one that does. - Step hints are only as fresh as the last missions fetch. Any recorded step event marks the board stale, so it refetches right away if the board is mounted (the drawer keeps it mounted) and otherwise on the next focus; completion is only as fresh as the last award.
- A mission row with no
deep_linkis listed but has no button, and is never offered as "Next mission". - Deleting or unapproving a mission's achievement hides the mission and drops it from the drawer badge. When that (or unapproving the mission rows) leaves no listed mission, the drawer row itself disappears; the screen is still reachable by
scout://missionsand shows its empty state. - "Pick 2 or more stops" has no hint: the server only sees a trip once it is saved, so picking and saving look the same.
What this feature does NOT do
- No daily or weekly missions. No expiry, no time windows, no resets. Every mission is permanent until its row is unpublished.
- No generated missions. All four missions are hand-authored rows; nothing is derived from a user's collecting progress.
- No national-park mission. Visiting a national park finishes Visit a park (Grass Act); nothing tracks national parks as their own goal.
- Completion means only "the achievement is earned." No partial credit, no XP, no level, no streak, no points.
- No mission-specific results in the completion overlay (it does not say "you added 4 patches").
- Nothing on Home. The Home screen does not show, pitch or badge missions, and the menu button has no badge. The open count lives on the drawer row and the Missions screen only.
- No push notification for missions.
- No admin screen for missions. They are seeded locally and published; there is no review toggle. Turning them off means unapproving the rows in the local database and publishing; prod's admin cannot do it.
- Nothing is live until published. The rows ship unapproved; do not describe missions as available to users before the rows are approved and published and the build is out.
- It does not award anything itself: the missions endpoint is read-only and never calls
award().
Tests that cover it
mobile/src/components/missions/__tests__/missionArtPatches.test.ts— the old card art's picker (nearest first, catalog top-up, no repeats). The redesigned screen no longer draws card art.
Backend:
backend/src/achievements/__tests__/metric-satisfied.spec.ts(bare token,>=Nboundary, missing key fails closed)backend/src/achievements/__tests__/build-earned-context.db.spec.ts(the extracted context: recorded tokens becomefeatureEvents, a clientkey>=Nevent folds intofeatureCounts, a client event never overwrites a server-derived count)backend/src/achievements/__tests__/achievements.patch-source.db.spec.ts(the in-person filter: onlysource = 'gps'collections earn, exercised throughaward())backend/src/achievements/__tests__/feature-events.controller.db.spec.ts(the three new tokens accepted, unknown still rejected)backend/src/missions/__tests__/derive-missions.spec.ts(complete iff earned, step cascade, hidden cases, malformed steps, openCount)backend/src/missions/__tests__/missions.db.spec.ts(GET /api/missionsagainst real rows; for each visit mission, a paired camera-roll import that leaves it open vs an in-person patch that completes it with every step done, and Near Me lighting only the first step)backend/src/missions/__tests__/seed-missions.db.spec.ts(the seed: all four rows, unapproved, each ending on its achievement's metric)backend/src/content-publish/__tests__/publish-apply.service.spec.ts(themissionentity, including inserting a new row withON CONFLICT (slug))
Mobile:
mobile/screen-tests/missions.test.tsx(Up next, all done, empty, loading, error and retry, a failed refetch keeping the board, both mission buttons opening their routes (the gold button and an open mission's row), no button for a mission without a deep link, a badge's arc absent at 0 steps, no card backgrounds and nothing behind a badge, earned at full colour with a check vs 38%, the badge art wholly inside its ring (confirmed failing against the first build, whose art crossed it), and focus: one request on mount, none on re-focus over a fresh board, one over a stale board), registered inmobile/screen-tests/screenTestRegistry.tsmobile/src/components/achievements/__tests__/AchievementDeck.missions.test.tsx(overlay vs card, mission first, nothing dequeued on mount, empty cache, hardware back closes the overlay and is consumed, listener removed on unmount, accessibility-modal + one announcement, Next skips linkless and still-queued missions)mobile/screen-tests/celebration-achievement-order.test.tsx(the_layoutwiring:CelebrationWatcherhands the loaded missions to the deck, so a listed mission's achievement plays the overlay)mobile/src/components/navigation/__tests__/drawerSections.test.ts(row hidden without a listed mission or without a session, badge, icon collision)mobile/src/query/__tests__/invalidateAfterAward.test.ts(invalidateAfterAwardandinvalidateMissions) andmobile/src/api/__tests__/featureEvents.test.ts(a POST that awards nothing invalidates missions only; an award invalidates both; a failed POST invalidates nothing)mobile/src/hooks/__tests__/usePhotoImport.featureEvents.test.tsx(limited access does not record photo access, hand-picked and full access do; a no-GPS scan recordsphoto_import_scanned, an aborted or throwing scan does not) andmobile/screen-tests/feature-events.test.tsx(the step events)mobile/src/theme/__tests__/index.test.ts(colors.surface.void)- Maestro:
mobile/maestro/tests/missions.yaml(drawer to Missions to both missions visible; written, not yet run on a device)
Open questions
- Should approving missions get an admin toggle (like the achievements review screen), or is seed plus publish enough until generated missions exist?