Scout — Full Product Context → feature documentation

Missions

Missions are tasks Scout gives you, on their own drawer screen. Four are authored (Scan your camera roll, Build a day trip, Visit a park, Find something weird); finishing one means earning its achievement.

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:

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

How it works

  1. Content. Missions are authored rows in a missions table, published like any other content (entity mission, backend/src/content-publish/content-entities.ts:560; it publishes after achievements, order: 14 at :570, and references its achievement by slug).
  2. 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>=N checks a count; a missing key reads as 0; an empty metric is never satisfied). The evaluator's qualifiesApp calls 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 both award() and missions, so missions read exactly what achievements are judged against.
  3. deriveMissions (backend/src/missions/derive-missions.ts:42) is the pure core. A mission whose achievement is missing, retired or unapproved is hidden. complete is whether the user has that achievement's user_achievements row. A step is done if 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". openCount is the number of listed missions not complete.
  4. MissionsService.list (backend/src/missions/missions.service.ts:18) loads active + approved missions, their achievements, the user's earned ids and buildEarnedContext, and hands them to deriveMissions. It is read-only and never calls award().
  5. Mobile data. useMissions (mobile/src/hooks/useMissions.ts) wraps missionsQuery (mobile/src/query/queries/missions.ts, 30-second staleTime), 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 through invalidateAfterAward (:19), which invalidates the achievements query and calls invalidateMissions. Awards reach it from mobile/src/api/featureEvents.ts:124 and 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).
  6. 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) and opened_trip_planner (the swipe deck, mobile/app/trip-planner.tsx, with trips_v2 off; Plan my day, mobile/app/trip-plan.tsx, with it on — the mission's scout://trip-planner link redirects there, see trips-v2.md). photo_access_granted follows 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_scanned is 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.
  7. 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, missions prop passed from mobile/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

Key files

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

What this feature does NOT do

Tests that cover it

Backend:

Mobile:

Open questions