Scout — Full Product Context → feature documentation

Shareable field records and public web surfaces

Two mobile screens turn a user's collected patches into a printable-feeling 'field record': the 50 States map (a US outline shaded by how deep the collection goes in each…

Screen recordings

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

The 50 States 5.7s · recorded 2026-09-09
The National Parks 5.2s · 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 12:44 16.4s · recorded 2026-09-09 · unedited
Full recording · 09-09 12:44 14.7s · recorded 2026-09-09 · unedited

Summary

Two mobile screens turn a user's collected patches into a printable-feeling "field record": the 50 States map (a US outline shaded by how deep the collection goes in each state) and the National Parks map (a dot per park, with the two parks a standard US projection can't place boxed into a margin inset). Both are display-only — no tap targets — and both export to a fixed 1080x1350 PNG via the same useShareCard hook for posting to Instagram/etc.

Separately, scout-patches.com is one NestJS origin (backend/) serving two kinds of pages: a handful of server-rendered Handlebars pages for anything that must never 500 or must be crawlable (terms, privacy, support, brand, /context, public profiles), and a React SPA (landing/, built and copied into backend/public/ at deploy time) for the marketing home page, /app, and /discover/:patchId. A 2026-08-28 commit deleted the waitlist entirely — every store badge across both projects now resolves to the live App Store/Play listings, never a waitlist or beta signup.

Status

Shipped and live in production for both halves. No feature flag gates either the 50 States/National Parks screens or any of the web surfaces described here — they are unconditionally visible, including to guests (no isVisible gate on their drawer rows, unlike messages/import; mobile/src/components/navigation/drawerSections.ts:232-233).

