Summary
A signed-in member can bookmark a patch, a collection or a campaign,
and everything they've bookmarked appears on one screen at scout://favorites,
grouped by kind with a filter rail. It is explicit curation only — the screen
shows exactly what the user tapped a bookmark on, and nothing is inferred from
what they've been doing.
Favoriting a patch and favoriting that patch's community board are the same act. A board in Scout is a patch's board, so there is one row per patch and the bookmark on the board header and the one on patch detail toggle it.
This replaced a narrower, patch-only feature: board_favorites plus a horizontal
strip of favorited boards on the Community hub. Both are gone (see What this
feature does NOT do).
Status
Shipped. Not behind any feature flag. No FeatureFlag/StoreFeatureFlag row
governs it, and no config gates it.
Member-only. Guests — who otherwise have real backend sessions and can post,
comment and import photos — cannot favorite anything. Every route is behind
JwtAuthGuard (backend/src/favorites/favorites.controller.ts:14), the drawer
row is gated isSignedIn rather than hasSession
(mobile/src/components/navigation/drawerSections.ts), and tapping a bookmark as
a guest raises the sign-in prompt without firing a request
(mobile/src/components/favorites/FavoriteButton.tsx:46).
User-facing surfaces
- Favorites screen —
mobile/app/(drawer)/favorites.tsx→FavoritesScreen(mobile/src/screens/FavoritesScreen.tsx), deep linkscout://favorites, drawer row Favorites in the YOU section (between Community and Import). Registered inmobile/src/dev/deepLinkRoutes.ts. Its chrome is the achievements header (mobile/app/achievements.tsx): a reserved back-button row, the title Your Favorites stacked below it with the saved count right-aligned against it, then a full-bleed pill rail. There is no hamburger on this screen — the back control (favorites-back) is the only top chrome, so the drawer is not reachable from here; it falls back to/when there is no stack to pop, which is the coldscout://favoritescase. - Bookmark on patch detail — in the hero's top-right chrome, beside the vouch
heart (
mobile/src/components/patch-detail-v2/HeroSwiper.tsx, testIDpatch-hero-favorite). Renders as the design system'sIconButton,glasswhen off andaccentwhen on. - Bookmark on a patch's community board — the board header
(
mobile/src/screens/PatchCommunityScreen.tsx, behind the routemobile/app/patch-community/[id].tsx; testIDcommunity-favorite). Same favorite as the patch's. - Bookmark on campaign detail —
mobile/app/campaign/[id].tsx, testIDcampaign-favorite, mirroring the back button on the opposite edge. - Bookmark on a campaign collection —
mobile/src/components/campaign/CampaignCollectionScreen.tsx, testIDcampaign-collection-favorite.
What the screen shows
Three sections in fixed order — Campaigns, Collections, Patches — each with a
brass uppercase heading and a count, newest-first inside each. Above them a pill
rail (All N, then one pill per kind with its count) narrows to a single kind
without collapsing the grouping, so the screen keeps one shape rather than
becoming a second layout. The rail carries no bottom padding — the section
heading's own top margin is the entire gap, and stacking both left a dead band
under the tabs. A kind with zero favorites keeps its pill, dimmed, so the rail
does not reflow under the thumb when the last item of a kind is removed.
Rows use the same idiom as Near Me's distance bands
(mobile/src/components/near-me-v3/DistanceBands.tsx) and are deliberately kept
in step with it: no card chrome, a 52px thumbnail, a serif name
(type.display.cardSmall), one meta line, and a gold data stat pinned right
in the slot Near Me fills with a distance. Section heads are brass, matching Near
Me's band heads.
The stat is collected/total for a campaign or collection (e.g. 1/11) and a
post count for a patch (1 post / 4 posts). Campaign and collection rows also
carry a thin progress bar under the name; patch rows have none. That pair —
progress bar, plus patch art rendered contain on a raised swatch while a
photograph renders cover — is what makes the row kinds tellable apart without a
label. The bookmark sits outside the row's press target, exactly as Near Me's
navigate button does, so unfavoriting is not also a tap that opens the thing you
just removed. Tapping a row opens /patch-modal/<id>,
/campaign-collection/<id> or /campaign/<id>.
How it works
- Toggle.
FavoriteButtoncallsuseFavorites().toggle(kind, entityId).wasis read from the TanStack cache, not the rendered list, so two taps in one frame don't both act on the same stale decision (mobile/src/hooks/useFavorites.ts). - Optimistic write.
favoriteToggleMutation(mobile/src/query/queries/favorites.ts) cancels in-flight list queries, snapshots the cache, writes optimistically, rolls back inonErrorand invalidates inonSettled. Rows are matched on kind AND entityId, never id alone. - Request.
POSTorDELETE /api/favorites/:kind/:entityId. - Server.
FavoritesServicevalidates the kind, checks the entity exists, then upserts on the(user, target)unique index — so favoriting twice is one row. Removal is adeleteMany, so unfavoriting something already gone is a no-op. - Read.
GET /api/favoritesreturns a newest-first list of{ kind, entityId, createdAt, postCount? }— ids only. - Resolution on-device.
FavoritesScreenjoins those ids against the content already cached fromGET /api/sync/content, and recomputes collection and campaign progress locally viaderiveCollectionsWithProgress/deriveCampaignsWithProgress— the same client-side derivation the rest of the app uses. Nothing about progress is stored or sent.
Why the API returns ids
The app already caches every patch, collection and campaign under one query key.
Hydrating favorites server-side would ship a second, staler copy of content the
client already holds. postCount is the single exception — post counts are not
in the sync payload, so patch rows carry one.
Data model
UserFavorite → user_favorites (backend/prisma/schema.prisma):
id(uuid),userId,kind('patch' | 'collection' | 'campaign'),createdAtpatchId?/collectionId?/campaignId?— real FKs topatches,collections,campaigns, allonDelete: Cascade@@unique([userId, patchId]),@@unique([userId, collectionId]),@@unique([userId, campaignId])— Postgres allows unlimited NULLs in a unique index, so each constrains only rows of its own kind@@index([userId, createdAt(sort: Desc)])— drives the list queryuser_favorites_one_targetCHECK (migration only; Prisma cannot express it): exactly one FK is set andkindnames that same column
Three FKs rather than a polymorphic (entity_type, entity_id) pair because
patches and collections are genuinely deleted through the content-publish flow.
Without the cascade, a favorite would survive as a dangling id and render as a
card with no name; the alternative is a scheduled sweep that has to know every
entity type.
user_favorites is user data, not publishable content, so it is deliberately
absent from backend/src/content-publish/content-entities.ts — same as
user_patches.
API surface
All JwtAuthGuard, all in backend/src/favorites/favorites.controller.ts:
| Route | Behavior |
|---|---|
GET /api/favorites |
Newest-first FavoriteDto[]; postCount on patch rows |
POST /api/favorites/:kind/:entityId |
Idempotent upsert. 400 unknown kind, 404 unknown entity |
DELETE /api/favorites/:kind/:entityId |
Idempotent delete, always { isFavorited: false } |
Registered in backend/api-tests/route-registry.ts and driven by an api test.
Key files
Backend:
backend/prisma/schema.prisma—UserFavorite, and thefavoritesback-relations onPatch,Collection,Campaign.backend/prisma/migrations/20260902120000_user_favorites/migration.sql— creates the table, copiesboard_favoritesforward, drops it.backend/src/favorites/favorites.service.ts— all data access;COLUMNmaps kind → FK column and must agree with the CHECK constraint.backend/src/favorites/favorites.controller.ts,favorites.module.ts,dto/index.ts.backend/src/achievements/feature-context.ts— theboard_favoritedachievement metric now counts patch favorites.
Mobile:
mobile/src/api/favorites.ts—favoritesApi,Favorite,FavoriteKind.mobile/src/query/queries/favorites.ts— query + optimistic toggle.mobile/src/hooks/useFavorites.ts— the one hook.mobile/src/components/favorites/FavoriteButton.tsx— the one toggle,bareandchromevariants.mobile/src/components/favorites/FavoriteRow.tsx,FavoritesSkeleton.tsx.mobile/src/screens/FavoritesScreen.tsx— grouping, filter rail, all states. Split into a view model and a layout in one file:FavoritesScreenViewModelImpl()owns every hook, the id→content resolution, the filter state and the navigation actions and returns aFavoritesScreenViewModel;FavoritesScreenLayout({ viewModel })is pure UI and touches no hook, so it renders correctly from a hand-built object.FavoritesScreenis the two-line composition of the pair, and is still what the route renders. Filtering and grouping happen in the view model, which hands the layout a readysectionsarray — the layout never derives which rows belong where.mobile/src/dev/mocks/favorites.tsx— the screen's eight DEV mock states (scout://dev-screen-mock/favorites/<state>), which exist because of the view model split above. See screen-mocks.mobile/src/components/ui/FilterPill.tsx—FilterPill+FilterPillRail, the gold-fill pill rail shared with the Achievements screen. The screen owns the pills' labels, counts and dim rule; the component owns how they look and the rail's layout.mobile/research/favorites-approved.html— the approved design (rows revised 2026-09-02 to the Near Me band idiom);favorites-lab.html— the four variants it was chosen from.
Configuration and flags
None. No feature flag, no app-config value, no environment variable.
Edge cases and known limits
- Guest taps a bookmark — sign-in prompt, no request fired.
- Failed toggle (offline, 500) — the optimistic write rolls back and the list query is invalidated, so the server stays authoritative. The user retries; the change is not queued.
- Favorited entity deleted server-side — Postgres cascades the row away; the
next
GET /api/favoritessimply omits it. - Favorited entity missing from the device's sync cache — the row is
skipped, not rendered as a nameless card. Reachable in normal use:
adminOnlycollections are stripped from the sync payload, and a stale cache lags a content publish. A consequence is that such a favorite is invisible on the screen while still existing server-side, and the header count reflects only what resolved. - Filter matching nothing — its own state ("No collections saved"), distinct from the screen-level empty state.
- No favorites at all — the pill rail and the header count are hidden entirely, not rendered with zeroes.
- A patch and a collection sharing an id — handled: every lookup and every cache mutation matches on kind and id.
What this feature does NOT do
- It does NOT surface "continue where you left off". Nothing is auto-added, auto-ranked, or inferred from progress, recency or location. A campaign the user is halfway through does not appear here unless they bookmarked it. This was considered and explicitly rejected.
- It does NOT let you reorder, pin to top, tag, annotate, or foldering favorites. Sort is newest-first within a fixed section order, full stop.
- A favorites row does NOT show distance, bearing or a navigate action. It borrows Near Me's look, not its behavior — favorites are not location-aware and nothing here is sorted or filtered by where you are.
- It does NOT work for guests. A guest cannot favorite anything and the drawer row is hidden from them, even though guests hold real sessions elsewhere in the app.
- It does NOT sync offline writes. A toggle made with no connection is rolled back, not queued for replay.
- It does NOT appear on the Home screen, or anywhere else. The drawer row and
scout://favoritesare the only entry points. - There is NO separate "favorite board". The Community hub's horizontal strip
of favorited boards is deleted, and so is the
board_favoritestable. Favoriting a patch is what following its board now means. - It does NOT share, publish or expose favorites to anyone else. There is no public favorites list, no friend view, no count shown to other users.
- It does NOT notify. Favoriting produces no notification for anyone, and new activity on a favorited board does not notify the user who favorited it.
- It does NOT affect unlock, progress, achievements or the store. The one
achievement metric that touches it —
board_favorited— counts patch favorites, exactly as it counted board favorites before.
Tests that cover it
backend/src/favorites/favorites.service.spec.ts— 12 unit tests: unknown kind 400s before touching the DB, unknown entity 404s, upsert idempotency, per-kind column writes, cross-kind delete isolation, no-op delete, newest-first ordering,postCountpresence and zero, and nogroupBywhen nothing patch-shaped is favorited.backend/src/favorites/favorites.db.spec.ts— real Postgres: the cascade actually fires, the CHECK rejects a two-target row and a mismatched kind, and the unique indexes coexist across many NULLs. Running in the db tier also proves the hand-written migration applies to a clean database.backend/api-tests/favorites.api.spec.ts— all three routes through the real guard chain, both branches: 401 for anonymous, and a full round-trip proving a POSTed favorite comes back from GET and disappears on DELETE.mobile/src/components/ui/__tests__/FilterPill.test.tsx— the pill's selected/unselected/dim opposites, and the rail's layout contract. The layout assertions are a regression guard: the rail was a bare<ScrollView horizontal>, whoseflexGrow: 1(React Native's ownbaseHorizontal) made it split the viewport with the list and stretched every pill into a screen-height capsule.mobile/src/query/__tests__/favoritesQueries.test.ts— optimistic add/remove, rollback on failure, cross-kind isolation, and the request actually sent.mobile/screen-tests/favorites.test.tsx— populated and empty as paired opposites (each verified to fail when the other's seed is used), plus the skip-missing-entity rule and the error state. A second pair drives the rail: narrowing to a kind that HAS rows (the section heading survives — the grouping never collapses) against narrowing to a kind that has none (the per-kind empty state, not the screen-level one). Both were verified failing against a view model whosesectionsignores the filter; the other eight tests stayed green, which is what makes them the only cover on that branch.mobile/screen-tests/community.test.tsx— regression: a signed-in member sees no favorites strip and the screen issues no favorites request.mobile/maestro/tests/favorites.yaml— the E2E, on a production-simulator build against prod. Empty state (rail and count absent, no hamburger) → bookmark a patch from the detail hero, a campaign, and a collection, each from its own screen → all three resolve as named rows under their sections → the filter rail narrows without collapsing the grouping → the patch's community board already readsselected: true, which is the one-favorite-per-patch claim proven across two surfaces → clearing it there removes the row → the empty FILTER state → unfavourite from the rows down to the screen-level empty state again. Entities are named (san-francisco-golden-gate-bridge,san-francisco,route-66), never globbed: a globbed row would pass without the id → sync-cache join thatGET /api/favoritesreturning ids only makes load-bearing. The guest branch is deliberately absent — the suite may never enter as a guest.- Drift:
mobile/src/dev/__tests__/deepLinkRoutes.test.ts,screenTestRegistry.test.ts,drawerSections.test.ts(icon collision + the YOU section's order and the row'sisSignedIngate).
Open questions
- The migration's copy-forward has not been exercised against production
data. It was verified against the worktree clone (one row) and against a
clean fixture database (zero rows). How many
board_favoritesrows exist in prod is unknown from here — the prod database was deliberately not queried. - Scroll behaviour and the hero chrome over real imagery are still only eyeballed. The Maestro flow (below) verified the screen end to end on a production simulator build on 2026-09-02, so the states and the toggles are no longer unverified — but nothing asserts how a long list scrolls, and the bookmark's contrast over an arbitrary hero photograph is a judgement call no test makes.