Training plans and nutrition plans share one set of code. A template belongs to the studio. A program is a plan assigned to one trainee. Both are edited with the same builder components. Code lives in [organizationSlug]/templates/:

Routes

Build links with templateListHref(basePath, scope) and templateEditorHref(basePath, id, scope) from modules/shared/lib/template-routes.ts. The template editor serves both plan types, but the sidebar decides which of its two plan items to highlight from the scope search param. Only the page knows the template’s real type, so it renders TemplateScopeSync, which replaces the URL with the right scope when it differs. Bookmarks and direct links then highlight correctly. ?ai=1 on the list page opens the import wizard on load. The view strips the param afterwards so a refresh does not reopen it.

What the editor page loads

Both editor pages follow the same steps:
  1. Load the plan (/program-templates/{id} or /programs/{id}). A 404 becomes notFound().
  2. For a trainee program, check program.clientId === clientId. A mismatch is notFound().
  3. If isFilePlan(plan), render PdfPlanPage and stop. See below.
  4. Load the whole library: /foods with pageSize: 5000 or /exercises with pageSize: 2000.
  5. Normalize the content with normalizeNutritionContent or normalizeTrainingContent.
  6. Call withMissingById(items, ids, loadOne) from modules/shared/lib/plan-library-refs.ts. If the plan references a library item that the list did not return, it is fetched one by one. A plan must never render a row whose exercise or food is unknown.
  7. Render NutritionBuilder or ProgramBuilder.
A trainee program also gets personalCopy (the trainee’s name and where to exit to) and, when opened with ?back=, an afterSaveHref.

File plans

A plan is a file plan when it has a pdfUrl. The trainee reads a document instead of built days. modules/shared/lib/plan-kind.ts owns the rules:
Every reader must branch on isFilePlan before normalizing content. Both normalizers fabricate a “Day 1” for an empty plan. Opening a file plan in a builder and saving would persist that fake day.
PdfPlanPage shows the document. Trainee programs open it with readOnly. PDFs are uploaded with templates/media-upload.ts, which sends small files through the uploadPlanPdf action (POST /content/media with scope: "plans") and larger ones through the chunked relay.

Training content

modules/shared/lib/training-schema.ts:
A StrengthRow holds exerciseId, muscle, sets, reps, repType, repsHigh, rest, weight, rir, tempo, notes, setRows, alternatives, supersetParentId and techniqueVideo. Numeric values are strings, because they are edited as text. normalizeTrainingContent(raw) accepts any stored shape and returns schema version 2. It migrates older plans on read, so the builder only ever sees the current shape. emptyTrainingContent(), makeDay(index), makeStrengthRow() and makeSetRows(count) build defaults. A superset partner row has supersetParentId set. Its rest is fixed to SUPERSET_REST ("00:00"), and the partner’s rest cell shows a read only mirror of the parent’s rest (SupersetRestCell.tsx).

Nutrition content

modules/shared/lib/nutrition-schema.ts:
  • Targets are per day: kcal, protein, carbs, fat, water, and optional portion targets mbpProtein, mbpCarb, mbpFat. kcalOverride is set once the coach types a calorie target over the one derived from portions.
  • A NutritionMeal has a type (breakfast, snack_am, lunch, pre, post, dinner), an optional custom label, time, prep, items and an optional target (MealTarget).
  • A MealItem has foodId, qty, note, alts (substitutes) and an optional countsAs of protein or carb.
  • NutritionMeta carries goal, autoAlts, and the import flags imported, source, draft and goalSet.
Portion math (MBP) is in mbp.ts and mbp-totals.ts. Whether a studio uses portions, its anchors and its unit label come from studio settings and are passed into the builder as mbpEnabled, mbpAnchors and mbpLabel.

The training builder

builder/ProgramBuilder.tsx composes: columns.ts defines the table columns. OPTIONAL_COLUMNS is tempo and rir. visibleFromColumns and columnsFromVisible convert between the saved meta.columns and the set of visible keys.

Auto-fill

auto-fill.ts copies sets, reps and rest between rows of the same day while the coach types. The fields are FILL_FIELDS: sets, reps, repsHigh, rest. It tracks ownership in a Map keyed by field and row id. The value is the row the cell was filled from.
  • liveFill(rows, sourceId, field, value, owners) writes the source cell, then updates every other row that may follow it.
  • A row follows when its cell is empty, or when the cell is still owned by that same source. A cell the coach typed in themselves is never overwritten.
  • Rest is never filled into a superset partner. Reps are only copied between rows with the same repType.
  • Typing in a cell releases its ownership (releaseFill).
  • appendWithBaseline makes newly added exercises inherit sets, rest and reps from the first non superset row, and marks them as owned by it so later edits to the first row keep flowing down. Reps are not copied when the first row’s rep type is minutes.
auto-fill.test.ts covers these rules.

The nutrition builder

builder/NutritionBuilder.tsx composes: Foods come from the ["food-library", slug] query, so an edit made in the foods page or in settings shows here without a reload. Both SortableFoodList and SortableDayTabs pass id={contextId} to dnd-kit’s DndContext, where contextId comes from useId(). dnd-kit otherwise generates its own id, which differs between server and client and causes a hydration mismatch.

Saving and exiting

Both builders register with the unsaved changes guard using the plan variant. handleSave decides where to go after a save with traineePlanSaveExitHref(afterSaveHref, personal, backHref) in builder/save-exit.ts: When there is a destination, the builder calls holdExit() before saving, releaseExit() if the save fails and leave(destination) when it succeeds. See Shared components. Saving a template and saving a program are different API calls. The builder does not know which one it has. It receives an onSave prop from the page.

Assigning a template

AssignTraineesDialog (shared) is the one dialog for assignment. The builders wire it through builder/use-assign-trainees.ts and the list pages through use-template-list-assign.ts. Release scheduling (when the plan appears in the app) is modelled in modules/shared/lib/plan-release.ts and plan-release-model.ts, with SchedulePlansDialog as the UI.

Folders

Templates are grouped in folders by plan type. Actions: listFolders(slug, type), createFolder, updateFolder, deleteFolder, against /program-folders. FolderColorPicker picks the folder colour.

The import wizard

templates/import/ turns an existing document into a plan. rollbackPlanImportItems removes the library items created in a wizard run that was abandoned. import-wizard-model.ts holds the state machine and is unit tested. An imported plan is saved with meta.imported and meta.draft. In the builder, trainingMissingMarkers and the nutrition equivalent flag values the document did not carry, shown through import-markers.tsx. generateAiTemplate and loadExerciseAlternatives are the other AI backed actions in this file.

Tests worth reading first

In templates/builder/: auto-fill.test.ts, program-days.test.ts, meal-items.test.ts, save-exit.test.ts and substitute-tags.test.ts. In templates/import/: import-wizard-model.test.ts. In modules/shared/lib/: plan-kind.test.ts, training-import.test.ts, nutrition-import.test.ts, meal-target.test.ts, mbp.test.ts and plan-release.test.ts. They state the rules more directly than the components do.