Scout — Full Product Context → feature documentation

Patch unlock and location tracking

Scout's core mechanic: a patch is 'collected' when the device's GPS position is proven to be inside that patch's real-world footprint.

Screen recordings

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

You're inside 2.3s · recorded 2026-09-09
Explore nearby 2.9s · recorded 2026-09-09
Nearby spotlight 2.3s · recorded 2026-09-09
Badge unlocked 2.27s · recorded 2026-09-11
Badge unlocked + card 7.33s · recorded 2026-09-11
356 ft away 10.1s · recorded
Crossing in 3.43s · recorded
You collected The Sphere 4.3s · recorded
Badge unlocked 2.8s · recorded
Collected 1.83s · recorded
Crossing in 3.43s · recorded
Cross in and unlock 7.8s · recorded

Full sessions

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

Full recording · 09-09 13:10 39.4s · recorded 2026-09-09 · unedited
Full recording · 09-11 11:17 11.2s · recorded 2026-09-11 · unedited
Full recording 21.4s · recorded · unedited

Summary

Scout's core mechanic: a patch is "collected" when the device's GPS position is proven to be inside that patch's real-world footprint. Most footprints are actual polygons (a park boundary, a city limit, a state line, a street corridor) stored as PostGIS geometry and checked server-side via ST_Contains; a smaller set of patches (anything with no polygon at all) fall back to a simple 50-meter radius around a pin, decided entirely on-device. Containment is checked continuously in the background while the app is installed (subject to OS throttling and the user's permission grant), on every foreground app resume, and again, authoritatively, whenever the Compass screen detects the user has entered a patch's drawn area. A separate, narrower "Check In" action lets a user manually claim a city or state patch — but it does not use polygon containment at all; it string-matches the device's last reverse-geocoded city/state name against the patch name, entirely client-side, no network call.

Status (shipped / beta-badged / flagged off)

Fully shipped, unconditionally on for every user. There is no feature flag gating any part of this: not in the mobile client's flag registry (mobile/src/config/feature-flags.ts, which lists cloud_album, guest_mode, alltrails_integration, feature_flags_visible, submit_patch, buy_patch — nothing location-related) and nothing in the backend's flag definitions (backend/src/admin/feature-flag-definitions.ts) either. The only gates are the OS location permission and the user's own "Location Tracking" toggle in Settings (shouldUseLocation, mobile/src/services/location/trackingDecision.ts:18) — both are user consent, not a rollout mechanism.

User-facing surfaces

How it works (end-to-end mechanism)

