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", "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)

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-meNearMeScreenLayout (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].tsxmobile/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:

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

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