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 insrc/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
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:
- Header with the number of measurements and, when
sinceDateis set, the date of the first one. - Current weight card. Shows
currentWeightKg, a pill with the change sincestartWeightKg, the goal weight, and the remaining kilograms whengoal.remainingKgis above 0. When a goal andgoal.pctexist, a progress bar is drawn at that percentage.MeasurementsTrendChartis embedded in the card. - Circumference tiles, one per entry in
summary.circumferences, with the current value and the delta. - New measurement button, which opens the sheet in create mode.
- Filter chips: all, weight only (
weightKg !== null), circumferences only (the row has at least one circumference). - 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 witheditable: falseare not pressable.
Good and bad colouring
Whether a change is coloured as success or danger depends on direction:- Weight:
gainingis 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:
- If weight is on,
logWeight({ weightKg, note }). - If circumferences changed,
logMeasurements(changes). The note goes here asnotesonly when weight is off. - For each picked photo,
uploadFile(...)and thenlogProgressPhoto({ url: fileUrl, pose }). Photos are uploaded one after another.
- If the row has a
weightEntryIdand weight is on,updateEntry.mutateAsync({ id, input: { weightKg, note } }). An empty note is sent asnull. - If circumferences changed,
logMeasurements({ ...changes, date: editRow.date }). The note is attached only when the row has no weight entry.
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.
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:
trendpoints are rounded to one decimal and sorted by date ascending.currentWeightKgfalls back to the last trend point, then 0.startWeightKgfalls back to the first trend point, then the current weight.streakdefaults to{ current: 0, best: 0, activeToday: false, lastWorkoutOn: null }.weeklyCompliancePctstaysnullonly when the server sends an explicitnull. A missing key becomes 0, for older backends.filePlangoes throughnormalizeFilePlanFlags.
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 usesuploadFile from src/lib/api/upload.ts.
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:
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 sendsupload.plus the original extension when that is alphanumeric, orjpg. A Hebrew file name therefore never reaches a header.headerMime(mimeType)passes a well-formedtype/subtypethrough and replaces anything else withapplication/octet-stream. This blocks header injection through a crafted name or type.
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
mimeTypedoes not start withimage/, or isimage/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 byjpg. A HEIC photo becomes a JPEG. - If shrinking is unavailable or fails, the original source is uploaded as is.
shrinkImage matter and are covered by scripts/test-shrink-image-guard.cjs:
- Missing native module.
loadManipulator()probesrequireOptionalNativeModule('ExpoImageManipulator')and only then does a dynamicimport('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. - Uri only.
ImageManipulator.manipulate()must receive the uri string. The code comment records that passing a native shared object, such as anexpo-imageref, crashes the app on iOS withexpo-modules-jsi57.0.0.
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
ApiErrorwith 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. useTechniqueVideoshows the same string.
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.