There is exactly one OS location subscription in the app (mobile/src/services/location/locationWatch.ts) — every screen and hook reads the resulting position from the Zustand store, never opens its own watchPositionAsync. On top of that there are two independent unlock paths that share one core routine:

  1. Background/foreground automatic unlock (mobile/src/services/location/tracker.ts)

    • A native expo-task-manager background task (LOCATION_TASK_NAME = 'TRAVEL_PATCHES_LOCATION_TRACKER') is registered at module load and started via Location.startLocationUpdatesAsync once the user has permission, has the Settings toggle on, and has completed sign-in/guest choice (shouldAutoStartTracking, mobile/src/services/location/trackingDecision.ts:49).
    • Every fix (background task callback, or the foreground watch) is stored unconditionally to the app state (setLocationFix), however coarse — so the UI always has something to show. Accuracy is only enforced at the moment of an unlock decision (isPreciseEnoughToUnlock, mobile/src/services/location/locationWatch.ts:91): a fix must be <= 75m accuracy (MAX_LOCATION_ACCURACY_METERS) to be allowed to decide a containment check, except a source: 'dev' fix (the Set Location tool or the drive simulator), which always passes regardless of "accuracy" because it has none.
    • runUnlockPass(userLocation) (tracker.ts:468) is the single routine both the background task and the foreground checks funnel through. It splits the uncollected catalog into:
      • Polygon patches (anything with a protectedAreaId or geofenceId) — cheaply pre-filtered on-device to those within 50km + the patch's own polygon reach (getPolygonPatchesNearby, polygonBboxRadiusM), then sent as a batch to the backend POST /api/location/check-polygons. Whatever comes back in insideIds is claimed.
      • Everything else — decided entirely on-device: within pointUnlockRadiusMeters (server-configured AppConfig value, default 50m, read from an MMKV mirror the background task can access even with a cold query cache — mobile/src/lib/backgroundConfig.ts) of the patch's lat/lng (shouldUnlockPatch, tracker.ts:434).
    • A claim goes through LocationProvider's registered callback (handlePatchUnlock, mobile/src/providers/LocationProvider.tsx:205), which re-checks the patch isn't already owned (collectPatch, idempotent), is gated on canAutoUnlock (mobile/src/domain/autoUnlockGate.ts) so a background fix arriving before the account's own collection has loaded from the server cannot re-"collect" and re-celebrate a patch the user already owns, sends a local notification, and enqueues the patch for the celebration modal.
    • Nested unlock: one containment check can return multiple patch ids at once — the backend query has no notion of "closest" or "one at a time," it just returns every polygon (across both protected_areas and geofences) that contains the point. A single GPS fix inside the Saint Louis Zoo is simultaneously inside the zoo, Forest Park, the city of St. Louis, and the state of Missouri, and all four collect from that one fix (comment at tracker.ts:313). runUnlockPass claims each returned id in turn; multiple celebrations queue and are shown one after another. The queue is the store's unlockedQueue and is the ONLY copy — CelebrationWatcher drains it one celebration at a time, leaving a patch in the queue (rather than copying it anywhere) whenever the active screen forbids a celebration, with a 1000ms CELEBRATION_SETTLE_MS beat between consecutive ones. Until 2026-09-02 it handed deferred patches to a single-slot local queuedPatch and cleared the store queue, so each deferred patch overwrote the previous one: a four-patch nested unlock showed only the first and the last, and the middle two were destroyed unrecoverably. Covered by mobile/screen-tests/celebration-achievement-order.test.tsx.
  2. Compass arrival re-check (mobile/app/compass.tsx:305-344) — the background pass batches updates every 60 seconds (deferredUpdatesInterval: 60000), so standing inside a boundary while looking at the Compass screen could otherwise wait up to a minute. The screen independently computes isInside from the same drawn geometry (isPointInRings against the polygon it is rendering, or a haversine circle test for a point patch) and fires one authoritative checkPatches() call (which is checkNearbyPatches()runUnlockPass, the exact same routine as above) the instant it detects entry — so it does not "trust" its own visual containment test to grant the patch, it just uses it to know when to ask the backend again sooner.

  3. Manual Check In (mobile/src/hooks/useVerifyLocation.ts) — a completely separate code path used only for collectionType === 'city' or 'state' patches. It does not call POST /api/location/check-polygons and does not do polygon containment. It lower-cases and trims the patch name and compares it against useAppStore().cityName / stateName, which are the last reverse-geocoded place names (Location.reverseGeocodeAsync, set by LocationProvider's geocode effect). If it matches, it calls the same collectPatch store action directly — no backend round-trip at all. This is a real behavioral gap from the automatic path: the polygon-containment rewrite that deleted the old reverse-geocoded name-matching layer for automatic unlock (documented at mobile/src/services/location/tracker.ts:203 and in CLAUDE.md) left this manual Check In flow untouched — it still runs on string equality against a geocoder's opinion of the place name, not on the same polygons the map draws. See "What this feature does NOT do."

  4. The "Set Location (Dev)" tool and the drive simulator feed the exact same production pipeline. Picking a place (or driving a simulated route) calls setDevLocationFix/publishDevFix (mobile/src/services/location/locationWatch.ts:211,243), which writes a source: 'dev' fix to the same store field the real OS watch writes to, and tears down the OS watch while active. Because isPreciseEnoughToUnlock always returns true for source: 'dev', a simulated location can unlock real patches through the normal automatic pipeline — this is by design (it's the primary tool for testing unlock logic without traveling) and is gated to dev builds / admin accounts only (dev-location.tsx:182). Maestro's setLocation command is a different mechanism (an OS-level simulated GPS fix, source: 'live') — per a comment in mobile/maestro/tests/background-location-demo.yaml, the real native background TaskManager task does not fire for a Maestro-simulated location; only the foreground checks (Compass arrival, the periodic web poll) do.

  5. The "Simulate Unlock (Dev)" tool (mobile/app/(drawer)/dev-simulate-unlock.tsx/compass?patchId=<id>&sim=1mobile/src/hooks/useUnlockSimulation.ts) stages an arrival on one patch, on the Compass, in about 18 seconds. It reuses the drive simulator's headless clock wholesale — a two-point route with one stop at the end — so the position is published through the same publishDevFix path and the Compass's own arrival check (step 2 above) fires from inside the boundary exactly as it would for a real walk-up. An uncollected patch therefore unlocks for real and syncs.

    Two things are specific to it. First, the approach is measured from the unlock BOUNDARY, not from the patch pin: approachRoute (mobile/src/utils/unlockSim.ts) steps out to the nearest ring vertex, verifies it is genuinely outside via the same isPointInRings the Compass uses, and starts 60m beyond it — so the run crosses the edge whether the fence is a 160m building footprint or a 40km forest, which a fixed offset from the pin cannot do in both directions. It then stops 25m past the plane (MARGIN_IN_M) rather than continuing toward the pin: breaking the polygon's plane is the entire event, and everything after it is just walking. (It was 80m until 2026-09-11, which marched a run into anything smaller than a park most of the way to the middle before stopping.)

    Second, the run forces a celebration when the production path cannot produce one: shouldForceCelebration enqueues the patch onto unlockedQueue directly if it was already collected before the run (nothing left to unlock), or if containment never tripped — and stays out of the way when the real unlock already queued one, so an arrival never celebrates twice. The trigger is the crossing, not the end of the walk: the hook takes the Compass's own isInside and settles the run the instant it flips. For an already-collected patch that fires in the same tick, since nothing can race it; for an uncollected one it waits FORCE_DELAY_MS for the real check's backend round-trip to land first. Arrival is only the fallback, for runs that cannot cross a boundary at all (no geometry, or a pin outside its own polygon). This forced enqueue is the only thing in the whole feature that is not the production path, it is gated by the same shouldSimulate dev/admin check, and it never writes a collection — it only shows the modal.

Data model

Two geometry tables exist only as raw SQL (deliberately not modeled in Prisma — see the comment at backend/prisma/schema.prisma:224) and are accessed exclusively through $queryRaw:

API surface

Key files

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions