A nutrition plan is stored in Program.content or ProgramTemplate.content when type is NUTRITION. Like the training plan, the contract is a TypeScript type and a tolerant normalizer, not a Zod schema. Macros are not stored in the plan. Items reference foods by id and quantities, and every reader computes calories and macros from the referenced FoodLibraryItem values (merged with the studio’s FoodLibraryOverride).

Where the contract lives

Plan shape

normalizeNutritionContent always returns schemaVersion: 1.

meta

days[]

A plan with no days normalizes to one empty day. Days rotate around the trainee’s current day. nutritionDayIndexForOffset(dayCount, offset) maps a whole-day distance from today to a day index, wrapping around. Today is always the first day and tomorrow the second.

days[].targets

Targets live on each day. The top-level targets key is the legacy shape: when present it seeds every day that has no value of its own. The defaults are DEFAULT_NUTRITION_TARGETS. On imported content the fallback is all zeros, so a missing target stays visibly missing.

days[].meals[]

items[]

items[].alts[]

Portions (MBP)

Perform can express a plan in portions instead of grams. A portion of a macro is a fixed number of calories, the studio’s anchor. roundMbp(x) snaps a portion count to the 0.05 grid and returns 0 for anything not finite and positive. Use it for every stored or displayed portion value. Anchors are per studio and are read from Studio.settings by readMbpAnchors. Changing an anchor re-prices the food library, which is why a value outside the bounds is refused. See Studio settings JSON. A food’s portions come from its macroType (protein, carb or fat) and its exchange quantities (proteinExchangeQty, carbExchangeQty, fatExchangeQty), or from its servingTiers. Those columns are described in Exercise and food libraries. When a plan has no explicit portion targets, mbpTargets(targets, anchors) converts the gram targets through the anchors, each macro through its own.

Alternatives

There are three sources of substitutes for a plan item:
  1. items[].alts[], picked by the coach.
  2. Automatic alternatives, when meta.autoAlts is true. The backend computes them in apps/core-api/src/modules/trainee/food-alternatives.ts from foods of the same macroType.
  3. The trainee’s own swap for one day, stored in the day snapshot’s swaps map.
A food with showAsAlternative = false is never offered as an alternative anywhere. A food the trainee excluded through Client.foodPreferences is filtered at read time.

Client.foodPreferences

Validated by foodPreferencesBody in apps/core-api/src/modules/clients/clients.schema.ts:
Each list holds up to 5000 ids. Only ids are stored. Names are resolved when the preferences are read.

NutritionDayLog.data

One row per trainee per day, unique on (clientId, date). The API accepts any object (saveNutritionDayBody.data is a record of unknown values), so the app owns the shape and the backend parses it defensively with parseDaySnapshot.
The client keys everything as <dateKey>__<id>. The parser strips the date prefix with idOf, so on the server each map is keyed by the plain item or meal id.
A custom meal with a logId exists in two places: the snapshot holds the day’s arithmetic, and the MealLog row holds the photo and feeds the history tab and the coach’s meal view. Anything that sums a day must count it once, so drop the linked log from the log side.
Today and yesterday are editable in the app. Older days show the saved snapshot read-only.

MealLog.items and FavoriteMeal.items

logMealBody.items is an array of free-form records, default []. The row-level calories, protein, carbs and fat columns hold the totals, and source is a MealSource: The item records are written by the app and by the meal analysis. Do not assume a fixed shape. Read the totals from the columns and treat items as display detail. The analysis output contract is in AI providers.

DailyMetric.waterLogs

An array validated by entrySchema in apps/core-api/src/modules/trainee/water-log.ts:
DailyMetric.waterMl is the running total. applyWaterLog makes writes idempotent: a repeated requestId is a no-op, an entry can be deducted only once, and the total can never go below zero.