Scout — Full Product Context → feature documentation

Collections, campaigns, categories, and progress

Scout's content is organized in three flat, non-nested layers, all rows in Postgres tables, no recursion or tree structure: - Category — a top-level browsing bucket (e.g.…

Screen recordings

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

Campaigns 12.8s · recorded 2026-09-09
Every kind of place 16.83s · recorded 2026-09-09
12 of 15 collected 1.6s · recorded 2026-09-09
The stops 8.9s · recorded 2026-09-09
Visitor's Guide 4.43s · 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:42 32.9s · recorded 2026-09-09 · unedited
Full recording · 09-09 13:09 51.7s · recorded 2026-09-09 · unedited

Summary

Scout's content is organized in three flat, non-nested layers, all rows in Postgres tables, no recursion or tree structure:

Progress ("2 of 8 collected") is never stored — it is recomputed on every render on-device by joining user_patches against patch_collections/collections/campaigns. There is no UserCollectionProgress or UserCampaignProgress table. Completing a collection or campaign awards nothing server-side: no achievement, no badge, no "Master Patch," no XP. It only flips a derived boolean (isComplete/progress === 1) used purely for UI (checkmarks, a "Complete" tag, a profile stat count). This confirms the long-standing internal claim that no Master Patch exists.

A campaign's own page wears its campaign family's header and colours (the same sixteen families as its collections' ring screens) and leads with one of two things: the whole US map for the six campaigns that are geography (Route 66, National Parks, City Challenges, National Forests, 50 States, US Cities — every patch on it as a heat map), or the spine for every other campaign (one progress ring for the campaign, then its collections in order). Either folds to a one-line header, remembered per campaign. Since 2026-10-06; the old road / trail / timeline skins under a Ken Burns-style hero are no longer rendered by any screen. The campaigns list (app/(drawer)/campaigns.tsx) shows every campaign as a framed photo medallion in a two-up grid, most patches collected first (since 2026-10-06; it used to be a snap-scrolling stack of full-bleed cover photos).

Since 2026-10-08 a new campaign page ships behind new_campaign_screen (on by default): the campaign's own photo runs under its header, with its name, progress and a bar of its collections over the photo's foot; then Closest to you (the nearest patch you haven't collected anywhere in the campaign, with Navigate) and its collections. The header folds away to a slim bar with the name and percentage. It sits on black with the campaign's own accent colour wherever brass would be — and every collection's ring screen does the same behind the same flag. The ring is the collection screens' own; the campaign page doesn't use it. Turning the flag off brings back the page and colours described in the paragraph above.

City Challenges is not a distinct data type — it is one specific campaign (id: 'great-american-cities') whose member collections happen to be curated per-city. Nothing in the schema marks a collection as "a city collection"; it's convention plus curation (patch_collections membership) plus a client-side name/state fallback for patches not explicitly curated.

Surprising/notable findings, all verified in code:

Status (shipped / beta-badged / flagged off)

Fully shipped. No FeatureFlag/StoreFeatureFlag row governs Category/Collection/Campaign visibility; the only conditional visibility is Collection.adminOnly (see Configuration below), which is a per-row content flag, not a feature flag.

Live catalog size (verified 2026-10-29, counted from GET https://scout-patches.com/api/sync/content): 3,553 patches, 225 collections, 24 campaigns, 12 categories, 56 patch types, 3,830 patch↔collection memberships. By collection_type the catalog is dominated by the 2026-10 state-park expansion: state_park 1,564, museum 193, historic_site 121, roadside_americana 117, district 115, park 108, natural_feature 102, forest 96, urban_park 91, stadium 89, monument 78, landmark 65, cemetery 63, lighthouse 63, city 60, route66 52, the rest smaller. Any patch/collection count quoted elsewhere in this doc (mock strings like "84 of 1,874 patches") is the design fixture's, not today's catalog. Re-count from the feed before quoting a number; PATCH_COLLECTION_INVENTORY.md is a generated snapshot that goes stale.

Every collection in a campaign opens The Ring (CampaignRingScreen) instead of the campaign-collection grid — City Challenges since 2026-10-05, every other campaign since 2026-10-06, each in its campaign family's own header and colours. It is not behind a feature flag. Only a collection with no campaign keeps CampaignCollectionScreen.

The campaign page (CampaignScreen) was redesigned the same day in the same language — the map or the spine, in the family's header — and replaced the old page in place. That redesign shipped with no feature flag and no fallback to the page before it. The old road / trail / timeline components (CampaignRoad, CampaignTrail, CampaignTimeline) are still in the repo but no screen renders them. The header buttons on both screens are the app's glass chrome (IconButton glass, FavoriteButton chrome); The Ring shipped in 1.1.100 with opaque circles instead, fixed the same day. Since 2026-10-08 that chrome is clear Liquid Glass (Scout.GlassSurface, see design-system) in the shared navbar, Scout.TopBar, on the campaign pages and every ring screen: it floats over the content — pinned at the top, transparent, everything scrolling under it — and a toggle that is ON (a saved bookmark, a folded header) is glass tinted with the accent.

new_campaign_screen (default ON, 2026-10-08) replaces it with a second redesign, NewCampaignScreen — the campaign's photo under its title and a bar of its collections, Closest to you, then its collections, on black with the family's accent — and switches every collection's ring screen to the same black + colour accents treatment (black ground, the accent where brass was, an accent glow under the ring, a flat accent Navigate button, no checks on the board). Off, both screens are exactly as described above: CampaignScreen is kept intact as the way back, and The Ring keeps its family ground and gold button. The flag is declared in both registries (backend/src/admin/feature-flag-definitions.ts, mobile/src/config/feature-flags.ts), so admin can turn it off. Design source: mobile/research/campaign-screen-lab.html round 4 (header F · Location photo, colour Black + colour accents; D · Ring was picked first the same day, then swapped for F so the ring stays the collection screens').

User-facing surfaces (screens, routes, deep links)

How it works (end-to-end mechanism)

  1. Authoring (local DB only). Categories, Collections, Campaigns, and the patch_collections join rows are edited in the local admin (backend/src/admin/api/collections-api.controller.ts, campaigns-api.controller.ts) or by one-off scripts (backend/src/scripts/create-road-trips-campaign.ts, restructure-*-campaign.ts, etc.) run against the local dev DB. There is no admin endpoint to create a brand-new Campaign row — campaigns-api.controller.ts exposes GET, PATCH :id, POST :id/reorder, POST/DELETE :id/collections[...], but no POST to insert a campaign. New campaigns are created by hand-written scripts that upsert directly via Prisma (e.g. create-road-trips-campaign.ts:1-40), then the admin UI is used to attach/reorder/edit them afterward.
  2. Publish. Per backend/src/content-publish/content-entities.ts, category, campaign, and collection are all classified as published content entities (lines ~78-233), same mechanism as patches — Alan runs the local→prod content-publish flow (owned by another doc/skill; not re-described here).
  3. Sync to device. SyncService.getContent() (backend/src/sync/sync.service.ts:194-327) is the one place the mobile app fetches this data (GET is proxied through the sync controller). It runs one $transaction pulling category, collection, patch, patchType, patchCollection, and campaign rows and returns them as a flat SyncContentResponseDto. The mobile app caches this response under one TanStack Query key (contentQuery, keys.content()).
  4. Client-side join + progress. Every screen that needs "how much of X is done" re-derives it from the cached content payload plus the user's collected-patch list (user_patches, itself synced separately) via pure functions in mobile/src/hooks/contentDerivations.ts. Nothing is precomputed or cached across renders except by React memoization (useMemo).
  5. No server round-trip for progress. Collecting a patch (a GPS check-in) writes one UserPatch row via the sync/collect endpoints (owned by another feature doc — location unlock). That write is the only state change; every collection/campaign percentage anywhere in the app is recomputed from it locally, on the next render, with no dedicated "recompute progress" server call.

Data model (Prisma models and key fields)

All in backend/prisma/schema.prisma:

API surface (endpoints, auth requirements)

Mobile-facing (read):

Admin-facing (all behind @UseGuards(AdminGuard), mutations additionally behind ContentWriteGuard — local-only per CONTENT_EDITING_ENABLED):

Key files (annotated)

Backend:

Mobile — data/derivation:

Mobile — honest-progress rules:

Mobile — screens/spines:

Configuration and flags

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Mobile (unit, pure-function level, jest):

Mobile (unit, The Ring):

Mobile (screen tier, jest.screens.config.js):

Backend:

Open questions