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:
- 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:388-402(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:388-402, "dead code left by the SQLite removal, tracked for a follow-up"). ThePurchaseSheet/CollectionWelcomeScreencomponents are still mounted by the layout (map.tsx:1374-1392) 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 62 patches. - 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'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'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:
- hero (spotlight) — a stable-random uncollected patch from the in-range set, reseeded only when the radius dial changes, never on a plain re-render (
mobile/app/(drawer)/near-me.tsx:203-210). - walkable marquee — patches ≤
WALKABLE_MILES= 2 mi (mobile/src/components/home-v2/format.ts:65). - distance bands — "A short drive" (2–10 mi) and "Worth the trip" (10 mi+), each only rendered if non-empty (
nearData.ts:85-88). - radar empty state — when nothing is within the current dial radius at all, a rotating radar UI (
NearRadarEmpty.tsx) locks onto the nearest patch just beyond range and offers a one-tap "Widen to N mi" usingwidenTargetMi(), which snaps to the next dial stop that would include it.
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 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):
- 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: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):
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)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— route wrappermobile/app/(drawer)/near-me.tsx— the screen:NearMeScreenViewModel(what it shows),NearMeScreenViewModelImpl(every hook),NearMeScreenLayout(pure). This wassrc/components/near-me-v3/FieldGuideNearMe.tsxuntil that file was deleted — it was the whole screen wearing a component's clothes, which forced the route's view model to be{ content: ReactNode }and left the screen impossible to mock. See screen-mocks.md.mobile/src/components/near-me-v3/nearData.ts— pure feed logic (dial stops, banding, travel estimate)mobile/src/components/near-me-v3/WithinDial.tsx— the radius dialmobile/src/components/near-me-v3/WalkableMarquee.tsx— ≤2 mi carouselmobile/src/components/near-me-v3/DistanceBands.tsx— 2–10 mi / 10 mi+ list rowsmobile/src/components/near-me-v3/NearRadarEmpty.tsx— empty-radius radar UI with widen CTAmobile/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 (1491 lines), split intoMapScreenViewModel(:289),MapScreenViewModelImpl(:452, every hook) and the pureMapScreenLayout(:1094); camera state machine, all handlers. Two exported discriminants carry the state:MapScreenStatus(:226) andMapNotice(:243).markerStateFor/buildMapMarkers/fitCamera(:402,:420,:445) 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— photo-medallion pinmobile/src/components/map/MapCameraRail.tsx— center/nearest/collected-toggle/radius-filter railmobile/src/components/map/MapTopBar.tsx— location pill + campaign-filter iconmobile/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
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
- 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. - 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:243-267) 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). - 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 does not currently sell anything. Tapping a pin opens only the peek 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.
- 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).
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. Covers: spotlight hero within range, a collected nearby patch kept-and-marked rather than hidden, mixed collected/uncollected list marking, and a no-crash guarantee when the content fetch fails (explicitly noting there is no error UI to assert on this screen).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, and the 100 mi default radius actually excluding a farther patch on first paint.
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/near-me-v3/__tests__/nearData.test.ts— feed building, banding, count-line formatting.mobile/src/components/map/__tests__/mapFilters.test.ts— collected/campaign/radius filter composition and ordering.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.