Summary
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. "nature", "history"). Every
Collectionbelongs to exactly one. Since 2026-10-23 a category is also a campaign's interest:Campaign.categoryId(nullable) puts the campaign on that category's shelf in the Campaigns list. One table, two roles — a collection's label and a campaign's shelf. - Collection — a themed set of patches (e.g. "Illinois" inside Route 66, or "St. Louis" inside City Challenges). Every
Patchcan belong to any number of collections via a join table. - Campaign — an optional grouping of collections into one multi-part "journey" (e.g. Route 66's eight state collections, or National Parks' eleven regions). A collection has at most one campaign (
Collection.campaignIdis a single nullable FK), or none.
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:
- The campaign page and the gallery cover now print a percentage by the same rule (
campaignCounts.ts): a started campaign never reads "0%" (one patch into 250 reads "<1%"). Until 2026-10-06 the campaign page printed a bare rounded percent and disagreed with the cover. buildCampaignRail/RailStop(a "journey rail" concept referenced in comments as belonging to aCampaignShowcaseCard) is dead code today — no live screen imports it. The comment describing "the index journey rail" describes a UI variant that no longer exists; the shipped gallery uses a different, simpler mechanism.- Owning a collection via a store purchase (
UserPurchase) makes it render as 100% complete client-side, with no patches actually collected — this is a display convenience, not data mutation (purchasedCollectionIdsshort-circuit inderiveCollectionsWithProgress). - Completing a City Challenge surfaces a promotional "20% off, earned not bought" banner on the Home screen that links to the in-app store — but the button passes no proof of completion to the store (it just opens
/appsigned in), so whether/how that discount is actually enforced is outside what this document can verify (store domain, out of scope here).
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)
scout://campaigns→mobile/app/(drawer)/campaigns.tsx→CampaignsListScreen(mobile/src/screens/CampaignsListScreen.tsx) — the campaigns list. Drawer entry point. Redesigned 2026-10-06 (design C ofmobile/research/campaigns-list-lab.html; no flag): the "☰ Campaigns" bar (the Missions screen's) with "84 of 1,874 patches" on the right (distinct patches: one in several campaigns counts once,deriveCampaignPatchTotals,mobile/src/hooks/contentDerivations.ts:50), then All campaigns ("started / total") in a two-up grid. Each campaign is its photo in a medallion with its progress arc, inside its family's frame (Route 66's pink and teal neon, the wild lands' contours, an engraver's double rule, a compass bezel…), a gold check once finished, 38% while untouched; under it the name and one line — "All 33 patches · complete", "10 of 55 patches · 1 of 8 states", or for an untouched campaign just its size ("64 patches in 6 collections", never "0%"). On the house black: a family's colour is an accent only (frame, arc, the number), never a background. Sorted by most patches collected; campaigns with the same count — every one you haven't started — keep the app's order (sortOrder). So a finished campaign sits above one in progress with fewer patches. Tap a tile to open the campaign.- Shelves by interest — built 2026-10-23 on develop, not released yet (
mobile/research/campaign-interests-lab.htmlA · Shelves, picked by Alan; no flag). The grid is cut into one shelf per interest — a campaign's category — in the categories'displayOrder: Nature, Road trips, Bucket lists, Cities, History, Landmarks, Spooky, Animals. Each shelf is headed by the interest's name and "started / total". Above the list, outside the scroll view so it stays put, the goldFilterPillrail from Achievements and Favorites: All plus a pill per shelf; a pill narrows the list to that one shelf (screen state only — it resets to All on every visit, and falls back to All if a sync takes that shelf away). Within a shelf the order is the grid's: most collected, thensortOrder. A campaign with no interest, or one whose category the device doesn't have, sits on a last More shelf — it never disappears. When no campaign has a known interest (a backend older than the column, or before the interests are published) the screen is the single "All campaigns" grid with no rail, as above.
- Shelves by interest — built 2026-10-23 on develop, not released yet (
scout://campaign/<campaignId>withnew_campaign_screenON (the default) →NewCampaignScreen(mobile/src/screens/NewCampaignScreen.tsx): the photo — the campaign's own photo (else its first collection's) runs under the header from the top of the screen to just past the progress line, darkened at the top for the clock and sunk into the black at its foot; over its foot, left-aligned: the kicker, the name and the progress line ("10 of 55 patches · 1 of 8 states complete"); then one bar with a segment per collection, each as wide as its share of the campaign's patches and filled in the accent to its progress (City Challenges' bar has 64 thin segments). No ring, no map and no moving atmosphere. Fold — the button beside the bookmark (a chevron; glass while open, filled with the family's accent while folded) hides the whole header (photo, title and bar); the campaign's name and its percentage ("Route 66 13%" — "<1%" once started, never "0%") move into a glass capsule centred in the navbar, so Closest to you and the collections start at the top. Remembered per campaign across launches, in the samecollectionsPrefs.collapsedCampaignIdsthe flag-off page folds with. Closest to you — the nearest patch you haven't collected anywhere in the campaign (art at full strength, distance and heading, walk/drive time, a two-line blurb), Navigate (Collect when you are standing on it — both open the compass) and an info button; left out with no location fix or once the campaign is complete. Then its collections, exactly as the flag-off page lists them (the grid for map campaigns, the spine for the rest; a tap opens the ring). Everything on black, with the family's accent on the bar, kicker, numbers, rules and a flat accent Navigate button — City Challenges and US Cities stay brass because brass is their accent (50 States was brass too until 2026-10-09; on black it read as a second City Challenges, so it is flag blue now). Asolocampaign still hands straight to its ring.scout://campaign/<campaignId>→mobile/app/campaign/[id].tsx(picks bynew_campaign_screen; this bullet is the flag-OFF page) →CampaignScreen(mobile/src/screens/CampaignScreen.tsx) — one campaign's page, in its family's header (atmosphere, motif, ground, the wild lands' backdrop photo — shared with The Ring):- The map — Route 66, National Parks, City Challenges, National Forests, 50 States and US Cities (
CAMPAIGN_MAP_IDS,campaignMap.ts). The kicker, the motif, the name, tagline and progress ("10 of 55 patches · 1 of 8 states complete"), then the whole US map, never cropped (Alaska and Hawaii in their insets). Every patch is on it as a heat map: no numbers; neighbours within 9pt merge into one dot whose area grows with its patches (2.6pt alone, capped at 9pt), translucent so crowded places build up; the solid core is the share you've collected. Route 66 draws its road under its patches, Chicago to Santa Monica. You are on it (a sky-blue dot) when the app has a location. A patch the US map can't place (American Samoa, the Virgin Islands) is named under it — "+2 off the map (…)" — never dropped. Below: All N <states/regions/cities>, every collection as a medallion (its photo, its own progress arc, a check when done, 38% when untouched) in a 3-column grid. The photo behind a wild campaign's header (National Parks, National Forests, State Parks) is the campaign's pinned one where it has one (CAMPAIGN_BACKDROP_PATCH,ringFamily.ts— National Parks pins New River Gorge, chosen 2026-10-06 because the first region's photo, a bright Crater Lake sky, fought the map; compared inmobile/research/campaign-backdrop-lab.html), looked up among the campaign's own members so a patch that leaves the catalog falls back rather than leaving nothing; otherwise the first region's photo that isn't the campaign's own. - The spine — every other campaign. One progress ring round the campaign's photo, its motif (era flag, glyph) and its completion inside ("55%"; "<1%" once started; "0%" only when untouched), then the name, tagline and progress, then The <theaters/chapters/…>, in order: every collection on a dashed thread in campaign order, numbered, the one to pick up marked Keep going (the furthest-along unfinished one) or Start here (none started: the nearest to you, else the first).
- Tapping any collection opens its ring (
/campaign-collection/<id>). There is no other button on the page — no "Keep going" section, no gold CTA. - Fold — the button beside the bookmark (a map icon over a map, a chevron over the spine) folds the header to one line: a small medallion with the campaign's progress arc, the name and the count, so the collections start near the top. Folded, the header runs no atmosphere at all (no particles, no Skia canvas, no backdrop photo) — the cheap way to read a long campaign. Glass when open, the brass accent while folded (the chrome's toggle convention). Remembered per campaign across launches (
collectionsPrefs.collapsedCampaignIds, persisted in MMKV with the rest ofcollectionsPrefs). - Design source:
mobile/research/campaign-screen-lab.html(round 3).
- The map — Route 66, National Parks, City Challenges, National Forests, 50 States and US Cities (
scout://campaign-collection/<collectionId>→mobile/app/campaign-collection/[id].tsx→CampaignCollectionScreen(mobile/src/components/campaign/CampaignCollectionScreen.tsx) — one collection's member-patch grid, reached by tapping a campaign stop (or directly for asolo-kind campaign, see below).- The same route, for any collection in a campaign, is The Ring (
CampaignRingScreen,mobile/src/components/campaign/CampaignRingScreen.tsx); asolocampaign's page (Zoos, Aquariums, Major Airports, Four Corners) hands off to it too. Every patch circles the next patch you need, standing in the middle as its art (or, when it has none, its Clasp pin — its place photo in a glass pin in the campaign colour,ringCenterincityRing.ts, since 2026-10-19; patch-art-placeholder) — floating gently up and down with the unlock celebration's idle float — with its own place photo full-bleed behind the ring, on every family (2026-10-08,RingDial,mobile/src/components/campaign/ring/RingDial.tsx:36). Once you have them all the middle is a photo circle again: the collection hero (then the campaign's own photo) with "Complete". Patches you hold show at full strength and the rest at 38% opacity (opacity only, never greyscale), with no badges and no distances on the ring; a solid "satin" arc from 12 o'clock covers what you hold over a dashed track; past 20 patches the ring splits into two concentric rings round a smaller medallion. Below: Closest to you (the nearest patch you lack — art, distance and heading, walk/drive time, a 2-line blurb, Navigate, or Collect when you are standing on it, plus an info button) and All of <name> (a 3-column board with checks on what you hold, distance pills on the rest, and All / Collected / To find filters). Tapping any patch on the ring or the board opens its detail. - Black + colour accents on The Ring (
new_campaign_screenON, the default): the ground is black instead of the family's; every accent stays; the light pooled under the ring is the family's accent at the strength the aquarium ring always had (RingAtmospherepoolRgb); the Navigate/Collect button is one flat fill of the accent (AccentButton) instead of the gold; and the board drops its checks — the patches you hold at full strength, the rest at the ring's 38%. Closest to you stays at full strength. It folds, like the new campaign page: the button beside the bookmark hides the ring and title, and the collection's name and percentage ("Aquariums 10%", same honest rule) move into a glass capsule centred in the navbar, so Closest to you and the board start at the top — remembered per collection (collectionsPrefs.collapsedCollectionIds, its own list: folding a campaign never folds its collections, and a solo campaign's ring keeps its own fold). The atmosphere's motion (embers, neon, fog, bubbles…), the frame, the motif and the layout are unchanged. Off, The Ring is exactly as the bullet above describes — with no fold button, even if a fold was saved. - The header and colours follow the campaign's family (
ringFamily.ts): City (City Challenges, US Cities — black and brass, rising embers: the shipped look), Route 66 (a neon motel-sign night — pink and teal tubes that flicker, chasing bulbs, neon stars — under the real 1926 US 66 shield), Road (Road Trips, Roadside Attractions, Major Airports — a night highway, headlights and tail-lights), Wild lands (National Parks, National Forests, State Parks — a real park photo behind the ring, topo contours and fireflies, the region's glyph), History (Civil War, Founding & Revolution, Native America, Wild West — sepia engraving, smoke and dust, the era's flag), After dark (Ghost Adventures — moon, fog, wisps), Habitat (Aquariums — rippling light and bubbles), Zoo (Zoos — the wild lands' contours and fireflies in its own greens, a leaf-dashed frame; split off Habitat on 2026-10-09 so a zoo stopped wearing the aquarium's teal and fish), Arena (Sports — sweeping floodlights), Atlas (50 States — a turning globe wireframe, a compass bezel) and Coast (Lighthouses, since 2026-10-21 — an ice-blue rim crossed by the lamp's beam as two bright arcs, and a custom lighthouse glyph, theregionIcons.tsxlighthouse, since lucide has none; design sourcemobile/research/lighthouse-family-lab.html, variant B · Sea fog + the Tower glyph. Its atmosphere slot borrows the arena's beams, unseen while atmospheres are off), and wave 1's Volcanic (Volcanoes — a crater rim, a cone with a plume), Fossil (Fossils — sediment layers round the photo, Lucide's shell as an ammonite) and Cemetery (Graveyards, named Historic Cemeteries until 2026-10-21 — a wrought-iron fence, a plain headstone), all 2026-10-21 frommobile/research/wave1-families-lab.html; the volcano and the headstone are customregionIcons.tsxglyphs. Then Liberty (American Icons — a flag's border, two rails with a ring of 13 stars, and Lucide's star) and Corners (Four Corners, asolocampaign — four corner brackets round the photo, Lucide'sscan), 2026-10-22 frommobile/research/icons-corners-families-lab.html. Each family colours the ground and the accents (ring, arc, section rules, kicker, filters, distances, checks); the gold Navigate button, the text and the patch art are the same on every campaign. Every atmosphere gets busier as you collect and turns to its completion colour when the collection is done. Design source:mobile/research/campaign-ring-lab.html. The accents (narrative.ring.families,mobile/src/theme/narrative.ts) were re-tuned on 2026-10-09 so each family owns its own hue at Route 66's intensity: City brass#E8B86A, Route 66 pink#FF5FAE, Road orange#FE9045, Wild lands chartreuse#B0DE49, History sealing-wax red#F46956, After dark violet#B28AFF, Habitat teal#7CCFC4, Zoo green#52D982, Arena blue#5EA3FD, Atlas flag blue#4D7BFD, Coast ice blue#8EC5FF(2026-10-21; the blue farthest from Arena and Atlas), Volcanic magma crimson#F21764(orange-red lava sits between Road and History and no shade there passes), Fossil sandstone#BC8E58, Cemetery lichen#80A56C, Liberty verdigris#12B0A7(the Statue of Liberty's patina) and Corners map magenta#B950B2.src/theme/__tests__/ringPalette.test.tsholds the rules: every accent at OKLCH chroma ≥ 0.08, every pair ≥ 0.09 apart in OKLab, the Navigate label ≥ 4.5:1 on its accent. The moving atmospheres are switched off for now (2026-10-08 — no embers, neon, bubbles or the rest; one switch,HEADER_ATMOSPHERE,mobile/src/components/campaign/ring/RingParts.tsx:210, covers The Ring, the campaign page and Home's City Challenge; the backdrop photo, frames, motifs and colours stay). - Both routes are registered in the deep-link registry:
mobile/src/dev/deepLinkRoutes.ts:42,106,107. - Both screens now carry a bookmark in their hero chrome, mirroring the back
button on the opposite edge —
campaign-favoriteon campaign detail andcampaign-collection-favoriteon a collection. Bookmarking is member-only and the saved items appear atscout://favorites; see favorites. Note that a favorited collection resolves to/campaign-collection/<id>even when it has no campaign —CampaignCollectionScreenfalls back to a plain'COLLECTION'kicker with no theme whencampaignis null. - There is no standalone
/collectionsroute or deep link. A non-campaign collection (campaignId: null) has no dedicated campaign-family screen in this scope; it is reached through category/patch-browser surfaces owned by other parts of the app (out of scope for this doc). - Profile screen: renders
collectionsCompleted/totalCollectionscounts fromuseProfileStats()(mobile/src/hooks/useProfileData.ts) — out of scope in detail (owned by another surface) but it is the one place completion is aggregated into a headline number.
How it works (end-to-end mechanism)
- Authoring (local DB only). Categories, Collections, Campaigns, and the
patch_collectionsjoin 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-newCampaignrow —campaigns-api.controller.tsexposesGET,PATCH :id,POST :id/reorder,POST/DELETE :id/collections[...], but noPOSTto 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. - Publish. Per
backend/src/content-publish/content-entities.ts,category,campaign, andcollectionare 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). - Sync to device.
SyncService.getContent()(backend/src/sync/sync.service.ts:194-327) is the one place the mobile app fetches this data (GETis proxied through the sync controller). It runs one$transactionpullingcategory,collection,patch,patchType,patchCollection, andcampaignrows and returns them as a flatSyncContentResponseDto. The mobile app caches this response under one TanStack Query key (contentQuery,keys.content()). - 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 inmobile/src/hooks/contentDerivations.ts. Nothing is precomputed or cached across renders except by React memoization (useMemo). - No server round-trip for progress. Collecting a patch (a GPS check-in) writes one
UserPatchrow 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:
Category(schema.prisma:18-36):id,name,displayName,displayOrder,iconName,imageUrl,color. Has manyCollectionand manyCampaign. Ids arecat-<name>.displayOrderputs the eight campaign shelves first (1-8) and the four collection-only categories after (States 9, Sports 10, Culture 11, Airports 12) — set bynpm run campaigns:assign-interests(backend/scripts/assign-campaign-interests.ts, idempotent, dry run unless--apply).Collection(schema.prisma:38-71):id,name,description,categoryId(FK → Category, required),campaignId(FK → Campaign, nullable),campaignOrder(Int, position within its campaign)iconUrl/iconBlurhash,patchCount(denormalized count, recomputed server-side after publish — see comment atschema.prisma:44andcontent-entities.ts:227-229, never trusted as a live source client-side beyond driving the progress denominator)productId/productHandle/priceUsd— Shopify linkage (store domain, out of scope)storeVisible(Boolean, default true) — store catalog visibility (out of scope here)adminOnly(Boolean, default false) — see Configuration belowpublicPurchase(Boolean, default false) — see Configuration belowhatStyle,sortOrder- Relations:
category,campaign?,patches: PatchCollection[],purchases: UserPurchase[],submissions: PatchSubmission[]
Campaign(schema.prisma:73-95):id,name,tagline,description,heroImageUrl/heroBlurhash,color,sortOrder,categoryId(FK → Category, nullable,ON DELETE SET NULL; migration20261023130000_campaign_category) — its interest, the shelf it sits on in the Campaigns list; published content (content-entities.ts), synced ascategory_id, set in the admin campaign page's Interest picker (PATCH /api/admin/campaigns/:id{ categoryId }, 400 for an unknown category). Has manyCollection. Carries noadminOnly/visibility column of its own — its visibility is entirely derived from whether it has ≥1 visible member collection (see Configuration).PatchCollection(schema.prisma:247-258): pure join table,patchId+collectionId,@@unique([patchId, collectionId]). This is what lets one patch belong to multiple collections at once (e.g. a patch curated into both a region collection and, separately, a themed collection) — nothing in the schema prevents or limits multi-membership.UserPatch(schema.prisma:785-808): the collected-patch record progress is computed from —userId,patchId,collectedAt,source('gps' | 'import' | 'manual' | 'admin' | 'unknown'; only'gps'earns achievements, see unlock-and-location).- No
UserCollectionProgress,UserCampaignProgress,CollectionCompletion, orMasterPatch-shaped table exists anywhere in the schema. Grepping all 60+ models for anything progress/completion-shaped for collections/campaigns turns up nothing;Achievement/UserAchievement(schema.prisma:1766-1822) is the only reward table in the schema, and itsmetric/family/placeTypefields key offPatch.collectionType(a patch's type slug, e.g. "national_park") — never off a specificCollection.idorCampaign.id(backend/src/achievements/achievement-evaluator.service.ts:8,86). Completing a named collection or campaign has no achievement counterpart.
API surface (endpoints, auth requirements)
Mobile-facing (read):
GET /api/sync/content(viaSyncService.getContent,backend/src/sync/sync.service.ts:194) — returnscategories,collections,patches,patchTypes,patchCollections,campaignsin one payload, filtered perisAdmin(see Configuration). Standard app auth (not admin-only), optionalsinceparam for delta sync.
Admin-facing (all behind @UseGuards(AdminGuard), mutations additionally behind ContentWriteGuard — local-only per CONTENT_EDITING_ENABLED):
backend/src/admin/api/collections-api.controller.ts:GET /api/admin/collections,GET /:id,GET /:id/enrich-status,POST /(create),PATCH /:id,DELETE /:id,POST /:id/patches(add member patch),DELETE /:id/patches/:patchId, plus enrichment/image endpoints (:id/enrich-all,generate-missing-images,:id/generate-image) — out of scope for this doc.backend/src/admin/api/campaigns-api.controller.ts:GET /api/admin/campaigns(list, with_count.collections),GET /:id(detail incl. members + an "available to add" pool of campaign-less collections),PATCH /:id(edit name/tagline/hero/etc.),POST /:id/reorder(swap sortOrder with neighbor),POST /:id/collections(attach a collection, appends atmaxCampaignOrder + 1),DELETE /:id/collections/:collectionId(detach, setscampaignId: null),POST /:id/collections/:collectionId/reorder(swapcampaignOrderwith neighbor). NoPOST /api/admin/campaignsto create one — see "How it works" step 1.
Key files (annotated)
Backend:
backend/prisma/schema.prisma:18-86,247-258— Category/Collection/Campaign/PatchCollection models.backend/src/sync/sync.service.ts:194-327— the one query/serialization path that ships this content to mobile, including theadminOnlyfilter and the "campaign must have ≥1 visible member" rule (sync.service.ts:215-226).backend/src/content-publish/content-entities.ts:78-233— classifies which columns of Category/Campaign/Collection are published content vs. excluded (e.g.patch_countis excluded, always server-recomputed).backend/src/admin/api/campaigns-api.controller.ts— campaign CRUD (minus create) + member reordering.backend/src/admin/api/collections-api.controller.ts— collection CRUD + member patch attach/detach.backend/src/scripts/create-road-trips-campaign.ts,restructure-*-campaign.ts— how new campaigns are actually minted (direct Prisma script, not the admin API).
Mobile — data/derivation:
mobile/src/hooks/contentDerivations.ts:67-133—deriveCollectionsWithProgress,deriveCollectionsForCampaign,deriveCampaignsWithProgress: the one shared implementation of "how much of this collection/campaign is done," including the purchased-collection 100% short-circuit (lines 76-84).mobile/src/hooks/useCollections.ts,useCampaigns.ts,useCampaignStops.ts— thin hooks wrapping the above derivations with live query data (content + purchases + collected patches).mobile/src/hooks/useProfileData.ts:60-101— whereisComplete/completionDateper collection are computed for the profile screen;isComplete = collectedCount > 0 && collectedCount >= totalCount,completionDate= thecollectedAtof the most recently collected member patch. Purely a display derivation.mobile/src/domain/cityChallenges.ts— City Challenges resolution:supportedCities()derives oneSupportedCityper collection under thegreat-american-citiescampaign, pinning each to a state from its curated members;buildCityIndex()/cityForPlace()resolve an arbitrary photographed place to its city collection, curated membership taking priority over a name+state fallback match.
Mobile — honest-progress rules:
mobile/src/components/campaign/campaignCounts.ts— the single shared "how do we state a percentage honestly" module:started = collectedCount > 0;pctLabelisnull(nothing rendered) until started, then'<1%'ifMath.round(pct) === 0else`${pct}%`;barWidthPctis0when unstarted, elseMath.max(pct, 2)so one collected item always draws a visible sliver.mobile/src/components/campaign/CampaignShowcasePanel.tsx:122-129— rendered by no screen since 2026-10-06 (the list redesign; the list now follows the same rule incampaignTileLine). Where the rule used to land on screen: the progress hairline is only rendered{counts.started ? <track/> : null}— an unstarted campaign's gallery cover draws no progress track element at all (comment: "Never a full-width empty track, which is what made sixteen unstarted campaigns read as sixteen failures").
Mobile — screens/spines:
mobile/src/screens/buildCampaignsListData.ts— the campaigns list's pure derivation:buildCampaignsListData(campaigns, categories)(tiles, the stable most-collected sort, the started count, and the shelves by interest with "More" and the no-shelves fallback) andcampaignTileLine(the line under each tile).mobile/src/screens/CampaignsListScreen.tsx— the campaigns list:CampaignsListScreenViewModelImpl(useCampaigns,useCategories, the pill selection, navigation) and the pureCampaignsListScreenLayout(the pill rail, a section per shelf), plusCampaignsListSkeleton. Draws each tile with the sharedFamilyFrame(ring/RingParts.tsx) andMedallion(ring/Medallion.tsx, also the campaign page's stop medallion).mobile/src/query/queries/content.ts—needsFullContentResync: a cached content payload whose campaigns lack thecategoryIdkey (written by a build older than interests, whosetransformCampaigndropped it) is full-synced once instead of delta-synced, or those campaigns would never get their shelf.mobile/src/components/campaign/CampaignShowcasePanel.tsx— one full-bleed cover (photo + scrim + name/tagline/counts + optional progress hairline). Rendered by no screen since 2026-10-06; kept in the repo.mobile/src/screens/CampaignScreen.tsx— the campaign page, split into a purebuildCampaignScreenData(statusloading/not-found/solo/ready; the family, motif and kicker; map or spine; the stop medallions; "Keep going"; the honest percentage; the map model; the backdrop),CampaignScreenViewModelImpl(every hook: the route param,useCampaignStops, the fold pref, your location, the window width) and the pureCampaignScreenLayout, plusCampaignScreenSkeleton(the page-loader house style). Asolo-kind campaign hands straight toCampaignRingScreen.mobile/app/campaign/[id].tsxis only the route file that re-exports it.mobile/src/components/campaign/campaignMap.ts— pure:CAMPAIGN_MAP_IDS/campaignLayout(map or spine),clusterPoints(greedy screen-space clustering, ported from the lab'smap-cluster.js),heatDot(radius by count, capped; the collected core),buildCampaignMap(every patch once, clustered at the drawn width; Route 66's road east to west through its stops in order; you; what's off the map).mobile/src/domain/albersUsa.ts—projectUsMap(lat, lng): a port of d3-geo'sgeoAlbersUsafor a single point, at the exact fitusStateShapes.tswas generated with, so a patch lands in its own state. Returns null outside the lower 48 / Alaska / Hawaii boxes, as d3 does. Pinned to d3's own output in its test.mobile/src/components/campaign/ring/RingParts.tsx— the family header's pieces, shared by The Ring and the campaign page:Check,Motif(with asmallsize for the spine ring),RingGround,RingHeaderField(the backdrop photo +RingAtmosphere+ the fade),HeaderChrome(the glass BackIconButtonand a right-hand slot).mobile/src/components/campaign/CampaignCollectionScreen.tsx— the stop-level screen: one collection's member-patch grid under the parent journey's identity. Split intoCampaignCollectionScreenViewModelImpl(every hook,CampaignCollectionScreen.tsx:264) and the pureCampaignCollectionScreenLayout(CampaignCollectionScreen.tsx:378), the same house pattern asCampaignScreen.buildCampaignCollectionScreenData(CampaignCollectionScreen.tsx:198) is the one pure derivation ofstatus(not-found/ready), the distance sort, the all/collected/uncollected filter, the hero source and the percentage;campaignCollectionMotif(:152) picks the hero badge as a discriminator (flag/glyph/marker/none) andcampaignCollectionKicker(:174) the identity line — asolocampaign borrows the campaign's own kicker rather than repeating the title. It stays undercomponents/campaign/rather than moving toscreens/because it has TWO consumers:mobile/app/campaign-collection/[id].tsx(the route) andCampaignScreen'ssolobranch, which embeds it. The hero image is a member patch's own location photo (lowest patch id, so it is stable against the user's location), falling back to the campaign hero and then to nothing.mobile/src/components/campaign/CampaignRoad.tsx— rendered by no screen since 2026-10-06 (the campaign page redesign). "road" spine: an asphalt rail with a gold centerline, mile-marker nodes, per-stop fanned patch thumbnails (LayeredThumbnailStack) and a per-state progress bar (comment lines 1-4).mobile/src/components/campaign/CampaignTrail.tsx— rendered by no screen since 2026-10-06. "trail"/"list"/default spine: a dashed SVG footpath (strokeDasharray="3 8", chosen over RN's unreliableborderStyle: 'dashed', lines 24-28) with nature-glyph nodes instead of mile markers.mobile/src/components/campaign/CampaignTimeline.tsx— "timeline" spine (era U.S.-flag glyphs viaeraFlags.tsx). Rendered by no screen (it was unreachable even before the redesign: no campaign selectedkind: 'timeline').mobile/src/config/campaignThemes.ts— the per-campaign theme table:kind: 'road' | 'list' | 'trail' | 'timeline' | 'solo', kicker/route copy, start/end labels, per-collection marker/icon maps. The campaign page reads onlykind: 'solo'(hand straight to the ring), thekicker(sentence-cased byringFamily.campaignKicker) andgroupLabel("states", "theaters"); road / list / trail / timeline no longer pick anything.mobile/src/hooks/useCampaignStops.ts—buildCampaignStops: for each ordered member collection, up to 3 member patches as thumbnail specs, collected ones sorted first so "the front badge is one you have," andmembers— every member patch with its coordinates, state, location photo and whether it's yours (the campaign page's map and stop photos).mobile/src/components/campaign/CampaignRingScreen.tsx— The Ring (no flag; renamed fromCityChallengeScreenwhen it grew past cities). Same house split:buildCampaignRingScreenData(pure:loading/not-found/ready, the family — from the collection's owncampaignId— with its kicker and motif, the ring order andringPlan, the closest patch and its facts, the medallion and backdrop photos, the ember level, the filtered board),CampaignRingScreenViewModelImpl(every hook) andCampaignRingScreenLayout, plusCampaignRingSkeleton. The layout draws each family's frame round the medallion and its motif (the US 66 shield, anEraFlag, or aPipGlyph). Navigate and Collect both open/compass; every patch opens/patch-modal/<id>.mobile/src/components/campaign/ringFamily.ts— pure:CAMPAIGN_RING_FAMILY(all 24 campaigns placed — Lighthouses, Volcanoes, Fossils and Graveyards added 2026-10-21, American Icons and Four Corners 2026-10-22; an unknown one falls back to the city header),ringMotif(shield / era flag — 34 stars for the Civil War, the 13-star ring for the Revolution — / the campaign theme's glyph for that collection / the family's own),ringKicker("City Challenge"; the campaign's name; a solo campaign's tagline; the theme's kicker if the campaign row hasn't synced).FAMILY_GLYPHis each family's fallback glyph (after dark a ghost, habitat a fish, zoo a paw print, arena a trophy, coast the lighthouse, volcanic the volcano, fossil the shell, cemetery the headstone, liberty the star, corners the corner brackets, road a car — so Major Airports namesplaneas its themepipIcon).mobile/src/components/campaign/glyphNames.ts— pure: the glyph names a theme may use (REGION_ICON_NAMES, the hand-drawn nature glyphs inregionIcons.tsx;LUCIDE_PIP_NAMES, the lucide icons registered inpipGlyph.tsx) andisPipGlyph. Both component maps are typed against these lists. An unregistered name renders nothing, silently — which is why every theme's glyphs are checked against it in a test.mobile/src/components/campaign/ring/RingAtmosphere.tsx— the header atmospheres in Skia, one canvas and one UI-thread clock per header (still under reduced motion). The city's isEmbersBackground.quietleaves out what is drawn in a circle round the medallion (Route 66's neon tubes and bulbs, the wild lands' contours) — the campaign page sets it behind a map (a folded header mounts no atmosphere at all);scaleshrinks those ring-shaped elements (and the atlas globe) to a smaller medallion, e.g. the spine's 170pt ring.mobile/src/theme/narrative.ts→narrative.ring— each family's ground gradient, accents, atmosphere colours and completion colour.cityis exactly the brass/void tokens.mobile/assets/images/route-66-shield*.png— the 1926 US Highway 66 shield, public domain (a federal road-sign design), rasterised unmodified from Wikimedia Commons (File:US_66_(1926).svg).mobile/src/components/campaign/cityRing.ts— the ring's pure math, no React:withDistance,ringOrder(collected first, then nearest),closestOpen,closestFacts("1.5 mi SE" / "Walk 29m", or "You're here" underisAtPatch),ringPhoto(the next patch's location photo, else the collection hero),pickHeroPatch(the smallest-id-with-a-photo rule — shared withCampaignCollectionScreen),backdropFor(the wild lands' background photo, never the medallion's),groundColorAt(the ground's colour at a depth, so a fade ends on it),ringLayout,ringPlan(one ring of 50pt art up to 20 patches; two rings past that),emberLevel.mobile/src/components/celebration/skia/EmbersBackground.tsx— the celebration's spark field, now with optionalwidth/height/base/count/glow/palette/sizeprops. With no props it is unchanged; The Ring passes a header-sized, transparent field with its glow on the medallion.mobile/src/dev/mocks/campaign.tsx— the campaign page's DEV screen mock (scout://dev-screen-mock/campaign): one state per campaign that has a page (all fifteen — every campaign but the threesoloones), slugged by the screen's owncampaignLayout(map-route-66,spine-sports, …) and listed in the Campaigns screen's order. Route 66 asserts its road, National Parks its off-map parks, Civil War its "Keep going"; the rest assert their title. Then the edge states: an unstarted spine (Ghost Adventures, "0%"), a finished one (Wild West, "100%"), both folded headers, a campaign with no collections, loading and not-found. Rows:mobile/src/dev/fixtures/campaignScreen.ts— GENERATED bymobile/scripts/gen-campaign-fixtures.tsfromresearch/js/data.generated.js: every member patch's real coordinates and photo (City Challenges alone is 64 collections, 1,006 patches); progress is the lab's synthetic profile.mobile/app/campaign-collection/[id].tsx— picks the screen:CampaignRingScreenwhen the synced collection has acampaignId, elseCampaignCollectionScreen.mobile/src/screens/CampaignScreen.tsx'ssolobranch hands off toCampaignRingScreenas well.mobile/src/dev/mocks/campaign-ring.tsx— the DEV screen mock (scout://dev-screen-mock/campaign-ring): the city's started (interactive filters), new (standing in the zoo), close, complete and no-location states, then one started state per campaign (slug = campaign id —zoos,major-airports,state-parks, …, labelled by family), loading and not-found. Rows:mobile/src/dev/fixtures/campaignRing.ts, GENERATED bymobile/scripts/gen-campaign-fixtures.ts— one real collection of every campaign but City Challenges (whose ring is St. Louis): the research lab's pick where it made one (RING_PICKS: Route 66 Arizona, Pacific Coast Highway, National Parks California, Civil War Eastern Theater, Haunted Hotels & Inns, MLB Ballparks, 50 States Midwest), else the campaign's first collection; a solo campaign's only one. It renders black + colour accents by default, as the app does, plus aprevious-coloursstate with the flag off.mobile/src/screens/NewCampaignScreen.tsx— the new campaign page behindnew_campaign_screen: a purebuildNewCampaignScreenData(the shared core fromCampaignScreen'sbuildCampaignPageCore, then the header photo, Closest to you over every member patch, and the fold),NewCampaignScreenViewModelImpland the pureNewCampaignScreenLayout(the photo header, itsCollectionsBar, its own skeleton).mobile/app/campaign/[id].tsxpicks it by the flag.mobile/src/components/campaign/campaignDial.ts— pure:campaignDialPatches, a sample of a campaign's patches for a ring (every patch up to 40, once each; past 40 the campaign's share, collected first then nearest). Unused since the campaign page moved to the photo header (2026-10-08); kept, with its tests, pending a decision to delete it — as isRingAtmosphere'sAccentGlow, the still accent wash the ring header had.mobile/src/components/campaign/CampaignStops.tsx— the collection list both campaign pages draw (CampaignStopsSection: the grid or the spine) and its types.mobile/src/components/campaign/ring/closest.ts+ClosestCard.tsx— Closest to you, shared by The Ring and the new page:closestFromNext(pure) and the card (gold button, orAccentButtonon black + colour accents).mobile/src/components/campaign/ring/Fold.tsx— the fold, shared by the new campaign page and The Ring:FoldButton(glass open, the family's accent folded),FoldedTitle(the name and percentage as a centred glass capsule,TopBar.Heading) andfoldedBarHeight. The folds live incollectionsPrefs(collapsedCampaignIds,collapsedCollectionIds;toggleCampaignCollapsed,toggleCollectionCollapsed, persisted).mobile/src/components/campaign/ring/AccentButton.tsx— the flat accent CTA;RingParts.tsx'saccentsOnBlack(a family palette on black) andhexRgb;RingAtmosphere.tsx'spoolRgb(the accent pool) andAccentGlow(the new page's still wash).mobile/src/dev/mocks/new-campaign.tsx— the new page's DEV screen mock (scout://dev-screen-mock/new-campaign): every paged campaign started (its photo, its bar), then nothing collected, complete, no location fix, folded (Route 66 and the Civil War), loading and not found.IconButtongained an optionaltintso the folded button takes a family's accent instead of brass. Its fixture rows now carry each patch's name, art and blurb (scripts/gen-campaign-fixtures.ts).mobile/src/components/campaign/campaignRail.ts—stopState()(completed/in_progress/untouchedfrom collected vs. total),resolvePipGlyph(); also containsbuildCampaignRail/sampleEvenly, which per grep is currently unused by any shipped screen (dead code from a prior gallery variant, see Summary).
Configuration and flags
Collection.adminOnly(defaultfalse): whentrue, the collection is excluded from the mobile content sync entirely for non-admin sessions.SyncService.getContentapplies{ adminOnly: false }to the collection filter and{ collection: { adminOnly: false } }to the patch-collection join filter wheneverisAdminis false (sync.service.ts:194-213) — this is deliberately not gated onNODE_ENV(prod runs withNODE_ENVunset; an env-based gate previously leaked store-only packs into the app, per the comment atsync.service.ts:196-200). Admins see everything (for QA). This means "hidden from the earn-in-app catalog" — but calling it "store-only" was inaccurate before 2026-09 and this doc is correcting itself:adminOnlywas never actually a "still sold in the store" flag by design, it just happened to have no effect there.StoreCollectionsService.getCollections(backend/src/store-collections/store-collections.service.ts:195-238, filters onlystoreVisible: trueat:203) returnsadminOnlyas a plain field and always did — it never filtered on it, and for a long time neither did any of its four consumers (the in-app "Your sets" shelf filter, its collection-id fallback,getCollectionDetail's handle/id lookup, orgetProductContext). The result, confirmed live before the fix: 15 legacyadminOnlycollections the mobile app had stopped syncing (major-cities,us-states,national-parks,civil-war-battlefields,founding-revolution, and ten more) were still reachable and buyable as $8 "sets" through the in-app store — offering scouts sets they could never actually complete, since the app never gave them any members to collect. All five doors are now closed (docs/features/commerce-and-store.md's "Edge cases" has the detail — the fifth was the backend filter's own side effect, akindternary on the storefront product pages that defaulted the set gate'spublicPurchasetotruefor exactly the contexts the filter had started refusing);adminOnlynow means "hidden from the earn-in-app catalog and the store" everywhere except the genuinely-intentional exception,Collection.publicPurchasepacks, which areadminOnly: trueby design and stay store-visible on purpose (see below).- Campaign visibility is derived, not stored. A
Campaignrow has no visibility column;SyncService.getContentrequirescollections: { some: { adminOnly: false } }for non-admins (sync.service.ts:215-226), so a campaign whose every member collection isadminOnlynever syncs to a regular device at all — the whole campaign, not just its collections, disappears. Collection.publicPurchase(defaultfalse): "store-exclusive" / open-purchase flag — a collection's patches can be bought directly as a set without being earned via GPS check-in first (StoreCollectionsService,backend/src/store-collections/store-collections.service.ts:22-23,87-88,110-114). This is orthogonal toadminOnly:publicPurchasegoverns how a patch can be obtained (store purchase vs. earned unlock),adminOnlygoverns whether the collection appears in the in-app earn-it catalog at all. Full store mechanics are out of scope for this doc.new_campaign_screen(defaulttrue, both registries): the new campaign page (NewCampaignScreen) and black + colour accents on every collection's ring screen. Off:CampaignScreenand The Ring's family colours, both intact. No serverFlagsLoaded gate — the client default is ON, so a cold start draws the new page; only a server that has turned it off swaps it back. Nothing else gates categories/collections/campaigns as a feature — see Status above.
Edge cases and known limits
- Fixed 2026-10-06: the campaign page's percentage follows the honest-progress rules. It used to print a bare
Math.round(progress * 100), so a campaign one stop into 258 read "0%" on its own page while its gallery card read "<1%". The redesigned page printscampaignCounts'spctLabel— "<1%" once started; "0%" only for a campaign with nothing collected. - The map's collected cores count patches you hold; the medallions count the collection's progress. A collection bought in the store reads 100% on its medallion (the
purchasedCollectionIdsshort-circuit) while its patches' dots on the map stay uncollected, because the map is built fromuser_patches. Both are the existing rules; the page just shows both. - A campaign's map clusters at the width it's drawn. Neighbours within 9pt merge — on a phone that is roughly 100 km — so nearby patches (two in the same city, or St. Louis's edge) share one dot. A tablet draws more, smaller dots.
patchCountis a denormalized column, recomputed server-side after every content publish (content-entities.ts:227-229), not derived live frompatch_collectionson the client. If it ever drifts from the real join-table count between publishes, every progress percentage for that collection is wrong until the next publish/recompute.- A patch can be a member of multiple collections simultaneously (
PatchCollectionhas no uniqueness constraint beyond[patchId, collectionId]itself), so collecting one patch can move the needle on several collections' — and thus several campaigns' — progress at once. No code path treats this as unusual; it is the normal case for, e.g., a patch curated into both a themed collection and a City Challenge collection. - City Challenge state assignment can fall back to a majority vote.
supportedCities()(cityChallenges.ts:78-97) prefers the state of a member patch whosecitystring name-matches the collection, but falls back to "whichever state has the most members" when no member'scityfield matches the collection name exactly — a city collection with no exactly-named member could theoretically be assigned the wrong state. CampaignCollectionScreenhas no loading and no error branch. It readsuseContent()andusePatches()for their DATA only and ignores both hooks'isLoading/error, socollectionis null until the first content sync lands — and the screen renders "Collection not found." for the whole of that window, and again if the sync fails outright. On a warm start the catalog is already cached and nothing is visible; on a cold start from ascout://campaign-collection/<id>deep link it is a real flash of a false error. Its siblingCampaignScreendoes distinguish the two (status: 'loading' | 'not-found'). Not fixed as of 2026-09-04; the screen-mock gallery deliberately registers noloadingorerrorstate because either would be a pixel-identical copy ofnot-found.- The hero has a third fallback with no art in it.
heroUriis a member patch's location photo, else the campaign'sheroImageUrl, elsenull— and with both gone the 322dp hero is just the scrim over bare canvas, with the kicker and title floating in it. No shippable collection reaches this today (every one has at least one member patch with a location photo), but a hero-less campaign plus unenriched patches would; seescout://dev-screen-mock/campaign-collection?state=no-hero-art. - The Ring is chosen only once content has synced. The route needs the collection's
campaignId, so a coldscout://campaign-collection/<id>deep link shows the campaign-collection screen (and its "Collection not found." flash, below) until the sync lands, then swaps to The Ring. A warm start has the catalog cached and goes straight to The Ring. - The Ring reorders as you collect. Patches sit collected-first round the ring so one arc can cover them, so collecting one moves it to the lit side; and the patch in the middle — and the photo behind the ring — follow the next patch you need, so they change too. Both by design.
- "Closest to you" is straight-line nearest, not a route. It is the nearest uncollected patch by haversine distance, with the Home hero's walk/drive estimate (
travelFromMeters: on foot up to 2 mi). With no location fix the section is left out entirely and the board shows no distances. - Big collections get two rings. One ring holds up to 20 patches (City Challenges run 10–18; past ~17 the art shrinks a little to keep 92% of its arc). Past 20 — Aquariums 21, MLB Ballparks 29, Zoos 36, Major Airports 40 — the patches split across two concentric rings round a smaller medallion, so even 40 keep about 36pt art (
ringPlan). - A campaign added later gets the city's header until it is placed in
CAMPAIGN_RING_FAMILY. While a patch is still to collect, every family lays that patch's place photo behind the ring, so the art in the middle and the photo behind it are always one place. A finished collection shows the photo circle and "Complete"; only the wild lands then keep a backdrop, and it is never the medallion's photo. A next patch with no location photo falls back to the collection hero. On a busy backdrop photo the 38%-opacity patches read less clearly than on a plain ground (known, not yet tuned). - A
solo-kind campaign (Zoos, Aquariums, Major Airports, Four Corners) has no campaign page at all —campaign/<id>hands straight to its one collection's ring (CampaignRingScreen); there is no intermediate page for these. - Lists are never downsampled. In the collection list, City Challenges' 64 cities are 64 medallions in its grid, State Parks' 50 collections are 50 stops on its spine. The fold exists so the list can start near the top. (
sampleEvenly/buildCampaignRailincampaignRail.tsare still dead code.) - The new page's collections bar thins with the campaign. One segment per collection, as wide as its share of the patches, with a 2pt floor — City Challenges' 64 cities make 64 hairline segments. It is decorative (hidden from screen readers, which hear the progress line); the list below is where each collection is named.
- The new page's Closest to you spans the whole campaign. It is the nearest uncollected patch in any of the campaign's collections, straight-line (haversine), as on The Ring — not the next one in campaign order.
What this feature does NOT do
- It does not award anything for completing a collection or campaign. No achievement, no XP, no badge, no "Master Patch." This was explicitly checked against
Achievement/UserAchievement/achievement-rules.ts/achievement-evaluator.service.ts— the achievement system keys entirely offPatch.collectionType(a type slug) and place counts, never offCollection.idorCampaign.id. Completion only flips a client-computedisComplete/progress === 1boolean used for display (a checkmark node, a "Complete" label, a profile-screen tally). - It does not persist progress anywhere. There is no server-side progress table for collections or campaigns; every screen recomputes it from
user_patcheson every render. - It does not gate content behind campaigns. A patch's location-unlock (geofence) is completely independent of collection/campaign membership — a patch can be physically unlockable regardless of whether its parent campaign or collection has been "started."
- A campaign sits on exactly one shelf. There is no multi-interest campaign and no tag list;
Campaign.categoryIdis a single nullable FK. The pill selection is not remembered between visits. Interests do not change collection categories: patch detail and Favorites still label a collection by the collection's own category, and the five interest-only categories (Road trips, Bucket lists, Landmarks, Spooky, Animals) label no collection today. - It does not let admins create a new Campaign row through the admin UI/API. New campaigns require a hand-written script run against the database directly (see "How it works," step 1) — the admin API only edits, reorders, and (dis)connects existing campaigns/collections.
- "City Challenges" is not a distinct data type. There is no
Collection.typeorkind: 'city'column. It is one campaign (great-american-cities) with curated per-city member collections; the "which city does this photo belong to" logic (cityForPlace) is entirely client-side, computed frompatch_collectionsmembership plus a name/state string-match fallback, not a queryable server property. - The Ring does not award, promise or advertise anything for finishing a city. The complete state shows "All N patches collected" and "Complete" on the medallion, and nothing else — a "Your reward · 20% off" section was designed and deliberately removed (2026-10-05) because nothing confirms that discount is real.
- The Ring's Collect button does not collect. Navigate and Collect both open the compass screen; collecting still happens the way it always does (arriving inside the patch's polygon). The label only says which one you need.
- The Ring does not plan a route, or show distances on the ring. No suggested order, no walking areas, no map — those were round-1 lab directions that were not chosen. It applies to every campaign's collections; a collection with no campaign keeps the campaign-collection grid.
- The Ring never tints the patch art. With
new_campaign_screenoff, a family's colour reaches the ground and the accents only and the Navigate button stays gold; with it on (the default) the ground is black and the button takes the family's accent — the patch art is never recoloured either way. It uses no third-party logo: the Route 66 shield is the public-domain federal highway marker, not any association's or business's mark. - The flag-off campaign page has no call to action and no map interaction. No "Keep going" section and no gold button (both were in the lab and deliberately removed — every collection is one tap away); the map is a picture, not a control: no zooming, panning, tapping a dot or a state, and no spotlighting a collection on it (a lab direction that was removed). It does not show distances to collections, plan a route, or say which campaign to do next.
- The new campaign page has no ring and no map. The ring is the collection screens'; the page's header is the campaign's photo and a bar, and the bar is not a control. Folding it hides the header only — Closest to you and the collections never fold. Its only call to action is Closest to you's Navigate/Collect, which opens the compass and does not collect. Black + colour accents is not a user setting: it is the flag, for everyone.
- The campaign map is not a share card. It is drawn on screen only; the National Parks and 50 States share cards are separate (
ParksShareCard,StatesShareCard) and keep their own fixed-size dots. - It does not verify or transmit collection-completion proof to the store. The Home screen's "20% off, earned not bought" completion banner (
mobile/src/components/home-v3/PlaceBody.tsx:225-259) routes to a generic signed-in store WebView (mobile/app/store-webview.tsx) with no completion token, discount code, or collection id passed — whatever enforces that discount, if anything does, lives entirely in the store domain (out of scope here; not verified by this document).
Tests that cover it
Mobile (unit, pure-function level, jest):
mobile/src/components/campaign/__tests__/campaignCounts.test.ts— the honest-progress rules directly: unstarted campaigns getpctLabel: null/barWidthPct: 0; 1-of-258 renders'<1%'with a flooredbarWidthPct: 2; started campaigns state a real percentage; solo/single-collection campaigns hide the group count; divide-by-zero safety for an empty campaign.mobile/src/hooks/__tests__/store.campaign-progress.test.ts—deriveCollectionsWithProgress/deriveCollectionsForCampaign/deriveCampaignsWithProgressagainst a small Route 66 fixture (Illinois/Missouri member collections + a non-member Chicago collection), asserting correct membership filtering and ordering.mobile/src/hooks/__tests__/useCampaignStops.test.ts— the thumbnail-fanning derivation (buildCampaignStops), including the "collected members sort first" rule, andmembers: every member patch (not just the three fanned) with its place and whether it's yours.mobile/src/hooks/__tests__/store.campaigns.test.ts,campaignsPersisted.test.ts— campaign hook/query behavior.mobile/src/config/__tests__/campaignThemes.test.ts— theme table sanity (every campaign resolves to a validkind, etc.).mobile/src/components/campaign/__tests__/campaignRail.test.ts—stopState/resolvePipGlyph/buildCampaignRail(the last of which, per the Summary above, is otherwise dead code — tested but unused).mobile/src/hooks/__tests__/useCollections.test.tsx—useCollections/useCollectionprogress derivation.
Mobile (unit, The Ring):
mobile/src/components/campaign/__tests__/campaignDial.test.ts— the (currently unused) ring sample: every patch under the cap, a shared patch once, the share past the cap (26 of 1,006 → 1 of 40; 47 of 89 → 21 of 40), collected first then the given order, and the two rounding guards (1 of 1,006 still lights one; 1,005 of 1,006 still shows one open). Each rule confirmed failing with it removed.mobile/src/components/campaign/__tests__/cityRing.test.ts— every derivation over the 15 real St. Louis rows: distance/bearing sort, collected-first ring order, closest patch (and null when complete or with no fix), the distance/journey facts ("You're here", walk vs drive), the shared hero rule, the medallion photo and its fallbacks,backdropFor(never the medallion's photo),groundColorAt, the ring layout,ringPlan(one ring to 20, two rings that never touch and clear the frame for 21/29/36/40), and the ember level.mobile/src/components/campaign/__tests__/glyphNames.test.ts— every glyph every campaign theme and family names is onePipGlyphcan draw. Confirmed failing on the 18 audit glyphs before they were registered inLUCIDE_PIP, where they would have rendered blank.mobile/src/theme/__tests__/ringPalette.test.ts— the accent rules overnarrative.ring.families: chroma ≥ 0.08, every pair ≥ 0.09 apart (OKLab), Navigate label ≥ 4.5:1. Confirmed failing on the pre-2026-10-09 palette (History 0.065, Arena 0.024, City = Atlas and eight more pairs too close).mobile/src/components/campaign/__tests__/ringFamily.test.ts— every live campaign placed, the agreed families (Zoos its ownzoofamily, not Aquariums' habitat), the city fallback, motifs (the shield, the era flags, a theme's own glyph winning over the family's, the family fallback, and every glyph the 2026-10-09 icon audit changed — Zoos' paw print, Major Airports' plane, Sports' trophy, Ghost Adventures' house / bed / lock / skull / cross, Roadside Attractions' eight, Road Trips' six, the South's sun; each case failed against the old map, which gave the asylums a snow peak and Big Food wheat) and kickers (city, a stop, a solo campaign's tagline, the theme's kicker with no campaign row).
Mobile (screen tier, jest.screens.config.js):
mobile/screen-tests/campaign-ring.test.tsx— The Ring through the real routes: which screen mounts (campaign vs no campaign — a pair that falsifies itself: a route that never mounts The Ring fails 20), the city's ring, medallion, progress, Closest to you, completion and board (as before), and every family from a real collection: Route 66's header, shield and atmosphere; the city with no motif; National Parks' backdrop (never the medallion's photo) and region glyph; the Civil War's flag; MLB Ballparks on exactly two ring radii (forcing one ring fails it); and Aquariums handed off from its solo campaign page with its tagline.campaign-collection-id.test.tsxkeeps its "CITIE" kicker regression by mountingCampaignCollectionScreendirectly.mobile/screen-tests/campaign-new.test.tsx—new_campaign_screenON through the real routes, on whole real campaigns from the generated fixture: the campaign's own photo under the header (and its first collection's when it has none), no ring and no map; the collections bar (a segment per collection sized by its patches — 12, 14, 3, 4 for the Civil War — filled to its progress, part-way at 3.4% for one ballpark of 29, all full when complete, in the family's accent); Closest to you near the Gemini Giant (Navigate → compass, info → detail), across the campaign (by Meramec Caverns, Route 66's second state), standing on Antietam (Collect), absent with no fix and when complete; the collection list and its taps; the fold (opens unfolded; folding hides the photo, title and bar and puts "Route 66 13%" in the top bar, remembered for the campaign; unfolding forgets it; per campaign; the button glass open and the family's accent folded; "0%" untouched and "<1%" for one patch of State Parks' 250); black page and accent kicker and button, each paired with the flag off; the Aquariums ring on black (solo hand-off, flat accent CTA, no board checks, 38% on what you lack, Closest at full strength) paired with the flag off; the Zoos ring in its own family (itszooheader, its green CTA and paw print — confirmed failing while Zoos was still habitat) paired with Aquariums keeping the teal and fish; and its fold ("Aquariums 10%" in the bar, remembered per collection, opening folded in the accent, unfolding, the campaign's fold not folding it, no fold with the flag off). Confirmed failing with each of: the family ground kept, the CTA left gold, the first collection's photo over the campaign's, no photo fallback, equal segments, segments ignoring progress, the bar in brass, Closest from only the first collection, the board undimmed, the checks kept, the ring screen's ground kept, the route ignoring the flag, the header never folding, no top bar, the fold button left brass, the fold not read from the store, no percentage, a plain rounded percentage (1 of 250 → "0%"), and the collection fold not read, written to the campaign list, working with the flag off, or never hiding the ring.mobile/screen-tests/campaign-id.test.tsx— pinsnew_campaign_screenOFF (as doescampaign-ring.test.tsx): the flag-off campaign page end to end through its route, on real catalog rows (Route 66's Illinois and Missouri, three Civil War theaters, National Parks' Islands & Shores): the spine (the ring with the era flag and "33%" inside, the progress line, theaters in campaign order with the finished one checked, "Keep going" on the part-way theater, "Start here" on the first — or the one nearest you — with nothing collected, "<1%" once started, a tap opening the ring); the map (kicker and US 66 shield, "All 2 states", six patches drawn as four heat dots, a solid core only on what you hold and none with nothing collected, the road, you on the map with a location and not without, "+2 off the map (American Samoa, United States Virgin Islands)", a tap opening the ring); National Parks' pinned New River Gorge backdrop over the first region's Arches (confirmed failing before the pin) and its fallback without the patch; the glass header chrome and the fold's accent-while-folded state; the fold (map and spine, persisted per campaign, honoured on mount, another campaign's fold not leaking, and no atmosphere mounted while folded — confirmed failing before the fix); and "Campaign not found." The "<1%" and fold tests were each confirmed failing against deliberately broken code.mobile/screen-tests/campaign-ring.test.tsxalso pins The Ring's header buttons as the glass chrome — confirmed failing against the 1.1.100 ring screen, whose buttons were opaque.mobile/src/domain/__tests__/albersUsa.test.ts— the projection against d3-geo's own output for 11 places across all three of Albers USA's pieces (lower 48 corners, Alaska, Hawaii), and null for Puerto Rico, American Samoa, London and non-numbers; confirmed failing with Alaska's rotation broken.mobile/src/components/campaign/__tests__/campaignMap.test.ts— map-vs-spine for every campaign (and a future one), the clustering rules, the heat dot's size and core, a shared patch drawn once, clustering at the drawn width, the off-map names, Route 66's road order, and you.mobile/src/domain/__tests__/store.campaignCollapsed.test.ts— both folds (campaigns and collections, one parametrised block): toggling, per entity, leaving the other collections prefs and the other fold alone, persisted, and an older persisted store loading everything unfolded. Confirmed failing with the collection fold written to the campaign list.mobile/screen-tests/campaigns.test.tsx— the campaigns list on real rows with real derived progress: a tile per campaign under the bar with the overall count; most collected first (a finished campaign, then one under way, then an untouched one — confirmed failing with the sort removed) and, paired, the app order when nothing is collected; the three tile lines and the finished check; a tap opening the campaign; no tile with a background of its own; and the empty state with no count. Shelves by interest (2026-10-23): a shelf per interest in display order, each shelf's started count, a pill narrowing the list to one shelf and All bringing it back, an uncategorised campaign on More, the picked shelf going away falling back to All (confirmed failing without the fallback), and — the paired opposite — no campaign on an interest keeping the single grid with no rail; the shelf tests were confirmed failing with the builder's shelves blanked.mobile/src/screens/__tests__/buildCampaignsListData.test.ts— the shelf grouping: display order, no shelf for a category without campaigns, the sort within a shelf, started counts, More for an unset or unknown interest, and the flat fallback.mobile/src/query/__tests__/content.test.ts(campaign interests) —category_idmapped; a cache from an older build full-syncs and is replaced (confirmed failing withoutneedsFullContentResync); a current cache,categoryId: nullincluded, keeps the delta.backend/src/sync/__tests__/sync.service.campaigns.spec.ts,backend/src/admin/api/campaigns-api.spec.tsandbackend/admin-ui/src/pages/CampaignDetailPage.test.tsx—category_idon the sync payload (null included); setting, clearing and rejecting an unknown interest, through the whitelisting pipe; the admin Interest picker and its save payload.mobile/screen-tests/screen-mocks.test.tsx— renders every state of the DEV screen-mock gallery, including thecampaignstates (mobile/src/dev/mocks/campaign.tsx): one per paged campaign, an unstarted spine, a finished campaign, both folded headers, a campaign with no member collections, loading and not-found. It also renders the elevencampaign-collectionstates (mobile/src/dev/mocks/campaign-collection.tsx,scout://dev-screen-mock/campaign-collection): the Zoossolohand-off (interactive — its view toggle and filters really work), photo-card view, part-way and fully-collected progress, both empty filters, an empty grid, the trail glyph + singularised "· REGION" kicker on the longest collection name in the catalog, the Route 66 mile marker, the bare hero with no art at all, and not-found. Fixtures are four real shippable collections (Zoos, Route 66's Illinois, National Parks' Appalachia & the Mid-Atlantic, O'Fallon Illinois) with all 57 of their real member patches; the only synthetic fields areisCollected(progress is user state; the catalog's best real campaign progress is 2%) and the nulled location photos on the no-hero state. Each state asserts content only it produces, so a mock that has silently stopped tracking the screen fails the build.mobile/scripts/__tests__/gen-campaign-fixtures.test.ts— re-runs the campaign fixture generator and diffs both files against the committed ones, and fails if any campaign is missing from either mock: the page fixture must cover every campaign whose theme is notkind: 'solo'(the app's own rule), the ring every campaign but City Challenges. Falsified by restoring the old five-campaign / eight-family fixtures (4 failures).
Backend:
backend/test/sync/sync.service.spec.ts— exercisesgetContent()including the campaign query path (mockedprisma.campaign.findMany); does not appear to specifically assert the "campaign with all-adminOnly members is hidden" rule by name in the excerpt reviewed — worth double-checking if that exact behavior needs its own regression test.- No backend spec file found that specifically targets
campaigns-api.controller.tsorcollections-api.controller.tsCRUD by name in this pass (not exhaustively verified — see Open questions).
Open questions
- Whether
backend/test/sync/sync.service.spec.tsactually asserts the "a campaign with onlyadminOnlymember collections stays hidden from non-admins" rule (sync.service.ts:215-226) as its own test case, versus only covering it incidentally, was not fully confirmed — the file was sampled, not read end to end. - Whether the store's "20% off, earned not bought" discount for a completed City Challenge is enforced anywhere (a real Shopify discount code, a signed URL, a server check) is unverified — it is explicitly out of scope for this document (store domain), and the one code path traced (the Home completion banner → generic store WebView) does not pass any proof of completion.
- Whether any admin-facing UI exists to create a brand-new
Campaignrow at all (versus only via a one-off script) was checked only against thecampaigns-api.controller.tsREST surface; it's possible a distinct admin UI flow callsPOST /api/admin/collectionsand then attaches it in a way that amounts to "creating" a campaign through some other endpoint not reviewed here — but no such endpoint was found in the controller. - Non-campaign collections (
campaignId: null, e.g. themed one-off sets) have no dedicated screen route found in the deep-link registry — how a user actually reaches one in the current app was not traced (likely via a category/patch-browser surface owned by another doc's scope).