Everything a trainee does to a nutrition day is an edit on top of the coach’s plan. The edits live in one Zustand store, useNutritionSession in src/features/nutrition/store/nutritionSession.ts. The server keeps one JSON snapshot per trainee per date. useNutritionDaySync moves data between the two.

State shape

Every edit map holds all dates at once. Entries are namespaced by a key prefix.
dateKeyOf is not an ISO date. It uses getMonth() unpadded, so the month is zero-based and 5 January 2026 is 2026-0-5. The date sent to the API is a separate value built by toDateParam (2026-01-05). Do not mix the two.

What each map means

CustomMealItem extends AiMealItem (id, name, kcal, protein, carbs, fat, optional mbp, eaten) with optional qty, unit, base, food and barcode. The extras let the app rescale the item later. The code comment states the server ignores them. CustomMeal has id, label, items, optional source and optional logId. logId links the meal to a row in the meal log. The comment in the store explains why: the log keeps the photo, the history and the coach’s view, the snapshot keeps the arithmetic, and the server drops a linked log when it sums the day so the meal counts once.

Action rules worth knowing

  • setSwap and setMealSwaps drop the item’s portions entry when the chosen option changes. An amount belongs to the food it was set on.
  • setItemRemoved(…, true) also drops the item’s portion.
  • setPortion only stores a portion that passes isValidPortion. Anything else clears the entry.
  • setMealStatus clears the replacement entry when the new status is not 'REPLACED'.
  • setReplacement(…, replacement) sets the status to 'REPLACED'. Passing null removes both.
  • clear() resets everything and deletes the persisted preferred day. clearAccountState() in src/features/auth/stores/authStore.ts calls it on sign out and on a studio switch.
'EATEN_OUTSIDE' is read by MealRow, MealScreen and data/calc.ts, and the screen has an undo button for it. No current screen sets it. Days saved with that status still render.

What is persisted

The store is created with plain create. There is no persist middleware, so the edit maps are in memory only and are rebuilt from the server on the next launch. One thing is written to disk: the preferred nutrition day. The file holds planId, dayId and untilMs. It is not read or written when IS_PREVIEW is true (the embedded web preview).

The day snapshot

NutritionDaySnapshot is the wire format. It is the nine edit maps without swapOptions:
collectDaySnapshot(state, dateKey) picks the entries whose key starts with `${dateKey}__`. The keys inside the snapshot keep their date prefix. portions is always sent, even empty, so the server can tell a cleared amount from a build that does not know about amounts. hydrateDay(dateKey, snapshot, swapOptions) does the reverse. It removes that date’s entries from each map and writes the snapshot’s entries in, leaving other dates untouched.

Server sync

useNutritionDaySync() in hooks/useNutritionDaySync.ts is mounted by PlanScreen. The response type is NutritionDayResponse: date, data (snapshot or null) and optional swapOptions, a map from item id to MealItemAltDto used to render swaps the plan payload only previews.

Hydration

Runs once per date per mount, tracked in a loaded ref.
  1. Read the local snapshot for the date.
  2. If the server has data: when the local snapshot is empty, hydrate with the remote. Otherwise hydrate with mergeSnapshots(remote, local), where local entries win on the same key. The baseline lastSaved[iso] is set to the serialized remote, so the merged result differs from it and is saved by the next step.
  3. If the server has nothing: the baseline is an empty snapshot, so edits made before the response arrived still count as unsaved.
The merge exists because a meal added from the home screen lands in the store before the nutrition tab ever mounts. Hydrating over it would lose the meal, and skipping hydration would overwrite the server’s day.

Saving

A second effect runs on every change to an edit map. It skips future dates and dates not yet loaded. It serializes collectDaySnapshot and compares it with lastSaved[iso]. When they differ it starts a timer of SAVE_DEBOUNCE_MS (600 ms), then records the new baseline and calls the save mutation. On success the mutation invalidates the ['home'] queries. normalizeSnapshot builds its object with the same keys in the same order as collectDaySnapshot, because the comparison is a string comparison.
Two edge cases follow from the code as written. The effect cleanup clears the debounce timer, including when the selected date changes, so an edit followed by a date change inside the 600 ms window is not sent until that date is shown again. The baseline is written before the request is sent and the mutation has no error handler that restores it, so a failed save is not retried until the day changes again.

The editable day window

lib/day.ts:
Today and yesterday are editable. Everything older is read-only, and future dates cannot be picked in DateNavigator. What the window controls:
  • PlanScreen passes press, tick and delete handlers to rows only when isEditable is true, and only then shows the add buttons.
  • For a read-only day, PlanScreen ignores the store and renders from savedDayEdits(dateKey, savedDay), built from the GET /day response. If that day has no snapshot, it shows the diary from GET /v1/trainee/nutrition/menu?date=.
  • MealResultScreen logs to the selected date only when it is editable. A read-only date falls back to today: logDateMs = isEditableDay(dateMs, todayMs) ? dateMs : todayMs.
  • MealScreen auto-opens the add sheet for a composed meal only on an editable day.
When a meal is logged for yesterday, consumedAtFor(dateMs, todayMs) returns noon of that day as an ISO string. For today it returns undefined and the server uses the current time.

Device day keys and time zone

Day boundaries follow the device clock, not a fixed zone.
  • startOfDay and startOfDayMs in lib/day.ts use setHours(0, 0, 0, 0) on a local Date.
  • src/lib/time/day.ts exports localIsoDate(date) and todayIsoDate(), which build YYYY-MM-DD from local getters. toDateParam in lib/format.ts wraps localIsoDate.
  • src/lib/time/deviceTimezone.ts exports getDeviceTimeZone(). It reads Intl.DateTimeFormat().resolvedOptions().timeZone, falls back to getCalendars()[0]?.timeZone from expo-localization, and caches the result for REFRESH_MS (60 000 ms).
  • deviceTimeZoneHeader() returns an X-Timezone header when a zone is known. src/lib/api/client.ts and src/lib/api/upload.ts add it to every request, so the server can resolve “today” the same way the device does.

Keeping today current

src/app/(app)/_layout.tsx calls refreshToday() on mount and then schedules it again at nextNutritionRefreshMs(now) plus 250 ms. That is the earlier of the next calendar midnight and the next 05:00 (NUTRITION_DAY_ROLLOVER_HOUR = 5). refreshToday recomputes todayMs. If the trainee was viewing today, dateMs follows to the new day. A fixed past selection stays fixed. A selection that would now be in the future, after a clock or zone rollback, is pulled back to today. It also expires the preferred day.

Preferred day and the home hint

A plan can have several days (for example a training day and a rest day). The app remembers which one the trainee is following today. lockPreferredFrom runs inside every action that records eating: ticking an item, marking a meal eaten, adding a food, replacing a meal, adding or ticking a custom meal. It saves the currently viewed plan and day (viewPlanId, viewDayId) as the preferred day when all of these hold:
  • No preferred day is active.
  • The tab is on today, and the edit is for today.
  • A plan and a day are in view.
savePreferredNutritionDay never overwrites an active entry. The entry expires at nextNutritionRolloverMs(now), the next 05:00 device time, so a late dinner logged after midnight keeps the same plan day. PlanScreen also calls setTodayPick with the plan and day on screen whenever it shows today. The home screen reads both through homeDayHintOf(state):
  1. todayPick when its dateKey matches today.
  2. Otherwise the preferred day, when one is set.
  3. Otherwise null.
useHome in src/features/home/hooks/useHome.ts puts the result in its query key and getToday appends it to the request: GET /v1/trainee/home?planId=&dayId=. It is a hint. With no hint the request is sent without those parameters. See Home screen.