Scout — Full Product Context → feature documentation

Discovery surfaces: Home, Near Me, Explorer map

Three mobile screens answer 'what's around me': the Home tab (scout://, mobile/app/(drawer)/index.tsx), Near Me (scout://near-me), and the Explorer/Discovery map (scout://map).

Screen recordings

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

Nearest patch 3.1s · recorded 2026-09-09
The unlock area 6.4s · recorded 2026-09-09
City Challenge 1.7s · recorded 2026-09-09
14 things to find here 6.63s · recorded 2026-09-09
Achievement unlocked 1.9s · recorded 2026-09-09
The story 7.23s · recorded 2026-09-09
Established 1929 6s · recorded 2026-09-09
Near Me 4.1s · recorded 2026-09-09
35 patches in view 6s · recorded 2026-09-09
You earn them by going there 2.4s · recorded 2026-09-09

Full sessions

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

Full recording · 09-09 13:06 11.2s · recorded 2026-09-09 · unedited
Full recording · 09-09 13:15 93.9s · recorded 2026-09-09 · unedited

Summary

Three mobile screens answer "what's around me": the Home tab (scout://, mobile/app/(drawer)/index.tsx), Near Me (scout://near-me), and the Explorer/Discovery map (scout://map). All three are read-only browse surfaces backed by the same synced content catalog (patches, collections, campaigns) plus the live device GPS fix. Home is not a single screen — it is a state machine that swaps its entire body depending on where the device physically is (nowhere near anything tracked / near a City Challenge / standing in a City Challenge stop / standing inside a National Park). Near Me is a simpler single-purpose radius browser with a spotlight hero and distance-banded list. The Explorer map is a self-hosted MapLibre vector map (no Google/Apple/Mapbox dependency) fed by a PMTiles archive the backend serves, with photo-medallion pins, campaign/radius/collected filters, and an explicit "preview another city" picker that is architecturally prevented from ever unlocking a patch.

Status (shipped / beta-badged / flagged off)

All three surfaces are fully shipped, not flagged, and are the app's default landing tab (Home) and drawer entries. No feature flag gates any of Home/Near Me/Map as a whole.

Two things that look like they should be part of this feature are built but functionally dead, and should never be described as live:

User-facing surfaces (screens, routes, deep links, entry points)

Screen Route Deep link File
Home /(drawer)/index (default tab) scout:// mobile/app/(drawer)/index.tsxFieldGuideHome (view model + layout live in mobile/src/components/home-v3/FieldGuideHome.tsx)
Near Me /(drawer)/near-me scout://near-me mobile/app/(drawer)/near-me.tsx (view model + layout, one file)
Explorer map /(drawer)/map scout://map mobile/app/(drawer)/map.tsx

Registered in the deep-link registry at mobile/src/dev/deepLinkRoutes.ts:40,45 (Near Me and Discovery Map are both tagged category: 'Core').

Entry points into these three from elsewhere in the app:

How it works (device → API → DB → response)

Home's mode selection (selectHomeContext)

The entire dynamic-home mechanism is one function, selectHomeContext in mobile/src/components/home-v3/selectors.ts:310-376, which takes the device fix, the synced catalog, the collected-patch set, two configured radii, and an already-resolved "am I inside a national park" answer, and returns one of four states:

type HomeContext =
  | { kind: 'none' }
  | { kind: 'band'; challenge: NearestChallenge }
  | { kind: 'place'; challenge: NearestChallenge; mode: PlaceMode }   // mode: 'arrival' | 'progress' | 'complete'
  | { kind: 'inside'; patch: Patch }

The decision ladder, in priority order (selectors.ts:310-376):

  1. place — distance to the nearest uncollected-or-not stop of the nearest City Challenge collection is ≤ placeModeRadiusMiles (default 2 mi, mobile/src/domain/app-config/types.ts:25,32). This outranks even standing inside a national park — "at the Saint Louis Zoo you are inside the Zoo, Forest Park, the city and the state at once, and the city is the screen." mode is 'arrival' (0 collected), 'complete' (all collected), or 'progress' (else).
  2. inside — the device is inside the polygon of a patch that belongs to a collection under the national-parks campaign (see below). Suppresses everything else on the screen — trail, journeys, import band, explorer-map card all come off; it's treated as its own screen, not home with a park layered on top.
  3. band — no place/inside match, but the nearest City Challenge's nearest stop is within bandRadiusMiles (default 25 mi, cityChallengeRadiusMiles) and the Challenge is not already fully collected (a finished Challenge produces 'none' here, but still wins place mode if you're standing on it — see selector's comment).
  4. none — nothing nearby; Home shows its default hero + a ladder in "slot 3" (an active trip resume card > a day-trip marquee, in that priority). The ladder is resolved once in the view model into a HomeSlot union (FieldGuideHome.tsx:613-624) and rendered by DefaultSlot (FieldGuideHome.tsx:730-747), so "a trip AND a band" is not a state the screen can be asked to draw.

This is proximity, not polygon containment, and the code is explicit about why (selectors.ts:257-280): no City Challenge collection has any city-type member patch to join against (all 64 have zero), the unlock pass only ever evaluates uncollected patches so a resident who already owns their city patch would never register as "inside" it, and 12 of the 64 Challenge cities have no city patch to test against at all. So place/band mode is a straight-line distance to the nearest Challenge stop, never a city boundary check. The stated trade-off: someone two miles from a Boston stop but technically standing in Cambridge gets Boston's place mode.

National park detection (inside) is the one state that IS real polygon containment — because unlike City Challenges, a national park's own patch carries the boundary directly. The pipeline (mobile/src/hooks/useInsidePark.ts):

  1. Membership (pure, on device)selectNationalParks() (selectors.ts:482-517) filters the catalog to patches that (a) belong to a collection under the national-parks campaign (not the national-parks collection, which is admin_only and never reaches the device — selectors.ts:437-461) and (b) have a non-null protectedAreaId or geofenceId (i.e. actually carry a polygon). This currently resolves to 62 patches.
  2. Proximity pre-filter (pure, on device)getPolygonPatchesNearby() (mobile/src/services/location/tracker.ts) narrows those 62 to whichever are within reach of the device fix, using each patch's polygonBboxRadiusM so a park whose pin sits far from its own boundary (Grand Canyon) is not wrongly excluded.
  3. Containment (network round trip) — the surviving candidate ids are sent to POST /api/location/check-polygons (insideParkQuery in mobile/src/query/queries/insidePark.ts), which runs an ST_Contains query server-side and returns which candidate ids actually contain the point.
  4. Smallest-polygon-wins (pure, on device)selectInsidePark() (selectors.ts:520-537) picks the smallest containing polygon by polygonAreaSqM when more than one contains the user (a preserve drawn around a park, a recreation area overlapping a park boundary). A park with an unknown/uncomputed area sorts last, only winning if it's the sole candidate.

The query key is a coordinate rounded to 3 decimal places (~110 m cell, mobile/src/query/queries/insidePark.ts:16-21) with a 5-minute staleTime, so this network call is cheap and only fires at all when step 2 found at least one nearby park candidate — the ordinary home screen (nowhere near a park) never makes this request.

Home's nearest-patch hero (default state)

When homeContext.kind is 'none' or 'band', the hero shows the single nearest uncollected patch in the entire synced catalogmobile/src/hooks/useNextPatch.ts:60-83 does a plain linear scan (haversineDistance) with no radius cap and no real-stop filter (city/state/region container patches are eligible). This is a genuinely unbounded "nearest patch anywhere on Earth you haven't collected," not a nearby-only search; see Open Questions.

Near Me's feed (near-me.tsx view model / nearData.ts)

Near Me always computes from the live device fix (never a preview — see the "cannot unlock" guarantee below) with a user-adjustable radius dial: 5 / 25 / 100 / 250 mi, default 25 mi (NEAR_WITHIN_STOPS, DEFAULT_WITHIN_MI in mobile/src/components/near-me-v3/nearData.ts:9-10). This dial state is local useState, not persisted across app restarts — a different radius mechanism from Home's marquee, which reads a separate, AsyncStorage-persisted 50 mi default (mobile/src/hooks/useNearbyPatches.ts:12-13).

nearRealStops(deriveNearbyPatches(...)) (nearData.ts:45-47) drops city/state/region container patches and, unlike every other "nearby" surface in the app, includes already-collected patches, each marked — Near Me answers "what is around me," and a place you've already visited is still around you (mobile/app/(drawer)/near-me.tsx:182-201). The feed is split into:

Explorer map (MapScreen / FieldGuideMap)

The map is MapLibre Native (@maplibre/maplibre-react-native), not Apple/Google Maps — chosen specifically because those platforms cannot be restyled to match the app's theme (mobile/src/components/map/FieldGuideMap.tsx:1-7). Its style JSON (fieldGuideStyle(), mobile/src/utils/mapStyle.ts) points its vector-tile source at the backend, not any third-party tile host.

Backend tile shim (backend/src/tiles/):

Pins: PatchMarker.tsx draws every visible patch as a TripMedallion — the same brass-ringed photo medallion used on the road-trip map and day-trip trail — sourced from locationImageUrl (falling back to patchUrl). There is deliberately no "locked" visual state (see Status section). A name chip renders under the medallion only when pickLabeled() (mobile/src/utils/mapLabels.ts) determines there's screen-space clearance at the current zoom; the currently-selected/console patch's name is always kept first so the chrome and the pin it's describing never disagree.

Camera behavior — pan without rezoom is a deliberate, tested invariant. Tapping a pin (handleMarkerPress, map.tsx:735-760) issues applyCamera({ mode: 'point', center: [...] }) with no zoom key at all. FieldGuideMap.tsx:137-148 explicitly omits the zoom property from the native camera "stop" object rather than passing it as undefined, because MapLibre reads an absent key as "leave zoom alone" — passing undefined still marshals a key across the bridge. Only explicit re-framing actions (auto-zoom-to-fit on first location fix, "Center on me," "Go to nearest") pass an explicit zoom (FOCUS_ZOOM = 13).

Filters (mobile/src/components/map/mapFilters.ts), applied in this fixed order — collected → campaign → radius (filterMapPatches, mapFilters.ts:104-131):

Location picker (preview another city): LocationPickerModal mounts LocationPicker inline as a Modal, never as a route push — pushing a route would unmount the map's heavy MapLibre subtree, which tears down on blur (LocationPickerModal.tsx:1-12, map.tsx:1120-1129 — the layout's inactive branch, plus resetVisit at map.tsx:675-706). It is only reachable when the drawer's "Map" nav item armed it (CustomDrawer.tsx:165 calls armMapPicker() before navigating); every other entry point into /map (a home card, scout://map, a route push from elsewhere) arrives with the picker disarmed and the location pill inert — confirmed by MapTopBar.tsx:81-89 (onPressPlace only wired when armed) and a regression test the code comments cite directly (maestro/tests/map.yaml, caught 2026-08-26 as a first-drawer-entry bug).

