This page covers three things: body tracking (src/features/tracking/), the progress screen (src/features/progress/), and the upload pipeline every photo and video in the app goes through (src/lib/api/upload.ts and src/lib/media/shrinkImage.ts).

Routes

The three redirects exist because the separate weight, measurements and progress-photo screens were folded into the measurements log. Old links still land somewhere useful. /measurements is opened from the weight tile in ProfileHealthTiles and from useHomeModel().openMeasurements (used by the glass home theme). /progress is opened from an ActionCard on the profile screen. No router.push to /track/steps was found in src/.

Tracking endpoints

All in src/features/tracking/data/trackingApi.ts. logMeasurements takes an optional ISO civil date. Omitted means today. Editing a past day passes that day’s date. The water and steps calls return DailyMetricTotals ({ waterMl, steps }). Water logging UI belongs to the nutrition feature, see Nutrition overview.

Summary shape

The server computes deltas, goal progress and the editable flag. The app only formats them.

Hooks

Measurements log screen

MeasurementsLogScreen is the one place weight, circumferences and the day log live. It reads a single query, useMeasurementsSummary(), and supports pull to refresh. From top to bottom:
  1. Header with the number of measurements and, when sinceDate is set, the date of the first one.
  2. Current weight card. Shows currentWeightKg, a pill with the change since startWeightKg, the goal weight, and the remaining kilograms when goal.remainingKg is above 0. When a goal and goal.pct exist, a progress bar is drawn at that percentage. MeasurementsTrendChart is embedded in the card.
  3. Circumference tiles, one per entry in summary.circumferences, with the current value and the delta.
  4. New measurement button, which opens the sheet in create mode.
  5. Filter chips: all, weight only (weightKg !== null), circumferences only (the row has at least one circumference).
  6. Log rows. Each row shows the date (prefixed with the today label when it matches todayIsoDate()), the source label, time and note, the weight with its delta, and circumference chips. Rows with editable: false are not pressable.

Good and bad colouring

Whether a change is coloured as success or danger depends on direction:
  • Weight: gaining is true when a goal exists and is above the start weight. When gaining, an increase is good. Otherwise a decrease is good.
  • Circumference tiles: for arm, an increase is good. For every other key, a decrease is good.
  • A zero or missing delta is neutral.

Trend chart

MeasurementsTrendChart draws an SVG area, line and dots from summary.trend, with a dashed line at the goal weight. It renders nothing with fewer than two points. The vertical range is the minimum and maximum of all points and the goal, padded by 1 kg each way. Time follows the layout direction: when RTL is applied, the oldest point is on the right.

Measurement sheet

MeasurementSheet records a weigh-in, circumferences and progress photos in one form, and edits a row the trainee created. A new measurement is always stamped now. There is no date picker. Props: visible, onClose, summary and editRow (null means create).

Inputs

The form re-seeds each time the sheet opens for a different target. This is done with an in-render key comparison, not an effect.

What gets saved

Saving is enabled when weight is on or at least one circumference changed. changedCirc() compares each value with a baseline: the row’s values in edit mode, the latest summary values in create mode. Only changed keys are sent. If there is no baseline at all, every entered value counts as new. Create mode, in order:
  1. If weight is on, logWeight({ weightKg, note }).
  2. If circumferences changed, logMeasurements(changes). The note goes here as notes only when weight is off.
  3. For each picked photo, uploadFile(...) and then logProgressPhoto({ url: fileUrl, pose }). Photos are uploaded one after another.
Edit mode:
  1. If the row has a weightEntryId and weight is on, updateEntry.mutateAsync({ id, input: { weightKg, note } }). An empty note is sent as null.
  2. If circumferences changed, logMeasurements({ ...changes, date: editRow.date }). The note is attached only when the row has no weight entry.
A row with a weightEntryId also gets a delete action, confirmed with an Alert, which calls useDeleteWeightEntry(). After a successful save, the sheet swaps its form for a summary line built from what was saved (weight, number of circumferences, number of photos, and the change against the previous weight). Any failure shows a generic error toast and leaves the form open.
In create mode the sheet calls logWeight, logMeasurements and logProgressPhoto from trackingApi.ts directly, not through the mutation hooks. Nothing invalidates the ['measurements'] query on that path, so the log shows the new entry only after a refetch (pull to refresh, or the query going stale). Edit and delete do invalidate, because they use the hooks.

Profile health tiles

ProfileHealthTiles renders two tiles under the profile header. The sleep tile is a static coming-soon placeholder. The weight tile reads useMeasurementsSummary(), shows the current weight and the change since the start weight, and opens /measurements.

Steps screen

StepsScreen is a single number field. parseSteps strips non-digits and requires a non-negative integer. Save calls useLogSteps(), shows a toast and goes back. Device health data is a separate path, see Health integration.

Progress screen

