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 single-purpose radius browser drawn as a collection: the collection ring of every patch inside a radius you set, Closest to you, then a sortable, filterable grid of all of them. The Explorer map is a self-hosted MapLibre vector map (no Google/Apple/Mapbox dependency) fed by a PMTiles archive the backend serves, with patch-art pins, a bottom console that describes the selected patch the way Home's hero does, 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 and are the app's landing tab (Home) and drawer entries. Near Me and the map are not flagged.

Near Me was rebuilt in place on 2026-10-12 (2c63f04f, shipped in v1.1.107, live on the App Store) to the picked mock C · The ring (mobile/research/near-redesign-lab.html): the collection ring, Closest to you and the collection board, with My Patches' Sort and filter in place of search. There is no flag and no old screen behind it — the spotlight hero, the walkable strip, the two distance bands and the search overlay are gone from this screen.

The Explorer map was redesigned in place on 2026-10-12 (bb102ef4, shipped in v1.1.107, live on the App Store) to the picked mock A · Spotlight (mobile/research/map-redesign-lab.html): patch-art pins, Home's hero as the console, Home's Corners top bar and a liquid-glass rail. There is no flag and no old screen behind it — the previous medallion pins and console are gone from this screen.

The Home described here — the field-guide Home and its four modes — is now the new_home_screen-OFF path. Since 2026-10-02 the route (mobile/app/(drawer)/index.tsx:19) mounts the new Home by default (the flag defaults ON), a different, collecting-first screen documented in new-home-screen. That screen has none of the park or band modes below. Since 2026-10-08 (not yet released) it does have its own City Challenge mode — within placeModeRadiusMiles of a stop, the city's ring is its hero — decided by the same selectHomeContext as the place mode below, but drawn as the City Challenge page's ring, not this doc's place hero. Everything in this doc about Home is still true of the field-guide Home, which ships unchanged and returns the moment the flag is turned off. But it is no longer what a user sees by default. The two share their lifecycle side effects (mobile/src/components/home-v3/useHomeLifecycle.ts) and their error state (HomeErrorState.tsx).

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.tsx → FieldGuideHome (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 64 patches (Kings Canyon, the 63rd, joined the catalog 2026-10-07 with its NPS boundary).
  2. Proximity pre-filter (pure, on device) — getPolygonPatchesNearby() (mobile/src/services/location/tracker.ts) narrows those 64 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 catalog — mobile/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: the ring (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:21-22). 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).

The in-range set is nearRealStops(deriveNearbyPatches(...)) (mobile/app/(drawer)/near-me.tsx:159): city/state/region container patches are dropped and, unlike every other "nearby" surface in the app, already-collected patches are kept, and marked — a place you've been is still around you. Everything below is one pure derivation, buildNearRing (nearData.ts:150), built on the collection ring screen's own helpers (campaign/cityRing.ts, campaign/ring/closest.ts), so Near Me's ring and a collection's ring cannot disagree:

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 its patch art — the patch itself, full colour, 46pt, a shadow off its own outline (the collection ring's orbit tile, laid on the map). The patch the console is describing grows to 66pt over a soft glow in that patch's campaign colour — held or not (markerStateFor marks the console's patch now first); a collected patch carries the brass check. A patch with no art yet is its Clasp pin — its place photo in a glass map pin in its campaign colour, the campaign's icon in the clasp, the tail's tip on the coordinate (ClaspMapPin, PatchMarker.tsx; patch-art-placeholder) — so a pin is never an empty box. (Until 2026-10-19 it was the place photo in the road-trip map's TripMedallion.) The console shows the same pin where the art would float. Pins are not clustered — where patches crowd they overlap, by decision (2026-10-12). There is deliberately no "locked" visual state (see Status section). A name chip renders under a pin only when pickLabeled() (mobile/src/utils/mapLabels.ts) determines there's screen-space clearance at the current zoom; the console patch's name is always kept first so the chrome and the pin it's describing never disagree.

Console (PatchConsole.tsx): Home's hero in small, built from the same parts. Its words and colour come from Home's own derivation — spotlightFor (mobile/src/components/map/spotlight.ts) runs the patch through targetFor (home-new/selectors.ts), so the kicker is the patch's themed campaign (Winchester Mystery House reads "Ghost Adventures" in After dark lilac, not its San Jose City Challenge brass), with the campaign's motif, the rarity badge and the facts line (distance + heading, drive/walk time, type). A patch in no collection reads "Your next patch" in house brass. The place photo sits behind through RingHeaderField, fading into black (no photo → the art on black); the patch art floats up off the sheet onto the map; glass arrows either side step to the next nearer/further patch; the count line reads "Nearest · 1 of 97". Go collect it (in the campaign's colour) opens the Compass for that patch, exactly as Home's hero does. A patch you already hold reads Directions instead and hands straight off to Apple/Google Maps turn-by-turn (openTurnByTurn) — not the Compass, which swaps its own button to "View the patch" for a held patch and so offers no way out to directions. The info square opens the patch's page. The buttons are announced as buttons to VoiceOver/TalkBack (AccentButton carries the role and its label). The sheet itself — the photo band, the floating art, the padding — is ConsoleSheet (mobile/src/components/map/ConsoleSheet.tsx), which the Album's map card shares, so the two maps' cards are one design. Distances are measured from the map's origin (a previewed city, else the device fix), so the console and the pins agree. With no origin at all (a campaign shown nationwide before any fix), the console still names the patch and its campaign, with no distance or heading (targetBase).

Chrome: the top bar is Home's Corners bar (MapTopBar.tsx, Scout.TopBar + Scout.IconButton glass): menu left, the place centred with what is in view under it ("97 patches in view · 100 mi"), the campaign filter right (tinted while a campaign is focused). The camera rail (MapCameraRail.tsx) is one Scout.GlassSurface capsule (Liquid Glass on iOS 26+); an ON filter is a brass circle. The compass and road maps keep GlassChrome's bar and rail.

Camera behavior — pan without rezoom is a deliberate, tested invariant. Tapping a pin (handleMarkerPress, map.tsx:747) 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:1132-1138 — the layout's inactive branch, plus resetVisit at map.tsx:687-705). 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

new_home_screen (default ON) chooses between the new Home and the field-guide Home this doc describes. See new-home-screen. No flag gates Near Me or the map. 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