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
-
Compass —
mobile/app/compass.tsx, deep linkscout://compass, the only compass route inmobile/src/dev/deepLinkRoutes.ts:55(there is nocompass-modalvariant; a target patch arrives as thepatchIdquery param, e.g.router.push({ pathname: '/compass', params: { patchId } })inmobile/src/components/home-new/NewHome.tsx:242; verified 2026-10-29). Shows a live map with the target patch's unlock-area polygon (or a radius circle for point patches) drawn under its pin, a pulsing "you are here" marker, a proximity readout, and a compass needle bearing to the patch. Rebuilt 2026-10-12 (32d2eaf0, shipped in v1.1.107, live on the App Store), in place with no flag, to the picked mock B · Map + dial (mobile/research/compass-redesign-lab.html): the map stays, in the patch's campaign colour (the area's fill and edge, the pin — now the patch art, as on the Discovery map — the dial and the CTA), and the console is the Discovery map's Spotlight console with the patch art inside a real compass (CompassDial,mobile/src/components/compass/CompassDial.tsx:59): the card turns with the phone so N points north (useDeviceHeading: true north where the phone reports it, else magnetic —useDeviceHeading.ts:41); a fixed lubber at 12 o'clock; the patch's mark on the rim at its bearing, meeting the lubber — ON COURSE — within ±12°; the outer arc fills with the warmth. With no compass heading (a simulator, an uncalibrated phone) the card holds north-up and the console says "No compass heading — north is up". The kicker is the patch's themed campaign (Home'stargetBase). The warmer / hot ladder (mobile/src/components/compass/proximity.ts:35), by metres to the unlock area's EDGE: Cold (> 800) · Warmer (≤ 800) · Hot (≤ 150) · Burning (≤ 30) · Found it (inside), with a 10% hysteresis going colder so GPS wander at a boundary does not flap it (HYSTERESIS,proximity.ts:49). Haptics (useCompassHaptics,mobile/src/hooks/compass/useCompassHaptics.ts:34, expo-haptics): one tap at the rung's strength (light / medium / heavy) on getting WARMER; a heartbeat at each rung's tempo (3 s / 1.5 s / 0.7 s; none a drive away); one selection tick on swinging onto the bearing; one success notification inside, once per visit (GPS jitter across the boundary does not repeat it). Walking away is quiet (no tap, the heartbeat just slows). A patch you already hold buzzes the same on the way but gets no success inside. The first reading after opening (or coming back) is a silent baseline, and the ladder stays silent until the unlock area has loaded — before that the distance is to the pin, and the area arriving must not read as you walking closer (app/compass.tsx:465). Nothing taps while another screen is on top of the compass (a patch page, the lightbox): the gate isuseIsFocused, not just the route being alive. Inside an uncollected patch the button reads "Collecting your patch…" and takes no tap — the arrival re-check below does the work; if that check throws, or settles without the patch becoming yours withinARRIVAL_GRACE_MS(8 s,app/compass.tsx:135), it becomes Try again, which re-runs it. The compass's success buzz is followed a moment later by the celebration's own. The waiting states (loading,acquiring) are the house page-loader skeleton, with the status sentence under it in one column. Reachable from the drawer and from a "Get Directions"-style action on a patch. The frame closes as you do: the camera holds you and the unlock area in one view — and once you are withinNEAR_AREA_M(150m) of the boundary it drops the far side and frames only you and the nearest point on the edge, because on anything the size of a zoo the whole-area fit is dominated by its far side and 94ft from the edge looks identical to 900ft. Inside the area the whole-area fit returns. It re-fits every time you move 15% of your remaining distance (floored at 25m so a stationary phone's GPS wander cannot re-frame it forever), so each fit is tighter than the last.fitBothBounds(mobile/src/utils/compassMapFit.ts) owns that arithmetic, and itsMIN_SPAN_DEGfloor exists ONLY to stop a bounds collapsing to a point (an infinite zoom in MapLibre) — it is the z19.5 span, matchingDRIVER_ZOOM_MAX, the deepest zoom the app already ships on the same z14 archive. It was 0.004 (~444m) until 2026-09-11, which silently cancelled the zoom-in for the entire final approach: the compass rendered the identical frame at 133ft and at 59ft. Everything else it can be doing is oneCompassStatusvalue (compass.tsx):inactive(blurred — the map surface and its animations are dropped),loading,permission-denied,acquiring(permission granted, no fix yet),not-found,tracking. Web has no state of its own: it runs this screen on MapLibre GL JS (mobile/src/lib/map/maplibre.web.tsx). Every one of those is reachable without a GPS fix or a magnetometer through the Screen mocks gallery (scout://dev-screen-mock/compass,mobile/src/dev/mocks/compass.tsx). -
Home / Near Me — surfaces the nearest uncollected patch (
mobile/src/hooks/useNextPatch.ts) and drives which patch the Compass opens to by default when nopatchIdparam is given. -
Patch action sheet —
mobile/src/components/patches-v2/PatchActionSheet.tsx— shows a "Check In" row only for city/state patches, wired touseVerifyLocation().checkInLocation. -
Celebration screen —
mobile/app/celebration.tsx, route/celebration?patchIds=id1,id2,...(comma-joined, innermost first; the singular?patchId=...is still accepted for thescout://celebrationdeep link and the dev force-path). Full-screen modal animation shown the moment a patch is actually collected (auto or via check-in).ONE CELEBRATION PER ARRIVAL (product decision, 2026-10-02). A global watcher (
CelebrationWatcherinmobile/app/_layout.tsx) pushes the WHOLE head pass ofunlockedQueueas one celebration and drains it withdequeueUnlockedBatch. For a nested arrival (the Saint Louis Zoo case: zoo + Forest Park + Saint Louis + Missouri from one GPS fix) the innermost patch is the hero and the other three sit under it as "Also unlocked" (mobile/src/components/celebration/AlsoUnlocked.tsx, the same row the first-run reveal uses), and the tally counts the whole arrival: the badge count rolls up by four, and a collection that two of them share rolls up by two (arrivalCollectionProgressinCelebrationFooter.tsx). Order comes fromselectHeadBatch(mobile/src/domain/selectors/unlockBatch.ts— every entry sharing the queue head's passId, sorted INNERMOST-FIRST bypolygonBboxRadiusM, because claim order fromgetPolygonPatchesNearbyis NOT nesting order). Separate arrivals are still separate celebrations, one per pass.History: one modal per arrival ("N at once", 2026-09-20) → one modal per PATCH (2026-09-21) → one per arrival again with the hero + "Also unlocked" layout (2026-10-02), after a fresh account at the Saint Louis Zoo got four celebrations in a row at Home.
Every celebration is therefore the single-patch
CelebrationScreenViewModelImpl/ pureCelebrationScreenLayout/CelebrationStagechoreography, with the reveal expressed as oneCelebrationPhasediscriminant (waiting/playing/settled) rather than a compoundimageReady && !reduceMotion && !skipped; the stage carries acelebration-stage-<phase>testID so a flow can wait for the reveal to land, and its Continue button iscelebration-continue, the id the Maestro suite taps.The celebration sells nothing (2026-10-11). It used to carry a "Purchase Patch" button, delayed until the reveal settled (
purchasable && purchaseReady). That button, its gate and itscelebration-purchasetestID are removed — stale-doc fix: this paragraph described code that no longer exists. The patch detail screen's Shop tab and the action sheet's "Purchase Patch" row are the only purchase entry points (both behindbuy_patch,defaultEnabled: falsein both registries but readingtruein production since 2026-10-15 and selling — re-verified 2026-10-29 viaGET https://scout-patches.com/api/feature-flags; seepatch-shop.md).mobile/screen-tests/celebration.test.tsx:152and:386pin that no Purchase button renders even withbuy_patchon and the reveal settled.The reveal escalates with the patch's rarity tier (2026-10-02,
fe84cf65— shipped in v1.1.98; store builds before it play the previous stage). Two stages sit behind flags on the same route and the same view model (viewModel.variant, decided bycelebrationVariantinmobile/src/domain/celebrationVariant.ts); the design ismobile/research/celebration-rarity-lab.html(A and B):- Ember Surge —
celebration_ember_surge, default ON (mobile/src/components/celebration/ember-surge/EmberSurgeStage.tsx). The reveal above, made explosive. Common, Rare and Epic play one surge and differ only in colour (2026-10-02): a 0.9s held breath with sparks pulled into the trembling silhouette, then a burst, a shockwave, a flash and a screen shake, then a breathing halo and holo foil. Legendary goes further: lights out, a 1.6s hold, a bigger burst, two shockwaves, a harder shake, god-rays and a gold edge (the patch outline blurred gold,skia/GoldEdge.tsx). Both patch-shaped glows — the gold edge and the charge-up silhouette every tier shows — draw on aGlowCanvasthat overhangs the patch by three blur-sigmas; on a patch-sized canvas they were cropped to a hard-edged square (fixed 2026-10-02). The embers burn the house orange until the reveal, then a wave from the patch recolours them to the tier — Rare blue, Epic violet, Legendary gold; Common stays orange — and they stay that colour. The rarity badge stays suppressed for Common, as before. The head says the tier once: Common gets the "BADGE UNLOCKED" kicker; Rare+ gets no kicker, just the pips + tier word under the name, and no obstacle note on this screen (the note stays on the share image and on Card Pull's card). - Card Pull —
celebration_card_pull, default OFF, wins when both are on (mobile/src/components/celebration/card-pull/CardPullStage.tsx). The patch arrives as a face-down collectible card that leaks the tier's light and shakes once per step above Common (0/1/2/3), then flips. The card shows N° (the badge count), the art, name, place, the authored obstacle note, the date and coordinates, and the tier as a word — including Common. Epic+ cards carry holo foil, Legendary a moving gold-foil frame, and the card tilts with the phone (reanimated's rotation sensor). - Both off ⇒ the stage described above, untouched, as the rollback.
Both stages add the same footer: the badge count rolls from the number before this unlock (
previousTotal) to the live total, the first collection's progress bar lights its new segment, and a Share button opens a menu (share/CelebrationShareMenu.tsx): Share video (Ember Surge only — the server-rendered MP4, see celebration-share-video), Share image (a 1080×1920 JPEG ofshare/CelebrationShareCard.tsx, mounted off screen only after the choice, handed to the OS share sheet throughuseShareCard) and Copy link (the patch's/celebrate/:patchIdpage). Analytics surfacecelebration, contentvideo/image/celebration_link. Per-tier haptics come fromuseCelebrationHaptics().triggerReveal; there is ONE sound for every tier. The tier-by-tier numbers live in one table,mobile/src/components/celebration/tierFx.ts, and the particle field isemberFieldModel.ts(pure, unit-tested) drawn byskia/EmberField.tsx.Testing it without collecting anything: DEV → Screen mocks → Celebration has a settled still for each stage × tier (real tiered patches: Gateway Arch, Isle Royale, Dry Tortugas, Gates of the Arctic), each with a ▶ Play unlock button; DEV → Simulate Unlock has tier quick-picks, a stage picker (Flags / Ember Surge / Card Pull / Previous) and Celebrate now. Both open
/celebration?patchId=<id>&variant=<stage>; thevariantparam is honoured only in a dev build or for an admin. - Ember Surge —
-
Settings → Location Tracking —
mobile/app/(drawer)/settings.tsx— the user-facing on/off switch for using location at all, independent of the OS permission. The switch reflects the PREFERENCE (locationTrackingEnabled) — the thing a tap sets — while the subtitle beneath it reports what is actually happening, in three states: "Currently off", "Active in the background" (a background task really is running), and "Using location while the app is open" (switched on, but nothing running in the background). The third state is not hypothetical: it is every web session, since background tracking is a no-op there, and any device session where the background start failed quietly. The row previously read the background TASK for both, so it could announce "Active in the background" over a task that had never started. -
Set Location (Dev) —
mobile/app/(drawer)/dev-location.tsx, drawer entry gated to dev builds or admin accounts (if (!__DEV__ && !isAdmin) return <Redirect href="/" />). Lets a tester pin the device to a hardcoded coordinate (any city/state patch pin, a handful of named landmark pins, or three "off-patch" test coordinates chosen to sit outside every polygon). -
Onboarding → the location ask, step 4 of the tutorial —
mobile/app/onboarding-tutorial.tsx(there is no standalone/onboarding-locationscreen any more; it was deleted 2026-09-20 and the ask moved inside the tutorial — see onboarding). The Location row's copy isLOCATION_SCREEN.default.bodyfrommobile/src/components/permissions/PERMISSION_SCREENS.ts, reused verbatim because it explicitly discloses background use before the OS prompt fires ("Scout uses your location — including in the background, even when the app is closed — to automatically unlock a patch..."), which App Review 5.1.1(iv) requires.
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:
-
Background/foreground automatic unlock (
mobile/src/services/location/tracker.ts)-
A native
expo-task-managerbackground task (LOCATION_TASK_NAME = 'TRAVEL_PATCHES_LOCATION_TRACKER') is registered at module load and started viaLocation.startLocationUpdatesAsynconce 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<= 75maccuracy (MAX_LOCATION_ACCURACY_METERS) to be allowed to decide a containment check, except asource: '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
protectedAreaIdorgeofenceId) — cheaply pre-filtered on-device to those within 50km + the patch's own polygon reach (getPolygonPatchesNearby,polygonBboxRadiusM), then sent to the backendPOST /api/location/check-polygonsin 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'sinsideIdsis 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 inmobile/src/services/location/__tests__/tracker.test.ts. - Everything else — decided entirely on-device: within
pointUnlockRadiusMeters(server-configuredAppConfigvalue, 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).
- Polygon patches (anything with a
-
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 oncanAutoUnlock(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_areasandgeofences) 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 attracker.ts:313).runUnlockPassclaims each returned id in turn (one GPS fix = onepassId), 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 afinally, 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'sunlockedQueueand 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):
CelebrationWatcherpushes the whole head pass as/celebration?patchIds=...and drains it withdequeueUnlockedBatch, with a 1000msCELEBRATION_SETTLE_MSbeat 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
queuedPatchand 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 —unlockedQueueis 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 istracker.test.ts("a pass is handed over whole") andunlockQueueBatching.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!tutorialSeenand on everyonboarding-*/auth/location-permissionroot. Its result is now combined with the settle timer asallowed && settled, notsettledalone: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.LocationProviderused to callsendPatchUnlockNotificationunconditionally, 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.
-
-
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 computesisInsidefrom the same drawn geometry (isPointInRingsagainst the polygon it is rendering, or a haversine circle test for a point patch) and fires one authoritativecheckPatches()call (which ischeckNearbyPatches()→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. -
Manual Check In (
mobile/src/hooks/useVerifyLocation.ts) — a completely separate code path used only forcollectionType === 'city'or'state'patches. It does not callPOST /api/location/check-polygonsand does not do polygon containment. It lower-cases and trims the patch name and compares it againstuseAppStore().cityName/stateName, which are the last reverse-geocoded place names (Location.reverseGeocodeAsync, set byLocationProvider's geocode effect). If it matches, it calls the samecollectPatchstore 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 atmobile/src/services/location/tracker.ts:203and 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." -
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 asource: 'dev'fix to the same store field the real OS watch writes to, and tears down the OS watch while active. BecauseisPreciseEnoughToUnlockalways returnstrueforsource: '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'ssetLocationcommand is a different mechanism (an OS-level simulated GPS fix,source: 'live') — per a comment inmobile/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. -
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 samepublishDevFixpath 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 sameisPointInRingsthe Compass uses, and starts beyond it bystartOutM: 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:
shouldForceCelebrationenqueues the patch ontounlockedQueuedirectly 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 ownisInsideand 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 waitsFORCE_DELAY_MSfor 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 sameshouldSimulatedev/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 carryeasing: 'ease'on both the declarative Camera andsetStop(FieldGuideMap'seasingprop): 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-runtickMson 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:
geofences(backend/prisma/migrations/0_init/migration.sql:269) —id,source text,osm_type,osm_id bigint,buffer_radius_meters,centroid_lat/lng,bbox_radius_meters,geom geometry(MultiPolygon,4326) NOT NULL,source_url,verified,verified_at,properties jsonb(documented elsewhere as always'{}'in practice),created_at,updated_at.protected_areas(migration.sql:624) — NPS-sourced boundary data:id,source,kind,name,external_id,state_codes text[],centroid_lat/lng,bbox_radius_meters,geom geometry(MultiPolygon,4326) NOT NULL,properties jsonb.patches(model Patch,backend/prisma/schema.prisma:88) links to at most one of the two viaprotectedAreaId(protected_area_id, FK-like but not enforced in Prisma) orgeofenceId(geofence_id) — never both. Also carrieslatitude/longitude(the display pin, independent of the polygon),collectionType, and a GENERATED PostGIS point columngeomused only by an unrelated road-trip corridor query (not by unlock).geofences.sourcevalues, frombackend/src/admin/api/geofence-preview.types.ts:13and confirmed inbackend/scripts/polygon-enrich.ts:osm— a real OSM way/relation footprint, resolved by the local extract or Overpass.osm_line— a linear feature (a street, trail, or corridor) with no natural polygon;ST_Buffer'd into a corridor around an OSM way centerline.osm_buffered— a real OSM footprint (still carriesosm_type/osm_id, still re-resolvable) expanded by a recorded radius (buffer_radius_meters) for subjects nobody can physically stand inside — a statue, a sign, a monument plinth.manual— hand-ingested GeoJSON, no OSM element behind it.buffered_point— the fallback: a plain circle drawn around the pin because no real footprint was found at all. This is the weakest source and the one geofence review treats as lowest-confidence (backend/src/admin/api/geofence-review.service.ts:152).- Correction to
CLAUDE.md: its Patches & Geofences section lists onlyosm,osm_line,manual,buffered_point— it omitsosm_buffered, which is a distinct fifth value with different semantics frombuffered_point(a real, re-resolvable OSM footprint vs. a fallback circle with none). Both appear in the live source column.
- On the mobile side, the synced
Patchdomain type carriesprotectedAreaId,geofenceId, andpolygonBboxRadiusM(a numeric bbox radius from the pin, used only for the client-side pre-filter distance —mobile/src/domain/types.ts:742-744) but never the raw geometry itself; geometry is fetched lazily per-patch only when the Compass screen needs to draw it (GET /api/location/geofence/:patchId).
API surface
POST /api/location/check-polygons(backend/src/location/location.controller.ts:28) — body{ lat, lng, patchIds: string[] }(patchIdscapped at 50,CheckPolygonsDto). Returns{ insideIds: string[] }. Runs one raw SQLUNIONquery joiningpatchestoprotected_areasand togeofencesseparately,ST_Contains(geom, ST_SetSRID(ST_Point(lng, lat), 4326))on each leg, filtered byvisiblePatchSql(a patch must belong to at least one non-admin_onlycollection to be checkable — hidden/orphaned patches are excluded,backend/src/common/patch-visibility.ts). This is the exact routinemobile/src/services/location/tracker.tscalls for every automatic unlock pass. No@UseGuardsdecorator anywhere onLocationControllerorLocationModule, and no global auth guard is registered inAppModule— this endpoint is unauthenticated. It accepts any lat/lng and any list of patch ids the caller already knows (there is no session check tying the request to a specific device/user), and returns only which of those ids the point falls inside.POST /api/location/check-batch— used by camera-roll photo import (out of scope for this document); does both a point-radius match for non-polygon patches and a polygonUNIONmatch, batched over up to 100 locations.GET /api/location/geofence/:patchId— returns the drawable GeoJSON + metadata (kind,source, bbox,pinInside) for one patch's unlock area, viaGeofencePreviewService.getGeoJson. This is what feeds the Compass map's polygon/circle overlay. Also unauthenticated.- The manual Check In flow makes no backend call at all — it is pure client-side string comparison (see "How it works" §3).
Key files
backend/src/location/location.controller.ts— the two POST endpoints + the geofence GeoJSON GET.backend/src/location/location.service.ts:26—checkPolygons, the raw SQL containment query, with extensive inline rationale for why tolerance lives in geometry (osm_buffered) rather than a separate radius column.backend/src/location/dto/location.dto.ts— request/response shapes and validation caps (50 patch ids, lat/lng range).mobile/src/services/location/tracker.ts— the whole client unlock pipeline: background task definition,runUnlockPass, polygon pre-filtering, radius fallback,checkNearbyPatches(foreground entry point).mobile/src/services/location/locationWatch.ts— the single OS subscription, accuracy gate (isPreciseEnoughToUnlock, 75m bar), the dev fix mechanism.mobile/src/services/location/geofence.ts— haversine distance/radius math,hasPolygonGeometry(the single definition of "is this a polygon patch" shared by camelCase and snake_case shapes).mobile/src/services/location/trackingDecision.ts— pure decision functions for whether tracking should run at all / auto-start.mobile/src/services/location/permissions.ts— OS permission wrappers.mobile/src/providers/LocationProvider.tsx— wires the tracker's callbacks to the app's data layer; owns the auto-start effect, the reverse-geocode effect (feedscityName/stateName), and thecanAutoUnlockgate.mobile/src/domain/autoUnlockGate.ts— the pure gate function preventing auto-collect before the account's own collection has authoritatively loaded.mobile/src/hooks/useVerifyLocation.ts:100—supportsCheckIn(city/state only) and the name-matching Check In implementation.mobile/app/compass.tsx— the Compass screen, split intoCompassScreenViewModel/CompassScreenViewModelImpl(every hook) and a pureCompassScreenLayout; arrival re-check atcompass.tsx:305-344.mobile/src/utils/compassMapFit.ts— what the camera frames:fitBothBounds(you + the unlock area, or you + the nearest edge insideNEAR_AREA_M),shapeCoordinates(the area alone), and theMIN_SPAN_DEGdegenerate-bounds guard.shouldFitBoth/EDGE_ARROW_THRESHOLD_METERSare exported and tested but called from nowhere — leftovers from an edge-arrow design that never shipped.mobile/src/hooks/compass/geofenceGeometry.ts— containment (isPointInRings) plus the two readings derived from the same point-to-segment routine:distanceToUnlockArea(how far from being inside, zero once in) andnearestPointOnUnlockArea(the stretch of edge the camera frames close up).mobile/src/components/compass/proximity.ts— the warmer / hot ladder (pure)mobile/src/hooks/compass/useCompassHaptics.ts— the compass's only hapticsmobile/src/components/compass/CompassDial.tsx— the turning compass round the patch artmobile/src/components/compass/CompassConsole.tsx— the Spotlight console with the dialmobile/src/hooks/compass/useCompassReadings.ts— target patch, distance, bearing and device heading, plus the 1Hz GPS and magnetometer leases. Was aCompassProvidercontext with one consumer; became a hook when the screen moved to the view-model pattern, because a*ViewModelImplowns every hook and cannot depend on a provider its own layout mounts.mobile/app/celebration.tsx,mobile/app/_layout.tsx(CelebrationWatcher) — the unlock celebration UI and its queue. The screen is a view model plus a pure layout;mobile/src/dev/mocks/celebration.tsxis its Screen mocks entry (single-patch states only — the batch reveal has no arrangeable mock state of its own yet). Known gap recorded there: the screen readsusePatch'spatchand drops itsisLoading/error, so apatchIdthat resolves nowhere still renders a full celebration with an empty headline and a·where the badge goes.mobile/src/domain/selectors/unlockBatch.ts(selectHeadBatch) — the patches owed one celebration: everyunlockedQueueentry sharing the head's passId — wherever it sits, not only the leading run — sorted innermost-first (polygonBboxRadiusMascending, a stable sort) — the one place a nested unlock's claim order (unrelated to nesting) becomes display order. It feeds the WATCHER (the whole pass, one celebration,[0]as the hero) and the ONBOARDING REVEAL, which shows a whole pass on one screen the same way.mobile/src/components/celebration/— the rarity stages:tierFx.ts(the per-tier ladder),emberFieldModel.ts+skia/EmberField.tsx(the particle field),ember-surge/EmberSurgeStage.tsx,card-pull/CardPullStage.tsx+card-pull/CelebrationCard.tsx,CelebrationFooter.tsx,share/CelebrationShareCard.tsx,DevReplayButton.tsx(Screen mocks only).GhostButton.tsxandCelebrationVignette.tsxare shared with the previous stage.mobile/src/components/celebration/AlsoUnlocked.tsx— the "Also unlocked" row (art + name per patch) under a nested arrival's hero. Shared by the celebration footer (CelebrationFooter.tsx, every stage incl. Card Pull; the previous stage renders it above its count line) and the first-run reveal (FirstUnlockSurge).mobile/src/hooks/usePatches.ts(useCatalogPatches) resolves the celebration'sids[1..]to rows from the synced catalog, with no per-id network fallback (unlikeusePatch) — every patch the tracker unlocks came from that catalog; an id it does not hold is left out of the row.mobile/src/components/celebration/PatchUnlockReveal.tsx— the first-run reveal's WAITING and EMPTY states only ("You're not inside anything — yet."). The celebration screen does not mount it, and the reveal playsFirstUnlockSurgewhenever its batch is non-empty (see onboarding). Its fan / "N at once" branch is reachable only frommobile/screen-tests/patch-unlock-reveal.test.tsx, which covers the component directly.mobile/app/(drawer)/dev-location.tsx— the Set Location dev tool.mobile/src/services/location/driveSimulator.ts— the dev drive simulator; publishes through the samepublishDevFixmechanism, doing no unlock logic of its own.mobile/app/(drawer)/dev-simulate-unlock.tsx— the Simulate Unlock launcher. Picks a patch and pushes/compass?patchId=<id>&sim=1; that is its entire job.mobile/src/hooks/useUnlockSimulation.ts— mounted byapp/compass.tsx. Starts the approach and settles its celebration on the boundary crossing (FORCE_DELAY_MSis the margin on the real check's backend round-trip, and is skipped entirely for an already-collected patch, which nothing can race).mobile/src/utils/unlockSim.ts— the pure motion plan:approachRoute(where the run starts and ends, found from the boundary, with a step out scaled to the fence bystartOutM), the demo pacing (APPROACH_SECONDS,LEAD_IN_MS,APPROACH_TICK_MS), and theshouldForceCelebrationdecision table. The run's fixed camera frame isapproachFrameBoundsinmobile/src/utils/compassMapFit.ts.backend/prisma/schema.prisma:88-240—model Patch, with the deliberately-unmodeled-geometry comment.backend/prisma/migrations/0_init/migration.sql:269,624— rawgeofences/protected_areastable definitions.backend/src/admin/api/geofence-preview.types.ts— the canonicalGeofenceSourceunion (5 values) and its doc comment distinguishingosm_bufferedfrombuffered_point.
Configuration and flags
- No feature flags gate unlocking itself (see "Status"). Two flags pick which
CELEBRATION plays once a patch is collected:
celebration_ember_surge(default on) andcelebration_card_pull(default off; wins when on). Both off is the previous celebration. Declared in bothbackend/src/admin/feature-flag-definitions.tsandmobile/src/config/feature-flags.ts. AppConfig.pointUnlockRadiusMeters— server-configured, default 50 (backend/...AppConfigService/mobile/src/domain/app-config/types.ts:29), the radius used for every non-polygon patch. Mirrored into MMKV (mobile/src/lib/backgroundConfig.ts) so the background task can read it synchronously even with a cold query cache; a stored0or missing value falls back to the same 50m default rather than producing a zero-radius geofence that can never unlock.AppConfig.photoMatchRadiusMeters— default 150, used only by the camera-roll import batch path (out of scope here).MAX_LOCATION_ACCURACY_METERS = 75— hardcoded client constant (locationWatch.ts:13), the accuracy bar a fix must clear to be trusted for an unlock decision (not for display).- Location accuracy tier is platform-split on purpose: Android requests
Accuracy.High(Balanced maps to Android's "accurate to within 100m" and would rarely clear the 75m bar), iOS requestsAccuracy.Balanced(iOS typically reports 5-65m on that tier and it costs less battery) — see the comment block atlocationWatch.ts:41. - Foreground/background update cadence: normal cadence is a 250m/30s filter
(25m on Android, to avoid Android's fused provider withholding the very
first fix); "high" cadence (held by the Compass screen while it is the current route) is
5m/1s. The background OS task itself uses a flat 25m
distanceIntervalwithdeferredUpdatesInterval: 60000(batches every 60s) regardless of foreground cadence. patchIdspercheck-polygonsrequest capped at 50 server-side (ArrayMaxSize(50)) — the client batches to that cap (see step 3 of How it works); locations percheck-batchcapped at 100.
Edge cases and known limits
-
Analytics tells a Check In from a geofence unlock (2026-10-02).
collectPatch(patchId, method)tagspatch_collected.metadata.method:geofencefor polygon containment,check_infor the manual city/state Check In (a name match),manualfor the ⋯ menu's Unlock Manually (analytics-events.md). -
How a collect is recorded decides whether it earns achievements (2026-10-19). Achievements are for LIVE in-app unlocks; only
user_patches.source = 'gps'earns (IN_PERSON_SOURCE,backend/src/achievements/feature-context.ts:12).sourceWritten by Earns? gpscollectPatchfor a geofence or Check In unlock (mobile/src/domain/store.ts,method≠'manual')yes manualcollectPatch(id, 'manual'), the ⋯ menu's Unlock Manuallyno importcollectPatchBatch, the camera-roll importerno adminthe admin "Unlock a patch" grant ( backend/src/patch-grants/patch-grants.service.ts), server-side onlyno unknownan omitted source, and every row from before the column existed no The sync DTO accepts only
gps,importandmanualfrom a client (backend/src/sync/dto/sync.dto.ts,CollectedPatchInputDto.source), so a client cannot claimadminorunknown. Deploy order: the backend must ship before an app build that sendsmanual— an older backend's@IsInrejects the whole sync push with a 400, which would stall that user's live unlocks too. -
The native rating ask (
mobile/src/hooks/useRatingPrompt.ts): the OS review sheet (expo-store-review), asked on Continue of a live unlock's celebration only, 700 ms later (REVIEW_PRESENT_DELAY_MS). A live unlock is a passCelebrationWatcherpushed (patchIds): the phone crossed a boundary after onboarding, a state line included. Each one is counted once, however many nested patches it earned (recordLiveUnlock, persisted as therating_live_unlocksstore flag, on this device). The ask fires at live-unlock rungs 1/5/15/40/100/250, at most once per 60 days, never under Maestro, never with a pre-prompt, a custom button or an incentive (App Review 5.1.1).- Does NOT ask: on a replay (
patchId: the patch screen, the collected list, a check-in, an admin patch-grant push, Settings and dev "Celebrate now"), on the first-run reveal, or after a camera-roll import. The compass unlock simulator (a dev/admin tool) does go through the queue, so its arrivals count and can ask. Imported, revealed and manual patches do not count. Until 2026-10-27 the ladder counted every collected patch from 3, and the reveal and import asked too: 54 of 55 asks fired on an import and earned about zero ratings. - The rung is stored under its own key (
rating_last_prompted_live_rung), not the old patch-countrating_last_prompted_milestone; the 60-day cooldown date is shared, so someone the old ladder asked recently waits out the cooldown. - The bookkeeping is per device, like the OS card itself: the store's
reset()(sign-out, Delete Account) keeps the three keys (RATING_DEVICE_KEYS,mobile/src/domain/ratingKeys.ts; pinned bystore.ratingSurvivesReset.test.ts). Only a reinstall starts it at 0, so that phone's next live unlock can ask again (both stores also cap the card). - Pinned by
hook-tests/useRatingPrompt.test.tsxandcelebration.test.tsx→the review ask.
- Does NOT ask: on a replay (
-
Permission answers and accuracy are measured.
getPermissionStatus()(mobile/src/services/location/permissions.ts) now also returnsaccuracy(full/reducedon iOS,fine/coarseon Android, else null), and the onboarding permission step sendspermission_resultwith it, so an "Approximate" grant is no longer indistinguishable from a precise one (analytics-events.md). -
Background relaunches write analytics rows, tagged. iOS starts the app in the background to deliver location updates, and each such JS process sends
session_startandapp_openlike a real launch. Since 2026-09-30 both carrymetadata.launch_state('background'for these), and only a user bringing the app to the front sendsapp_foreground. Retention countsapp_foreground, neverapp_open(analytics-events.md). -
Background checks fail about 1% of the time, not 90%.
unlock_passlogs a background or arrival pass only when it unlocked something or a containment request failed, so its rows are mostly failures: on 2026-11-04 the hub read 1,263 errors in 1,386 background passes, while nginx showed ~98k iOSPOST /api/location/check-polygonsa month, all but ~1% answered. Every movement pass that asks the server is now also counted into a daily tally (mobile/src/services/location/unlockTally.ts, sent asunlock_pass_tally), which is the denominator the hub uses. Failures cluster as instant (no signal), the ~60 s URLSession timeout, and passes suspended mid-request. A failed background pass is not retried; the next pass, or the foreground pass on the next open, checks again from wherever the phone is then. -
Location permission denied: the app never blocks core browsing. Screens that need a position (Map, Compass) show an explicit denied state with a path to device Settings (
mobile/app/(drawer)/map.tsx:1158-1191, the layout'spermission-deniedbranch; the pre-permission explainer is thepermission-undeterminedone above it); Compass shows "The compass needs your location. You can turn it on for Scout in Settings." and nothing unlocks automatically. Compass reads that fromuseLocation().permissions.foreground. It used to infer it fromcurrentLocation !== null, which showed the same Settings sentence to a user who had granted permission and simply had no fix yet — the ordinary indoor cold start. That case is now its ownacquiringstatus and says "Looking for a GPS signal…". Camera-roll photo import is the documented App-Review-required alternative unlock path for users who decline location entirely (comment atPERMISSION_SCREENS.ts:53). -
The Compass cannot tell "nothing is near" from "the lookup failed."
useCompassReadingsconsumesusePatch(patchId)for its DATA and drops that hook'serror, anduseNextPatchhardcodeserror: nullwhile discardingcontentQuery's own error (mobile/src/hooks/useNextPatch.ts). So a failed content sync, or a?patchId=deep link naming a patch this account never receives, both land onnot-found— "Move closer to a patch to start tracking." — which is a terminal, confident sentence about the catalog shown for a request that failed. Known and unfixed; the same pattern has now been found on four migrated screens. -
Background permission is requested only after foreground permission is granted (
requestAllPermissions,permissions.ts:64); a user who grants "While Using" but denies "Always" still gets foreground-only unlock (foreground checks, Compass arrival re-check, the periodic web poll — but no background task). -
Coarse fixes are stored but cannot unlock. Every fix, however inaccurate, is written to the store so the UI is never stranded at "Locating…"; only
isPreciseEnoughToUnlock(75m bar) decides whether a fix is allowed to decide a containment check. -
A
source: nullfix (no fix at all) never passes the unlock-precision check; a fix withaccuracyM === null(device never reports it) does pass, on the theory that refusing it would make the app unusable on such devices. -
recentlyUnlocked30s suppression (tracker.ts:61) prevents the same patch from being re-processed for 30 seconds after a successful claim, but only after a successful claim — a declined unlock (already owned, or deferred becausecanAutoUnlockisn't ready yet) is deliberately not suppressed, so the very next location update retries it. -
Auto-unlock is gated off until the account's own collection has loaded (
canAutoUnlock), specifically to prevent an already-owned patch (most commonly "the state you're standing in") from being re-collected and re-celebrated on every cold start before the server's collection list has synced. It falls back to trusting locally-persisted data when there's no session to sync against at all (offline-first guest), but refuses when a reload was deliberately triggered and local data was blanked pending it. -
check-polygonsandcheck-batchreturn every containing polygon, with no notion of "closest" or a cap on how many patches one point can claim in a single pass — this is what makes nested unlock work, but it also means a densely-nested location (a landmark inside a park inside a city inside a state) can queue several patches from one fix, all tagged with the same passId. None of them are trimmed, capped or de-duplicated: they share ONE celebration (hero + "Also unlocked"), but each still sends its OWN local notification, so a four-deep nest is four notifications. The "Also unlocked" row is uncapped too — it wraps, and a very deep nest makes the footer tall. -
Maestro's
setLocationdoes not exercise the real background TaskManager task (documented in-line inmobile/maestro/tests/background-location-demo.yaml) — only foreground paths (Compass arrival, the periodic web-only poll) fire for a Maestro-simulated GPS position. A literal app-closed background-collect capture needs a real device. -
The Check In flow's failure mode is silent-ish: it reports "You don't appear to be in {name}" whenever the last reverse-geocoded city/state string doesn't exactly match (case/whitespace-insensitively) the patch name — a geocoder returning a slightly different name than the patch's canonical name (e.g. a borough name vs. the patch's city name) would produce a false "wrong location" even while standing inside the patch's real polygon.
-
There is also a separate "Unlock Manually" action in the patch's ⋯ menu (
PatchActionSheet, shown to every user while the patch is uncollected) that collects a patch with zero location check of any kind, after a confirm ("Use this if you've been to {name} and Scout didn't unlock it on its own."). It is an honour-system fallback so nobody who has truly been somewhere is denied the patch when GPS or photo data miss them. It moved there from the admin-onlyPatchDevSheeton 2026-10-19. Do not advertise it (owner, 2026-10-19): no marketing, onboarding, permissions or store copy should mention it or describe patches as unlockable by hand. Like every collect, it also gives the patch its Shop tab (it opens the store whenbuy_patchis on, and since 2026-11-03 the "would you buy this?" sheet when it is off). Since 2026-10-19 it recordssource: 'manual', so it earns no achievements or XP (it wrote'gps'before that, from the one hardcoded value incollectPatch), and its confirm says so ("Manual unlocks don't count toward achievements.",mobile/src/components/patches-v2/manualUnlockCopy.ts). That is permanent for the patch: once collected, a later live visit does not re-collect it, and sync never rewrites an existing row'ssource. -
An admin can unlock a patch for someone from the admin user page ("Unlock a patch", admin-and-content-ops). The row is
source: 'admin'(no achievements), and the member gets a Scout message whose push opens the celebration. Same do-not-advertise rule.
What this feature does NOT do
-
A manual or admin unlock never earns achievements — ever, for that patch. Going back later with working GPS does not upgrade it to a live unlock: the patch is already collected, so nothing re-collects it, and sync never rewrites
source. The only way back is to uncollect and unlock it live. -
The compass makes no sound. Haptics only, by decision (2026-10-12).
-
There is no setting to turn the compass haptics off (2026-10-12) — beyond the phone's own system haptics switch, which iOS honours.
-
The compass taps nothing in the background. Only while it is the screen you are looking at (not under a patch page or the lightbox) and the app is in the foreground; never in your pocket with another app up.
-
The compass does not tap on getting colder. Walking away is silent; only the heartbeat slows.
-
The rarity celebration does not count collectors. Nothing on it — or on its share image — says how many people hold a patch, a percentage, or "you're the first"; the share hook is the authored obstacle note. Rarity changes the light and the haptics, never what you can do.
-
There are no per-tier sounds: every tier plays the same reward sound.
-
The celebration stages do not change the unlock queue: still one patch per modal, same route, same Continue (
celebration-continue). -
It does not unlock by proximity/distance for anything with a real polygon. Being close to a park boundary is explicitly insufficient and is tested against in production E2E (
mobile/maestro/tests/compass.yamldrives to 284m outside the Phoenix Zoo geofence and asserts it is still uncollected — "the only assertion in the entire suite that proves unlock is polygon containment rather than proximity"). -
It does not unlock city/state patches by polygon containment when the user taps "Check In" — that specific manual action is a name-string comparison against reverse-geocoded place names, not the same mechanism that automatically unlocks those same city/state patches when the user physically arrives (which is polygon containment, since city/state carry real boundary polygons now). Do not describe the app as "one unlock rule for everything" without qualifying that the manual Check In button is the one exception.
-
It does not require a photo, camera, or any form of visual proof to auto-unlock a patch — that verification model (camera capture + AI confidence check) was removed; see the pruned
VerificationErrorCodeunion inuseVerifyLocation.ts(camera_permission_denied,ai_not_confident,network_errorare explicitly gone). -
It does not tie the unlock check to any authenticated session at the network layer —
POST /api/location/check-polygonsandGET /api/location/geofence/:patchIdcarry no auth guard. Authorization to keep a collected patch happens client-side and via the separately authenticated sync endpoints, not at the containment-check layer itself. -
It does not let a previewed or "browse another city" view of the map unlock anything —
getCurrentLocation()(the sole origin for every unlock check) reads only the real device fix from the store, never a trip/preview location; this is asserted bymobile/src/domain/__tests__/previewSafety.test.ts, which scans source for any caller that might hand a non-device origin into the unlock path. A dev-tool simulated location is the one deliberate exception, gated to dev builds/admin accounts. -
The unlock simulator's forced celebration does not collect anything. It pushes the patch onto the celebration queue so the modal renders; it never calls
collectPatch, never hits the sync endpoints, and leaves the account exactly as it found it. A patch that was already yours is still just yours afterwards. (An uncollected patch on that same run does get collected — but by the ordinary arrival check, not by the force.) -
The Compass does not offer any speed or pause control while a simulated approach is running, and shows no sim chrome at all. The run is ~5 seconds by construction; leaving the screen stops it.
-
It does not currently gate any of this behind a feature flag or staged rollout — everything described here is live for 100% of users on every shipped build.
-
It does not apply any buffer/tolerance uniformly across all polygon patches from a shared column — tolerance for un-enterable subjects (a statue, a plinth) is baked into that specific geometry (
osm_buffered) rather than being a general "get close enough" rule.
Tests that cover it
-
Celebration stages:
mobile/src/domain/__tests__/celebrationVariant.test.ts(flag precedence),mobile/src/components/celebration/__tests__/tierFx.test.ts(the ladder is monotonic),.../emberFieldModel.test.ts(burst, cull, the recolour wave's old/new palette, inflow, flash);mobile/screen-tests/ celebration.test.tsx(default = Ember Surge, Card Pull wins, both off = previous stage; Rare kicker + note vs Common suppressed; the card prints Common and invents nothing for an unjudged patch; count roll vs nothing to roll; no Purchase button even withbuy_patchon; Share captures and opens the sheet; the?variant=override and its rejection),dev-screen-mocks.test.tsx(Play unlock) anddev-simulate-unlock.test.tsx(tier pick + Celebrate now). Each new screen test was confirmed failing against a mutated stage first. -
Backend, live-DB integration (
backend/src/location/location.geometry.db.spec.ts,location.catalog.live.spec.ts): exercisescheckPolygons/checkBatchagainst real PostGIS geometry — interior/exterior points, nested containment ("one point returns every boundary containing it"), the catalog-wide random-interior-point sweep, pin-to-polygon distance integrity, and thatcheck-polygonsandcheck-batch(the live-tracking and photo-import paths) agree on the same point. -
Backend, unit (
location.service.spec.ts): boundary lat/lng validation, 50/100 item caps, error handling (InternalServerErrorExceptionon a PostGIS failure),photoMatchRadiusMeterssourcing from AppConfig. -
Backend (
location-geofence.controller.spec.ts): the GeoJSON passthrough. -
Mobile unit tests,
mobile/src/services/location/__tests__/:tracker.test.ts(background task lifecycle,shouldUnlockPatchradius behavior, geocode caching),locationWatch.test.ts(cadence, the 75m accuracy gate including the "dev fix always passes" case),geofence.test.ts(haversine math,hasPolygonGeometry),permissions.test.ts,trackingDecision.test.ts(the auto-start decision matrix),driveSimulator.test.ts. -
mobile/src/utils/__tests__/compassMapFit.test.ts— the framing maths, including the regression guard that the fitted span keeps SHRINKING across the last few hundred metres (confirmed failing against the old 0.004 floor), and that a big area's far side leaves the frame once you are close to its edge but returns when you are far away or inside it. -
mobile/src/components/compass/__tests__/proximity.test.ts— the ladder: every boundary, the hysteresis, inside, on course (±12°, never without a heading), no distance yet. -
mobile/hook-tests/useCompassHaptics.test.tsx— the taps: silent baseline, a tap on warming and none on cooling, the 3 s / 1.5 s / 0.7 s heartbeat, the on-course tick (and its baseline), one success inside, once per visit through boundary jitter and fresh for a new target (none for a held patch), silence when the screen is left, backgrounded or unmounted. -
mobile/screen-tests/compass.test.tsx— B · Map + dial over the Gateway Arch's real memberships: the themed campaign (National Parks over its City Challenge) and the no-campaign opposite (its type, in the bar and the console), Cold / Warmer / Hot / Burning at real distances to a square area, inside → Found it + "Collecting your patch…", a failed unlock check → Try again (which re-runs it), a held patch inside → View the patch with no success buzz, walking into the area → one success buzz, no heading → north-up + hint, facing it → ON COURSE (and facing away not), walking across into Hot → one tap then the heartbeat, opening near the patch → silent while the area loads, nothing taps under another screen, and the skeleton while finding you. -
mobile/src/hooks/compass/__tests__/geofenceGeometry.test.ts— containment, plus distance-to-area measured to the nearest EDGE rather than the nearest vertex (which on a long boundary overstates the walk eightfold). -
mobile/src/utils/__tests__/unlockSim.test.ts— the approach plan, asserted against the sameisPointInRingsthe Compass uses: starts outside the rings and ends inside them for a park-sized fence, a 40m footprint and a 40km polygon alike; the step out scales with a big polygon or circle and keeps its 60m floor on a small one; the pin-outside-its-own-polygon and no-geometry fallbacks; and the ~5s pacing, driven through the realstepDrivecursor rather than asserted back at its own constant. -
mobile/hook-tests/useUnlockSimulation.test.tsx— the run itself against the REAL simulator singleton: the position walks gradually from outside the fence to inside it; the celebration lands on the crossing (in the same tick for an already-collected patch, after the grace period for an uncollected one) and only once however many fixes follow it; a celebration is forced when containment never trips; it is NOT forced when the real unlock collected the patch mid-run; the run survives being handed a re-identified patch object mid-walk; it holds still for the lead-in, then moves in many small steps a second; it hands the Compass its route; and nothing starts withoutsim=1, before the geofence resolves, or once the user leaves the Compass. -
mobile/screen-tests/compass.test.tsx— withsim=1and the device seeded in San Francisco, the camera frames every side of a St. Louis fence and nothing near San Francisco; paired with the same seed minussim, whose production frame does reach San Francisco. Once the walk starts the camera dollies to under half the fence's width as oneeasemove lastingAPPROACH_SECONDS. -
mobile/src/utils/__tests__/compassMapFit.test.ts—approachFrameBoundscontains every fence vertex and both ends of the walk, and takes no position. -
mobile/src/services/location/__tests__/driveSimulator.test.ts— a faster per-runtickMscovers the same ground per second (walks included), a tick that fires late still covers the real elapsed time, andstartDelayMspins the start without moving until it elapses. -
mobile/screen-tests/celebration-glow.test.tsx— the gold edge's and the silhouette's canvases overhang the patch by three times the blur sigma read off the rendered Blur/Shadow, with the art shifted to stay registered. -
mobile/screen-tests/dev-simulate-unlock.test.tsx— the launcher produces the{ patchId, sim: '1' }push, paired with the empty-catalogue branch that proves it came from the seeded patch. -
mobile/maestro/tests/unlock-sim.yaml(dev-build only) — runs the simulator twice on the same patch: once to unlock it for real, then again with it already collected, which is the one case the production pipeline can never produce. -
mobile/src/domain/__tests__/previewSafety.test.ts— a source-scanning test asserting no caller anywhere hands the unlock path a non-device location origin. -
mobile/screen-tests/screen-mocks.test.tsx— mounts all nine Compass gallery states (mobile/src/dev/mocks/compass.tsx) and asserts real content in each, which is the only automated coverage of theacquiring,not-foundand no-magnetometer faces: none of them is reachable from a screen test without revoking a permission or removing hardware. -
Mobile screen tests:
mobile/screen-tests/compass.test.tsx,celebration.test.tsx,dev-location.test.tsx(registered in the screen registry per repo convention).screen-mocks.test.tsxalso mounts the seven celebration gallery states (mobile/src/dev/mocks/celebration.tsx) — the only automated coverage of the no-artwork fallback, the longest-name clamp and the resolves-to-nothing frame. -
mobile/screen-tests/celebration-achievement-order.test.tsx— rendersCelebrationWatcher(and, for its content cases, the real/celebrationscreen) against the Saint Louis Zoo nested-unlock case directly. It proves a nested unlock drains WITHOUT DROPPING a celebration; that a shared-passId pass produces ONE PUSH PER PATCH, innermost first, with the queue draining completely and no push ever carrying a comma-joined id list; that the screen shows the innermost patch alone with none of the batch card's vocabulary; that a single unlock renders exactly as it always did; that the achievement deck is held behind an owed celebration; and that Continue iscelebration-continue(the id the Maestro flows tap) and NOT the reveal component's own default, which must no longer be present at all.It also carries the ONBOARDING half, as a falsifiable pair: no celebration may be pushed while any onboarding root owns the screen, whatever is queued and however long the timers run, AND nothing is consumed while it waits — paired with the opposite branch, the same queue celebrating normally once
tutorialSeenflips and the user is home. That pair fails against thereturn settled;version ofuseSettled, which is the bug it was written for. -
mobile/screen-tests/patch-unlock-reveal.test.tsx—PatchUnlockRevealdirectly: the empty state (with its Continue button — never a dead end), the single-patch state, the no-art fallback glyph, the achievements row shown/hidden, and thecontinueTestIDprop defaulting to this component's own id while an override replaces (never duplicates) it — the states the celebration screen itself never reaches (it only mounts this component for> 1patches). -
E2E:
mobile/maestro/tests/compass.yaml— staged multi-leg walk toward the Phoenix Zoo with an explicit "just outside the polygon, still locked" assertion at 284m, then nested collection of state → city → park → zoo in one final fix.mobile/maestro/tests/background-location-demo.yaml— not a pass/fail test; a scripted flow for recording the Play Store background-location-use disclosure video.
Open questions
The exactResolved 2026-10-29:scout://deep link route(s) for the Compass screen.mobile/src/dev/deepLinkRoutes.ts:55registers only/compass; nocompass-modalroute exists anywhere undermobile/app/.- Whether
POST /api/location/check-polygonsbeing unauthenticated is a deliberate design decision (the request only reveals which of a caller-supplied set of already-synced patch ids a point falls inside, and costs a bounded query) or an oversight was not something I could confirm from code alone — no comment inlocation.controller.tsorlocation.module.tsaddresses it either way, and I could not find a security-review note calling it out. Flagged here rather than asserted. - Whether "Check In" was ever intended to be upgraded to real polygon containment now that city/state patches carry real boundary polygons, or whether the string-match design is intentional (e.g. to allow check-in without a precise/recent GPS fix), was not evident from code or comments — it reads as a leftover from before the city/state polygon migration, but I could not verify that as fact rather than inference.
- I did not verify what specific screens/hooks besides Home, Near Me, Map,
and Compass call
checkNearbyPatches()/checkPatches()(Day Trip arrival is explicitly out of scope for this document per the assignment, but it is referenced by comments in this codebase as another caller of the samerunUnlockPassroutine).