src/features/progress/ is read-only. It has one endpoint and one screen. getProgressOverview() calls GET /v1/trainee/progress and normalises the response:
  • trend points are rounded to one decimal and sorted by date ascending.
  • currentWeightKg falls back to the last trend point, then 0. startWeightKg falls back to the first trend point, then the current weight.
  • streak defaults to { current: 0, best: 0, activeToday: false, lastWorkoutOn: null }.
  • weeklyCompliancePct stays null only when the server sends an explicit null. A missing key becomes 0, for older backends.
  • filePlan goes through normalizeFilePlanFlags.
useProgress() uses query key ['progress', 'overview'] with a 60 second staleTime. When data arrives it also calls syncHomeWidgets({ streak }). See Live Activity and widgets. ProgressScreen renders four blocks: WeightTrendCard filters points to the selected range. If the range is empty it keeps the last point only, and with fewer than two points it shows a not-enough-data message. Dragging on the chart shows a tooltip for the nearest point. The chart claims the responder and calls onActiveChange, which ProgressScreen uses to pass scrollEnabled={!chartActive} to Screen. This is the same scroll lock pattern as the form range slider.

Upload pipeline

Every binary upload uses uploadFile from src/lib/api/upload.ts.
Callers found in src/: form upload fields (useFormUploads), progress photos (MeasurementSheet), the profile avatar (ProfileScreen), technique videos (useTechniqueVideo) and the meal result screen (src/app/(app)/meal/result.tsx).

Request

uploadOnce sends the file as a raw binary body, not multipart, with createUploadTask from expo-file-system/legacy:
The device time zone header from deviceTimeZoneHeader() is added as well. Without a token the call throws ApiError('Session expired.', 401) before sending anything. Two helpers keep header values safe:
  • headerFileName(name) passes a name through only if it is printable ASCII. Otherwise it sends upload. plus the original extension when that is alphanumeric, or jpg. A Hebrew file name therefore never reaches a header.
  • headerMime(mimeType) passes a well-formed type/subtype through and replaces anything else with application/octet-stream. This blocks header injection through a crafted name or type.
The response is parsed as JSON. A non-2xx status throws ApiError(message, status, code, body). On success the file URL is read from body.data.fileUrl or body.fileUrl. A 2xx without a fileUrl is treated as a failure.

Image shrinking

uploadFile first calls prepareImageForUpload(input) from src/lib/media/shrinkImage.ts. Rules:
  • A source whose mimeType does not start with image/, or is image/gif, is returned unchanged. Videos are never touched.
  • Otherwise shrinkImage(uri) decodes the image, resizes it only when its longer edge is above 1600 px (landscape is bounded by width, portrait by height), and saves a JPEG at quality 0.7.
  • The result is { uri: shrunkUri, mimeType: 'image/jpeg', name } with the extension replaced by jpg. A HEIC photo becomes a JPEG.
  • If shrinking is unavailable or fails, the original source is uploaded as is.
Two guards in shrinkImage matter and are covered by scripts/test-shrink-image-guard.cjs:
  1. Missing native module. loadManipulator() probes requireOptionalNativeModule('ExpoImageManipulator') and only then does a dynamic import('expo-image-manipulator'). On a store binary built before the module was added, the probe returns nothing and the original file is uploaded. This is why shrinking needs a new native build but the code is safe to ship over the air.
  2. Uri only. ImageManipulator.manipulate() must receive the uri string. The code comment records that passing a native shared object, such as an expo-image ref, crashes the app on iOS with expo-modules-jsi 57.0.0.
Every native object created during a shrink is released in a finally block. shrinkImage(uri, { base64: true }) also returns base64. The nutrition feature uses that for meal photos (useMealImage).

Retry policy

  • A thrown upload task or an empty response becomes ApiError with status 0, which is retryable. That covers a dropped connection.
  • The image is prepared once, before the loop, so retries resend the same shrunk file.
  • Before each retry onProgress(0) is called so progress bars restart, then the loop waits 1 second, then 3 seconds.
  • After the third failed attempt the last error is thrown.
  • Any other status, including 500, 400 and 413, fails on the first attempt.
scripts/test-upload-retry.cjs checks each of these cases.

The 413 case

isPayloadTooLarge(error) is true for an ApiError with httpStatus === 413. A 413 is never retried, because sending the same bytes again cannot succeed. Callers use it to show a specific message instead of a generic failure:
  • Form upload fields set the status tooLarge, show the file-too-large string and offer only a replacement, not a retry.
  • useTechniqueVideo shows the same string.
Progress photos and the avatar do not special-case it and show their generic error.

Preview and retry in forms

The form upload hook adds an instant preview and a manual retry on top of the pipeline. It prepares the image itself so it can show the shrunk local file while the bytes upload. See the upload section of Forms rendering.
useFormUploads calls prepareImageForUpload and then passes the prepared source to uploadFile, which calls prepareImageForUpload again. On the form path a photo is therefore re-encoded as JPEG twice. The second pass does not resize, since the image is already within 1600 px.

Avatar upload

ProfileScreen picks or shoots a square photo (allowsEditing, aspect 1:1, quality: 0.8), shows it immediately through a localPreview state, uploads it, then calls setAvatarUrl(fileUrl) on the auth store. On failure the preview is dropped and an error toast is shown.