A training plan is stored as one JSON document in Program.content or ProgramTemplate.content when type is TRAINING. There is no Zod schema for it. The contract is a TypeScript type plus a normalizer that accepts anything and returns valid TrainingContent.

Where the contract lives

The backend and frontend files must change together. A field added in one and not the other is dropped on the next save, because both normalizers rebuild every object from a whitelist of known keys.

Versions

Nothing is migrated in the database. Old content is upgraded each time it is read and is written back as v2 the next time the plan is saved.

What the normalizer migrates

Shape

Almost every prescription value is a string, including numbers. The builder edits text inputs and the normalizer keeps what the coach typed. Parse at the point of use.

meta

Optional keys are absent, not false or '', when they do not apply. That keeps content saved before a feature existed byte-identical after a normalize.

meta.requirement

The cardio and daily steps requirement of the plan. The four modes: Helpers in training-schema.ts answer the questions callers actually ask: requirementHasCardio, requirementHasSteps, requirementCompensates, requirementDailySteps, weeklyCardioMinutes(requirement, workoutsPerWeek), dayCardioMinutes(day, requirement, workoutsPerWeek) and dayStepsGoal(day, requirement). A weekly cardio target is spread across the week’s workouts. dayCardioMinutes returns the target divided by workoutsPerWeek, rounded, unless the day has its own override. The trainee app receives this as a computed movement block, built by apps/core-api/src/modules/trainee/movement.ts from the requirement, the day due today, logged CardioLog minutes and step counts. It reports per-day and weekly targets and totals for cardio and steps, a met flag for each, and todayMet.

days[]

A plan with no days normalizes to one empty day d1.

days[].strength[]

Supersets

A superset is two linked rows, not a nested object. The partner row carries supersetParentId. The rest between the two exercises is SUPERSET_REST (00:00), and the pair shares the parent’s rest after the round. The builder shows the partner’s rest as a read-only mirror of the parent’s. The mobile app pairs rows in src/features/workouts/lib/supersets.ts.

setRows[]

techniqueVideo

Controls whether the trainee is asked to film this exercise. techniqueVideoRequired(rule) is true when mode is coachDemand and at least one trigger is set. The request quota shown to the trainee is derived in apps/core-api/src/modules/trainee/technique-prompts.ts from these rules and the trainee’s TechniqueVideo rows. Nothing is stored for it. stampTechniqueRequest(raw, exerciseId, requestedAt) rewrites the stored shape, not normalized content. It touches only the technique rule of rows that prescribe that exercise, including a v1 nested superset partner, and returns how many rows it stamped.

What is not in the JSON

Summary helper

summarizeTraining(raw) normalizes and returns discipline, dayCount, exerciseCount and workoutsPerWeek, plus imported and draft flags when set. List endpoints use it so they do not ship whole plans.