Scout — Full Product Context → feature documentation

The patch catalog and patch detail

Scout's entire patch catalog — every collectible location — lives behind one screen component, PatchBrowserScreenV2…

Screen recordings

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

Collected patches 3.7s · recorded 2026-09-09
Your collection 8.9s · recorded 2026-09-09
Badge unlocked 4.5s · recorded 2026-09-09
Collected / uncollected 7.3s · recorded 2026-09-09

Full sessions

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

Full recording · 09-09 13:14 37.5s · recorded 2026-09-09 · unedited

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" until the drawer got its own in-place search on 2026-10-14, "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 "search" screen — it is filter state on one component. "My Patches" has had a screen of its own since 2026-10-09 (my-patches, behind my_patches_screen, default ON); with that flag off, My Patches falls back to this browser filtered to collected. 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], which mounts one of two detail screens on the new_patch_detail_screen flag (default ON, mobile/src/screens/PatchModalScreen.tsx:184, :444):

The previous screen is a tabbed detail view: Overview (always shown), History, an optional per-type tab, Gallery, Community, and a conditional My Visit tab. The hero image pager heads Overview only. The Community tab is the patch's board, so it opens exactly like the board screen (2026-09-28): the shared BoardBanner (mobile/src/components/community/BoardBanner.tsx) — the patch photo full-bleed with a BOARD eyebrow and the name, then "N scouts recommend it" — under floating back and bookmark buttons, with no stats row or Navigate button. Every other tab gets the compact mini header (back + name). Pinned by the paired patch-modal-id.test.tsx cases (board banner on Community, mini header on Gallery). Its Gallery tab (GalleryTabV2) is still the old public PatchPhoto gallery — the official photos, the scout-photo grid and the "Add a photo" upload, through usePatchPhotos and useGalleryActions (mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:43-51, :140-150) — and its hero pager still mixes in those gallery photos (the whole hero set is capped at ten, useHeroImages.ts:46). It was deliberately left that way when the new screen's gallery became Scout posts (2026-10-11, amended spec): those pieces go when the previous screen is retired. Its My Visit tab is the shared MyVisitTabV2, so it offers Post like the new screen.

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:77 — gates which of them the tab bar will ever show. Today that list holds two entries: battlefieldData and cemeteryData. So only "Battle" (military battlefields) and "Graves" (a cemetery with notable burials, new screen only) get a visible type-specific tab, and each has its own redesigned body rather than the old v2 component. The other 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)

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

Entry point Route What it pre-sets
Drawer → the search field — No longer an entry (2026-10-14). The drawer's field is a real input that searches in place (see navigation-drawer). ?search=1 (openWithSearch) still works as a deep link, but nothing in the app opens it
Profile → "See all" / "Browse all" (Collected section), the drawer's My Patches row, Home's Patches door scout://my-patches with my_patches_screen on (my-patches); with it off, scout://browse-patches?status=collected (one rule, myPatchesHref, mobile/src/components/my-patches/myPatchesHref.ts) Flag off: 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
The old Trophy Case route scout://collected-patches (mobile/app/collected-patches.tsx) A redirect to My Patches since 2026-10-09; the Trophy Case screen is gone
Gallery tab → expand icon scout://patch-gallery/<patchId> Full-screen photo gallery (separate screen, not detailed here)

The Trophy Case — a second, independently built screen for collected patches at /collected-patches, grouped by collection with progress bars — was retired on 2026-10-09. Its route now redirects to My Patches (my-patches), which draws collected patches in the app's shared patch grid with sort and filter.

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 always; Plan right after it, only when the screen passes plan (useHasVisitInfo: the synced visitInfoIds lists the patch, PatchDetailScreenV3.tsx:73-74; useVisibleTabs.ts:59-67; it is not derived from the patch object); 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); then Shop (a cart icon), last, only when the patch is collected AND has a productHandle AND patch art (patchUrl) (shouldOfferPurchase). Shop is a button, not a body: both screens intercept it and leave the active tab unchanged. With buy_patch on it pushes /store-webview onto /app/patch/<handle>. With it off (production today; in store builds since v1.1.117) the tab still shows and opens the paused store's "would you buy this?" sheet (store-interest.md) (useVisibleTabs.ts:56-58, PatchDetailScreenV3.tsx:84-93; see patch-shop.md). 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:

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' | 'manual' | 'admin' | 'unknown', default 'unknown' — only 'gps', a live in-app unlock, qualifies for achievements). This is the only row a catalog "Remove from Collection" action deletes (see below).

PatchVisitInfo (schema.prisma:588-608, table patch_visit_info) — one researched visiting-info record per patch (hours, closures, tickets, cost, best time, "know before you go", sources, each dated by checked_on), keyed on patch_id (unique, cascades with the patch; Patch.visitInfo, schema.prisma:243). Content: authored locally and published to prod. Feeds the Plan tab; see plan-tab and visit-info-pipeline. The live NPS alert mirror NpsAlert (nps_alerts) is prod-only and not part of the patch row.

PatchPhoto (schema.prisma:517, table patch_photos) — the old 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). Retired from the new screen (2026-10-11, shipped in v1.1.106), not deleted: the new screen's Gallery and hero read board posts instead (Post, with its images and, since 2026-10-11, imageSourceHashes — see community-and-moderation); the previous screen, the /patches/:id/photos endpoints and builds already in the field still use it.

API surface (endpoints, auth requirements)

Method & path Auth Used for
GET /api/sync/patch/:id/visit-info None The Plan tab's record plus the park's live NPS alerts; 404 for no published record or a patch the public cannot see (backend/src/visit-info/visit-info.controller.ts:10). No feature flag; see plan-tab
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

Detail

Backend

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

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