New 50 States and National Parks screens are built behind album_places_redesign (defaultEnabled: false in both registries, mobile/src/config/feature-flags.ts:27 / backend/src/admin/feature-flag-definitions.ts:33; built 2026-10-28) — and the flag is ON in production (GET https://scout-patches.com/api/feature-flags returned true on 2026-10-29). With the flag on, scout://states-map and scout://national-parks-map are the Album's map with your collection in it (see "The 50 States on the album's map" and "The National Parks on the album's map" below), each sharing a "Photo map" card; with it off (a client that cannot reach the flag endpoint falls back to the registry default), the older screens exactly. Members on a current build therefore see the map versions today; re-check the live flag before saying otherwise.

User-facing surfaces (screens, routes, scout:// deep links, public URLs)

Mobile (drawer → PROGRESS section):

Public web (all on scout-patches.com, one origin):

How it works

The 50 States map

The rule for when a state fills (counts toward "N/50") is exact and distinct from the rule for its shading depth. A state fills — is added to earned and counted in totals.states — only when the user has collected that state's own collectionType: 'state' patch (e.g. state-california); this is the only fill signal (mobile/src/domain/statesProgress.ts:59-93). Any other collected patch whose patch.state resolves into that state (a park, a city, a monument) adds to the state's depth count and to totals.patches, but does not fill the state on its own — and if the state patch itself was never collected, the code deletes any partial depth accumulated for that state (statesProgress.ts:85-93, comment: "Places in a state whose state patch is NOT collected must not light it"). Depth maps onto a four-step tier via fillTierFor (statesProgress.ts:108-116): passed (dimmest — state patch collected, 0 other patches, "drove through, never stopped"), shallow (1–4 other patches), deep (brightest, 5+), empty (unfilled). The map screen prints a legend under the map with exactly these tiers: "Passed through", "1–4 places", "5+ places", "Not yet" (mobile/app/(drawer)/states-map.tsx:98-101).

The denominator is fixed at 50 (TOTAL_STATES), derived from the US_STATE_SHAPES geometry, not the catalog — DC, territories, and Canadian provinces present as state-type patches are counted by useUserProgress's statesVisited but silently dropped here to keep the denominator exactly 50 (statesProgress.ts:41-46). patches.state is free text (has held both "Florida" and "FL"); every lookup runs through canonicalStateName so the two forms don't split into two states (statesProgress.ts:47-52).

Rendering (mobile/src/components/states/UsStatesMap.tsx) is plain react-native-svg, not react-native-maps — tiles can't be restyled to the field-guide palette or captured reliably for a share image (UsStatesMap.tsx:9-11). 9 states too small to letter in place (VT, NH, MA, RI, CT, NJ, DE, MD, plus Hawaii) get a right-margin index column instead, and only lit states are lettered in place at all — labeling all 50 at phone width was judged to bury the map's signal (mobile/src/components/states/mapLayout.ts:14-19,33-53).

The 50 States on the album's map (flag album_places_redesign)

Built 2026-10-28 (Alan: "the same map and functionality" as the Album's "Your America"). mobile/app/(drawer)/states-map.tsx renders StatesAtlasScreen when the flag is on and the old Survey Plate (StatesFieldRecordScreen, above) when it is off. The map, its three levels, glide, card and framing are the shared mobile/src/components/album/map/AtlasScreen.tsx (useAtlasViewModel + AtlasScreenLayout), which the Album's map uses too; this screen hands it a source built from the collection:

The National Parks on the album's map (flag album_places_redesign)

Built 2026-10-28 on the same shared map. mobile/app/(drawer)/national-parks-map.tsx renders ParksAtlasScreen when the flag is on and the old plate (ParksFieldRecordScreen) when it is off. Data: mobile/src/domain/parksMap.ts.

The National Parks map

Deliberately simpler: a park is one patch, so the fill rule is just "is this park's patch collected" (mobile/src/domain/parksProgress.ts:6-11,52-58). The 64-park roster (TOTAL_PARKS = 64) is generated and pinned into mobile/src/domain/nationalParks.ts, not read from live collection membership, so an unrelated CMS edit can never move the "x of 64" denominator (nationalParks.ts:16-21) — regeneration is a documented manual pipeline (psql → gen-national-parks.mjs using d3-geo/topojson → re-emit the file). Every park is projected with the same d3.geoAlbersUsa fit (960x600, 6-unit inset) as the 50-states geometry, so a park dot lands inside its own state polygon for free.

Two parks the Albers-USA projection cannot place at all — American Samoa National Park and Virgin Islands National Park — get x: null, y: null, offMap: true (that null return is the detection mechanism, no hand-kept exception list, mobile/src/domain/nationalParks.ts:22-25,173,176), drawn in a boxed "OFF THE PLATE" inset in the map's lower-right margin (mobile/src/components/parks/parksLayout.ts:23-29) rather than distorting the projection — Hawaii's own parks (Haleakalā, Hawaiʻi Volcanoes) render on-map normally in its standard lower-left inset. Unearned dots paint before earned ones (SVG has no z-index; a lit dot must not be buried under a close unlit neighbor, parksLayout.ts:38-48), and a lit dot gets a halo ring because the tightest real pair (Carlsbad Caverns/Guadalupe Mountains, 7.7 viewBox units apart) would otherwise merge into one blob (parksLayout.ts:15-19). The screen's "roll" beneath the map lists only regions/parks the user actually holds, with an explicit empty-state copy block otherwise (mobile/app/(drawer)/national-parks-map.tsx:132-164).

The "Looking a little empty?" import callout (both maps)

Users were opening these screens, seeing one lit state (or no parks at all) and leaving. A one-state map is what a user who has only ever unlocked on foot looks like: every trip photo from before Scout is still in the camera roll, unclaimed. So both screens carry a callout directly under the map plate, above the stubs, that opens /import-modal (the camera-roll import — see camera-roll-import.md). Copy: "Looking a little empty?" over "Scan your camera roll to fill in the states you've already been to" (50 States) or "Scan your camera roll for the parks you've already visited" (National Parks).

The share/export pipeline (shared by both maps)

useShareCard (mobile/src/hooks/useShareCard.ts) captures an off-screen view with react-native-view-shot's captureRef at a fixed 1080x1350 (Instagram's 4:5 feed size) regardless of device pixel ratio, shares via shareFile (mobile/src/lib/shareFile.ts: RN Share on iOS, expo-sharing on Android), then deletes the temp file; isSharing prevents a debounce-less double-tap from firing two captures. Each screen renders its visible plate/stats for interaction, and separately renders an off-screen, fixed-width StatesShareCard/ ParksShareCard at the literal export size (left: -10000, never paints but is still capturable) — both reuse the exact same UsStatesMap/UsParksMap SVG component the live screen uses, so the export can't drift from what the user sees. Both cards carry the Scout wordmark, "FIELD RECORD" kicker, the same three stats, an "N of 50 states so far" / "N of 64 parks so far" subline, and a footer. Since 2026-10-13 the footer names the sharer's invite code — Scout · Invite code K7M2QX over scout-patches.com/i/K7M2QX (InviteCodeLines, footer "2 · Name the code" in mobile/research/invite-attribution-lab.html) — and a guest's card keeps Scout · scout-patches.com/get. A first share mints the code (useInviteCode().ensureCode, passed as useShareCard's prepare) and the capture waits for the card to re-render with it; the text beside it is built from the same answer (message receives it), so a slow mint never splits them. The text beside the card (iOS only; expo-sharing has none) is "My 50 States so far on Scout:" / "My National Parks so far on Scout:" and the sharer's /i/<code>?src=states-map / parks-map link (/get?src=… for a guest). (The subline said "earned on foot" until 2026-09-30; imports and drive-throughs fill these maps too, so it was not true.) Capture failure is logged and swallowed (useShareCard.ts:60-63) — the screen never crashes on a failed share.

The public web surfaces

The backend (backend/, NestJS) and the landing app (landing/, a standalone Vite + React + react-router-dom project) are two separate npm projects shipping to one origin. At deploy time npm --prefix ../landing run build runs and its dist/ output is copied wholesale into backend/public/ (.github/workflows/deploy.yml:183-185,213). Nest's Express layer serves backend/public/ as static assets and falls back to public/index.html for any GET not on an exclusion list — this is what makes /, /app, and /discover/:id work as a client-routed SPA on the same origin as the API (backend/src/main.ts:105-129). That list is centralized and tested, not just remembered (backend/src/spa-fallback.ts's servesSpaShell(), asserted exhaustively by backend/api-tests/spa-fallback.api.spec.ts against every registered GET route) — its comments document three real incidents it caught: /tiles rendering the map empty, /terms//eula answering 200 with marketing copy to App Store review, and public profiles//get//card being swallowed by the SPA shell.

