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:
- Digital collection paywalls on the map.
mobile/src/hooks/useMapPatches.ts:41-63computespatchPaidCollectionMapas a permanently emptyMapwith a long comment explaining why: "Digital collection unlocks are deprecated — nothing is sold that way any more." The column it used to read (Collection.productId) now holds a Shopify product id for the physical patch, not an in-app-purchase identifier. So every patch on the map medallion renders with no locked state at all (mapFilters/PatchMarkernever compute'locked'), confirmed bymobile/app/(drawer)/map.tsx:370-390(markerStateFor's own comment: "Deliberately NOlockedstate... Discovery is for browsing places; whether a collection has been bought is a fact about the STORE"). - Tapping a locked pin to open a purchase sheet.
handleMarkerPressinmap.tsxno longer routes toPurchaseSheet(map.tsx:370-390, "dead code left by the SQLite removal, tracked for a follow-up"). ThePurchaseSheet/CollectionWelcomeScreencomponents are still mounted by the layout (map.tsx:1390-1410) and theirpurchaseCollection/welcomeCollectionfields are still on the view model, but nothing on the map sets either — the two states are only reachable in the Screen mocks gallery (scout://dev-screen-mock/map?state=purchase). The screen used to callusePurchases()for ahasAccessit copied into a ref nothing read; that call is gone.
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:
- Home's hero "Explore Nearby" button always routes to
/near-me(both in nearest-patch mode,FieldGuideHome.tsx:715, and in "inside a park" mode,FieldGuideHome.tsx:901), except when a City Challenge stop is next — then it opens that stop's patch modal directly (FieldGuideHome.tsx:851-855). Every navigation is a view-model action (openNearMe,openPatch, …,FieldGuideHome.tsx:207-232); the layout never touches the router. - Home's
ExplorerMapCard(mobile/src/components/home-v3/ExplorerMapCard.tsx) is a stylized decorative SVG map preview, not a live map render — canvas-drawn roads/grid with a Skia gradient — that links to/mapwhen tapped. - The nav drawer's "Map" item arms the map's city-picker (see below) before navigating; every other way of reaching
/map(a home card, a deep link) does not.
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):
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."modeis'arrival'(0 collected),'complete'(all collected), or'progress'(else).inside— the device is inside the polygon of a patch that belongs to a collection under thenational-parkscampaign (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.band— noplace/insidematch, but the nearest City Challenge's nearest stop is withinbandRadiusMiles(default 25 mi,cityChallengeRadiusMiles) and the Challenge is not already fully collected (a finished Challenge produces'none'here, but still winsplacemode if you're standing on it — see selector's comment).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 aHomeSlotunion (FieldGuideHome.tsx:613-624) and rendered byDefaultSlot(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):
- Membership (pure, on device) —
selectNationalParks()(selectors.ts:482-517) filters the catalog to patches that (a) belong to a collection under thenational-parkscampaign (not thenational-parkscollection, which isadmin_onlyand never reaches the device —selectors.ts:437-461) and (b) have a non-nullprotectedAreaIdorgeofenceId(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). - 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'spolygonBboxRadiusMso a park whose pin sits far from its own boundary (Grand Canyon) is not wrongly excluded. - Containment (network round trip) — the surviving candidate ids are sent to
POST /api/location/check-polygons(insideParkQueryinmobile/src/query/queries/insidePark.ts), which runs anST_Containsquery server-side and returns which candidate ids actually contain the point. - Smallest-polygon-wins (pure, on device) —
selectInsidePark()(selectors.ts:520-537) picks the smallest containing polygon bypolygonAreaSqMwhen 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:
- The ring — the collection ring (
RingDial,near-me.tsx:295) of the nearest 40 stops in range (RING_MAX,nearData.ts:96; capped by distance first,nearData.ts:165, so far-off patches you hold never push near ones off it; two rings past 20). A stop with no art is on the ring as its Clasp pin (patch-art-placeholder) — until 2026-10-19 art-less stops were left off the ring; among those 40, what you hold sits first from 12 o'clock under the satin arc. In the middle, the nearest patch you have not collected, as its art — or, with no art yet, its Clasp pin (ringCenter,cityRing.ts; since 2026-10-19, it was the photo circle before) — with its place photo behind (RingHeaderField); when everything in range is yours, the photo circle instead ("Complete" once all are held) — the collection ring screen's own rule. House brass on the void (accentsOnBlack(ringPalette('city')),near-me.tsx:75) — a neighbourhood is no campaign. Title: "Within N mi" / "Around <city>" / the progress line, which counts every stop in range, not just the ring's 40. - The WITHIN dial and the count line under it (
nearCountLine), unchanged. - The all-collected banner when nothing in range is left to collect — in brass now (it was the green collected outline).
- Closest to you — the ring screen's
ClosestCard(near-me.tsx:318): Navigate (Collect when you are standing on it) opens the Compass, info opens the patch. - The board — "Everything within N mi", the ring's All / Collected / To find pills (
BoardPills, shared with the ring screen) overPatchGrid, every stop in range (art-less ones too), collected first — three to a row in a FlashList (near-me.tsx:271), as My Patches', so ~300 stops at 250 mi in a dense metro mount a window, not all; everything above the grid is the list's header. Full colour: a gold check on what you hold, a distance pill on the rest (accent inside half a mile) — no tile is dimmed (dim: false,near-me.tsx:259; picked 2026-10-12, unlike the campaign ring's black board, which holds the rest at 38%). - Sort and filter — My Patches' button and pull-down, shared (
FilterButton,PullDownFilterMenu,mobile/src/components/filter-menu/): Sort Nearest (default), A–Z, Rarest first (NEAR_SORT_LABELS,nearData.ts:102); filter by Campaign — each stop's THEMED campaign (themedCampaignOf,mobile/src/components/filter-menu/facets.ts:19, the rule My Patches and Home colour a patch by; "Other" for none, last; equal counts A–Z) — and Rarity, offering what is in range, with counts, plus any pick that has fallen out of range (the dial narrowed) at 0, so it can be unticked. The facet counting and matching are one module shared with My Patches (filter-menu/facets.ts). It narrows the board only: the ring, its progress, the count line and the bar always count everything (boardCounts,nearData.ts:205). A dot on the button is the only "filtered" sign; a filter that matches nothing says so with Clear filters. - Nothing in range — the radar (
NearRadarEmpty,near-me.tsx:363) locks onto the nearest patch you do not hold beyond the dial and offers "Widen to N mi" (widenTargetMi). Nothing within 250 mi at all — "Nothing on the map out here yet" and a Plan a day trip button (near-plan-trip, opens/trip-planner). - Location off — Home's
HeroEmptypermission card ("Continue", App Review 5.1.1(iv)); the bar reads "Location off" with no "Live location" kicker (near-me.tsx:191-194). - Finding you — the ring-shaped page-loader skeleton until there is a fix AND a place for it (
near-me.tsx:183).cityNamepersists across launches butcurrentLocationdoes not, so gating on the name alone drew an empty neighbourhood on a cold launch.
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/):
TilesService(tiles.service.ts) opens a PMTiles archive — either a local file path or anhttps://URL (range requests) — read viaPMTILES_URLconfig. It exists becausepmtiles://custom protocol handling isn't usable frommaplibre-react-nativeon either platform (Android support is version-gated, iOS unconfirmed), so the backend re-serves it as plain tiles instead.GET /tiles/:z/:x/:y.mvt(tiles.controller.ts:40-62) is unauthenticated, cachedpublic, max-age=31536000, immutable(a tile never changes; a map refresh means publishing a new archive). A tile with no data returns 204, not 404, so the client treats it as "empty ocean" rather than a broken URL and doesn't retry.GET /tiles/metareturns the archive's min/max zoom and bounding box, also unauthenticated.- If
PMTILES_URLis unset, the service logs a warning and every tile request 503s (ServiceUnavailableException) — the map would render as a blank/undecorated surface with markers but no basemap.
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):
- Collected toggle —
MapCameraRail's eye icon (map-toggle-collected). - Campaign filter —
CampaignFilterModal, opened from the top bar's layers icon (map-filter-campaign); options are "All," a specific campaign, or "Other" (patches in no campaign at all, via theNO_CAMPAIGNsentinel). - Radius filter —
DistanceFilterModal, a slider from 5–250 mi in 5 mi steps, default 100 mi (DistanceFilterModal.tsx:12-15). The radius is forced on ("All campaigns" always applies it) and only becomes optionally-toggleable once a single campaign is focused — turning it off then reveals that whole campaign nationwide (isRadiusActive,mapFilters.ts:79-81).
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):
cityChallengeRadiusMiles— default 25placeModeRadiusMiles— default 2- (Not used by these three screens, but adjacent:
pointUnlockRadiusMetersdefault 50,photoMatchRadiusMetersdefault 150 — belong to the unlock mechanic, out of this doc's scope.)
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
mobile/app/(drawer)/index.tsx— route wrappermobile/src/components/home-v3/FieldGuideHome.tsx— the screen, split intoHomeScreenViewModel/HomeScreenViewModelImpl(every hook,:235-681) and the pureHomeScreenLayout(:750). The four modes are collapsed into aHomeBodydiscriminated union in the impl (:596-628) and the layout switches onbody.kind(:840).HomeScreenStatus(:114) names the load precedence —fonts>error>loading>ready— that used to be three booleans read in a fixed order. The split stays in this file rather than moving into the route because the screen-mock slug is derived from the file name, andindex.tsxyieldsindex.mobile/src/dev/mocks/field-guide-home.tsx— all four modes plus the cold-start states in the Screen mocks gallery (scout://dev-screen-mock/field-guide-home)mobile/src/components/home-v3/selectors.ts— all pure mode-selection logic (selectHomeContext:301-354,nearestChallenge:52-107,selectNationalParks:479-508,selectInsidePark:598-616)mobile/src/hooks/useInsidePark.ts— wires the 3-step park-containment pipelinemobile/src/hooks/useNextPatch.ts— unbounded nearest-uncollected-patch scan for the default heromobile/src/components/home-v3/homeLoadGate.ts—isLocationResolving(), gates the skeleton on the live (non-persisted) fix, not the persisted city namemobile/src/components/home-v3/ContextBand.tsx— the "band" mode UI (City Challenge you're near but not in). An unboxed section, not a card (redesigned 2026-09-28): eyebrow "<CITY> IS <N> MI <DIRECTION>" with the collected/total count, the "<City> City Challenge" title (both tap through to the Challenge), then the three closest stops you don't have as the same numbered trail rows place mode's "THE STOPS" draws (stopTrail.tsx, shared withPlaceBody) — each row keeps its true position in the Challenge, the closest wears "· CLOSEST" and a brass ring, and a row taps through to that patch. Then "SEE ALL <n>" (to the Challenge) and "Or build a day trip from all <n>" (to the trip planner, the marquee this band displaced). It used to be a brass-bordered card with a bead per stop, three 96pt photo tiles, a "+N MORE" tile and a compass guide line; the first trail row now carries what the guide line said.mobile/src/components/home-v3/PlaceHero.tsx/PlaceBody.tsx— "place" mode UImobile/src/components/home-v3/InsideBody.tsx/heroInsideStats.ts— "inside a park" mode UImobile/src/components/home-v3/ExplorerMapCard.tsx— decorative map-preview card linking to/mapmobile/src/components/home-v3/DayTripMarquee.tsx— default "slot 3" fallback (out-of-scope trip planner entry point)
Near Me
mobile/app/(drawer)/near-me.tsx— the screen:NearMeScreenViewModel(what it shows),NearMeScreenViewModelImpl(every hook),NearMeScreenLayout(pure), composed from the collection ring screen's parts. (It wasFieldGuideNearMe.tsxlong ago — see screen-mocks.md for why that file went.)mobile/src/components/near-me-v3/nearData.ts— pure logic: the dial stops,buildNearRing(the ring, Closest, the board, the filter and its facets),nearCountLine,widenTargetMimobile/src/components/near-me-v3/NearTopBar.tsx— menu · the place · Sort and filtermobile/src/components/near-me-v3/NearSkeleton.tsx— the ring-shaped page-loader skeletonmobile/src/components/near-me-v3/WithinDial.tsx— the radius dialmobile/src/components/near-me-v3/NearRadarEmpty.tsx— empty-radius radar UI with widen CTAmobile/src/components/filter-menu/—PullDownFilterMenu+FilterButton, shared with My Patchesmobile/src/components/campaign/ring/BoardPills.tsx— All / Collected / To find, shared with the ring screenmobile/src/dev/mocks/near-me.tsx— 9 gallery states over 22 real Boston rows throughbuildNearRing- No longer used by Near Me (kept, not deleted):
WalkableMarquee.tsx,NearSearchOverlay.tsx;DistanceBands.tsxis still imported byFavoriteRow mobile/src/hooks/useNearbyPatches.ts— Home's separate, persisted-radius nearby hook (feedsDayTripMarquee, not Near Me directly)
Explorer map
mobile/app/(drawer)/map.tsx— the screen, split intoMapScreenViewModel(:270),MapScreenViewModelImpl(:438, every hook) and the pureMapScreenLayout(:1124); camera state machine, all handlers. Two exported discriminants carry the state:MapScreenStatus(:201) andMapNotice(:217).markerStateFor/buildMapMarkers/fitCamera(:386,:406,:431) are the exported pure derivations the mock reuses.mobile/src/dev/mocks/map.tsx— 18 gallery states over 24 real Boston-area catalog rows; the only place the dead purchase sheet can be looked at. See screen-mocks.md.mobile/src/components/map/FieldGuideMap.tsx— MapLibre wrapper; pan-vs-zoom mechanics at lines 105–202mobile/src/components/map/mapFilters.ts— pure filter logic (collected/campaign/radius)mobile/src/components/map/PatchMarker.tsx— patch-art pin (ClaspMapPin, the Clasp pin, when a patch has no art)mobile/src/components/map/PatchConsole.tsx— the console: Home's hero in smallmobile/src/components/map/ConsoleSheet.tsx— the console's sheet (photo band, floating art,ConsolePatchArt), shared with the Album map's cardmobile/src/components/map/spotlight.ts—spotlightFor, the console's subject via Home'stargetFormobile/src/components/map/MapCameraRail.tsx— center/nearest/collected-toggle/radius-filter rail, one glass capsulemobile/src/components/map/MapTopBar.tsx— Home's Corners bar: menu, place + count, campaign filtermobile/src/components/map/DistanceFilterModal.tsx— 5–250 mi slider, default 100mobile/src/components/map/CampaignFilterModal.tsx— campaign focus pickermobile/src/components/map/LocationPickerModal.tsx— inline city-preview pickermobile/src/hooks/useMapPatches.ts— sorts/measures catalog from the map's chosen origin; dead paywall map at lines 41–63backend/src/tiles/tiles.controller.ts,tiles.service.ts— PMTiles shim
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
- No radius cap on Home's default hero.
useNextPatchwill surface the nearest uncollected patch in the whole catalog even if it's thousands of miles away (see How It Works). Whether that's intended product behavior or an oversight is not verifiable from the code alone — flagged as an Open Question. - Place/band mode is proximity, not a city boundary, by explicit design (see selectors.ts commentary quoted above) — a person just across a city line from a Challenge stop gets that city's place mode.
- 12 of 64 City Challenge cities have no city-type patch at all, and place/band mode does not need one — it only needs stop distance.
- A finished City Challenge disappears from "band" mode (nothing left to point at) but still triggers full "place" mode if you're standing on it (a deliberate celebratory exception,
selectors.tscomment aboveif (!challenge.next) return { kind: 'none' }). - The map's collected/uncollected medallion state carries no "locked" state, because the paywall it used to gate is dead (see Status).
- Home's map-preview card is not a live map — it's a decorative animated SVG, and should never be described as showing real data.
- Near Me's radius dial resets to 25 mi on every screen mount (local
useState, not persisted) — a different radius memory model than Home's day-trip marquee, which persists to AsyncStorage. Its Sort and filter resets too. - Near Me's ring holds at most 40 patches (the nearest 40); the board holds every stop in range — at 250 mi up to ~300 in a dense metro (NYC 316, DC 310 in the catalog) — virtualised in a FlashList.
- A patch with no art yet is counted, and sits on Near Me's board and its ring as its Clasp pin (patch-art-placeholder).
- The map's location picker only opens from the drawer nav entry — every deep link or in-app card into
/maparrives with the picker permanently disarmed for that visit, by design (confirmed by the regression fix cited inmap.tsx's comments). - The map has no reachable error state — a failed catalog fetch reads as an empty map.
useMapPatcheshardcodeserror: null(useMapPatches.ts:121-128) andmap.tsxdropsuseContent()'s ownisLoading/errorbesides, so when the content query fails the screen renders theemptynotice, "No patches match your current filters", over a basemap with no pins — visually identical to standing somewhere with nothing nearby. The error branch exists in the layout (MapNotice,map.tsx:217-231) and can be staged atscout://dev-screen-mock/map?state=error, but nothing in the app produces it. - The map's three status cards used to be able to draw on top of each other. Loading, error and empty were independent conditions pinned to the same absolute offset, and "permission granted, no fix yet" is also "zero patches in view" — so both cards rendered, stacked. They are one
MapNoticediscriminant now, with a fixed precedence (loading > acquiring > error > empty). - Pins overlap where patches crowd. There is no clustering, by decision (2026-10-12); a dense downtown is a pile of patch art, as it was a pile of medallions before.
- With no origin the console has no distance. Before any fix, the default radius filter drops every patch (no distance), so the map is empty; only a focused campaign with the radius switched off puts pins on the map then, and its console names the patch with no distance, heading or drive time.
- Tiles are unauthenticated by design (immutable, cacheable) — this is intentional, not an oversight, per the controller's own doc comment.
What this feature does NOT do
- Previewing another city on the Explorer map can never unlock a patch, anywhere in the app, for anyone. This is asserted as a source-level architectural invariant, not just a runtime check:
mobile/src/domain/__tests__/previewSafety.test.tswalks every.ts/.tsxfile undermobile/srcandmobile/appand fails the build if any production file callsuseNearbyPatches()with an explicit origin argument — the only legal call is the zero-argument form, which always resolves to the live device fix (resolveNearbyOrigin,useNearbyPatches.ts:60-65). The test further asserts the store carries no preview-location state or actions at all (tripLocation,setTripLocation,openTripForCity, etc. are allundefinedon the live store) — the mechanism that used to let a preview leak into the unlock path was deleted outright, not merely guarded. The unlock pass itself (getCurrentLocation(), feedingcheckNearbyPatches/checkPatches) reads the device fix directly and is entirely independent of whatever city the map happens to be showing. - Home does not show a live map anywhere on the screen. The "Explorer map" card is a static, decoratively-animated SVG illustration, not a rendered tile view.
- The Explorer map's console has no separate Apple/Google Maps button. For a patch you have not collected, Go collect it opens the Compass and the Compass's "Open in Maps" is the way out to turn-by-turn; for a patch you hold, the same button reads Directions and opens turn-by-turn directly.
- The Explorer map does not cluster pins, at any zoom.
- The Explorer map does not currently sell anything. Tapping a pin opens only the console / patch detail; the purchase-sheet code path exists in the file but nothing wires a marker tap to it (see Status).
- Home's "place" and "band" modes do not know or check city/state polygon boundaries — they are pure distance-to-stop calculations, never
ST_Containsagainst a city. - Near Me does not filter out already-collected patches. Unlike the day-trip marquee and other "what's left" surfaces, it deliberately shows everything in range, marking (not hiding) what you already own.
- Near Me has no search (since 2026-10-12). Searching the catalog is the patch browser's (
/browse-patches). Its filter narrows what is in range; it never reaches past the dial. - Near Me's filter never changes the ring or the counts — it narrows the board only.
- None of these three screens perform or trigger patch unlocking. They are strictly read/browse surfaces; unlock logic (
checkPatches/runUnlockPass) is a separate mechanism this document does not own (see CLAUDE.md scope note — the unlock mechanic and compass belong to a different feature doc). - Home's City Challenge band never lists every stop. It shows the three closest you have not collected; the rest are one tap away (SEE ALL) on the Challenge screen. It does not show collected stops at all, and it has no photo grid.
Tests that cover it
Screen-level (integration) tests — real seeded content rendered through the actual screen component tree:
mobile/screen-tests/index.test.tsx— Home. Covers: nearest-hero happy path, empty hero when the only nearby patch is already collected, content-load failure with/without a warm cache, trip-resume meter fusion, band→place mode transition on approach to a city, board/nest aggregation in place mode, place-hero swap logic (closest-uncollected, not merely closest), ordinary-home fallback when no Challenge is in range, park containment→insidemode transition (and the reverse — empty containment stays on ordinary home), suppression of trail/journeys/import-band inside a park, exclusion of parks with no National Parks campaign membership, and the app-load-complete signal firing only once loaded (not during skeleton).mobile/screen-tests/near-me.test.tsx— Near Me (13). Covers: the ring's kicker/title/progress over every stop and the nearest open patch in the middle; Closest to you → compass / patch; the board's order (collected first, check, distance pill, never dimmed); the pills; the filter button where search was; the menu narrowing the board only (dot on, ring and counts whole); A–Z; a filter matching nothing → Clear filters; everything held (no Closest, the banner, all checked); the radar widen; nothing within 250 → Plan a day trip; location off (Continue, "Location off" with no live kicker); and the no-crash fetch failure (there is still no error UI to assert).mobile/screen-tests/map.test.tsx— Explorer map. Covers: a real patch pin + console rendering, the undetermined-permission gate (Continue button), the denied-permission gate (Open Settings), the granted-but-no-fix loading state, the 100 mi default radius actually excluding a farther patch on first paint, and the Spotlight console over the real Winchester Mystery House rows from Cupertino: the themed campaign kicker (paired with the no-collection "Your next patch" branch), facts, Go collect it, the place photo and the art pin; no photo → no backdrop; no art → the Clasp pin on the map and in the console (its place photo, the Ghost Adventures ghost); stepping to a held patch → Directions + "Collected · 1 of 2" + the console glow on the held pin, and Directions → turn-by-turn (not the Compass); only the console's pin glows; the arrows disabled at the ends; standing on the patch → "Collect it" + "You’re here"; Go collect it →/compass?patchId, info →/patch-modal/<id>.
Unit tests (pure logic):
mobile/src/components/home-v3/__tests__/selectors.test.ts— the most exhaustive suite in this feature: every branch ofselectHomeContext's ladder (none/band/place/inside and their precedence over each other),trailWindowwindowing,selectPlaceHeroPatch's closest-uncollected-with-photo rule,selectPlaceNest/selectInsideNestchip derivation,selectNationalParks/selectInsideParkmembership and smallest-polygon-wins logic, andplaceAlbumHrefrouting.mobile/src/components/home-v3/__tests__/ContextBand.test.tsx— the Challenge band: kicker distance/heading and progress from the data, the three closest uncollected stops as trail rows (overflow and collected stops excluded, only the closest marked CLOSEST), true-position numbering paired against a zero-collected Challenge, no spine for a single remaining stop, where each tap goes (row → patch, header and SEE ALL → Challenge, link → trip planner), and no card treatment on the shared section frame. The home screen test asserts the same rows against the real Boston seed.mobile/src/components/near-me-v3/__tests__/nearData.test.ts—buildNearRing(the 40 cap, with art-less stops ON the ring since 2026-10-19, the middle patch, done, the board's order and pills, the menu narrowing the board only, themed-campaign facets with Other last, the sorts, no-match),nearFilterSet,nearRealStops, count-line formatting.mobile/src/components/map/__tests__/mapFilters.test.ts— collected/campaign/radius filter composition and ordering.mobile/src/components/map/__tests__/spotlight.test.ts— the console's subject: themed campaign over the City Challenge, house brass with no collection, measured from a previewed city, no type label without a type row, and no origin at all (no distance, not "here").mobile/src/domain/__tests__/previewSafety.test.ts— the source-level "no preview surface can reach the unlock or browse path" invariant described above.
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
- Is the unbounded (no-radius) nearest-patch scan in
useNextPatchintentional? It will surface a hero patch on the other side of the country if that's the closest uncollected one, with no distance ceiling and no "too far to be a real hero" cutoff. Nothing in the code comments confirms or denies this is deliberate; it reads as plausibly-accidental scope creep from an original nearby-only intent, but I could not verify original intent from the code alone. - Whether
PMTILES_URLis actually configured in production, and what regional extract it points to (bounds, size, freshness cadence) — this is operational/deployment configuration outside the checked-in code, not verifiable by reading source. - Whether the dead paywall/purchase-sheet code path on the map is scheduled for removal or reactivation — the comment says "tracked for a follow-up" but no ticket reference is present in the code itself.
- Backend test coverage for the tiles endpoints — no
.spec.tsexists; behavior (204-vs-error handling, cache headers) is exercised only by manual/production traffic as far as the repository shows.