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(orscout://compass-modal/[patchId]variants registered inmobile/src/dev/deepLinkRoutes.ts— verify against that file for exact route names). 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. 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):unsupported(web — no native map),inactive(blurred — the map surface and its animations are dropped),loading,permission-denied,acquiring(permission granted, no fix yet),not-found,tracking. 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?patchId=.... Full-screen modal animation shown the moment a patch is actually collected (auto or via check-in). A global watcher (CelebrationWatcherinmobile/app/_layout.tsx) pops one patch at a time off aunlockedQueuein the Zustand store and pushes this route. Split intoCelebrationScreenViewModelImpland a pureCelebrationScreenLayout, 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. - Settings → Location Tracking —
mobile/app/(drawer)/settings.tsx— the user-facing on/off toggle for background tracking, independent of the OS permission. - 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 → Location permission screen —
mobile/app/onboarding-location.tsx, using copy frommobile/src/components/permissions/PERMISSION_SCREENS.ts(LOCATION_SCREEN), which 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...").
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 as a batch to the backendPOST /api/location/check-polygons. Whatever comes back ininsideIdsis claimed. - 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; multiple celebrations queue and are shown one after another. The queue is the store'sunlockedQueueand is the ONLY copy —CelebrationWatcherdrains 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 1000msCELEBRATION_SETTLE_MSbeat between consecutive ones. Until 2026-09-02 it handed deferred patches to a single-slot localqueuedPatchand 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 bymobile/screen-tests/celebration-achievement-order.test.tsx.
- A native
-
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 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 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 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:
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.
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/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. 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/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),APPROACH_SECONDS, and theshouldForceCelebrationdecision table.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 any part of this (see "Status").
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)); locations percheck-batchcapped at 100.
Edge cases and known limits
- 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 celebrations from one fix. Every one of them is shown — the queue is never trimmed, capped or de-duplicated — so a four-patch fix means four full-screen modals in a row, each dismissed by hand, before the achievement deck announces anything.- 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, admin-only "Manually unlock patch" action
in
PatchDevSheet(mobile/src/components/patches-v2/PatchDevSheet.tsx:150) that collects a patch with zero location check of any kind. This is distinct from both the automatic polygon path and the public Check In action, and is not reachable by ordinary users.
What this feature does NOT do
- 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 ~18 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
- 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/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 pin-outside-its-own-polygon and no-geometry fallbacks; and the 15-20s 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; and nothing starts withoutsim=1, before the geofence resolves, or once the user leaves the Compass.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 ten Compass gallery states (mobile/src/dev/mocks/compass.tsx) and asserts real content in each, which is the only automated coverage of theacquiring,not-found,unsupportedand 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, the purchase branch and the resolves-to-nothing frame. - 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 exact
scout://deep link route(s) for the Compass screen (plainscout://compassvs. ascout://compass-modal/[patchId]form) should be confirmed against the livemobile/src/dev/deepLinkRoutes.tsregistry at time of writing rather than assumed from this document — it was not exhaustively cross-checked here. - 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).