This page covers the pure math behind nutrition quantities. The files are: MBP is the studio’s portion unit. The code comments write it in Hebrew and also call it an exchange. A studio turns it on with mbpEnabled. useMbp() in hooks/useMbp.ts returns enabled, anchors and label from the signed-in user. The label is the coach’s mbpUnitLabel or a default string.

Canonical quantity

Every quantity is stored in the food’s canonical unit:
  • Grams or millilitres for weight and liquid foods.
  • A piece count for unit foods. A food is a unit food when unitType === 'unit' or its legacy servingUnit is the Hebrew word for “unit” (isUnitFood).
PortionUnit is 'gram' | 'unit'. Millilitre foods use 'gram' and only differ in the label.
food-units.ts starts with a “KEEP IN SYNC (byte-identical)” header naming the backend foods/exchange.ts and the web modules/shared/lib/food-units.ts. It has no imports on purpose. Change all three together.

Stepper and typed quantity

Constants in lib/portion.ts: PORTION_INPUT_MAX_LENGTH is 5 characters. The typed floor is lower than the button floor on purpose. The button minimum only keeps minus off zero. Typing is how a trainee logs 5 g of oil or a quarter piece. PortionStepper (components/PortionStepper.tsx) takes unit, qty, onChange, optional unitLabel and optional max.
  • Minus calls onChange(Math.max(min, qty - step)) and is disabled at the minimum.
  • Plus calls onChange(min(max, qty + step)) and is disabled at max.
  • The value is a TextInput. While focused it shows the raw draft string exactly as typed, so a partial number is never reformatted under the cursor.
  • Every keystroke that parses calls onChange immediately. Saving straight from the keyboard keeps what was typed. Blur clears the draft.
  • Keyboard is decimal-pad for units and number-pad for grams.
  • Unit values render through formatQuarter (for example 1½).
parsePortionQty(unit, raw) turns the text into a number:
  1. Replace a comma with a dot.
  2. Add 0.25, 0.5 or 0.75 for each quarter glyph present, because the field hands the rendered glyphs back when part of the value is edited.
  3. Add the numeric part of the remaining digits and dots.
  4. Return null for zero or anything unparseable. The field then keeps the last committed quantity.
  5. Snap to the display grid: roundQuarter for units, Math.round for grams.
  6. Clamp between PORTION_TYPED_MIN[unit] and PORTION_MAX[unit].

Scaling macros for an added food

scalePortion(base, unit, qty, unitAmount) scales a library food’s base macros:
Defaults used when a food is first picked:
  • portionUnitOf(food) is 'unit' for a unit food, else 'gram'. A household-displayed gram food stays on grams.
  • defaultPortionQty(food) is 1 piece for a unit food, one household measure (householdMeasureGrams) when the food prefers household display, else servingSize, else 100.

The MBP grid

MBP values sit on a 0.05 grid. Household and unit counts keep quarters.
The source computes the divisor as Math.round(1 / MBP_STEP), which is 20. formatMbpValue(v) prints roundMbp(v) trimmed to two decimals. formatQuarter(v) prints a quarter glyph when the value is within QUARTER_TOLERANCE (0.02) of a quarter, else a trimmed number. lib/mbp.ts re-exports MBP_STEP and roundMbp and adds:

Anchors

An anchor is the kcal one portion of a macro stands for. DEFAULT_MBP_ANCHORS is proteinKcal: 180, carbKcal: 160, fatKcal: 180. The studio’s own anchors come from user.mbpAnchors. There are two rules, and the code is strict about which applies where. Aggregate rule, mbpFromMacros(macros, anchors). For a meal or day total, each macro converts through its own anchor:
Single food rule, portionsForFood(food, anchors). For one food or product, the total kcal is divided by the anchor of the one macro the food counts toward:
Exactly one component of the result is non-zero. pickMacroType returns the macro with the largest kcal contribution, and ties go to the earlier entry in protein, carb, fat order. Explicit exchange data from the server (food.mbp, mbpPer100, analysis.mbp) always takes priority over both rules.

Day goal

mbpGoal(targets, anchors) returns the coach’s mbpProtein, mbpCarb and mbpFat when the plan carries them. A missing component falls back to the gram target converted with mbpFromMacros. The comment explains why the coach’s values lead: plan items count as whole exchanges of a single macro, so comparing them to a gram conversion never adds up.

Plan item portions

A trainee can change the amount of a plan item for one day. The amount is an ItemPortion: foodId plus qty in canonical units. It is stored in the session store’s portions map (see Day log and sync).

Validity

isValidPortion(portion) mirrors the server’s day parser. A portion is kept when:
  • foodId is a non-empty string of at most 200 characters.
  • portionQtyOf(qty) is finite, above 0 and at most PORTION_QTY_MAX (5000).
portionQtyOf accepts a number or a numeric string and returns NaN for anything else.

Ratio and macros

