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

"The shelf" — on develop since 2026-10-29, not released (design: mobile/research/favorites-redesign-lab.html take A, picked by Alan; it replaced favorites-approved.html's Near Me-style list rows). Top to bottom:

The pure favoritesShelf (mobile/src/screens/favoritesShelf.ts) decides what the shelf holds: the order, the filter, each patch's distance and collected flag, and the counts.

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

UserFavorite → user_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