The module has no entry of its own in
modules/index.ts. It hangs off the clients router, which is mounted on both lanes, so every route below exists under both prefixes. The headings use the web lane path.
Auth and scoping
The router inherits two guards from the clients router:withCoachAccess(ctx.prisma)resolves the caller’s coach permissions once per request.requireAssignedClient(ctx.prisma)runs on every/:idpath. A coach restricted to their own trainees getsNOT_FOUNDfor a trainee they are not assigned to, the same answer as for a trainee of another studio. A dismissed team member getsFORBIDDEN.
requireRole guard. Inside the service, requireClient loads the trainee with studioId and deletedAt: null and throws NOT_FOUND (trainee not found) otherwise.
Shared rules
Dates and time zones
Every calculation works on civil dates (YYYY-MM-DD) in the trainee’s own time zone, resolved with resolveTimeZone(client.timezone, client.studio.timezone). “Today” is today for the trainee, not for the server. Weeks run Sunday to Saturday (weekStartIso).
Ranges
List views take arange query parameter.
string
default:"30d"
One of
7d, 30d, 3m. They map to 7, 30 and 90 days ending today (RANGE_DAYS).previousWindow).
File plans
When the trainee’s active plan is a file plan (a PDF or a link, see Programs), there is nothing to measure against. The views returnfilePlan: true and null for the targets and adherence percentage, so the UI can show the file plan note instead of a grade.
Active plan lookup
repo.activeProgram(clientId, type) picks the most recently updated Program with status: ACTIVE and an exact type of TRAINING or NUTRITION. COMBINED programs are not considered here.
Endpoints
GET /v1/web/clients/:id/tracking/summary
The overview cards at the top of the Tracking tab.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
A workout log counts as a session only when it is completed or has at least one done set (
countsAsSession). Opening a workout and leaving is not a session.
Response:
appActivity always has 14 entries, oldest first. The example is shortened.
Errors: NOT_FOUND (trainee not found), UNAUTHORIZED.
GET /v1/web/clients/:id/tracking/nutrition
Per-day nutrition for a range, with adherence and a comparison to the previous period.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
default:"30d"
7d, 30d or 3m.partner.nutritionDays in the partner module, which already merges the plan, the trainee’s day snapshots and free-logged meals without double counting. That source refuses spans over 62 days, so a 3m range is fetched in chunks of 62 days (NUTRITION_CHUNK_DAYS).
For each day the service adds:
status, see Nutrition day status.meals: which of the four slotsbreakfast,lunch,dinner,snackhave something logged. Plan meals map by type (snack_am,preandpostall count assnack). A plan meal counts when it was marked eaten outside, replaced, or has at least one eaten item. Custom meals count assnack. Free-loggedMealLogrows have no meal type, so they map by local hour: before 11 isbreakfast, before 16 islunch, before 19 issnack, later isdinner.
nutritionDayIndexForOffset(dayCount, daysBetween(today, date)). Today is always the first plan day.
unit is exchanges when the partner source reports mode: "mbp" for the first day, otherwise grams. In exchanges mode the protein, carbs and fat numbers are portions, not grams.
Response:
adherencePctisfullDays / totalDays, rounded. The denominator is every day in the range, not only logged days. It isnullfor a file plan.averageConsumedandaverageTargetaverage over logged days only. Calories are rounded to whole numbers and macros to one decimal. Either isnullwhen there is nothing to average.previousisnullwhen the previous span has no logged day. A span the trainee had not started logging in is not a baseline.
NOT_FOUND, VALIDATION (422) for an unknown range.
GET /v1/web/clients/:id/tracking/nutrition/insight
One AI-written Hebrew sentence that summarizes the nutrition data for the range.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
default:"30d"
7d, 30d or 3m.createNutritionInsightGenerator (ai/nutrition-insight.ts). The generator uses OpenAI with the model in OPENAI_FORM_SUMMARY_MODEL, one retry and a 12 second timeout.
sentence is null, with no model call, when OPENAI_API_KEY is not set, when no day is logged, or when there is no average.
Caching: the result is cached in Redis for six hours under a key built from the trainee id and a SHA-1 hash of the input numbers. A new log changes the numbers and therefore the key, so a cached sentence never describes stale data. A cache read waits at most 300 ms and then falls through to the model. Only a real sentence is stored, so a failed call is retried on the next view.
Response:
sentence is a Hebrew string or null.
Errors: NOT_FOUND, VALIDATION. A model failure does not raise. It returns sentence: null.
GET /v1/web/clients/:id/tracking/nutrition/:date
What the trainee logged on one day, meal by meal, as display lines.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
required
YYYY-MM-DD. Must be a real calendar date. 2025-02-30 is rejected.NutritionDayLog) and the MealLog rows of that day, then builds the list with describeLoggedNutritionDay. Three sources feed it, in this order:
- Plan meals, id
plan:<mealId>. A meal the trainee removed is skipped. For a replaced meal the line is the replacement’s name. For a meal eaten outside, every active item plus every added item is listed. Otherwise only items marked eaten are listed. A meal with no lines is left out. The food shown respects the trainee’s swap (a coach alternative or an automatic one) and their adjusted portion. - Custom meals, id
custom:<mealId>. Uses the eaten added items, or the linked meal log’s items when none are marked. The slot comes from the log’s local hour, orsnackwithout a log. Carries the log’snoteandphotoUrl. - Unlinked meal logs, id
log:<logId>. Free logs not tied to a snapshot meal.
repo.foodsByIds overlays the per-studio override on each library food. Quantities are formatted for display, for example in household units where the food defines them, otherwise grams with a Hebrew gram suffix. For a file plan there is no plan day, so only custom meals and free logs appear.
Response:
qty strings in the example are illustrative. The real suffix is Hebrew. A meal with no label, no foods, no photo and no note is dropped. When a meal’s only food line has the same name as its label and no quantity, foods is emptied to avoid repeating the label.
Errors: NOT_FOUND, VALIDATION for a bad date.
GET /v1/web/clients/:id/tracking/workouts
Workouts and steps for a range: an eight week grid, the session list, personal records, step stats and period KPIs.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
default:"30d"
7d, 30d or 3m.weeks: the last eight Sunday to Saturday weeks, oldest first, regardless ofrange. Each lists the completed workouts in that week.logs: sessions inside the range, newest first. A log with no done set and not completed is an abandoned start and is left out.statusiscompletedorpartial.volume: the sum of weight times reps over done sets that have both, rounded.personalRecords: the five most recent records in the range, newest first. See Personal records.steps.goal: the plan’s daily steps when the training plan’s movement requirement includes steps, otherwiseClient.stepsGoal, otherwisenull.steps.days: the last seven days.stepsisnullfor a day with no synced row.steps.source:appleHealthwhenClient.lastCheckInPlatformisios,healthConnectforandroid, otherwisenull.kpis.expected:weeklyTarget * rangeDays / 7, rounded.nullwithout a weekly target.kpis.totalVolumecounts every session.kpis.averageDurationSecaverages completed sessions that have a positive duration.previous: the same stats for the previous period, ornullwhen it has no session.
weeks always has eight entries and steps.days seven. The example is shortened. kpis.personalRecords is the count of all records in the range, while the personalRecords array holds at most five.
Errors: NOT_FOUND, VALIDATION.
GET /v1/web/clients/:id/tracking/workouts/:logId
One workout session in detail, compared with the earlier sessions of the same plan day.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
required
The
WorkoutLog id. Must belong to this trainee.repo.workoutLogsOfType: logs with the same dayId, or, for logs written before dayId was stored, the same label inside the same program. The service scans up to 40 of them, newest first, up to and including this log’s performedOn.
Per exercise (only exercises with at least one done set):
sets: the done sets, each withweight,repsandtargetReps.best: the top set of this session, by weight and then reps, among sets with reps above zero.isPersonalRecord: this session’s record-eligible best beats the trainee’s best from every log before this one (up to 500 logs). See Personal records.previousBest: the best set of the same exercise in the most recent earlier session of the same type, ornull.deltaKg: the difference in weight againstpreviousBest, when both sets carry weight. Otherwisenull.deltaReps: the difference in reps, used when the comparison is not weighted. Otherwisenull.
volumeHistory holds this session and up to seven earlier ones, oldest first. previousSessions holds up to five earlier ones, newest first.
Errors: NOT_FOUND (trainee not found or workout not found).
GET /v1/web/clients/:id/tracking/weight
Weight trend, goal progress and circumferences.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
default:"30d"
7d, 30d or 3m. Filters points, log and circumferences.history. The start, current and delta values use every weigh-in ever recorded.startKgis the firstWeightEntry,currentKgthe latest. Both fall back toClient.weightKgwhen there are no entries.deltaKgis current minus start, and needs at least two entries.goalcomes fromgoalProgress(start, current, goal).directionislosewhen the goal is below the start, otherwisegain.pctis(start - current) / (start - goal)as a percentage, clamped to 0 through 100. With a missing value, or when start equals goal,pctanddirectionarenull.weeklyRateKgis the change between the first and last point in range, scaled to seven days.nullwith fewer than two points or less than a day between them.weighInEveryDaysis the span in days between the first and last point in range divided by the number of gaps.- Each point’s
deltaKgis against the previous weigh-in, across the full history. sourceis one ofapp,coach,form,automation,import, fromweighInSource. A row with aformResponseIdor sourceformisform.COACHiscoach.automationisautomation. A source starting withautofitisimport. Everything else, including rows with no source, isapp.circumferences.tileshas one entry per key in the fixed orderchest,waist,hips,arm,thigh.deltais latest minus first over all time.improvingistruewhen the value moved in the wanted direction: up forarm, down for the rest. Both arenullwith fewer than two values, andimprovingisnullfor a zero delta.
points is oldest first. log is the same list newest first (emptied in the example). history is newest first and only includes days that carry at least one of the five circumference keys.
Errors: NOT_FOUND, VALIDATION.
POST /v1/web/clients/:id/tracking/weights
Adds a weigh-in on the trainee’s behalf. Returns 201.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
number
required
Coerced to a number. Positive, maximum 500.
string
YYYY-MM-DD, a real calendar date. Defaults to today in the trainee’s time zone.string
Trimmed, maximum 500 characters.
WeightEntry is created with source: "COACH". For today, recordedAt is the current instant. For an earlier date it is local noon of that day, so the civil day survives later time zone conversions.
Side effect: Client.weightKg is updated to the new value when this entry is the latest one, that is when there was no earlier entry or recordedAt is at or after the previous latest. A back-dated weigh-in older than the latest leaves it alone. No activity record or notification is written.
Response:
BAD_REQUEST (the date cannot be in the future). NOT_FOUND. VALIDATION for a bad body.
POST /v1/web/clients/:id/tracking/measurements
Adds circumferences for a day on the trainee’s behalf. Returns 201.
Auth: web lane or bearer lane, behind requireAssignedClient.
string
required
The trainee id.
string
YYYY-MM-DD. Defaults to today in the trainee’s time zone.number
Centimetres. Coerced, positive, maximum 300.
number
Same rule.
number
Same rule.
number
Same rule.
number
Same rule.
string
Trimmed, maximum 500 characters.
DailyMetric.measurements JSON, the same way the trainee app writes them. Keys already recorded that day, including ones this route does not know such as a neck value, are kept. Only the keys sent are overwritten. If the day already has a different note, the coach’s note is appended on a new line. The row is upserted on (clientId, date).
Response:
values returns only the five circumference keys, even when the stored JSON holds more.
Errors: BAD_REQUEST (the date cannot be in the future). NOT_FOUND. VALIDATION for a bad body, including at least one measurement is required.
Computation rules
All of these live inclient-tracking.compute.ts as pure functions on civil dates, so they can be tested without a clock or a database.
Nutrition day status
nutritionDayStatus(consumed, target) compares calories only.
Personal records
A record is judged per exercise. Exercises are matched onexerciseId. A logged row with no id is matched on its name instead (name:<name>).
- Which sets are eligible (
countsForRecord). The set must have reps above zero. A set with no weight, or with no target reps, always counts. A weighted set with a target counts only when the reps reached at least 80% of the target (RECORD_MIN_REPS_PERCENT). - Best set (
bestSet). Among eligible sets, the heaviest, with reps as the tie-break (outranks). - Beating a record (
beatsRecord). If either set carries weight, the new weight must be strictly higher. Reps alone never beat a weighted record at the same weight. If neither carries weight (bodyweight work), more reps wins.
Workout status
workoutStatus(completed, doneSets) returns completed when the log is completed, partial when it has at least one done set, and null otherwise. Only sets with done: true in the app-written entries JSON are read at all.
Who else uses this module
Other modules import the pure helpers fromclient-tracking.compute.ts, so the coach views and the rest of the product agree on one definition:
modules/checkins/checkins-insights.tsandmodules/checkins/checkins-tracking.ts, which also uses theNutritionDaysSourcetype from the service.modules/trainee/trainee.service.tsandmodules/trainee/measurements-summary.ts(weighInSource).modules/inbox/context-video.ts(parseExercises) andmodules/inbox/context-activity.ts(appActivityLevels,weekStartIso).