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.

Campaigns render themselves with per-campaign "skins" declared in a static theme config: a road with mile markers (Route 66, Road Trips), or a dotted footpath with nature glyphs (National Parks, and most others). A third renderer exists — an era-flag timeline — but no campaign in the theme table currently selects kind: 'timeline', so it ships as unreachable code; the only place it can be seen is the DEV screen-mock gallery (scout://dev-screen-mock/campaign?state=timeline). The campaign gallery (app/(drawer)/campaigns.tsx) is a snap-scrolling stack of full-bleed cover photos, two per screen height.

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, not gated behind any feature flag. rg -n "campaign|collection" backend/src/admin/feature-flag-definitions.ts returns nothing — 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.

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 rowcampaigns-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 (screen tier, jest.screens.config.js):

Backend:

Open questions