SSR pages (/terms, /privacy, /privacy-facebook, /support, /brand, /context, /u/:slug, /national-parks and its park pages) are Nest controllers rendering Handlebars under backend/views/, built to never 500: no auth, and either no DB call (terms/privacy/support/brand — static copy) or one wrapped to degrade gracefully. SPA pages (/, /app, /discover/:id) share one index.html + bundled JS — it has one static <title>/description for the whole app, no react-helmet or per-route Open Graph tag anywhere in landing/src (confirmed by grep). This is why the split matters for crawlers: a crawler or link preview that doesn't execute JS sees the generic Scout tagline for /discover/:patchId too, never the specific patch's name or art — the per-patch reveal exists only after client-side JS fetches /api/patch/:id. The SSR pages, by contrast, render real content directly in the HTML response (and for /u/:slug, per-profile <title>/OG tags pointing at a real /u/:slug/card.png image).

Data model

API surface

Key files (annotated path:line list)

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Invite code on the cards (2026-10-13): screen-tests/states-map.test.tsx and national-parks-map.test.tsx — the card prints Scout · Invite code K7M2QX and its /i/ URL for a scout with a code, and a guest's keeps Scout · scout-patches.com/get with no code line (falsified by printing the line for a null code); the states card is also captured only after a first-share mint has been drawn; both share texts carry /i/<code>?src=… (or /get?src=…). landing/src/pages/CelebratePage.test.tsx pins /celebrate's badges; backend/src/public-profile/public-profile.page.spec.ts pins /u/:slug's CTA src.

