[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:- Load the plan (
/program-templates/{id}or/programs/{id}). A404becomesnotFound(). - For a trainee program, check
program.clientId === clientId. A mismatch isnotFound(). - If
isFilePlan(plan), renderPdfPlanPageand stop. See below. - Load the whole library:
/foodswithpageSize: 5000or/exerciseswithpageSize: 2000. - Normalize the content with
normalizeNutritionContentornormalizeTrainingContent. - Call
withMissingById(items, ids, loadOne)frommodules/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. - Render
NutritionBuilderorProgramBuilder.
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 apdfUrl. The trainee reads a document instead of built days. modules/shared/lib/plan-kind.ts owns the rules:
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:
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 targetsmbpProtein,mbpCarb,mbpFat.kcalOverrideis set once the coach types a calorie target over the one derived from portions. - A
NutritionMealhas atype(breakfast,snack_am,lunch,pre,post,dinner), an optional customlabel,time,prep,itemsand an optionaltarget(MealTarget). - A
MealItemhasfoodId,qty,note,alts(substitutes) and an optionalcountsAsofproteinorcarb. NutritionMetacarriesgoal,autoAlts, and the import flagsimported,source,draftandgoalSet.
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). appendWithBaselinemakes 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 isminutes.
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 theplan 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
Intemplates/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.