Two small features sit next to nutrition. The shopping list is a checklist of foods the coach recommends. Food preferences let the trainee say which foods and food categories they do not eat, which reshapes the menu the server returns. Both are opened from the profile screen (src/features/profile/presentation/ProfileScreen.tsx) through router.push('/shopping-list') and router.push('/food-preferences').

Shopping list

Files

Two surfaces

The feature has two screens that share one list. Recommendations, inside the content tab. ContentScreen (src/features/content/presentation/ContentScreen.tsx) has a shopping section, keyed by SHOPPING_KEY = 'shopping' from src/features/content/lib/content.ts. When that section is active it renders ShoppingRecommendationsList: the foods the trainee can add, a search field, a category filter and a button to the list. The section can be opened directly with the section route param. The list, at /shopping-list. ShoppingListScreen shows the items already added, each with a checkbox and a remove button. Its “add items” button navigates back to the recommendations with router.navigate({ pathname: '/content', params: { section: 'shopping' } }).

Data shapes

Endpoints

List responses are wrapped as { items: T[] }. The API functions unwrap them and return an empty array when the body is missing.

Hooks and cache

Ticking an item is optimistic. onMutate cancels in-flight list queries, saves the previous list and flips checked in the cache. onError restores the saved list. onSettled invalidates the list either way. Adding and removing are not optimistic. They wait for the server and then refetch.

useShoppingSection

useShoppingSection(active, category, search) is what ContentScreen calls. The three queries only run while active is true, so nothing is fetched until the trainee opens the shopping section. It returns:
  • categories and foods for the list and the filter.
  • addedIds, a Set of the foodId values already on the list. ShoppingFoodRow uses it to show a check mark and disable the add button.
  • add(foodId), which runs the add mutation and shows a toast on success.
  • refetch(), which refetches all three queries.
  • isLoading and isRefetching flags.
The search text is debounced in ContentScreen with useDebouncedValue before it reaches the hook.

Categories

The server sends category values as keys. lib/shopping.ts maps them to labels and icons:
  • shoppingCategoryLabel(value) looks the key up in CATEGORY_LABEL_KEYS and returns the matching string. Known keys are meat, fish, dairy, fruit, vegetable, legumes, snacks, dessert, gluten_free, vegan, vegetarian, drinks and sugar_free. The value uncategorized has its own string. An unknown key is shown as is.
  • shoppingCategoryIcon(value) returns an Ionicons name, with basket-outline as the fallback.
Add a new category key in both maps and in the strings file.

List screen behaviour

ShoppingListScreen supports pull to refresh, shows three skeleton rows while loading, and an empty state with a hint when the list has no items. Removing an item shows a toast on success. A checked row is drawn struck through and dimmed. ShoppingRecommendationsList is a FlatList (initialNumToRender 10, windowSize 7, removeClippedSubviews). Its empty component is five skeleton rows while loading, then a badge whose text depends on whether the trainee is searching or filtering.

Food preferences

Files

The feature has no UI of its own for the picker. It reuses the intake form field: FoodPreferencesField from src/features/forms/components/fields/, the helpers in src/features/forms/lib/foodPreferences.ts, and the FoodPreferenceAnswer type from src/features/forms/data/types.ts. A trainee sees the same control in an intake form and on this screen.

Data shapes

The answer stores ids only. source says who last set the list: a saved override or the newest intake form. The screen reads only value. How the three lists combine, from isFoodExcluded in src/features/forms/lib/foodPreferences.ts:
  • A food is excluded when its id is in excludedFoodIds.
  • It is also excluded when its category is in excludedCategoryIds, unless its id is in includedExceptionIds.
toggleCategory drops the category’s exceptions when the category is switched either way, and keeps foods that were excluded one by one. readFoodPreferenceAnswer(value) normalises any input into the three arrays, dropping non-strings and duplicates.

Endpoints

The catalog is { categories: { id, name }[], foods: { id, name, categoryId }[] }. It is fetched with useFoodCatalog(true), query key ['forms', 'food-catalog'], stale time 5 minutes.

The screen

FoodPreferencesScreen builds a fake form field definition, PROFILE_FIELD, with type: 'food_preferences' and no config. In a form, a coach can hide categories through config.hiddenCategoryIds. Here nothing is hidden, so the trainee sees the whole catalog. State is a local draft:
  • Before the first edit, the picker shows the server value.
  • From the first edit, the draft is the source of truth and stays so until a pull to refresh clears it.

Saving

Edits are saved automatically. There is no save button.
  1. onChange stores the new answer in draft and in pendingRef, and restarts an 800 ms timer.
  2. flush sends pendingRef through useSaveFoodPreferences. It does nothing when a write is already in flight.
  3. When the write settles, flush runs again if a newer edit arrived meanwhile.
Only one PUT is in flight at a time, so two writes cannot overtake each other on a slow network. The effect cleanup calls flush on unmount, so leaving the screen sends an edit that is still waiting on the timer. A failed save shows a toast. Pull to refresh cancels the timer, clears pendingRef and the draft, and refetches both queries. The pending save is dropped on purpose: writing a discarded draft after the refresh would flip the chips back.

Cache effects

useSaveFoodPreferences is optimistic. onMutate cancels the query and writes the new value into the cached payload with source: 'coach'. onError restores the previous payload. onSettled invalidates three things:
  • ['food-preferences']
  • ['nutrition', 'plan'], because exclusions change the menu the server returns
  • homeQueryKey (['home', 'today']), because the home macros follow the menu
The exclusions are applied by the server when it builds the plan. The app does not filter plan items itself.

Loading and errors

The screen shows three skeleton rows while either query loads or the answer is not ready. If either query fails it shows a message and a retry button that refetches both.