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):
scout://states-map— the 50 States map (mobile/app/(drawer)/states-map.tsx, registered inmobile/src/dev/deepLinkRoutes.ts:51)scout://national-parks-map— the National Parks map (mobile/app/(drawer)/national-parks-map.tsx, registeredmobile/src/dev/deepLinkRoutes.ts:57)
Public web (all on scout-patches.com, one origin):
/— marketing home page./app— QR-hub "get the app" page./discover/:patchId— QR patch-discovery celebration page. All three: SPA.*(any unmatched path, incl. old/waitlistlinks) — falls through to the marketing home page (landing/src/App.tsx:16). SPA./brand— public-but-unlisted brand guide,noindex, nofollow. SSR./terms— Terms of Service / EULA./eula— 301 redirect to/terms. SSR./privacy— mobile-app privacy policy (App Store Connect / Play)./privacy-facebook— Meta/Instagram integration policy (Meta App Dashboard). SSR./support— support page (App Store Connect Support URL field). SSR./get— UA-sniffing smart link (App Store on iOS, Play on Android, else the site). SSR redirect, no HTML./card— permanent business-card redirect (302), increments a scan counter. SSR redirect./card/qr.svg,/card/qr.png— QR codes encoding the permanent/cardURL. SSR binary/SVG./u/:slug— public profile "flex page"/credential./u/:slug/card.png— its 1200x630 OG image. SSR./api/avatar/:seed.svg— deterministic generated avatar SVG. SSR binary endpoint./context,/context/upload— hidden, crawlable product-context page + its token-gated screenshot upload form. SSR./tiles/:z/:x/:y.mvt(+/tiles/meta) — self-hosted vector tile server the mobile map consumes. Not a page./api/discovery-links/:id/click— fire-and-forget click beacon fired by/discover/:patchId./api/sync/patch/:id— single-patch lookup it uses (mention only). Neither is a page.
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
Patch.collectionType— distinguishes a state-level patch ('state') forstatesProgress.ts, and classifies a patch as a "park" for profile stats viaPublicProfileService'sPARK_TYPESset (public-profile.service.ts:5).Patch.state— free-text state name/abbreviation; canonicalized on read for the 50 States map, never at write time.LandingConfig(singleton row) — admin-editable: video fields,appStoreUrl/playStoreUrl(public),iosBetaUrl/androidBetaUrl/inviteCopy(admin/app-only),cardRedirectTarget/cardScanCount(backs/card),businessCard(JSON, admin-only — excluded from the publicGET /api/landing-config) (backend/prisma/schema.prisma:1253-1283).LandingScreenshot— ordered rows for the screenshot carousel; defaults to 3 hardcoded S3 URLs if the table is empty.DiscoveryLinkClick— one row per/discover/:patchIdvisit:patchId, UTM fields,userAgent, a salted-hashipHash(never a raw IP) (backend/prisma/schema.prisma:1327-1342).Profile.slug— unique, permanent once assigned (ensureSlug,public-profile.service.ts:70-88); a guest's session-scoped display name is never used to seed it.Profile.publicEnabled—@default(true), public by default (opt-out; account-settings side out of scope here).
API surface
GET /api/landing-config— public; feeds the SPA pages' store badges/video/screenshots (landing-config.controller.ts:65-88). Admin-guarded CRUD siblings exist for the same config + uploads (out of scope beyond noting it exists).GET /api/sync/patch/:id— unauthenticated single-patch lookup used by/discover/:patchIdfor a not-yet-synced or admin-only patch (backend/src/sync/sync.controller.ts:26-30).GET /api/discovery-links/:id/click— public 204 click beacon; no-ops silently on an unknown patch id or DB error. An admin-guarded stats endpoint reports 7-day click analytics with week-over-week deltas.GET /get— 302 smart link viaresolveStoreUrl()(smart-link.ts:33-58) from the request User-Agent + the adminLandingConfigrow (read failure tolerated — falls back to the live listings, never 500s).GET /card— 302 toresolveCardTarget(cardRedirectTarget)(defaults/app); incrementscardScanCountbest-effort (public-profile.controller.ts:47-61).GET /card/qr.svg/.png— QR codes always encoding the one permanentCARD_URL, never the resolved target, so a printed card never goes stale (card-link.ts:3-13).GET /api/avatar/:seed.svg— deterministic (FNV-1a hash ofseed) grid avatar.GET /u/:slug,/u/:slug/card.png— profile page and its 1200x630 OG image (SVG rasterized to PNG viasharp).
Key files (annotated path:line list)
mobile/src/domain/statesProgress.ts:57-116—buildStatesProgress/fillTierFor.mobile/src/domain/nationalParks.ts:1-25— roster contract, off-map detection.mobile/src/domain/parksProgress.ts:52-78—buildParksProgress.mobile/src/components/states/{UsStatesMap.tsx,mapLayout.ts,StatesShareCard.tsx},mobile/src/components/parks/{UsParksMap.tsx,parksLayout.ts,ParksShareCard.tsx}— SVG rendering + layout + export cards for each map.mobile/app/(drawer)/states-map.tsx,national-parks-map.tsx— the two screens.mobile/src/hooks/useShareCard.ts— shared capture/share/cleanup.backend/src/spa-fallback.ts,backend/src/main.ts:83-129— SPA-vs-controller routing boundary, static serving + fallback wiring.landing/scripts/prerender.mjs— build-time prerender of the static routes, wired intonpm run build.prerenderedShellForinbackend/src/spa-fallback.tsis the server-side half.landing/src/App.tsx— the SPA's route table;*rendersNotFoundPage.landing/src/pages/NotFoundPage.tsx— the 404 body.backend/src/spa-fallback.ts'sisLandingRoute— the server-side mirror that decides 200 vs 404.backend/src/legacy-redirects/—/waitlist→ 301/.landing/src/pages/MarketingPage.tsx,DiscoveryPage.tsx,landing/src/api.ts— the live home page and QR discovery page + its fetch/click-beacon client.landing/src/lib/store-links.ts,backend/src/public-profile/smart-link.ts— two independently-maintained store-URL resolvers, both with no waitlist/beta branch.landing/src/lib/landing-config.ts(mergeLandingConfig) — the merge-not-replace fix behind the 2026-08-28 commit.backend/src/public-profile/*—smart-link.ts,card-link.ts,avatar.ts,og-card.ts,slug.util.ts,public-profile.service.ts,public-profile.controller.ts.backend/src/context/{content.ts,context.service.ts,context.controller.ts}— the/contextproduct-brief page this doc feeds into; token-gated viaCONTEXT_UPLOAD_TOKEN.landing/public/robots.txt,landing/public/sitemap.xml— real static files, copied verbatim intodist/by Vite and then overbackend/public/, whereapp.useStaticAssetsserves them ahead of the SPA fallback. Before they existed, both paths fell through the catch-all and answered 200 with the landing page's HTML —/sitemap.xmlunderContent-Type: application/json..github/workflows/deploy.yml:162-213— where/howlanding/distbecomesbackend/public.
Configuration and flags
- No feature flag gates either mobile map screen or any public web route described here — everything ships unconditionally.
CONTEXT_UPLOAD_TOKEN(env) — gates/context/upload; missing disables the surface, a bad token 404s (not 401/403) so its existence isn't disclosed (context.service.ts:33-40).DISCOVERY_IP_SALT(env, hardcoded default if unset) — salts the click-beacon IP hash.LandingConfig.appStoreUrl/playStoreUrl— admin overrides for every store badge and/get//card; shipped default when unset is the live App Store/Play listings, hardcoded in bothlanding/src/constants.tsandsmart-link.ts(kept in sync by hand, not shared). No third "waitlist"/"beta" branch remains anywhere in this resolution chain.LandingConfig.cardRedirectTarget— admin-settable; defaults to/app.
Edge cases and known limits
-
An unknown URL now answers 404, and
/waitlistanswers 301 (both since 2026-09-17). Previously every unknown path —/about,/shop,/store, outright nonsense — rendered the marketing home page under a 200: an unbounded set of URLs serving identical content, which is a duplicate-content signal and tells a crawler the site returns success for anything./shopand/storewere the sharpest edge, being exactly what somebody guessing the storefront would type.The fix has two halves that must agree.
isLandingRoute(backend/src/spa-fallback.ts) mirrors the client route table and sets a 404 status for anything not in it; the shell is still SENT, so the visitor gets a rendered page rather than a bare Express string.landing/src/App.tsxrendersNotFoundPagefor the same paths, so status and body agree.The two route tables are maintained separately —
landing/andbackend/are separate projects and cannot import from each other, the same arrangement asstore-links.ts/smart-link.ts. Add a route to one, add it to the other;landing/src/App.test.tsxcarries the reminder. A route added only to the client ships with a 404 status./waitlistis handled separately byLegacyRedirectsControlleras a 301 to/, because it is the most-referenced marketing path in the repo and 57 of the 81 people on the removed list were emailed links to it. -
/and/appare PRERENDERED at build time (since 2026-09-17).landing/scripts/prerender.mjsruns aftervite buildand renders each static route to real HTML withreact-dom/server+StaticRouter— the same machinerysrc/App.test.tsxalready uses, deliberately instead of adding Vike or vite-plugin-ssr. Each route gets its own<title>, description, canonical andog:tags, so/appis no longer indistinguishable from/in a search result.backend/src/spa-fallback.ts'sprerenderedShellForresolves a request to its built file; anything unknown falls back to the plain shell, including on a build that predates the script.redirect: falseon the public static mount is part of this. Prerendering writespublic/app/index.html, which makespublic/app/a directory, and serve-static answers a directory request with a 301 to the trailing-slash form — so/appstarted replying301 -> /app/. That is wrong twice:/appis an advertised URL, and the landing'scanonicalForstrips trailing slashes, so the canonical would have named a redirecting URL. With the redirect off the request falls through to the SPA fallback and is served at/appwith a 200.Before this the SPA served 41 characters of text — the
<title>and nothing else. Google renders JS on a second-wave crawl; most AI crawlers (ChatGPT, Perplexity) do not, so to them both primary marketing pages were blank documents. -
Prerendering did not make the pages substantial — they are hero-only.
/yields 373 characters and/app328. That is genuinely all the copy there is (MarketingPage.tsx:93, "hero only, no scroll"). The remaining thinness is a CONTENT gap, not a rendering one, and no amount of prerendering fixes it. -
/discover/:patchIdis deliberately NOT prerendered. It is a route pattern with one instance per patch; prerendering it means either thousands of near-duplicate pages — the thin-content trap just fixed on the storefront — or baking patch data into static HTML that goes stale on the next content publish. It is a QR-scan landing page, not a search surface, and keeps the SPA shell. -
OpenGraph, Twitter card, canonical and JSON-LD ship since 2026-09-17. Before that
landing/index.htmlset only<title>,description,viewportandtheme-color, so a link shared to Facebook, iMessage or Slack rendered as a bare URL with no image or description. The social tags and aMobileApplicationJSON-LD block are static — they describe the app, which is true for every route. The canonical is per-route viasrc/hooks/useCanonical.ts, because the static one inindex.htmlpoints at the origin root and would otherwise claim/appand/discover/:patchIdare duplicates of the home page.canonicalForalso drops query strings (campaign traffic arrives withutm_*andfbclid, each of which is a distinct URL to a crawler serving identical content), drops fragments, and normalises trailing slash and case. -
og:imageis a purpose-built 1200x630 card since 2026-09-17, served same-origin at/og-card.pngfromlanding/public/(the same static dir that shipsrobots.txtandsitemap.xml). It replaced/brand-assets/scout-wordmark-stacked-on-dark.png, which was 1467x1327 (1.11:1) — every previewer crops to 1.91:1, so the shared link was letterboxed or centre-cropped everywhere.backend/scripts/make-og-cards.pygenerates it and the storefront's from ONE layout, using the real Fraunces/Inter Tight files and theglobals.csspalette, so the two origins read as one brand. The generator RAISESCardOverflowrather than drawing a card whose copy collides with the domain line — a broken card is invisible until a customer sees it.src/lib/social-card.test.tsenforces the contract againstindex.htmlon disk:summary_large_imageimplies bothog:imageandtwitter:image, absolute, identical, and exactly 1200x630. -
Stale/dead marketing constants:
landing/src/constants.tsstill exportsCAMPAIGNS,FEATURED_PATCHES,JUMPSTART_MEMORIES,COMMUNITY_POSTS,MASTER_PATCH_ART(a Collections grid, campaigns section, mocked community board, photo-import section). None are imported or rendered anywhere inlanding/src(confirmed by grep) —MarketingPageis explicitly "hero only, no scroll" (MarketingPage.tsx:93). A reader ofconstants.tsalone would describe sections that don't exist on the live site. -
No per-route SPA metadata:
/discover/:patchIdhas no per-patch<title>/OG tags — a link preview (iMessage, Slack, Facebook) shows the generic Scout tagline, not the patch, unless the crawler executes JS (most don't). -
/u/:slug/card.png's OG SVG uses an off-brand palette:og-card.tshardcodes a purple gradient (#1a1a2e→#2d1b4e) and generic fonts, not the field-guide brass/canvas palette used everywhere else — likely predates the current design system. -
Neither map has tap targets, for two different reasons: the 50 States map's Album is photo-derived, so a filled state without a camera-roll import would open an empty page; a park dot is ~2.4pt wide at phone width, below the 44pt minimum touch target (
parksLayout.ts:10-13). -
/get/store badges can't warn about a config gap: a nullappStoreUrl/playStoreUrlsilently falls back to the live listing with no admin signal — this exact gap (NULL store URLs in prod while both apps were live) caused the bug the 2026-08-28 commit fixed; the fallback masks the gap rather than surfacing it. -
/card's scan counter is best-effort: an increment failure is swallowed and the redirect still happens, socardScanCountcan under-count. -
Share export failures are silent to the user:
useShareCardlogs and stopsisSharingbut shows no error toast on a failed capture or share sheet.
What this feature does NOT do
- Neither map lets a user tap into a state or park to see the photos earned there — a deliberate exclusion, not a gap.
- Neither map nor the discovery/landing pages show a non-authenticated visitor's own progress on the web — maps are mobile-app-only; web surfaces are marketing/QR/profile pages, not a web dashboard.
- Does not include the Shopify storefront,
store/, or the account/settings side of the public-profile opt-out (other agents own those). /discover/:patchIddoes not redirect — it renders in place on the SPA; the old server-side redirect flow was replaced by a client-side beacon.- The card QR never encodes the resolved redirect target, only the
permanent
/cardURL — intentional (a printed card must not go stale), but the image never reflectscardRedirectTarget.
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
- Whether
landing/src/constants.ts's unusedCAMPAIGNS/FEATURED_PATCHES/JUMPSTART_MEMORIES/COMMUNITY_POSTSexports are dead code slated for removal or a section planned to return — intent not determinable from code. - Whether
/u/:slug/card.png's off-brand purpleog-card.tspalette is a known/tracked gap or simply unnoticed — no ticket or comment found. - Exact production traffic of
/context(unlinked, direct-URL only) and whether the map screens'opened_states_map/opened_national_parks_mapevents feed any admin dashboard — neither verifiable without analytics access, out of scope for this read-only pass.