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 legacyservingUnitis the Hebrew word for “unit” (isUnitFood).
PortionUnit is 'gram' | 'unit'. Millilitre foods use 'gram' and only differ in the label.
Stepper and typed quantity
Constants inlib/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 atmax. - The value is a
TextInput. While focused it shows the rawdraftstring exactly as typed, so a partial number is never reformatted under the cursor. - Every keystroke that parses calls
onChangeimmediately. Saving straight from the keyboard keeps what was typed. Blur clears the draft. - Keyboard is
decimal-padfor units andnumber-padfor grams. - Unit values render through
formatQuarter(for example1½).
parsePortionQty(unit, raw) turns the text into a number:
- Replace a comma with a dot.
- 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.
- Add the numeric part of the remaining digits and dots.
- Return
nullfor zero or anything unparseable. The field then keeps the last committed quantity. - Snap to the display grid:
roundQuarterfor units,Math.roundfor grams. - Clamp between
PORTION_TYPED_MIN[unit]andPORTION_MAX[unit].
Scaling macros for an added food
scalePortion(base, unit, qty, unitAmount) scales a library food’s base macros:
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, elseservingSize, else 100.
The MBP grid
MBP values sit on a 0.05 grid. Household and unit counts keep quarters.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:
portionsForFood(food, anchors). For one food or product, the total kcal is divided by the anchor of the one macro the food counts toward:
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 anItemPortion: 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:
foodIdis a non-empty string of at most 200 characters.portionQtyOf(qty)is finite, above 0 and at mostPORTION_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):
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:
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 areCustomMealItem rows. lib/added-food.ts keeps enough context to resize them.
withFoodSource(item, source)attachesqty,unit,base(the food’s base macros andunitAmount) and the whole search row asfood.barcodeItem(product, grams, anchors)builds a scanned item withunit: 'gram', a per 100 gbaseand the product asbarcode.hasAddedQty(item)is true whenbase,unitandqtyare all present. Only then doesAiComponentRowshow 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.
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 initem.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.
FoodItemRowshows a count chip and the sheet listsitem.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)fromGET /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.
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 withMealTargetDto: 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:
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.