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 to the backend POST /api/location/check-polygons in batches of at most 50 ids (POLYGON_CHECK_MAX_IDS, mobile/src/services/location/tracker.ts, mirroring the backend's @ArrayMaxSize(50)), in parallel. Whatever comes back in any batch's insideIds is claimed; a batch that fails loses only its own patches, and the pass counts as failed only when every batch fails. Until 2026-10-02 this was ONE request, and in a dense metro the pre-filter routinely yields more than 50 candidates — the backend rejected every such request with a 400, so around Boston (and anywhere else that crowded) nothing polygon-based unlocked at all: not the state, not the city. A brand-new user at Boston Common got "You're not inside anything" on the first-run reveal. Regression tests: the "polygon containment above the backend's 50-id cap" block in mobile/src/services/location/__tests__/tracker.test.ts.
      • 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 (one GPS fix = one passId), and once the LAST claim is done it hands every patch the pass collected to the pass callback LocationProvider registered, which appends them in ONE store write (enqueueUnlockedPass(patches, passId)). It is in a finally, so a claim that throws cannot cost the ones already collected. The per-patch handler collects and notifies but does NOT queue. The queue is the store's unlockedQueue and is the ONLY copy — a patch the active screen forbids celebrating is simply LEFT in it, never copied anywhere else.

      Why the pass is handed over whole: the claims are sequential (each awaits the handler, which awaits a local notification), so until 2026-10-02 a nested arrival reached the queue one patch at a time over ~1.5s, and anything reading the queue mid-pass saw a fragment of it.

    • One celebration per ARRIVAL (2026-10-02): CelebrationWatcher pushes the whole head pass as /celebration?patchIds=... and drains it with dequeueUnlockedBatch, with a 1000ms CELEBRATION_SETTLE_MS beat before the next pass can push. See "Celebration screen" above for the layout and the 09-20 → 09-21 → 10-02 history.

      Before either of those (until 2026-09-02) the watcher 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. That invariant is unchanged and non-negotiable — unlockedQueue is the ONLY copy, and a patch the active screen forbids celebrating is LEFT in it, never moved into a second holder.

      Covered by mobile/screen-tests/celebration-achievement-order.test.tsx (the destruction bug by its "dropping none" case; the current behaviour by "plays a shared-passId pass as ONE celebration, innermost first", its opposite "keeps two separate arrivals as two celebrations", and the screen's "surges the innermost patch and lists the rest under 'Also unlocked'"). The whole-pass hand-over is tracker.test.ts ("a pass is handed over whole") and unlockQueueBatching.test.ts ("enqueues a whole pass in ONE write").

    • Nothing announces an unlock during onboarding — neither the celebration nor the notification. Two separate gates, and closing one never closed the other:

      • canCelebrateOnScreen (mobile/src/utils/celebrationGate.ts) blocks the MODAL while !tutorialSeen and on every onboarding-* / auth / location-permission root. Its result is now combined with the settle timer as allowed && settled, not settled alone: setSettled(false) lands one commit late, and a celebration really did push over the tutorial in that one commit.
      • canAnnounceUnlock (same file) blocks the NOTIFICATION, off the same list. LocationProvider used to call sendPatchUnlockNotification unconditionally, so "New Patch Collected!" banner-dropped over tutorial step 1. One deliberate difference: an UNKNOWN route means announce, because that is the backgrounded app, which is the notification's main path. The patch is still collected and still queued either way; the onboarding reveal (mobile/app/onboarding-reveal.tsx) shows it, and DEQUEUES the whole pass on Continue so nothing replays at home.
  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=1 → mobile/src/hooks/useUnlockSimulation.ts) stages an arrival on one patch, on the Compass, in about 5 seconds (APPROACH_SECONDS, plus a 1s still lead-in, LEAD_IN_MS; it was 18s until 2026-10-02). 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 beyond it by startOutM: 12% of the fence's bounding-box diagonal (START_OUT_FRACTION), never less than 60m (MARGIN_OUT_M) — 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, and the walk stays a visible slice of the frame at every scale. (It was a flat 60m until 2026-10-02, which on a forest-sized fence framed whole was a dot that never moved.) 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.

    It is built to be recorded as a demo (2026-10-02). During a run the Compass is SHOT rather than followed, in two shots (mobile/src/utils/compassMapFit.ts): it opens wide on the whole fence plus both ends of the walk (approachFrameBounds) for the lead-in, then — the moment the dot starts walking — dollies in on the walk (approachCloseBounds) in ONE eased camera move lasting the whole approach. That replaces its normal follow-fit, which chases the moving dot and, within 150m of the edge (NEAR_AREA_M), zooms in until the far side of the fence leaves the frame. The moves carry easing: 'ease' on both the declarative Camera and setStop (FieldGuideMap's easing prop): MapLibre applies a stop with no easing instantly whatever its duration, which is why the dolly first shipped as a one-frame snap — and why every OTHER Compass re-frame, which passes no easing, still jumps rather than glides. Before the route is planned the camera looks at the patch, never at the real device position the run is about to replace (on a simulator, that was San Francisco framed together with a Honolulu fence). And the run publishes 20 fixes a second (APPROACH_TICK_MS, a per-run tickMs on the drive simulator — trips still tick once a second) at the same pace, so the dot glides instead of hopping. Each tick advances by the REAL time elapsed, so a busy JS thread that fires the interval late cannot slow the run down. The rail's Fit button re-applies the current shot. The camera eases; the dot itself does NOT — it walks at constant speed.

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