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. - 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.
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:
- The campaign detail screen's own progress bar/percentage does not apply the "never print 0%" rule — only the gallery cover (
CampaignShowcasePanel) does, viacampaignCounts.ts. See "Edge cases" below. 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, 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)
scout://campaigns→mobile/app/(drawer)/campaigns.tsx→CampaignsListScreen(mobile/src/screens/CampaignsListScreen.tsx) — the campaign gallery. Drawer entry point.scout://campaign/<campaignId>→mobile/app/campaign/[id].tsx(a one-line re-export) →CampaignScreen(mobile/src/screens/CampaignScreen.tsx) — one campaign's detail/spine screen (Ken-Burns hero + themed spine).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).- 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-34):id,name,displayName,displayOrder,iconName,imageUrl,color. Has manyCollection.Collection(schema.prisma:36-68):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:70-86):id,name,tagline,description,heroImageUrl/heroBlurhash,color,sortOrder. 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' | 'unknown').- 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— where the rule actually lands 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/CampaignsListScreen.tsx— the campaign gallery:ScrollViewwithsnapToInterval={viewportHeight / 2}, so a flick always lands on exactly two full covers, never a clipped one (lines 62-73).mobile/src/components/campaign/CampaignShowcasePanel.tsx— one full-bleed cover (photo + scrim + name/tagline/counts + optional progress hairline).mobile/src/screens/CampaignScreen.tsx— campaign detail, split intoCampaignScreenViewModelImpl(every hook,CampaignScreen.tsx:147) and the pureCampaignScreenLayout(CampaignScreen.tsx:214), the house view-model pattern.buildCampaignScreenData(CampaignScreen.tsx:116) is the one pure derivation ofstatus(loading/not-found/solo/ready), the spinevariantand the hero source; the Ken-Burns hero lives inHeroBackdrop(scale 1.16→1, opacity 0→1,CampaignScreen.tsx:182-207); the layout switches onvariantto one of three spine renderers (CampaignRoad/CampaignTimeline/CampaignTrail,CampaignScreen.tsx:319-325); asolo-kind campaign skips the spine entirely and hands off straight toCampaignCollectionScreen(CampaignScreen.tsx:258-260).mobile/app/campaign/[id].tsxis only the route file that re-exports it.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— "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— "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). Currently selected by no campaign at all — Civil War and Founding & Revolution are bothkind: 'trail'in the theme table — so this renderer is live code on an unreachable branch.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.listand the unthemed default both render viaCampaignTrail(onlyroadandtimelineget their own branch —CampaignScreen.tsx:319-325;campaignSpineVariantatCampaignScreen.tsx:103is the mapping).mobile/src/hooks/useCampaignStops.ts:30-53—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."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.- No feature flag gates categories/collections/campaigns as a feature — see Status above.
Edge cases and known limits
- Detail-screen progress does not apply the honest-progress rules.
campaignProgressPct(mobile/src/screens/CampaignScreen.tsx:106-107) computespct = Math.round(progress * 100)directly and renders`${pct}%`and awidth: ${pct}%fill with no floor and no<1%/null suppression — unlike the gallery cover, a campaign one stop into 258 (0.4%) would show a literal "0%" and a zero-width bar on its own detail screen's hero, even though the gallery card for the same campaign correctly shows "<1%" and a 2%-wide sliver. This is a real inconsistency between the two screens, not a guess — both code paths were read directly. 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. - A
solo-kind campaign (Zoos, Aquariums, Major Airports) has no spine at all — tapping it on the gallery routes straight toCampaignCollectionScreenfor its one collection; there is no intermediate "campaign detail" moment for these. - Large trail campaigns (e.g. 54-city Great American Cities) are handled by the dead
sampleEvenly/buildCampaignRaildownsampling logic incampaignRail.ts, but since nothing currently renders that rail, there is no live UI path that actually downsamples a long list of stops —CampaignRoad/CampaignTrailrender every stop in the campaign, unsampled, in the detail screen's spine.
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."
- 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. - 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.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 (screen tier, jest.screens.config.js):
mobile/screen-tests/campaign-id.test.tsx— the detail screen end to end through its route: title/tagline and hero progress derived from seeded member collections; the "Campaign not found." branch for an id with no matching campaign; the trail stop cards' fanned patch art (present,contentFit: 'contain', vertically centred) and its falsification with nopatch_collectionsjoin rows.mobile/screen-tests/campaigns.test.tsx— the gallery.mobile/screen-tests/screen-mocks.test.tsx— renders every state of the DEV screen-mock gallery, including the tencampaignstates (mobile/src/dev/mocks/campaign.tsx): the road / trail / timeline skins, the no-hero + unthemed fallback, zero / partial / complete progress, 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.
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).