Data model (Prisma models and key fields)

backend/prisma/schema.prisma — the content graph these three screens read is entirely Patch (schema.prisma:88), Collection (schema.prisma:36), Campaign (schema.prisma:70), PatchCollection (schema.prisma:247, the many-to-many join), plus AppConfig (schema.prisma:1121) for the two home-mode radii. Relevant Patch fields for these screens: latitude/longitude (Decimal(10,7)), locationImageUrl/locationImageBlurhash, patchUrl, collectionType, protectedAreaId, geofenceId.

geofences and protected_areas — the polygon tables that back containment — are deliberately absent from schema.prisma. A comment at schema.prisma:225-236 explains: they are PostGIS-only, queried exclusively through hand-written $queryRaw, and any Prisma-modelled write against them would be rejected outright (the road-trip corridor's patches.geom column is GENERATED ALWAYS AS). Patch.protectedAreaId / Patch.geofenceId are the only client-visible pointers into that geometry; the geometry itself is only ever touched server-side.

AppConfig-sourced values consumed by Home (mobile/src/domain/app-config/types.ts:1-33, with device-side fallback defaults):

API surface (endpoints, auth requirements)

All content (patches/collections/campaigns) reaches these screens through the general content-sync query, not a discovery-specific endpoint (out of scope here — owned by the sync/content-sync feature).

Endpoint Method Auth Purpose File
/api/location/check-polygons POST none (no @UseGuards on LocationController, no global guard registered in app.module.ts) Given a lat/lng + candidate patch ids, returns which candidates' polygons contain the point. Used both by the unlock pass and by Home's "inside a national park" detection. backend/src/location/location.controller.ts:26-32
/api/location/geofence/:patchId GET none Returns a patch's unlock-area GeoJSON (compass map use, not this feature) location.controller.ts:19-21
/tiles/meta GET none Archive extent (min/max zoom, bbox) for building the map style backend/src/tiles/tiles.controller.ts:28-33
/tiles/:z/:x/:y.mvt GET none One vector tile, cached 1 year immutable tiles.controller.ts:40-62

Key files (annotated path:line list)

Home

Near Me

Explorer map

Configuration and flags

No feature flag gates Home, Near Me, or the map as a whole. The only runtime-tunable numbers are AppConfig.cityChallengeRadiusMiles and AppConfig.placeModeRadiusMiles (admin-editable in prod, with device-side fallback defaults of 25 and 2 respectively — mobile/src/domain/app-config/types.ts). PMTILES_URL is required backend config for the map tile archive to serve anything (see Status/API sections) — if unset, the map still renders markers and chrome but the basemap tiles all 503.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Screen-level (integration) tests — real seeded content rendered through the actual screen component tree:

Unit tests (pure logic):

No backend spec file exists specifically for TilesService/TilesController (backend/src/tiles/ has no .spec.ts sibling) — tile serving is untested at the backend unit level; see Open Questions.

Open questions