Mobile — domain logic (pure, no renderer): statesProgress.test.ts (fills-from-state-patch-only, depth excludes the state patch itself, passed-through is depth-0-not-absent, DC/territory/province dropping, canonicalization, fillTierFor's four steps, a cross-check that totals.states agrees with useUserProgress's statesVisited); parksProgress.test.ts (roster invariants — exactly TOTAL_PARKS, unique ids, on/off-map positions, region counts — plus dedup/region/rounding); mapLayout.test.ts/parksLayout.test.ts (paint order, inset placement, halo sizing); usStateShapes.test.ts (the geometry itself). All under mobile/src/hooks/__tests__/ or the relevant component's __tests__/.

Mobile — screen integration (real RN render, real SVG, no MapLibre stub): mobile/screen-tests/states-map.test.tsx seeds one collected state patch and asserts 1/50/2% vs. 0/50/0% with nothing collected (paired falsification); national-parks-map.test.tsx does the same for parks (1/64/1/11, roll shows "ALASKA"/"Denali" vs. the empty-roll copy). The import callout is covered on both screens: it shows at one state (and at zero) with the screen's own copy and pushes /import-modal on tap; it is absent at two states (the paired falsification, since only the count differs); absent once importScanCompletedAt is set; and on the states screen it stays hidden while a delayed GET /api/sync/user-data is still in flight, appearing only once the real one-state answer lands. The parks cases all hold zero parks and vary only the state count, which pins that screen to the states gate. Each guard was confirmed to fail its own test when reverted. statesProgress.test.ts → shouldPitchMapImport pins the 1/2 boundary and the scan suppression. mobile/research/tests/map-import-callout.test.mjs pins the same gate, placement (after the plate, before the stubs) and copy against the HTML mock.

Mobile — Maestro E2E: mobile/maestro/tests/share-maps.yaml, one flow for both screens, documented as exercising only the empty/near-empty state, which is also the state that shows the import callout, so it asserts states-map-import / national-parks-map-import on both; globs the collected-count assertion (\d+ of 50) rather than a literal, which broke once already. Explicitly does not cover fill tiers, the color ramp, lit dots, halos, region grouping, or the exported card image.

Backend: smart-link.spec.ts (resolveStoreUrl, incl. "never resolves a phone to a waitlist or a beta"); card-link.spec.ts/ .controller.spec.ts (redirect resolution, scan-count tolerance, QR generation); og-card.spec.ts (what the card says incl. plurals and zero stats; the PNG the route sends — 1200x630, field-guide background, brass kicker, medallion; a name full of markup characters; the photo path, fetched once; the 302 fallback; the cache, and a hit that skips getBySlug; a card whose photo fetch failed is not cached); public-profile.service.spec.ts (cardFingerprint moves only with name, photo or patches; null when the page is); public-profile.page.spec.ts (og:title says "1 patch collected"); backend/api-tests/profile-card.api.spec.ts (a real 1200x630 PNG over HTTP, a blank display name still draws the member card, 404 for an unknown slug); backend/api-tests/sitemap-route.api.spec.ts (every sitemap URL is a 200 and indexable); public-profile.page.spec.ts (404 on unknown/opted-out slug, serial derivation, visible-only catalog total, 12-tile overflow math, an explicit "never leaks an email, coordinates, or a home town" test); context.controller.spec.ts/content.spec.ts; landing-config.controller.spec.ts (public defaults, businessCard admin-only exclusion); backend/api-tests/spa-fallback.api.spec.ts (every registered GET route correctly excluded from the SPA catch-all).

Landing (separate Vite/Node test runner): store-links.test.ts ("the bug this file exists to prevent coming back": an unconfigured badge still lands on a real listing, never a waitlist); DiscoveryPage.test.tsx (install actions render/link correctly even with no store URLs configured, no shop CTA); landing-config.test.ts (mergeLandingConfig's merge-not-replace fix).

Open questions