Scout — Full Product Context → feature documentation

Favorites

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…

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

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

  1. Toggle. FavoriteButton calls useFavorites().toggle(kind, entityId). was is 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).
  2. Optimistic write. favoriteToggleMutation (mobile/src/query/queries/favorites.ts) cancels in-flight list queries, snapshots the cache, writes optimistically, rolls back in onError and invalidates in onSettled. Rows are matched on kind AND entityId, never id alone.
  3. Request. POST or DELETE /api/favorites/:kind/:entityId.
  4. Server. FavoritesService validates the kind, checks the entity exists, then upserts on the (user, target) unique index — so favoriting twice is one row. Removal is a deleteMany, so unfavoriting something already gone is a no-op.
  5. Read. GET /api/favorites returns a newest-first list of { kind, entityId, createdAt, postCount? }ids only.
  6. Resolution on-device. FavoritesScreen joins those ids against the content already cached from GET /api/sync/content, and recomputes collection and campaign progress locally via deriveCollectionsWithProgress / 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

UserFavoriteuser_favorites (backend/prisma/schema.prisma):

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:

Mobile:

Configuration and flags

None. No feature flag, no app-config value, no environment variable.

Edge cases and known limits

What this feature does NOT do

Tests that cover it

Open questions