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 new screen (flag on, what users see) —
PatchDetailScreenV3(mobile/src/components/patch-detail-v3/PatchDetailScreenV3.tsx), option A "Field photo" ofmobile/research/patch-detail-lab.html(picked on review 2026-10-08, replacing the option D layout first merged in #214). The location photo is the hero on every tab. On Overview it is a full-bleed pager (it swipes and auto-advances, with no dots). Since 2026-10-09 Overview's hero is arranged as Home's hero (HuntHero), shorter and over the patch's own photos: the patch art centred over the photo, as it is — no ring, circle or frame round it (a patch with no art stands its Clasp pin there instead — its place photo in a glass map pin in its campaign colour, the campaign's icon in the clasp; patch-art-placeholder) — with Home's idle float (still under Reduce Motion), then, all centred, the campaign's motif and its line in the campaign accent ("Ghost Adventures", worded byringKickeras Home and The Ring word it; none for a patch in no campaign), the name, the place ("San Jose · CA", a quiet line, no longer the gold uppercase eyebrow), the rarity and Home's own facts (heroFacts: "4.5 mi E", "7 min drive", "Haunted" — only the type without a location fix), the "Collected <date>" stamp, then Navigate + compass. ReusingheroFactsmeans the drive time matches the Home hero you tapped in from; the photo is dimmed through the middle like Home's backdrop. The art ignores touches, so a swipe anywhere on the photo still pages it. The hero's photo credit follows the photo the pager is showing. Every other tab opens on a shorter band of the same photo (its scrim darkening from 45% down so the title over it reads — ~0.76 dark where it starts, 2026-10-09) with the patch art, the tab's name and the patch name. The two swap on a tab change — no animation (a morph between them was tried and removed on review, 2026-10-08) and no fade-in on the photo (a fade on a remounted photo read as a flash). Navigation is the floating tab bar at the bottom — A's, which is today'sFloatingTabBar(patch-detail-v2/FloatingTabBar.tsx), reused, on My Visit too. Since 2026-10-09 it is clear glass —Scout.GlassSurface, the same Liquid Glass as the top bar's capsules and the nav buttons (see design-system) — with the content scrolling under it; it was an opaque dark pill over a dark fade. The previous screen shares the component, so it is glass there too. With seven tabs (battlefield + My Visit + Shop) the selected tab is icon-only so Shop fits a 375pt phone (COMPACT_AT,FloatingTabBar.tsx:43; decision 2026-10-11); six or fewer keep the label. Overview ends with a "More photos" strip — every hero photo after the first — and tapping one opens the lightbox (/patch-gallery/<id>) on the whole set at that photo (useOpenPatchLightbox, shared with Gallery). Scouts' photos are board posts (Post from My Visit, built 2026-10-11, not yet in a store release — see Status): the hero pager and "More photos" take their scout photos from the patch board's posts that have photos, credited@handle(postScoutImages,PatchDetailScreenV3.tsx:59-67), where they used to take thePatchPhotogallery's photos. The hero set is still the location photo, then the official photos, then these, deduped and capped at ten (mobile/src/components/patch-detail-v2/useHeroImages.ts). Those photos open as plain credited photos, with no post block. Until the location photo loads, the hero shows itslocationImageBlurhash(the place's colours) rather than black, with no fade-in, since a fade on a tab change read as a flash. The official and scout photos have no blurhash and show nothing until they load (2026-11-03). Gallery (GalleryBody,mobile/src/components/patch-detail-v3/Bodies.tsx:246) is the official photos plus Scout posts. It leads with the first official photo; the row under it scrolls through the other official photos, then every post photo — so a place with one official photo still has a row (2026-10-08). Then Scout posts · N: the board's posts that have at least one photo, newest first, as two-up 4:5 post tiles (photo, title, who, votes, comments; a photo-only post has no title line;ScoutPosts.tsx:131-152), led by a dashed Add photos tile (gallery-add-photos,ScoutPosts.tsx:99-111) — alone when no post has a photo, with no placeholder art. Add photos (quick photo post, 2026-10-25, not yet in a store release) asks a guest to sign in, then opens the system photo picker (up to 10); the picks go up at once as ONE untitled post on the patch's board — no title, no description — with a "Posting N photos…" tile while they upload, then the new tile and a "Posted to the <Patch> board" toast for 3 s; a failure turns the lead tile into "Couldn't post / Tap to retry" (or the server's message). It replaced the New post tile, which opened the titled board composer; that composer is on the Community tab only now. See community-and-moderation. A tile opens the post in the lightbox with its byline, upvote and comments (see community-and-moderation). It reads the samepostsQuery(patchId, 'new')as the Community tab; a failed read with nothing cached shows "Couldn't load posts" with Try again. The old "Scout photos" grid and its "Add a photo" upload square are gone from this screen. The screen is black with its campaign's accent wherever it was brass (2026-10-09, the campaign page's and ring screens' treatment, no flag): the hero's campaign line, the band's tab-name eyebrow, the section labels, the active tab, the story block, the progress bars, the timeline, the collected stamp, the recommend heart and a flat accent Navigate button (AccentButton) in place of the gold one. One palette, provided once (patch-detail-v3/accent.ts), from the collection that dresses the screen — a themed campaign beats a city one (primaryCollection,detailModel.ts:29): 139 patches are in a city's challenge AND a themed campaign, and the Saint Louis Zoo wears the Zoos' green, not St. Louis's brass, whatever order its collections arrive in. A City patch, or one in no campaign, stays brass — brass is the city's accent. The Battle tab stays brass (its colours are the two armies, and History's red accent would read as the red side). The family's motif sits beside the place as before; nothing moves over the photo (the family's ember atmosphere was removed on review, 2026-10-08). Section labels, and the tab name over the band's photo, are the previous screen's uppercase eyebrow ("THE STORY"), and History reuses the previous screen's own story block (StorySection: that eyebrow plus the large italic drop cap, both now taking an optionalaccent, brass by default). History ends on the lab's quiet source line — book, "Source · Wikipedia", arrow, in the tertiary grey (SourceNote,Bodies.tsx) — not the previous screen's raisedSourceLinecard, which the previous screen and the type tabs keep. The same tab set and order as before (useVisibleTabs). Its Battle tab is redesigned too — see Status. - The previous screen (flag off, kept intact as the way back) —
PatchDetailScreen(mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx), described in the rest of this paragraph.
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)
- Catalog browser, filters, search, patch detail, hero pager, and the "more" action sheet: fully shipped, no feature flag.
- New patch detail screen (location-photo hero with the patch at its foot,
the floating glass tab bar, black with its campaign's accent): shipped behind
new_patch_detail_screen, default ON (mobile/src/config/feature-flags.ts:45,backend/src/admin/feature-flag-definitions.ts:48). Off falls back to the previous screen, kept intact. NoserverFlagsLoadedgate — the client default is also ON, so a cold start draws the new screen; only a server that turns the flag off swaps it. - Redesigned Battle tab (new screen only —
BattleBody,mobile/src/components/patch-detail-v3/BattleBody.tsx:102, design sourcemobile/research/battle-tab-lab.htmldirection 4): numbers first — the dateline and the winner, then the two armies head to head with each commander's round portrait, troops engaged, casualties and the share each lost as bars from the centre, and the total; then the outcome as a Synopsis, the battle day by day on a dashed spine ("Day 1, Day 2…" only when the days run back to back), and everyone named as swipeable portrait cards; a card whose figure has awikipediaUrlopens that article on tap, as the previous roster did (most do — 295 of 353 figures in prod on 2026-10-10; the rest are plain cards). Every battle's two sides are coloured (Union / American / US blue against Confederate / British / Mexican red,narrative.battle); the previousBattleTabcoloured only Union and Confederate and drew the 39 Revolutionary, 1812 and frontier battles' armies in the same brass.battleModel(mobile/src/components/patch-detail-v3/battleModel.ts:111) reads the inconsistent data —troops/strength,date/time, casualty keys that only loosely match the side names, and the winner out of the outcome prose (including "U.S." and "Lee's greatest tactical victory"). - Plan tab — live for the 64 National Parks, in store builds since v1.1.112; no feature flag
(shows when a record is published and the build has the tab).
New screen only (
PlanBody,mobile/src/components/patch-detail-v3/plan/PlanBody.tsx): visiting information (open now on the place's clock, entry, tickets, best months, time needed, things to know, a dated and linked footnote) for a patch with a publishedpatch_visit_inforecord. Records are published for the 64 National Parks; every other patch has none, so no tab. Full doc: plan-tab; the data pipeline is visit-info-pipeline. - Graves tab — built 2026-10-23, not yet released: it reaches users
with the next app release, and its portraits and credits only once the
backfilled
cemetery_datais published to prod (/admin/publish); until then a cemetery in prod shows a Graves tab of monograms on a new build. (New screen only —GravesBody,mobile/src/components/patch-detail-v3/GravesBody.tsx:109, design sourcemobile/research/graves-tab-lab.htmlE · Spotlight + Portraits, picked 2026-10-23 in round 3; it replaced D's headstone grid, which was built first): on a cemetery patch whosecemeteryData.notableBurialsis non-empty, the type tab reads Graves with the Graveyards family's headstone glyph in the tab bar (TYPE_TAB_ICON,mobile/src/domain/patch-type-data.ts:91; drawn byFloatingTabBar.tsx:119). The first burial is the spotlight (Spotlight,GravesBody.tsx:71) — one portrait, name, years · profession, the line on why they matter, Read on Wikipedia — then "Also here": everyone else as a swipeable rail of portrait cards (PortraitCard, line 94, on Battle'sCardRail,Rail.tsx), each with name, years · profession and their one line; then "Worth seeing" (architecturalNotes) and a source line ("Portraits · Wikipedia"). No headstone photos, though the data still carries P1442 headstones. A person with awikipediaUrlopens it on tap (WikiLink,Rail.tsx, shared with Battle's cards). Every portrait carries its own photo credit on it, in the hero's wording (formatAttribution: author · licence; public domain = the name alone) and style (CREDIT_TEXT,PhotoHeader.tsx:292); card credits may take two lines so the licence is never cut. A portrait that fails to load drops out to the monogram, credit and all. It wears the campaign's accent (Graveyards' sage), unlike Battle. The data comes frombackend/scripts/backfill-cemetery-burials.ts, which keeps only people with a credited portrait (Alan, 2026-10-23): it finds each person by asking Wikidata who is buried at this cemetery under that name (buriedHereQuery,backend/scripts/lib/cemetery-burials.ts), falls back to the name lookup, takes Wikidata's P18 portrait when Wikipedia's summary has none, and removes every name it can't give one — group lines ("Thousands of yellow-fever victims"), names with no Wikipedia article, people with no known free photo (Homer Plessy), and catalog entries naming the wrong cemetery. Thenbackend/scripts/topup-cemetery-burials.tstops up any cemetery listing fewer than 3 people to 5 from who Wikidata says is buried there (best-covered first, each with a portrait and an English article; the why line is the first sentence of the Wikipedia intro; an overrideranksets the lead — Gettysburg opens on Amos Humiston). Local run, 2026-10-23: 339 names → 286 kept → 314 after 28 added. Still under 3: Charter Street (1), Key West (2), Alton City (1); Alton's Confederate Cemetery has none and no Graves tab. The monogram card remains only as the fallback for data not yet backfilled and for a portrait that fails to load. - Per-type detail tab (Overview → the dynamic middle tab, e.g. "Explore",
"Wildlife", "Statehouse"): shipped for two types on the new screen —
battlefieldData→BattleBodyandcemeteryData→GravesBody(mobile/src/domain/patch-type-data.ts:77,mobile/src/components/patch-detail-v3/PatchDetailScreenV3.tsx:151-158) — and one on the previous screen (flag off):battlefieldData→BattleTab(PREVIOUS_SCREEN_TYPE_TAB_KEYS,patch-type-data.ts:83, passed atmobile/src/components/patch-detail-v2/PatchDetailScreen.tsx:53), so the previous screen's un-redesignedCemeteryTypeTabV2still never shows. 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:29-46).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: off in production
(
buy_patchreadfalseon 2026-10-11; it read ON on 2026-09-13). It now opens the per-patch store page/app/patch/<handle>(patchShopPath,mobile/src/config/store.ts:148;mobile/src/components/patches-v2/PatchActionSheet.tsx:64-70), not the store home. Seepatch-shop.md. Gated on thebuy_patchfeature flag,defaultEnabled: false(backend/src/admin/feature-flag-definitions.ts:88,mobile/src/config/feature-flags.ts:81-86). Description: "Keep off until we're ready to fulfill orders at volume." Shown only when the patch is collected AND has aproductHandleAND has patch art (patchUrl) AND the flag is on (mobile/src/domain/purchaseGate.ts:12-19). - "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:42-44) — most patches never show it. - Post from My Visit — built 2026-10-11 (
62082888), shipped in v1.1.106 (its backend column is deployed). On both screens, your own visit photo in the lightbox offers + Post to the patch's board in place of "Add to gallery", and the My Visit grid tags posted photos "Posted". On the new screen, Gallery's "Scout photos" became Scout posts and the hero / "More photos" read post photos. No feature flag; the previous screen keeps itsPatchPhotoGallery tab. Store builds before 1.1.106 still show the old gallery. Full mechanics: community-and-moderation. - Rarity badge (hero on Overview tab, pip on browse/Trophy-Case cards): live on
prod (the live catalog carries a tier on 3,536 of 3,553 patches, 2026-11-06).
No feature flag; a null
rarityrenders no badge. Full detail, including why the hero shows Common but the card pip does not, is documented separately in patch-rarity.
User-facing surfaces (screens, routes, scout:// deep links, entry points)
| Entry point | Route | What it pre-sets |
|---|---|---|
| — | 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:
- 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). The five are written together or not at all — a replaced photo never inherits the old one's credit. - 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. - Rarity:
rarity(nullable, one ofcommon/rare/epic/legendary),rarityNote,raritySource('suggested'/'confirmed', server-only — never reaches the app). See patch-rarity for the full feature; not covered further in this doc beyond where it renders. - 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' | '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
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— now a redirect to My Patches (the Trophy Case screen it used to be was retired 2026-10-09).
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-v3/— the new screen (flag on):PatchDetailScreenV3.tsx(tab host with today'sFloatingTabBar; My Visit owning its own list; the Battle body only for a battlefield, kept brass),accent.ts(the campaign palette as a context,usePatchAccent; brass outside a provider),PhotoHeader.tsx(PhotoHero,PhotoBand— the photo, the glass chrome with today's testIDs — plus, on a patch with no art, a last filled-accent alert button that opens the "patch in the works" sheet,app/patch-art-pending/[id].tsx; see patch-art-placeholder),artPending.ts(that sheet's words),Bodies.tsx(Overview / History / Gallery / Community bodies),BattleBody.tsx,Spine.tsx(History's year timeline, Battle's day spine), and two pure, unit-tested modules:battleModel.tsanddetailModel.ts(place line, type label, the dressing collection,historyTimeline:45which joins a date range's end year to its anchor — "1934–63").usePatchDetailData.tsderives collections + progress, the header's family and campaign line, Home's facts (heroFacts) and the nearest uncollected patches of the dressing collection.mobile/src/components/patch-detail-v2/useGalleryActions.ts— add a photo / open scout or official photos in the lightbox for the previous screen's Gallery tab; the new screen uses only itsuseOpenPatchLightbox.mobile/src/components/patch-detail-v3/ScoutPosts.tsx— the new screen's "Scout posts": the Add photos lead tile (quick photo post, 2026-10-25; it replaced the New post tile) and the two-up post tiles (2026-10-11).mobile/src/domain/visitPost.ts—postsWithImages(the Gallery filter) andpostScoutImages(post photos for the hero and "More photos").mobile/src/components/patch-detail-v2/PatchDetailScreen.tsx— the PREVIOUS screen (flag off): 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 (buildVisibleTabs, line 51; itstypeTabKeyspicks the new screen's or the previous screen's list, and a type tab takes itsiconfromTYPE_TAB_ICON).mobile/src/domain/patch-type-data.ts—TAB_NAME_BY_DATA_KEY(tab-label priority order;cemeteryData→ "Graves", line 33),REDESIGNED_TYPE_TAB_KEYS(the new screen's gate, line 77),PREVIOUS_SCREEN_TYPE_TAB_KEYS(Battle only, line 83),TYPE_TAB_HAS_CONTENT(a cemetery needs at least one burial, line 86),TYPE_TAB_ICON(line 91),redesignedTypeTabKey/hasRedesignedTypeTab.mobile/src/components/patch-detail-v3/GravesBody.tsx— the Graves tab:Spotlight(71),PortraitCard(94),useFace(36, the image-error fallback), each portrait'sCredit.gravesModel.ts(63) is its pure, unit-tested model (lead/rest,formatYearsat 38, monograms, the credit line viaformatAttribution, the source line).Face.tsxis the portrait-or-monogram andRail.tsxthe card rail and Wikipedia-link card, both shared with Battle.mobile/src/dev/fixtures/hollywoodForever.ts— the Graves seed (real Hollywood Forever rows, three burials) shared by thegravesscreen-mock state, the screen test andgravesModel's test.backend/scripts/backfill-cemetery-burials.ts— fillsnotableBurials[](dry run by default;--applywrites LOCALLY, prod gets it through/admin/publish). Rules inbackend/scripts/lib/cemetery-burials.ts: Wikidata P1442 headstone, P569/P570 years, the P119 burial-place check (judgeBurialPlace),isLiving, overrides; the person lookup isbackend/scripts/lib/wikipedia-person.ts(resolvePerson, shared withbackfill-battlefield-data.ts); credits frombackend/scripts/lib/image-attribution.ts. Hand fixes inbackend/scripts/data/cemetery-burial-overrides.json(title,image,skip,confirmed,rank).backend/scripts/topup-cemetery-burials.tstops up thin cemeteries from Wikidata (topUpQuery,pickTopUps,proposeTopUpin the same lib).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 → scout photos (post photos on the new screen,PatchPhotogallery photos on the previous one), deduped by URL, capped at 10.mobile/src/components/patch-detail-v2/tabs/OverviewTabV2.tsx:45-48— the rarityRarityBadge(hero size, shows Common), Why Visit pull-quote +CollectionRowlist (per-collection progress).mobile/src/components/patch-browser-v2/PatchCard.tsx:43,72-73— the rarity pip corner (rare+ only, top-left) — see patch-rarity for why Common is suppressed here but shown on the hero above.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— the PREVIOUS screen's Gallery: official (NPS) photo carousel +PatchPhotogrid + 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. Both screens use it; since 2026-10-11 (not yet released) a photo opens with + Post and a posted one wears a "Posted" tag (useVisitPhotoPosting).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), Navigate in Scout (patches with coordinates — a drive to this one patch on the trip map,/navigate/[id]; seetrips-and-driving.md), 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 + patch art + 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:88), and OFF in production (verified live on 2026-10-11 viaGET https://scout-patches.com/api/feature-flags; it read ON on 2026-09-13). Gates the "Purchase Patch" row in the action sheet and the Shop tab. 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.new_patch_detail_screen—defaultEnabled: true, in both registries (mobile/src/config/feature-flags.ts:45,backend/src/admin/feature-flag-definitions.ts:48). On: the new detail screen and its redesigned Battle tab. Off: the previous screen. Read inPatchModalScreenViewModelImpl(mobile/src/screens/PatchModalScreen.tsx:184). The admin toggle needs a backend deploy before it appears.- The Plan tab (new screen only) has no feature flag; it needs the patch to have a published visit-info record. Not in the previous screen's tab set.
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', 'cemeteryData'](the previous screen keepsPREVIOUS_SCREEN_TYPE_TAB_KEYS,['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 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 My Patches, which always openspatch-modal/<id>. - 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
-
The Plan tab covers only the 64 National Parks. It has no feature flag, and a patch with no published visiting-info record has no tab. The previous screen has no Plan tab at all. See plan-tab for what it does and does not do.
-
The new screen's Gallery has no photo upload and no "gallery" of its own any more (2026-10-11, shipped in v1.1.106). A scout's photo reaches it only as a photo in a board post: "Add photos" posts library picks as one untitled post (2026-10-25; it replaced "New post"), and your own visit photos post one tap at a time from the My Visit lightbox. Neither asks for a title, and a photo added with Add photos is not recognised as "Posted" on My Visit (library picks carry no source hash). It does not show the retired
PatchPhotogallery photos (they still exist, and the previous screen still shows them). It shows only posts that have a photo — text posts stay on Community — and it is not paginated. -
The hero and "More photos" do not show a post's byline, votes or comments. They show post photos as plain photos credited
@handle; the post itself opens only from Gallery's Scout posts (or the board). -
The campaign accent never recolours the patch art, the photos, or the Battle tab. A patch in two themed campaigns wears the first one's colour in its collection list; it never blends or alternates them. The colour is not a user setting and has no flag — the old screen (
new_patch_detail_screenoff) stays brass. -
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. -
The new screen's hero Navigate button does not drive inside Scout. Like the previous hero's, it hands off to Apple/Google Maps (
openTurnByTurn). Scout's own turn-by-turn to one patch ("Navigate in Scout") is in the ⋯ action sheet on both screens. -
The new screen does not show a photo-pager indicator. The hero still swipes and auto-advances through the location, official and scout photos (post photos since 2026-10-11, shipped in v1.1.106); the dots were removed on review. Nothing on screen says how many photos there are except Gallery and the "More photos" strip.
-
The Battle tab does not guess a winner. It reads one from the outcome prose; four of the 69 battlefields (Appomattox's surrender, Fort Bowyer's split result, Fort Brown, Valley Forge) have no clear "X victory" and show no winner tag. A side neither colour rule recognises (Nez Perce, Red Stick Creek) takes whichever colour its opponent left — the colours mean "the two sides", not allegiance to anything beyond that.
-
The new screen's header does not stay on screen as you scroll. Back is in the header chrome and scrolls away with it, as on the previous screen; the floating tab bar stays.
-
Only two type-specific detail tabs are user-visible today: Battle and Graves (Graves on the new screen only). 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.
-
The Graves tab is not a visitor guide and not a burial index. It shows only the curated
notableBurialsnames already in the catalog (3–10 per cemetery), never "everyone buried here"; no hours, admission, maps or etiquette (visitorEtiquetteis in the data and not rendered); no map pin or directions to a grave. It does not honour everyone the catalog once named: the backfill removes anyone it can't give a credited portrait (53 of 339 names on the 2026-10-23 run, including real people such as Homer Plessy and the Boston Massacre victims' grave), and it removes a catalog entry that names the wrong cemetery rather than finding who is really buried there. It never adds names. The previous screen (flag off) has no Graves tab. -
Purchase-from-patch-detail does not buy anything in the app. The "Purchase Patch" action and the Shop tab open the store when
buy_patchis on (with it off, a build from v1.1.117 on opens the "would you buy this?" sheet instead,store-interest.md).buy_patchisdefaultEnabled: falsein both registries and readsfalsein production (2026-11-06 viaGET https://scout-patches.com/api/feature-flags; history: on 2026-09-13, off 2026-10-11, on 2026-10-15 and 2026-10-29 — seepatch-shop.md). On, it opens the store WebView on the per-patch page/app/patch/<handle>(patchShopPath,mobile/src/config/store.ts:148; seepatch-shop.md); there is no in-app purchase, no StoreKit, no Play Billing behind it. A client that cannot reachGET /api/feature-flagsfalls back to the registry default (off) and opens the sheet. -
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— the old Trophy Case route redirects to/my-patches.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), 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-detail-new.test.tsx— the new screen (flag on): it mounts by default and the previous screen when the flag is off (a pair); campaign accents (2026-10-09, each watched failing first): a zoo's Navigate, place eyebrow, section label and active tab in the Zoos' green with its paw-print motif, History's band and THE STORY in it too, a City + Zoos patch taking the green, paired with a City-only patch staying brass; the tab bar on clear Liquid Glass (expectClearGlass, failing on the opaque pill); the hero photo standing on its own blurhash with no fade, paired with no placeholder when it has none (2026-11-03, watched failing first); the hero centred like Home's (2026-10-09, failing on the left-aligned hero): the motif, the campaign line in its accent, a centred name and place; the facts exactly asheroFactsreads them for a fix a few miles off, paired with only the type without one; no campaign line for a patch in no campaign; the photos still swiping; the band's scrim at least 0.7 dark where its title starts (it was 0.34) while staying clear above; History's quiet source line opening the article, paired with none when there is no article; review fixes of 2026-10-08, each test watched failing first — the History drop cap is Fraunces italic 40 in brass (it had been handed the gallery caption's 12pt grey style, which shared its name), section labels and the header kickers are the gold eyebrow, and no ember layer mounts even once the patch art reports its layout; the Battle tab's numbers in their own rows, the winner, both sides' names and commanders, the synopsis and "Day 1" on a real Antietam row, paired with no Battle tab on a non-battlefield; the Graves tab (describe('the Graves tab'), real Hollywood Forever rows): the "Graves" tab label, the spotlight's name, years, line, portrait and its credit (public-domain "Albert Witzel" without a licence tail), the campaign's sage on its label, the source line naming Wikipedia alone though the seed carries a headstone, "Also here" as a horizontal rail whose card has its why line and two-line credit ("Donald Biddle Keyes"), cards and the spotlight opening Wikipedia, an unresolved name (Toto) as a monogram with no link and no credit, a portrait that fails to load falling back to the monogram without its credit — paired with no type tab on a cemetery with no burials; History's joined date range ("1934–63", real Alcatraz events); the collected stamp vs. none; collections with the nearest uncollected patches and never the patch itself; Gallery's Scout posts (2026-10-11, replacing the "Add a photo" / scout-photo grid tests): the lone Add photos tile (and no New post) on a board with no photo posts, each photo post drawn as a tile with its title, who, votes and comments from the seed, a tile opening the lightbox on that post paging across every post photo,describe('"Add photos"')(quick photo post, 2026-10-25: picks posted as ONE untitled post with the posting tile, the board refreshed after, a cancelled picker posting nothing, a failure becoming Retry that re-posts the same picks without reopening the picker, the server's message cleared by a tap, a guest asked for an account with no picker), and the paired Try again that refetches and draws the posts; the row under the lead photo — post photos filling it under a lone official photo, post photos after the other official photos; and the board's post photos in the hero pager and "More photos", credited to their author (@trailwarden). Falsified by forcing the route to the previous screen (9 of 10 fail, the flag-off test passes), by blanking the Battle data and by hiding the stamp — each fails its test.mobile/src/components/patch-detail-v3/__tests__/battleModel.test.tsanddetailModel.test.ts— the Battle model over six real battlefield rows (Antietam, Saratoga, Lake Erie, Chancellorsville, Big Hole, Appomattox): sides, colours, loose casualty keys, every winner form, day grouping and the back-to-back rule, figure placement, empty data; and the detail helpers — including the dressing collection preferring a themed campaign over a city one in either order (failed on the old first-campaign rule, which picked St. Louis for the Saint Louis Zoo). Falsified rule by rule (the "U.S." boundary, the day numbering, the possessive winner, the range join). Writing them caught a real bug:us_armydidn't read as the US side, because_is a word character to\b.mobile/src/components/patch-detail-v3/__tests__/gravesModel.test.ts— the Graves model over real Hollywood Forever rows: lead and rest in order, years in all four forms, the credit wording (a CC licence printed, public domain name only), no headstone though the row carries one, no credit for a portrait that isn't there, monograms, the source line with and without portraits, and no lead for no burials.mobile/src/components/patch-detail-v2/__tests__/useVisibleTabs.test.ts— the type-tab gate: a cemetery with burials gets "Graves" with the headstone icon, paired with no tab fornotableBurials: [], none on the previous screen's key list, and Battle unchanged on both.backend/scripts/lib/cemetery-burials.spec.ts,wikipedia-person.spec.ts,image-attribution.spec.ts— the backfill's rules: Wikidata facts, the burial-place check (same item, part of ours, within 3 km, a state is not evidence, a far cemetery is),isLiving, the override verdict, re-run idempotence; the shared person lookup over recorded summaries; the credit cleaner's hidden-span fix ("Unknown author Unknown author", watched failing first) and en.wikipedia files.mobile/screen-tests/patch-modal-id.test.tsx— pinsnew_patch_detail_screenOFF and covers the previous screen: 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's Posted tag (2026-10-11, replacing the old published badge): a posted visit photo tagged with its votes from the record and the board, paired with nothing posted and with a record that belongs to another patch; the caption's tap hint for a cloud-album account and a signed-out viewer; and a visit photo opening in the lightbox with Post, not the gallery button.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), and since 2026-10-11 the- Post pill on a visit photo there — one tap, no sheet, since the quick photo post of 2026-10-25 (see community-and-moderation for the cases).
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, and only the location image carrying a blurhash.mobile/src/domain/__tests__/visitPost.test.ts—postsWithImageskeeps only posts with a photo;postScoutImagesflattens every post photo, credited to its author.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 and Graves 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).