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:265-269).

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

Mobile (drawer → SHARE 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 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 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 62-park roster (TOTAL_PARKS = 62) 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 62" 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:130-155).

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 expo-sharing, 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, and a scout-patches.com footer. 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) 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

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/62/1/11, roll shows "ALASKA"/"Denali" vs. the empty-roll copy).

Mobile — Maestro E2E: mobile/maestro/tests/share-maps.yaml, one flow for both screens, documented as exercising only the empty/near-empty state; 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 (SVG shape, HTML-entity escaping); 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