Summary
Scout's entire patch catalog — every collectible location — lives behind one
screen component, PatchBrowserScreenV2
(mobile/src/components/patch-browser-v2/PatchBrowserScreenV2.tsx). It is
mounted by a single route file, mobile/app/browse-patches.tsx, and that one
mounting serves several different user-facing intents ("Find a patch",
"Browse all" from Profile, a single collection's patch grid, a category).
Which intent the user is in is entirely a function of query params/props;
there is no separate "My Patches" screen or "search" screen in the codebase —
it is filter state on one component. The component carries a fifth mode,
nearby ("Within X mi / Nearby Patches"), that nothing mounts any more —
Near Me was rebuilt on near-me-v3 and no longer touches this screen; the
prop and its hero framing are still live code, reachable only from the Screen
mocks gallery.
The screen follows the house view-model pattern: PatchBrowserScreenViewModel
(the data), PatchBrowserScreenViewModelImpl (every hook, taking the route's
props as its inputs) and the pure PatchBrowserScreenLayout, with the search /
grouping / hero derivations exported as pure functions so the mock cannot
disagree with the screen about what a section or a filter means.
Tapping a card opens patch-modal/[id] → PatchDetailScreen
(mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx), a tabbed
detail view: Overview (always shown, carries the hero image pager), History,
an optional per-type tab, Gallery, Community, and a conditional My Visit tab.
The most important finding in this feature is a gap between what is coded
and what is shipped: the app ships 33 type-specific detail-tab React
components (ParkTypeTabV2, MonumentTypeTabV2, ZooTypeTabV2, …) fully
wired to a dispatch table, but a single allow-list —
REDESIGNED_TYPE_TAB_KEYS in mobile/src/domain/patch-type-data.ts:76 —
gates which of them the tab bar will ever show. Today that list contains
exactly one entry: battlefieldData. So only "Battle" (military battlefields)
gets a visible type-specific tab; the other 32 type components (Park, Zoo,
Museum, Lighthouse, Haunted, etc.) render zero times in production even
though their patches carry fully populated *Data JSON and the components
themselves have no known bugs. This is a deliberate, in-code "not yet
redesigned" gate, not a broken feature.
Status (shipped / beta-badged / flagged off — name the flag and its default)
- Catalog browser, filters, search, patch detail, hero pager, and the "more" action sheet: fully shipped, no feature flag.
- Per-type detail tab (Overview → the dynamic middle tab, e.g. "Explore",
"Wildlife", "Statehouse"): shipped for exactly one type —
battlefieldData→BattleTab(mobile/src/domain/patch-type-data.ts:76,mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:130-146). The other 27 registered*Datashapes (park, monument, forest, museum, zoo, lighthouse, capitol, …) have finished v2 tab components undermobile/src/components/patch-detail-v2/tabs/types/but are built and off —useVisibleTabsnever adds a'type'tab entry for them, so the tab bar never shows them (mobile/src/components/patch-detail-v2/useVisibleTabs.ts:20-40).hauntedData→HauntingTabis coded and imported inPatchDetailScreen.tsxbut is likewise unreachable — it isn't inREDESIGNED_TYPE_TAB_KEYSeither, so its branch (patch.hauntedData ? <HauntingTab/> : …) is dead code under current data (the'type'tab button that would open it never renders). - "Purchase Patch" row in the patch action sheet: on in production,
though the hardcoded default is off.
Gated on the
buy_patchfeature flag,defaultEnabled: false(backend/src/admin/feature-flag-definitions.ts:32-39,mobile/src/config/feature-flags.ts:39-44). Description: "Keep off until we're ready to fulfill orders at volume." Shown only when the patch is collected AND has aproductHandleAND the flag is on (mobile/src/domain/purchaseGate.ts:10-16). - "Check In" action (manual GPS check-in without a photo): shipped, but only
offered for
collectionType === 'city'or'state'patches (mobile/src/hooks/useVerifyLocation.ts:100-102); every other patch type has no manual check-in affordance in the action sheet at all. - My Visit tab: shipped, but only ever renders for a patch that has ≥1
device-local "Retrace"-matched camera-roll photo
(
mobile/src/components/patch-detail-v2/useVisibleTabs.ts:36-39) — most patches never show it.
User-facing surfaces (screens, routes, scout:// deep links, entry points)
| Entry point | Route | What it pre-sets |
|---|---|---|
| Drawer → "Find a patch" | scout://browse-patches?search=1 (mobile/src/components/navigation/drawerSections.ts:309) |
Opens straight into search mode (openWithSearch), Nearby off |
| Profile → "See all" / "Browse all" (Collected section) | scout://browse-patches?status=collected (mobile/app/(drawer)/profile.tsx:474-476 — one browseCollected action on the view model, spent by both links) |
initialStatus: 'collected', hero reframes to "Your collection / Collected patches" |
| Home screen collection card / campaign collection "View all" | scout://browse-patches?collectionId=<id> |
Locks to that one collection, flat grid (no section headers) |
| Category tap | scout://browse-patches?categoryId=<id> |
Filters to that category's collections |
| Overview tab → a collection row | router.push('/browse-patches', { collectionId }) (mobile/src/components/patch-detail-v2/tabs/OverviewTabV2.tsx:70-73) — or /campaign-collection/<id> if the collection belongs to a campaign |
Same as above |
| Drawer / Home / Near Me → "Near Me" | scout://near-me → NearMeScreenLayout (mobile/app/(drawer)/near-me.tsx), its own screen built on near-me-v3 — it does NOT mount PatchBrowserScreenV2; it links here with a "Browse" button to /browse-patches |
n/a — the browser's nearby prop has no caller |
| Any patch card / search result / recommendation | scout://patch-modal/<patchId> (mobile/app/patch-modal/[id].tsx → mobile/src/screens/PatchModalScreen.tsx) |
Opens PatchDetailScreen |
| Trophy Case (a separate, non-filterable screen) | scout://collected-patches (mobile/app/collected-patches.tsx) |
Own grid UI: sections by collection, only collected patches shown, tapping a badge still opens patch-modal/<id> |
| Gallery tab → expand icon | scout://patch-gallery/<patchId> |
Full-screen photo gallery (separate screen, not detailed here) |
Note: /collected-patches ("Trophy Case") is a second, independently
built screen for viewing collected patches — it is not the same component
as browse-patches?status=collected. It has its own hero (trophy icon,
overall % progress bar), its own per-section progress bars, and renders only
collections that have at least one collected patch
(buildCollectedSections, mobile/app/collected-patches.tsx:150-181).
Tapping a patch there routes to
patch-modal/<id>, not the celebration screen (unlike tapping a collected
card inside the browser in "collected" filter mode — see below).
The Trophy Case is split into a view model and a pure layout
(CollectedPatchesScreenViewModelImpl at collected-patches.tsx:312,
CollectedPatchesScreenLayout at :362), so its states are reachable in the
DEV screen-mock gallery at scout://dev-screen-mock/collected-patches —
ten of them, including the stale-patchCount cases that produced the two
bugs listed under Edge cases below (mobile/src/dev/mocks/collected-patches.tsx).
It also has a loading and an error branch that it did not use to have:
while the content sync is in flight it shows the page-loader skeleton
(collected-patches.tsx:265) rather than "No patches collected yet", and a
content-query failure with nothing cached shows a retry card
(:468) rather than the same misleading empty state. A failed refresh over
sections that already loaded changes nothing on screen.
Both browse-patches, collected-patches, and patch-modal/[id] are
registered in the deep-link drift registry
(mobile/src/dev/deepLinkRoutes.ts:69-77), so they resolve at
scout://browse-patches, scout://collected-patches, and
scout://patch-modal/<id>.
How it works (the end-to-end mechanism: device → API → DB → response)
Catalog load. There is no server-side search or filter endpoint for the
catalog. On app boot (and on pull-to-refresh) the client calls
GET /api/sync/content once (unauthenticated except that an admin bearer
token unlocks admin-only rows — backend/src/sync/sync.controller.ts:17-23)
and receives the entire patch/collection/campaign/category catalog as one
payload (SyncService.getContent, backend/src/sync/sync.service.ts).
admin_only collections and their patches are stripped server-side for
non-admin callers (backend/src/sync/sync.service.ts:196-225). This payload
is cached client-side via TanStack Query (contentQuery,
mobile/src/query/queries/content.ts) and reused by every screen —
usePatchBrowser, usePatch, usePatches, usePatchCollections all read
the same cached array and recompute derived state with useMemo
(mobile/src/hooks/usePatchBrowser.ts:52-215).
Filtering/search is 100% client-side. usePatchBrowser takes the full
patch array plus a PatchBrowserFilters object and, in one useMemo,
applies (in order): text search (substring match on name/description,
lower-cased, no fuzzy matching, no server round-trip —
usePatchBrowser.ts:104-110), collection-status filter, category filter,
collection filter, a nearby radius filter (haversine distance against the
device's last known currentLocation), a "hide locked" filter (a collection
with a productId the user hasn't purchased), then a sort
(default = collected-first-then-A–Z, name-asc/desc, nearest, recent).
Nothing here calls the network per keystroke; the search input is merely
debounced 180ms before it re-runs this in-memory pipeline
(PatchBrowserScreenV2.tsx:650-657).
Search mode UI (SearchPanel.tsx) has three states: Recent (last 5
submitted queries, held in plain React useState — not persisted to MMKV
or any store, so it resets to empty on every fresh mount of the browser
screen, e.g. navigating away and back), Popular nearby (the 5 nearest
uncollected patches with a known distance, sorted by distance —
buildBrowserPopularNearby, PatchBrowserScreenV2.tsx:323-341), and live Results (substring match
against patch name + collection name, capped at 8 —
buildBrowserSuggestions, PatchBrowserScreenV2.tsx:300-321). There is no full-text search and no
server-backed autocomplete; "recents" and "results" are both derived
entirely from the already-downloaded catalog.
Filter sheet vs. filter pills. The FilterSheet modal
(FilterSheet.tsx) only exposes Sort, Status (Collected/Uncollected),
Nearby toggle, and a Within-radius slider (5–500 km). It has no UI for
picking a category or collection — those two filters are only ever set by
the caller (navigation params) and surface afterward as removable pills in
the top nav (buildBrowserPills, PatchBrowserScreenV2.tsx:241-298). The sheet also runs a
live "N patches" count on its Apply button by re-running usePatchBrowser
against a draft copy of the filters before commit
(PatchBrowserScreenV2.tsx:632-636).
Patch detail load. usePatch(id) first looks for the id in the cached
catalog (the common path — instant, no network). If not found (e.g. an
admin-only patch reached via deep link, or a patch not yet synced), it falls
back to GET /api/sync/patch/:id, an unauthenticated single-patch endpoint
built specifically for this case (backend/src/sync/sync.controller.ts:25-31,
mobile/src/hooks/usePatches.ts:135-152). Both code paths run through the
same mapSyncPatch serializer on the backend so a deep-linked patch renders
identically to a synced one (backend/src/sync/sync.service.ts:20-23,84).
Tab dispatch. useVisibleTabs computes the tab set from the patch object
alone: Overview and History always; the dynamic type tab only if
hasRedesignedTypeTab(patch) (currently battlefield-only); Gallery and
Community always; My Visit only if the device has ≥1 local visit photo for
this patch (mobile/src/components/patch-detail-v2/useVisibleTabs.ts). Tab
bodies live in a single ScrollView (except My Visit, which owns its own
FlashList because a whole-library camera-roll import can drop hundreds of
matched photos on one patch — mounting that many inside the shared
ScrollView was previously the slowest part of the screen). Switching tabs
resets scroll to top (PatchDetailScreen.tsx:71-73).
Data model (Prisma models and key fields)
backend/prisma/schema.prisma.
Patch (schema.prisma:88-241, table patches) — the core row for one
collectible location:
- Identity/media:
id,name,description,patchUrl,patchNoNameUrl,patchImageFlat(stitching-removed art for the embroidery pipeline — never rendered in the app),patchBlurhash,locationImageUrl+locationImageBlurhash+locationImageCredit/locationImageLicense/locationImageSourceUrl(hero photo attribution, required because most Wikimedia originals are CC-BY-SA). - Location:
latitude,longitude(Decimal(10,7)),protectedAreaId,geofenceId(unlock geometry — owned by the map/geofence feature, out of scope here). - Typing:
collectionType(a bare slug likepark,battlefield,city— matchesPatchType.slug), plus 35 separate nullable*Data Json?columns, one per type (battlefieldData,parkData,monumentData,forestData,preserveData,seashoreData,riverData,recreationAreaData,trailData,naturalFeatureData,memorialData,historicSiteData,historicalParkData,heritageAreaData,stateData,cityData,museumData,stadiumData,attractionData,hauntedData,route66Data,scenicBywayData,landmarkData,zooData,aquariumData,gardenData,cemeteryData,wildWestData,bridgeData,lighthouseData,theaterVenueData,religiousSiteData,districtData,marketData,capitolData,observationTowerData,themeParkData,urbanParkData,roadsideAmericanaData) —schema.prisma:169-207. A patch can have more than one populated; a fixed priority order (TAB_NAME_BY_DATA_KEY,mobile/src/domain/patch-type-data.ts:19-59) decides which one "wins" for the tab label when several are set. - Enrichment/history text:
establishedDate,historicalPeriod,historicalSignificance,keyEvents(Json,string[]),notableFigures(Json,string[]),tldr(feeds the Overview "Why Visit" pull-quote),shortDescription,city,state. - NPS-specific:
npsId,parkCode,npsUrl,designation,entranceFees,operatingHours,directionsInfo/directionsUrl,weatherInfo,activities/topics(String[]),npsImages(Json — feeds the Gallery tab's "Official photos" carousel and the hero pager). - Store:
productId,productHandle,storeVisible. - Status flags:
verified,verifiedAt,aiValidated. - Search:
searchInterest(Json),interestScore,interestCheckedAt— owned by thekeyword-researchskill, not the browser/detail UI. - Relations used by this feature:
collections: PatchCollection[],userPatches: UserPatch[],recommendations: PatchRecommendation[],posts: Post[],photos: PatchPhoto[]. - Deliberately not in the Prisma model: a generated PostGIS
geomcolumn used only by raw$queryRawspatial queries (documented atschema.prisma:225-238).
PatchCollection (schema.prisma:247-258, table patch_collections) —
join table, (patchId, collectionId) unique. One patch can be in several
collections; the browser's "group by collection" mode
(buildBrowserSections in PatchBrowserScreenV2.tsx:400-486) uses only the
first collection id found for a patch as its "primary" bucket when
grouping the all-patches view.
PatchType (schema.prisma:260-272, table patch_types) — slug
(unique), label, icon, bgColor, iconColor, sortOrder. Seeded from
INITIAL_TYPES in backend/prisma/seed-patch-types.ts (destructive seed —
prunes any row not in the list; see CLAUDE.md). This catalog is not used
by the browse/search/filter UI at all — the FilterSheet has no
type-based filter, and usePatchTypes/usePatchType are not imported by
any file under patch-browser-v2/ or patch-detail-v2/. It exists for the
map/markers and admin, outside this feature's scope. What the detail screen
does use is collectionType directly (as a raw string, title-cased for
the hero's "type" stat — HeroSwiper.tsx:52-57).
PatchRecommendation (schema.prisma:275-287, table
patch_recommendations) — (patchId, userId) unique. Backs the heart
("scout rec") button on the hero and its count
(backend/src/patch-recommendations/).
UserPatch (schema.prisma:785-806, table user_patches) — the
"collected" record: (userId, patchId) unique, collectedAt,
latitude/longitude (where it was collected), synced, and source
('gps' | 'import' | 'unknown', default 'unknown' — only 'gps' qualifies
for achievements). This is the only row a catalog "Remove from
Collection" action deletes (see below).
PatchPhoto (schema.prisma:405-430, table patch_photos) — community
Gallery-tab uploads: patchId, userId, url, blurhash, sourceHash
(dedupes a photo already published from the user's own device library).
Unique on (patchId, userId, sourceHash).
API surface (endpoints, auth requirements)
| Method & path | Auth | Used for |
|---|---|---|
GET /api/sync/content?since= |
None (optional bearer unlocks admin-only rows) | Full catalog: patches, collections, campaigns, categories, patch types (backend/src/sync/sync.controller.ts:17-23) |
GET /api/sync/patch/:id |
None | Single-patch fallback for a deep link not in the synced catalog; 404 if the id doesn't exist (sync.controller.ts:25-31) |
POST /api/sync/push |
JwtAuthGuard |
Pushes queued collects (patches[]) and queued removals (deletedPatchIds[]) — see below |
GET /patches/:patchId/recommendations |
OptionalJwtAuthGuard |
Recommendation count + whether the viewer has recommended |
POST /patches/:patchId/recommendations |
JwtAuthGuard |
Add a "scout rec" |
DELETE /patches/:patchId/recommendations |
JwtAuthGuard |
Remove a "scout rec" |
GET /api/patch/:id |
None | A different, narrower endpoint for the QR-code physical-patch discovery web screen (backend/src/public-patch/public-patch.controller.ts) — returns only id/name/art/city/state/collection-name/productHandle. Not used by the in-app browser or detail screen; documented here only because it lives under a similarly-named module. |
POST /api/sync/push deserves detail because it is exactly what "Remove
from Collection" calls: the mobile client never calls a dedicated
"uncollect" endpoint. Locally, uncollectPatch(patchId) in the Zustand
store only rewrites two local queues (pendingSync, pendingDeletes) — it
does not touch userPatches directly
(mobile/src/domain/store.ts:1614-1634). On the next sync push, the
patch id travels in deletedPatchIds, and the backend does exactly one
write:
// backend/src/sync/sync.service.ts:574-579
if (data.deletedPatchIds?.length) {
await this.prisma.userPatch.deleteMany({
where: { userId, patchId: { in: data.deletedPatchIds } },
});
synced.deleted = [...data.deletedPatchIds];
}
That is the entire server-side effect of removing a wrongly-awarded patch — see "What this feature does NOT do" below for what it deliberately leaves alone.
Key files (annotated path:line list)
Browser / catalog
mobile/app/browse-patches.tsx— the one route that mounts the browser as a modal, parsescollectionId/categoryId/status/searchquery params.mobile/src/components/patch-browser-v2/PatchBrowserScreenV2.tsx— the whole screen, split the house way:PatchBrowserScreenViewModel(:548),PatchBrowserScreenViewModelImpl(:598, every hook, taking the route's props as inputs) and the purePatchBrowserScreenLayout(:834). The derivations are exported pure functions so the screen mock reuses them rather than copying them:browserHero(:162),buildBrowserPills(:241),buildBrowserSuggestions(:300),buildBrowserPopularNearby(:323),buildBrowserSections(:400),buildBrowserRows(:488, the card-pair virtualization) andbrowserStatus(:528).PatchBrowserStatus(PatchBrowserScreenV2.tsx:526) — the body discriminant:fonts-loading|results|no-results|empty-catalog. It is what keeps "your search matched nothing" apart from "the catalog is empty"; before it, both drew a hero over blank space and the browser had no empty body at all (BrowserEmpty,:975). It is deliberately NOT a loading/error discriminant — see the known limit below.mobile/src/dev/mocks/patch-browser-screen-v2.tsx— the browser's twelve screen-mock states (scout://dev-screen-mock/patch-browser-screen-v2), seeded from 36 real catalog rows around Boston, includingno-results,empty-catalog,unbucketedand the caller-lessnearbyframing.mobile/src/components/patch-browser-v2/makeInitialFilters.ts— decides the entry-point default filters (e.g. auto-nearby only when neither status nor search intent was passed).mobile/src/hooks/usePatchBrowser.ts— the entire filter/sort/stat pipeline (lines 52-215), all client-side.mobile/src/components/patch-browser-v2/FilterSheet.tsx— Sort/Status/Nearby/Within UI only; no category/type picker.mobile/src/components/patch-browser-v2/SearchPanel.tsx— Recents (unpersisteduseState)/Popular nearby/live Results.mobile/src/components/patch-browser-v2/PatchCard.tsx— 3 visual states:collected,nearby-uncollected(within 805m,PatchBrowserScreenV2.tsx:45),far-uncollected.mobile/app/collected-patches.tsx— the separate "Trophy Case" screen (own grid, own progress bars), split view-model/layout;progressPercent(:102) is the single zero-guarded percentage helper all three of its bars use.mobile/src/dev/mocks/collected-patches.tsx— the Trophy Case's ten screen-mock states (scout://dev-screen-mock/collected-patches), includingstale-countandover-counted.
Detail
mobile/src/screens/PatchModalScreen.tsx— route glue: loads the patch, wires the action sheet, dev sheet (admin-only), check-in/uncollect handlers. Split view-model/layout:PatchModalScreenViewModelImplowns every hook (includinguseLocalSearchParams),PatchModalScreenLayoutis pure, and the exportedbuildPatchModalScreenDatais the single place loading / "Patch unavailable" / ready is decided.mobile/app/patch-modal/[id].tsx— thescout://patch-modal/<id>route, now a one-line re-export of the screen above.mobile/src/dev/mocks/patch-modal.tsx— the detail screen's twelve screen-mock states (scout://dev-screen-mock/patch-modal), including the Battle-tab-bearing patch, a patch with no*Dataat all, a patch with no location photo, the open action sheet collected vs. uncollected, the admin gear, and "Patch unavailable".mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx— tab host and the type-tab dispatch branch (129-146).mobile/src/components/patch-detail-v2/useVisibleTabs.ts— the actual gate for which tabs render; the ground truth for "Battle only" (lines 20-40).mobile/src/domain/patch-type-data.ts—TAB_NAME_BY_DATA_KEY(tab-label priority order),REDESIGNED_TYPE_TAB_KEYS(the shipping gate, line 76),hasRedesignedTypeTab.mobile/src/components/patch-detail-v2/tabs/types/dispatch.ts+tabs/types/index.ts— the 26-type (+2 handled elsewhere = 28 of 35*Datacolumns covered) dispatch table; fully built, mostly unreachable per the gate above.mobile/src/components/patch-detail-v2/HeroSwiper.tsx— image pager, stat strip, favourite/recommend heart, "Navigate there" CTA, compass button.mobile/src/components/patch-detail-v2/useHeroImages.ts— hero image ordering: location image → NPS official photos → community photos, deduped by URL, capped at 10.mobile/src/components/patch-detail-v2/tabs/OverviewTabV2.tsx— Why Visit pull-quote +CollectionRowlist (per-collection progress).mobile/src/components/patch-detail-v2/components/CollectionRow.tsx— the per-collection progress header row shown inside Overview.mobile/src/components/patch-detail-v2/tabs/HistoryTabV2.tsx— narrative + fact strip + adaptive chronology (timeline if ≥2 distinct years, else bullet "Quick facts").mobile/src/components/patch-detail-v2/tabs/CommunityTab.tsx— inlines the per-patch community board (post feed + composer), out of scope for deep coverage here.mobile/src/components/patch-detail-v2/tabs/GalleryTabV2.tsx— official (NPS) photo carousel + community photo grid + upload; "expand" routes to/patch-gallery/[id].mobile/src/components/patch-detail-v2/tabs/MyVisitTabV2.tsx— device-local camera-roll photos matched to this patch; only mounted when count > 0.mobile/src/components/patch-detail-v2/BattleTab.tsx/HauntingTab.tsx— the two "special" type tabs kept outside the generic dispatch table; only Battle is currently reachable.mobile/src/components/patches-v2/PatchActionSheet.tsx— the "more" sheet: Show Patch, Purchase Patch (flagged), Check In (city/state only), Get Directions, Send Feedback, Remove from Collection.mobile/src/hooks/useUncollectPatch.ts+mobile/src/domain/store.ts:1614-1634— the local "remove" mechanics (queue rewrite, no directuserPatcheswrite).mobile/src/domain/purchaseGate.ts—shouldOfferPurchase(collected + productHandle + flag).mobile/src/hooks/useVerifyLocation.ts:100-102,166-200—supportsCheckIn(city/state only) and the name-match check-in logic.
Backend
backend/src/sync/sync.service.ts—getContent(catalog, admin-only stripping at 196-225),getPatchById,push(574-579 is the "remove" delete).backend/src/patch-recommendations/— recommendation count/add/remove.backend/src/public-patch/— a different, narrower QR-discovery endpoint; not used by the in-app catalog/detail screens.backend/prisma/seed-patch-types.ts—INITIAL_TYPES, the patch-type catalog (unused by this feature's filter UI).
Configuration and flags
buy_patch—defaultEnabled: false(backend/src/admin/feature-flag-definitions.ts:32-39), but ON in production: the DB row overrides the registry default (verified live on 2026-09-13 viaGET https://scout-patches.com/api/feature-flags). Gates the "Purchase Patch" row in the action sheet. Defined in both the backend registry and the mobile mirror registry per CLAUDE.md's "feature flags need BOTH registries" rule. Read the default as the offline fallback, not as what users see.REDESIGNED_TYPE_TAB_KEYS(mobile/src/domain/patch-type-data.ts:76) is not a runtime feature flag (no admin toggle, no A/B) — it's a hard-coded array in source. Currently['battlefieldData']. Changing which type tabs are visible requires a code change and a build, not a flag flip.- No environment variable or remote config gates catalog search, filters, or the detail tabs beyond the two items above.
Edge cases and known limits
- The browser has no loading state and no error state.
usePatchBrowserreturnsisLoading(wired only to the pull-to-refresh spinner,PatchBrowserScreenV2.tsx:896-902) and never surfaces the content query'serrorat all. So a cold open with nothing cached, and a content sync that outright fails, both render the same thing an empty catalog does: the "No patches here yet" body. The copy is hedged ("The catalog arrives with the next content sync") precisely because it is shown in three situations the screen cannot tell apart. The fix is a page-loader skeleton per the CLAUDE.md async-loading rule plus a distinct error body; neither exists yet. - The two empty bodies are the only empty UI, and they arrived late.
Until the view-model split the browser rendered a hero over blank space
whatever the reason — a missed search read as an empty app.
browserStatusnow asks whether anything is scoping the view (a query, a pill, a status, a radius) before choosing between "Nothing matched that" and "No patches here yet". The SEARCH PANEL has always had its own separate no-match block (SearchPanel.tsx:86-102), which is why the gap was easy to miss: it only shows while the overlay is open. - The search panel flashes "No patches match" for one debounce. The
panel's
valueis the immediatesearchTextwhile itsliveResultsare computed from the debouncedfilters.search(PatchBrowserScreenV2.tsx:650-657), so for the first ~180ms after typing begins the panel decides it is in results mode with zero results. - Search has no fuzzy matching, no ranking beyond "first N matches
found," and searches only two fields (
name,descriptionfor the filter pipeline;name+ collection name for the live search suggestions). A misspelling returns nothing; there is no "did you mean." - "Recent" searches are not persisted. They live in a component-local
useState([])(PatchBrowserScreenV2.tsx:640) that resets to empty every time the browser screen unmounts and remounts (e.g. closing the modal and reopening it), unlike most persisted app state (which goes through MMKV). - "Popular nearby" is not actually popularity-ranked — it's the 5
nearest uncollected patches with a known distance, sorted purely by
distance (
buildBrowserPopularNearby,PatchBrowserScreenV2.tsx:323-341). No click-through or collection-count signal feeds it. - The Filter sheet cannot filter by category or collection — those filters only arrive as navigation params from another screen and show up as a removable pill; a user cannot open the sheet and pick "Show me only National Parks" from a blank state.
- Collection grouping picks a single "primary" collection per patch —
when a patch belongs to more than one collection, the all-patches grouped
view buckets it under whichever collection id happens to come first in
the
patchCollectionsjoin array (buildSections,PatchBrowserScreenV2.tsx:437-462); it does not appear under its other collections in that view. - 32 of 33 type-specific detail tabs never render, despite complete
data and complete components — see Status above. A patch whose only
*Datais, say,zooDatashows Overview/History/Gallery/Community and nothing zoo-specific in the tab bar. hauntedData→HauntingTabis effectively dead code under current gating: the branch exists inPatchDetailScreen.tsxbut the'type'tab that would trigger it is never added to the visible tab list for a haunted-only patch.- Tapping a collected card while browsing in "collected" status mode
jumps straight to the celebration screen, not the patch detail —
handlePatchPressspecial-casesfilters.collectionStatus === 'collected'(PatchBrowserScreenV2.tsx:676-689). This applies only inside the browser, not on the Trophy Case screen (collected-patches.tsx), which always openspatch-modal/<id>. - A collected patch that has no matching
patchCollectionsrow (an orphaned collect) is silently dropped from the Trophy Case's sections, even though it still counts toward the header's rawtotalCollectednumber — the screen explicitly gates its "empty" state onsections.lengthrather thantotalCollectedfor exactly this reason (comment atmobile/app/collected-patches.tsx:505-515). - Check-in name-matching is exact-lowercased-string equality between the
reverse-geocoded city/state name and the patch's
namefield (useVerifyLocation.ts:166-174) — a patch named "Saint Louis" won't check-in a user whose reverse-geocode returns "St. Louis." - The hero image pager auto-advances every 5 seconds
(
REEL_AUTO_ADVANCE_MS,mobile/src/hooks/usePagedReel.ts:15), pausing while the user is actively dragging and restarting its countdown after a manual swipe or dot tap (usePagedReel.ts:33-57). This is shared plumbing also used by The Album's spotlight hero, not patch-detail-specific code.
What this feature does NOT do
- Removing a wrongly-awarded patch ("Remove from Collection") deletes
exactly one row: the
user_patchesrow for that(userId, patchId)pair. It does not delete the user'sPatchPhotouploads for that patch, does not delete anyPost/community content tied to it, does not revoke anyUserAchievementthe collect may have contributed to (no revoke call exists anywhere in the push/delete path —backend/src/sync/sync.service.ts:574-579is the entire effect), and does not touch the patch's own catalog row. The user can re-collect it later with no history of the removal anywhere but analytics (analytics.trackPatchUncollected). - The browser's "Near Me" mode is not reachable.
PatchBrowserScreenV2still accepts anearbyprop that reframes the hero as "Within X mi / Nearby Patches" and hides the Nearby pill, but no route passes it — Near Me is its own screen (mobile/app/(drawer)/near-me.tsx, built onnear-me-v3). Any description of "the Near Me screen" is describing that screen, not this one. - The catalog browser does not run a server-side search. Every search,
filter, and sort happens against the full catalog already downloaded to
the device; there is no
/searchendpoint, no full-text index, no server-side pagination. - Only one type-specific detail tab is user-visible today: Battle. Do not describe the app as having "rich, type-specific detail pages for parks, zoos, museums, lighthouses, etc." in marketing copy — those components exist in the codebase but are not reachable by any user action.
- Purchase-from-patch-detail does not buy anything in the app. The
"Purchase Patch" action is now ON in production — it was off for a long time
and the hardcoded default is still
false, but the production flag row enables it (verified live on 2026-09-13 viaGET https://scout-patches.com/api/feature-flags) — so it does appear for ordinary users. What it does is open the store WebView (scout://store-webview); there is no in-app purchase, no StoreKit, no Play Billing behind it. It is still gated: a client that cannot reachGET /api/feature-flagsfalls back to the registry default and hides the row. - Manual "Check In" does not work for parks, monuments, museums, or any non-city/state patch — those patches unlock only through the automatic polygon-containment path (location tracking or photo import), which is outside this feature (owned by the map/geofence feature).
- There is no in-app "recently viewed" or view-history feature for the
catalog — patch-view analytics are tracked (
analytics.trackPatchViewed) but never surfaced back to the user as a list. - The catalog browser does not show admin-only collections or their
patches to a regular signed-in user — those are stripped server-side in
getContentregardless of what the client requests. PatchType/patch-type slugs are not a user-facing filter. They back the map's iconography and admin tooling, not anything in this browser or detail screen.
Tests that cover it
mobile/screen-tests/browse-patches.test.tsx— renders a card per catalog patch; renders no card with an empty catalog (positive/negative pair); and the two empty bodies, pinned apart: acollectedscope no patch satisfies renderspatch-browser-no-resultsand NOTpatch-browser-empty, while an unscoped empty catalog renders the reverse. (Verified by falsification — collapsingbrowserStatusto always returnempty-catalogfails the first of those two by name.)mobile/screen-tests/collected-patches.test.tsx— badge-per-collected-patch; no badges when nothing collected; empty state (not a blank list) for an orphaned collected-patch-with-no-collection-join; sane 0% progress bar when a collection'spatchCountis 0.mobile/screen-tests/screen-mocks.test.tsx— renders every patch-browser mock state (twelve, covering the grouped catalog, one collection, collected- only, a three-pill filtered view, the caller-lessnearbyframing, the search panel, search results, one result, no results, an empty catalog, an unbucketed "Other" section and the filter sheet), every Trophy Case mock state, which is what covers its loading, error and stale-count branches, and every patch-detail mock state, which is what covers the type-tab gate, the no-hero-photo fallback, the action sheet's collected/uncollected rows, the admin gear, the check-in error modal and the busy scrim.mobile/screen-tests/patch-modal-id.test.tsx— renders detail for a store-resolved patch; renders "Patch unavailable" for an id that resolves nowhere; hero eyebrow omitted when city/state both null; check-in offered for an uncollected city patch but not for an uncollected landmark patch; My Visit badge state (already-published vs. nothing-published vs. published-photo-belongs-to-a-different-patch).mobile/screen-tests/patch-gallery-id.test.tsx— covers the full-screen gallery route reached from the Gallery tab's expand icon (adjacent screen, not itself part of this doc's core scope).mobile/src/components/patch-detail-v2/tabs/__tests__/dispatch.test.ts— unit testsgetTypeTabKey's priority ordering and confirmsbattlefieldData/hauntedDataare deliberately excluded from the generic dispatch (they returnnull, routed toBattleTab/HauntingTabinstead) — this is the test that best documents the dispatch-table behavior, though it does not testuseVisibleTabs's separate gate that makes most of that dispatch table unreachable.mobile/src/components/patch-detail-v2/tabs/__tests__/GalleryTabV2.test.tsx,HistoryTabV2.test.tsx— tab-level rendering tests.mobile/src/components/patch-detail-v2/__tests__/useHeroImages.test.ts— hero image ordering/dedup.mobile/src/components/patch-detail-v2/tabs/_helpers/__tests__/myVisitGrid.test.ts,galleryHelpers.test.ts— pure helper coverage for grid layout / photo date formatting.
No test was found that specifically exercises useVisibleTabs's
hasRedesignedTypeTab gate (i.e., asserting that a zooData-only patch
shows no type tab) — this is a gap; the gate's behavior is currently only
documented by the source comments cited above, not proven by a test in this
tree.
Open questions
- Whether
REDESIGNED_TYPE_TAB_KEYShas a near-term rollout plan (e.g. a ticket to add more types) was not discoverable from code alone — treat "Battle only" as current, not as a permanent ceiling, when writing anything time-sensitive. - Whether
hauntedDatapatches (which exist in the catalog perINITIAL_TYPES'shauntedslug) are meaningfully common enough that the deadHauntingTabbranch represents lost UI for a real number of users, versus a handful of patches, was not determined (would require a DB query, out of bounds for this pass).