Client tracking powers the Tracking tab on a trainee’s card. It reads what the trainee logged in the app and returns computed views: adherence, statuses, personal records, trends and comparisons with the previous period. It also lets a coach add a weigh-in or a set of circumferences on the trainee’s behalf. For raw paged rows with no computation, see Tracking. 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:
  1. withCoachAccess(ctx.prisma) resolves the caller’s coach permissions once per request.
  2. requireAssignedClient(ctx.prisma) runs on every /:id path. A coach restricted to their own trainees gets NOT_FOUND for a trainee they are not assigned to, the same answer as for a trainee of another studio. A dismissed team member gets FORBIDDEN.
There is no 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 a range query parameter.
string
default:"30d"
One of 7d, 30d, 3m. They map to 7, 30 and 90 days ending today (RANGE_DAYS).
A range is compared against the equal-length span that ends the day before it starts (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 return filePlan: 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.
No query parameters. The windows are fixed: 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.
Eaten and target totals are not recomputed here. They come from 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 slots breakfast, lunch, dinner, snack have something logged. Plan meals map by type (snack_am, pre and post all count as snack). A plan meal counts when it was marked eaten outside, replaced, or has at least one eaten item. Custom meals count as snack. Free-logged MealLog rows have no meal type, so they map by local hour: before 11 is breakfast, before 16 is lunch, before 19 is snack, later is dinner.
The plan day for a date is found by rotation: 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:
  • adherencePct is fullDays / totalDays, rounded. The denominator is every day in the range, not only logged days. It is null for a file plan.
  • averageConsumed and averageTarget average over logged days only. Calories are rounded to whole numbers and macros to one decimal. Either is null when there is nothing to average.
  • previous is null when the previous span has no logged day. A span the trainee had not started logging in is not a baseline.
Errors: 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.
The service runs the nutrition view without the previous-period comparison, then passes the range length, unit, logged, full and over day counts, and the two averages to 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.
The service loads the active nutrition plan, the trainee’s day snapshot (NutritionDayLog) and the MealLog rows of that day, then builds the list with describeLoggedNutritionDay. Three sources feed it, in this order:
  1. 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.
  2. 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, or snack without a log. Carries the log’s note and photoUrl.
  3. Unlinked meal logs, id log:<logId>. Free logs not tied to a snapshot meal.
Food names and quantities come from the studio’s effective food rows: 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:
The 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.
How each part is built:
  • weeks: the last eight Sunday to Saturday weeks, oldest first, regardless of range. 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. status is completed or partial.
  • 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, otherwise Client.stepsGoal, otherwise null.
  • steps.days: the last seven days. steps is null for a day with no synced row.
  • steps.source: appleHealth when Client.lastCheckInPlatform is ios, healthConnect for android, otherwise null.
  • kpis.expected: weeklyTarget * rangeDays / 7, rounded. null without a weekly target.
  • kpis.totalVolume counts every session. kpis.averageDurationSec averages completed sessions that have a positive duration.
  • previous: the same stats for the previous period, or null when it has no session.
Response:
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.
“Same type” sessions are found by 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 with weight, reps and targetReps.
  • 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, or null.
  • deltaKg: the difference in weight against previousBest, when both sets carry weight. Otherwise null.
  • deltaReps: the difference in reps, used when the comparison is not weighted. Otherwise null.
Response:
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.
  • startKg is the first WeightEntry, currentKg the latest. Both fall back to Client.weightKg when there are no entries.
  • deltaKg is current minus start, and needs at least two entries.
  • goal comes from goalProgress(start, current, goal). direction is lose when the goal is below the start, otherwise gain. pct is (start - current) / (start - goal) as a percentage, clamped to 0 through 100. With a missing value, or when start equals goal, pct and direction are null.
  • weeklyRateKg is the change between the first and last point in range, scaled to seven days. null with fewer than two points or less than a day between them.
  • weighInEveryDays is the span in days between the first and last point in range divided by the number of gaps.
  • Each point’s deltaKg is against the previous weigh-in, across the full history.
  • source is one of app, coach, form, automation, import, from weighInSource. A row with a formResponseId or source form is form. COACH is coach. automation is automation. A source starting with autofit is import. Everything else, including rows with no source, is app.
  • circumferences.tiles has one entry per key in the fixed order chest, waist, hips, arm, thigh. delta is latest minus first over all time. improving is true when the value moved in the wanted direction: up for arm, down for the rest. Both are null with fewer than two values, and improving is null for a zero delta.
Response:
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.
A 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:
Errors: 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.
At least one of the five measurements is required. The values are merged into that day’s 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 in client-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 on exerciseId. A logged row with no id is matched on its name instead (name:<name>).
  1. 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).
  2. Best set (bestSet). Among eligible sets, the heaviest, with reps as the tie-break (outranks).
  3. 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.
In the workouts view the baseline is the trainee’s best per exercise from up to 500 logs before the range. The service then walks the range oldest first, records each session that beats the running best, and raises the running best as it goes. An exercise with no earlier best cannot produce a record, so a first ever session never counts as one.

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 from client-tracking.compute.ts, so the coach views and the rest of the product agree on one definition:
  • modules/checkins/checkins-insights.ts and modules/checkins/checkins-tracking.ts, which also uses the NutritionDaysSource type from the service.
  • modules/trainee/trainee.service.ts and modules/trainee/measurements-summary.ts (weighInSource).
  • modules/inbox/context-video.ts (parseExercises) and modules/inbox/context-activity.ts (appActivityLevels, weekStartIso).