scalePortionMacros(macros, ratio) multiplies kcal, protein, carbs and fat by the ratio with no rounding, like the server’s quantity factor. MBP is roundMbp(value * ratio) per macro. portionMacros(option, portion) is what the app counts. It scales as above, then replaces the MBP with an exact value when the food carries exchange sizes (proteinExchangeQty, carbExchangeQty, fatExchangeQty):
For a food with more than one exchange size (the lentils case), only the counted macro is priced. countedMacro picks countsAs, then the food’s macroType, then the first qualifying macro. Without exchange sizes, the re-rounded plan MBP is used, which the comment notes can land one 0.05 step off the server’s number.

How the row steps

portionScaleOf(option) decides how FoodItemRow edits an item, in the unit the row already reads in. It returns null when the amount cannot be edited: no foodId, no food, or a plan quantity of 0. A household amount such as 0.4 of a cup steps in grams, because quarter steps could never land back on the coach’s amount. max is the lower of PORTION_MAX[unit] and the largest stepper value whose canonical amount stays within PORTION_QTY_MAX. Conversion between stepper value and stored portion:
Returning null for the plan amount means stepping back onto it clears the trainee’s own amount. The row also has a “reset to plan quantity” chip that calls onChangePortion(null). portionQtyText(option, qty) rebuilds the amount text the way the server renders it, so an edited row reads like the rows around it: a household count with grams in brackets, a gram amount, or a plain qty unit.

Added food amount

Foods the trainee adds to a plan meal or a composed meal are CustomMealItem rows. lib/added-food.ts keeps enough context to resize them.
  • withFoodSource(item, source) attaches qty, unit, base (the food’s base macros and unitAmount) and the whole search row as food.
  • barcodeItem(product, grams, anchors) builds a scanned item with unit: 'gram', a per 100 g base and the product as barcode.
  • hasAddedQty(item) is true when base, unit and qty are all present. Only then does AiComponentRow show the amount chip. An AI text or photo estimate has none of them and cannot be resized.
rescaleAddedItem(item, qty, anchors): scaledFoodMbp(food, qty, scaledMacros, anchors): when the search row has server mbp, it scales linearly from the quantity the server computed it for and keeps the grid.
Without server mbp it uses the single food rule with the food’s macroType. scaledBarcodeMbp does the same from mbpPer100 with factor = grams / 100, and falls back to the single food rule with no macro type. addedQtyLabel(item) returns the units label, the millilitre label for a liquid, or the gram label. In MealComposerScreen a trainee can also type a macro directly. applyMacro detaches the row from its quantity: the typed number is the truth. In grams mode, kcal is recomputed as protein*4 + carbs*4 + fat*9 and MBP through mbpFromMacros. In MBP mode the typed value is the MBP for that macro, grams are derived as value * anchorKcal / kcalPerGram rounded to one decimal, and kcal is recomputed from the grams.

Meal and day totals

data/calc.ts works on a DayEdits object (the store’s maps) and a dateKey. Gram and kcal sums are plain additions. MBP sums go through addMbp, so every step lands on the grid.
DayEdits.portions is optional. A screen that builds DayEdits without it counts every item at its plan amount. All three screens pass it.

Alternatives

Each plan item carries its alternatives inline in item.options. AlternativesSheet (components/AlternativesSheet.tsx) lists them and calls onPick(optionId, option). Picking the main option passes null, which clears the swap. The plan’s autoAlts flag changes where the list comes from:
  • Off. The coach’s manual alternatives are the whole offer. FoodItemRow shows a count chip and the sheet lists item.options.slice(1).
  • On. The inline options are a short preview. The row chip shows no count. The sheet fetches the full list with getItemAlternatives(planId, itemId) from GET /v1/trainee/nutrition/alternatives. While it loads, the preview is shown with a spinner. If the fetch fails, the sheet says so and offers a retry, so a short preview is never mistaken for the complete list.
Server order is the display order. When any substitute is flagged betterAlternative or isPreferred, buildSheetRows regroups: the original, a trainer recommendations banner with the flagged foods, a divider, then the rest in the original’s macro group followed by the rest outside it. The macro group is the food’s declared macroType, else dominantMacro by kcal share. The list is a FlatList with a search field. Names are folded once with foldForSearch and matched with matchesSearchTerms from src/lib/search. A pick past the inline preview is not in the plan payload, so setSwap stores the full NutritionOption in swapOptions. After a reload, the GET /day response’s swapOptions supplies the same render data.

Meal target

A coach can limit one meal with MealTargetDto: kcal, mbpProtein, mbpCarb, mbpFat. resolveMealTarget(target, mbpOn, anchors): Non-positive and non-finite values count as no limit. mealTargetRows(resolved, current) returns one row per targeted component (protein, carb, fat order) or a single kcal row. Each row’s status uses a tolerance:
The epsilon exists because subtraction on the 0.05 grid can land a hair past an exact boundary. mealTargetLeft(rows) lists the rows still 'under' with the remaining amount. meal-target-text.ts turns limits and remainders into one compact line for MealRow, MealScreen and the hint in AddComponentSheet. MealScreen measures the gauge against mealTotalMacros, so everything the trainee added counts, ticked or not.