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:
categoriesandfoodsfor the list and the filter.addedIds, aSetof thefoodIdvalues already on the list.ShoppingFoodRowuses 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.isLoadingandisRefetchingflags.
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 inCATEGORY_LABEL_KEYSand returns the matching string. Known keys aremeat,fish,dairy,fruit,vegetable,legumes,snacks,dessert,gluten_free,vegan,vegetarian,drinksandsugar_free. The valueuncategorizedhas its own string. An unknown key is shown as is.shoppingCategoryIcon(value)returns an Ionicons name, withbasket-outlineas the fallback.
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
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 inincludedExceptionIds.
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.onChangestores the new answer indraftand inpendingRef, and restarts an 800 ms timer.flushsendspendingRefthroughuseSaveFoodPreferences. It does nothing when a write is already in flight.- When the write settles,
flushruns again if a newer edit arrived meanwhile.
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 returnshomeQueryKey(['home', 'today']), because the home macros follow the menu