Appearance
ProgramsAndExercise / Workout Programs / Builder
Program Builder turns a coach-edited program calendar into stable templates, assigned programs, and client workout snapshots.
The V1 baseline builder creates a week-by-week calendar from the coach's selected duration. Coaches define duration, days per week, muscle-group sections, exercise order, set prescriptions, notes, RPE/RIR targets, approved alternatives, and day-level copy rules. Exercise Library supplies provider-backed and coach-created exercise snapshots plus muscle taxonomy snapshots; Program Builder owns all coaching structure and assignment lifecycle.
Page status
- Calendar builder V1 baseline
- Template versions
- Assigned snapshots
- Coach web + mobile
V1 baseline decision
V1 baseline decision: Program Builder allows programs up to 8 weeks, renders generated week/day slots for the full duration_weeks calendar, and caps the UI to four visible weeks at a time with vertical scroll. Day workout content is edited on selected calendar days and can be deleted or duplicated to the same day across all weeks, even weeks, odd weeks, or the next week only. DailyExecution materializes a rolling 14-day window of dated workout tasks from the assigned program snapshot for the Today view, reminders, and offline workout mode.
Assignment decision
Assignment decision: a coach-client relationship can have only one non-terminal primary training program at a time. Assignment-specific personalization lives on the assignment snapshot or assignment revision; reusable template versions change only when the coach intentionally saves a reusable program update.
Program Builder Flows
Program creation is coach-owned. Client mobile consumes assigned snapshots and logs execution through DailyExecution, not by mutating the builder draft.
Coach Creates A Draft
- Coach starts from a blank program, a reusable template, or a copy of an assigned program.
- Coach sets title, description, duration in weeks, number of training days per week, optional goal, experience level, and equipment notes.
- Application service creates a
ProgramDraftwith generated week/day rows for the program calendar. - Draft autosave stores builder revisions with idempotency keys so web and coach mobile can recover from interrupted sessions.
- Draft remains private to authorized coaches until it is published or assigned.
Coach Builds Training Days
- Coach opens a day from the calendar; selecting the day shows the Exercise Library selection page or pane for that workout.
- Exercise search opens with that taxonomy node applied as a visible filter and lets the coach filter by exercise type taxonomy node plus all exercises, My Library, or Provider Library.
- If an exercise is missing, the add-exercise flow can open inline custom exercise creation backed by Exercise Library; when saved and active, the new custom exercise can be inserted immediately.
- When the coach selects an exercise, Program Builder requests an
ExerciseSelectionSnapshotfrom Exercise Library. - Program Builder inserts the snapshot into the active group with set, rep, load, rest, tempo, notes, RPE/RIR, training method, and ordering fields.
- Coach reorders exercises inside the group, adds approved alternatives, and can delete or duplicate the full day workout from the calendar.
Review, Publish, And Save As Template
- Coach opens review and receives validation warnings for empty days, empty groups, missing prescriptions, unavailable exercises, or impossible set ranges.
- Publishing freezes the draft into an immutable
ProgramVersionsnapshot with checksum and version number. - Reusable templates keep a template identity and can publish later versions without changing prior assignments.
- Archived templates disappear from new assignments but remain readable for historical assigned programs.
Assign To Client
- Coach selects a published version or publishes the current draft as part of assignment.
- Coach chooses one or more active coach-client relationships, start date, optional end date, and notification behavior.
- API rejects a relationship that already has a scheduled, active, or paused primary training program unless the request explicitly supersedes or cancels the old assignment.
- API creates relationship-scoped
AssignedProgramrecords with an immutable assignment snapshot. ProgramAssignedevent tells DailyExecution to create a rolling 14-day window of upcoming workout tasks and mobile sync hints.- Later edits create a new draft or assignment revision; they do not mutate submitted workout logs.
Calendar Builder UI
The coach-facing builder should behave like a compact program calendar: generated weeks, day notes, selected-day exercise search, and day-level duplicate/delete actions.
Hypertrophy Block
8 weeks / 5 training days
4 weeks visible / scroll
Week 1 / Day 3 selected Delete Duplicate: same day / all weeks Duplicate: same day / even weeks Duplicate: same day / odd weeks Duplicate: same day / next week only
Week 1Build
Day 1Mon
Push Strength
- Incline DB Press...
- Machine Shoulder...
- Cable Fly...
+2 more
Day 2Tue
Lower Volume
- Hack Squat...
- Romanian Deadlift...
- Leg Extension...
+3 more
Day 3Wed
Pull Hypertrophy
- Neutral Grip Pulldown...
- Chest Supported Row...
- Rear Delt Cable...
+2 more
Day 4Thu Rest
Day 5Fri
Full Body Pump
- Leg Press...
- DB Bench...
- Seated Row...
+4 more
Day 6Sat Rest
Day 7Sun Rest
Week 2Build
Day 1Mon
Push Strength
- Incline DB Press...
- Machine Shoulder...
- Cable Fly...
+2 more
Day 2Tue
Lower Volume
- Hack Squat...
- Romanian Deadlift...
- Leg Extension...
+3 more
Day 3Wed
Pull Hypertrophy
- Neutral Grip Pulldown...
- Chest Supported Row...
- Rear Delt Cable...
+2 more
Day 4Thu Rest
Day 5Fri
Full Body Pump
- Leg Press...
- DB Bench...
- Seated Row...
+4 more
Day 6Sat Rest
Day 7Sun Rest
Week 3Load
Day 1Mon
Push Strength
- Incline DB Press...
- Machine Shoulder...
- Cable Fly...
+2 more
Day 2Tue
Lower Volume
- Hack Squat...
- Romanian Deadlift...
- Leg Extension...
+3 more
Day 3Wed
Pull Hypertrophy
- Neutral Grip Pulldown...
- Chest Supported Row...
- Rear Delt Cable...
+2 more
Day 4Thu Rest
Day 5Fri
Full Body Pump
- Leg Press...
- DB Bench...
- Seated Row...
+4 more
Day 6Sat Rest
Day 7Sun Rest
Week 4Load
Day 1Mon
Push Strength
- Incline DB Press...
- Machine Shoulder...
- Cable Fly...
+2 more
Day 2Tue
Lower Volume
- Hack Squat...
- Romanian Deadlift...
- Leg Extension...
+3 more
Day 3Wed
Pull Hypertrophy
- Neutral Grip Pulldown...
- Chest Supported Row...
- Rear Delt Cable...
+2 more
Day 4Thu Rest
Day 5Fri
Full Body Pump
- Leg Press...
- DB Bench...
- Seated Row...
+4 more
Day 6Sat Rest
Day 7Sun Rest
Week 5Deload
Day 1Mon
Push Deload
- Incline DB Press...
- Machine Shoulder...
- Cable Fly...
+1 more
Day 2Tue Rest
Day 3Wed
Pull Deload
- Neutral Grip Pulldown...
- Chest Supported Row...
- Rear Delt Cable...
+1 more
Day 4Thu Rest
Day 5Fri
Mobility Circuit
- Goblet Squat...
- Incline Pushup...
- Cable Row...
+2 more
Day 6Sat Rest
Day 7Sun Rest
Exercise Library Page
Week 1 / Day 3
Pull Hypertrophy
Search back, row, pull... My Library Create
VID Neutral Grip Lat Pulldown Provider Library / Back / Machine
CM Chest Supported DB Row My Exercise / Back / Dumbbells
VID Rear Delt Cable Fly Provider Library / Shoulders / Cable
Note
Selecting any calendar day switches the Exercise Library view to that day workout. Exercise selection writes snapshots into the selected week/day slot, not into every week unless the coach chooses a duplicate action.
Note
The add-exercise flow includes inline custom exercise creation. The inline form delegates media, taxonomy, and validation rules to Exercise Library, then returns the created exercise snapshot to Program Builder.
Calendar Rules
- After duration is set, render
Week 1throughWeek Nwith seven day columns or configured training-day labels. - The scroll container should never expose more than four weeks at once on desktop; coaches scroll vertically for later weeks.
- A calendar day renders as a note-style workout summary: workout label, first three exercise names truncated, and remaining exercise count.
- Selecting a day changes the active context for the Exercise Library page or pane and prescription editor.
- The first builder release supports straight sets, supersets, and drop sets in the prescription editor; advanced training methods can follow later.
Day Workout Actions
Delete: clears the selected full-day workout while preserving the calendar day slot.Duplicate same day / all weeks: copies the selected workout to the same day number in every week.Duplicate same day / even weeks: copies it to weeks 2, 4, 6, and so on.Duplicate same day / odd weeks: copies it to weeks 1, 3, 5, and so on, excluding the source when appropriate.Duplicate same day / next week only: copies it to the same day number in the immediately following week.- Duplicating a day preserves exercise snapshots, prescriptions, alternatives, superset links, drop-set metadata, and coach notes in the target day copy.
Domain Model
Program Builder belongs to ProgramsAndExercise. It references IdentityAccess, CoachClientManagement, and Exercise Library by stable external ids and snapshots.
Aggregates & Entities
ProgramTemplate: workspace-owned reusable program identity, latest version pointer, archive state, goal labels, and template metadata.ProgramDraft: mutable builder aggregate for setup, program calendar days, muscle-group sections, exercises, alternatives, validation state, and autosave revision.ProgramVersion: immutable published snapshot created from a valid draft; assignable and historically stable.AssignedProgram: relationship-scoped active, paused, completed, canceled, or superseded program with assignment snapshot and effective dates.ProgramDay: one generated calendar day in a specific program week, including week number, day number, label, readiness status, and optional day notes.ProgramWorkoutGroup: muscle-group section anchored to Exercise Library taxonomy and ordered inside a day.ProgramWorkoutExercise: selected exercise snapshot, prescription, order, coach notes, cue text, and first-release training-method metadata for straight sets, supersets, and drop sets.ProgramExerciseAlternative: approved substitution snapshot for a specific program exercise and reason such as equipment availability.
Value Objects & Rules
ProgramDuration: positive integer weeks from 1 to 8 in Public V1; this cap protects calendar performance, mobile UX, and normal coaching block length.TrainingDaysPerWeek: integer from 1 to 7; changing it adds or removes draft day rows through a use case, not ad hoc UI mutation.ProgramDayDuplicationScope:ALL_WEEKS,EVEN_WEEKS,ODD_WEEKS, orNEXT_WEEK_ONLYfor copying a selected full-day workout to matching same-day slots.TrainingMethod:STRAIGHT_SET,SUPERSET, orDROP_SETin the first release. Supersets link exercises bytraining_method_group_id; drop sets store drop count, target change, and rest behavior in training-method metadata.ExercisePrescription: sets, reps, load target, rest seconds, tempo, RPE/RIR target, notes, warmup flag, optional progression rule, and training-method metadata.ProgramCalendarSnapshot: immutable week/day/group/exercise tree embedded inProgramVersionandAssignedProgram.ProgramValidationResult: empty day, empty group, missing prescription, invalid ranges, duplicate order, unavailable exercise, and warning severity.AssignmentEffectiveRange: start date, calculated end date from duration, timezone, pause/cancel/supersede state.- Exercises can be selected only from Exercise Library selection snapshots. A snapshot can originate from a third-party provider exercise or a coach-created custom exercise.
- Assignment personalization can change relationship-specific schedule, timezone, assignment notes, notification behavior, and minor client-specific prescription overrides through assignment revisions without creating a reusable template version.
- Reusable template versions change only when the coach chooses to save a reusable program update. Structural changes to an active assignment should supersede or revise that assignment, not silently mutate the template version.
- Published versions and assignment snapshots are immutable; edits create a new draft, version, assignment revision, or superseding assignment.
| Model | Owned By | Important Methods | Emits |
|---|---|---|---|
ProgramTemplate | ProgramsAndExercise | create(), startDraft(), publishVersion(), archive(), restore() | ProgramTemplateCreated, ProgramTemplateArchived |
ProgramDraft | ProgramsAndExercise | setStructure(), addGroup(), addExercise(), setTrainingMethod(), reorderExercises(), clearDayWorkout(), duplicateDayWorkout(), validateForPublish(), publish() | ProgramDraftCreated, ProgramExerciseAdded, ProgramTrainingMethodUpdated, ProgramDayWorkoutDuplicated, ProgramVersionPublished |
ProgramVersion | ProgramsAndExercise | fromDraft(), assertAssignable(), toAssignmentSnapshot() | ProgramVersionPublished |
AssignedProgram | ProgramsAndExercise | assign(), pause(), resume(), complete(), cancel(), supersedeWith() | ProgramAssigned, ProgramPaused, ProgramSuperseded, ProgramCompleted |
ExerciseLibrary | ProgramsAndExercise | Supplies taxonomy nodes and ExerciseSelectionSnapshot values. Does not own program order, prescription, notes, or alternatives. | ExerciseSelectionSnapshotCreated |
Table Structure
Store mutable drafts relationally for builder operations, then publish immutable JSON snapshots for versions and assignments.
| Table | Purpose | Important Columns | Constraints & Indexes |
|---|---|---|---|
program_templates | Reusable workspace-owned program identity and catalog metadata. | id, workspace_id, owner_user_id, title, description, goal, experience_level, equipment_tags text[], status, latest_version_id, created_at, updated_at, archived_at, cleanup_eligible_at | Index (workspace_id, status, updated_at desc); optional unique active title per owner only if product requires it; archived unreferenced templates become cleanup eligible after 7 days. |
program_drafts | Mutable builder state for new templates, template edits, or assignment copies. | id, workspace_id, template_id, source_version_id, relationship_id, owner_user_id, source_type, status, title, duration_weeks, days_per_week, builder_revision, validation_summary jsonb, last_saved_at, deleted_at, cleanup_eligible_at | Index (workspace_id, owner_user_id, status); index (template_id, status); check duration_weeks between 1 and 8; relationship_id is an external CoachClientManagement identity reference; abandoned unpublished drafts become cleanup eligible after 7 days. |
program_draft_days | Generated week/day slots in the draft program calendar. | id, draft_id, week_number, day_number, label, sort_order, status, notes, created_at, updated_at | Unique (draft_id, week_number, day_number); unique (draft_id, sort_order); cascade visibility from draft. |
program_draft_groups | Muscle-group sections inside a draft day. | id, draft_day_id, muscle_taxonomy_node_id, muscle_snapshot jsonb, label, sort_order, notes, created_at, updated_at, deleted_at | Index (draft_day_id, sort_order); taxonomy id references Exercise Library by UUID but rendering uses muscle_snapshot. |
program_draft_exercises | Coach-configured exercises inside a group. | id, draft_group_id, exercise_catalog_item_id, exercise_source_type, nullable provider_key, nullable provider_exercise_id, exercise_snapshot jsonb, prescription jsonb, training_method, training_method_group_id, training_method_metadata jsonb, sort_order, coach_notes, client_cues, created_at, updated_at, deleted_at | Index (draft_group_id, sort_order); index (training_method_group_id); partial index for active rows; snapshots make drafts render even if provider data changes or custom exercises are edited/archived later. |
program_draft_exercise_alternatives | Approved substitutions for one exercise. | id, draft_exercise_id, exercise_catalog_item_id, exercise_snapshot jsonb, reason, sort_order, created_at, deleted_at | Unique active (draft_exercise_id, exercise_catalog_item_id); alternatives inherit parent exercise visibility. |
program_versions | Immutable published version from a validated draft. | id, template_id, source_draft_id, version_number, title, duration_weeks, days_per_week, calendar_snapshot jsonb, validation_summary jsonb, checksum, published_by_user_id, published_at | Unique (template_id, version_number); check duration_weeks between 1 and 8; unique checksum per template optional; never update snapshot after publish. |
assigned_programs | Relationship-scoped program assignment and effective lifecycle. | id, workspace_id, relationship_id, program_version_id, assigned_by_user_id, status, start_date, end_date, timezone, assignment_snapshot jsonb, personalization_overrides jsonb, superseded_by_id, assigned_at, paused_at, completed_at, canceled_at, cleanup_eligible_at | Index (relationship_id, status, start_date); partial unique (relationship_id) where status is SCHEDULED, ACTIVE, or PAUSED; canceled assignments become cleanup eligible after 7 days when unreferenced by logs or audit requirements. |
program_assignment_revisions | Append-only audit of assignment changes, pauses, cancellations, and supersessions. | id, assigned_program_id, revision_type, changed_by_user_id, previous_status, next_status, reason, metadata jsonb, created_at | Index (assigned_program_id, created_at desc); append-only with retention or archival. |
Note
Use plain UUID identity references across bounded contexts. workspace_id, owner_user_id, and relationship_id are validated by policy/application services; the Program Builder domain model must not navigate or mutate IdentityAccess or CoachClientManagement aggregates.
Lifecycle Policy
Workout programming changes often, but clients need stable assigned plans and historical logs.
Draft And Template Lifecycle
DRAFT: editable by authorized coaches; can be autosaved and validated but not visible to clients.READY_FOR_REVIEW: passes hard validation but may contain warnings.PUBLISHED: creates immutableProgramVersion; source draft can be closed or copied for the next version.ARCHIVED: hidden from new template selection; existing versions and assignments remain readable.- Abandoned unpublished drafts become cleanup eligible after 7 days without activity and can be soft-deleted or purged by the cleanup job.
- Archived templates become cleanup eligible after 7 days only when no published versions, assignments, logs, analytics, or audit rows still reference them.
- Published versions are retained while referenced by assignments, logs, analytics, or audit rows.
Assignment Lifecycle
SCHEDULED: assigned with future start date; DailyExecution can create upcoming tasks close to the start window.ACTIVE: current assigned program used by Today view and workout logging.PAUSED: retained and hidden from normal task generation until resumed.COMPLETED: duration ended or coach marks complete; historical workout logs remain attached.CANCELEDandSUPERSEDED: stop future task generation but preserve assignment snapshot for history.- A relationship can have only one non-terminal primary assignment:
SCHEDULED,ACTIVE, orPAUSED. Assigning a replacement must cancel or supersede the existing assignment first. - Canceled assignments become cleanup eligible after 7 days when unreferenced by workout logs, analytics, notifications, or audit requirements.
Snapshot Stability
- Version snapshots include title, duration, week/day tree, group snapshots, exercise snapshots, prescriptions, alternatives, and validation checksum.
- Assignment snapshots copy the version snapshot plus relationship-specific dates, timezone, coach notes, notification settings, and optional client-specific personalization overrides.
- Assignment personalization does not create a new reusable template version unless the coach explicitly saves those changes back as a reusable template update.
- Workout logs reference the assigned program id and snapshot exercise ids that existed at execution time.
- Changing a provider exercise, template, or draft does not rewrite historical assigned snapshots or workout logs.
Conflict Handling
- Draft writes require expected
builder_revisionto prevent lost updates between web and coach mobile. - Publishing validates against the latest revision and returns conflict when another session saved newer changes.
- Assigned program edits use supersession or revision flow, not in-place mutation of already materialized workout logs.
- Client offline logs merge by client-generated workout-session ids in DailyExecution, not through Program Builder tables.
API Contracts
Coach endpoints manage drafts, publication, and assignments. Client endpoints read assigned snapshots through program and daily execution contracts.
| Endpoint | Purpose | Request | Response |
|---|---|---|---|
POST /api/v1/workspaces/{workspaceId}/program-drafts | Create a blank draft or copy from template/version/assignment. | Title, duration weeks from 1 to 8, days per week, source type, optional source id, optional relationship id. | ProgramDraftDto with generated week/day calendar, revision, validation warnings, and edit permissions. |
PATCH /api/v1/workspaces/{workspaceId}/program-drafts/{draftId}/setup | Update program shell fields and regenerate calendar slots when needed. | Expected revision, title, description, duration weeks from 1 to 8, days per week, goal, equipment tags. | Updated draft shell, generated/removed week/day summaries, next revision, validation result. |
GET /api/v1/workspaces/{workspaceId}/program-drafts/{draftId} | Load full builder state. | Coach actor and optional locale. | Draft calendar tree with weeks, days, groups, exercises, alternatives, snapshots, prescriptions, and revision. |
POST /api/v1/workspaces/{workspaceId}/program-drafts/{draftId}/days/{dayId}/groups | Add a muscle-group section. | Exercise Library taxonomy node id, sort position, expected revision. | Created group with taxonomy snapshot and next revision. |
POST /api/v1/workspaces/{workspaceId}/program-draft-groups/{groupId}/exercises | Add selected exercise into the active group. | Exercise selection snapshot id or exercise catalog item id, prescription defaults, sort position, expected revision, idempotency key. | Created program exercise, exercise snapshot, validation warnings, and next revision. |
POST /api/v1/workspaces/{workspaceId}/program-draft-groups/{groupId}/inline-custom-exercise | Create a custom exercise from the add-exercise flow, then insert it into the active group. | Expected revision, Exercise Library custom exercise payload, prescription defaults, sort position, idempotency key. | Created custom exercise summary, inserted program exercise, immutable exercise snapshot, validation warnings, and next revision. |
PATCH /api/v1/workspaces/{workspaceId}/program-draft-exercises/{exerciseId} | Update prescription, notes, cues, and training-method metadata. | Expected revision, prescription object, coach notes, client cues, trainingMethod=STRAIGHT_SET|SUPERSET|DROP_SET, training method metadata. | Updated exercise row and validation result. |
POST /api/v1/workspaces/{workspaceId}/program-draft-exercises/{exerciseId}/alternatives | Add an approved substitution. | Exercise catalog item id or selection snapshot, reason, expected revision. | Alternative row with immutable exercise snapshot. |
PATCH /api/v1/workspaces/{workspaceId}/program-draft-groups/{groupId}/exercise-order | Persist drag-and-drop order inside a group. | Expected revision and ordered exercise ids. | Updated order, duplicate/missing id validation, next revision. |
DELETE /api/v1/workspaces/{workspaceId}/program-draft-days/{dayId}/workout | Clear the selected full-day workout while keeping the generated calendar day. | Expected revision and optional reason. | Cleared day summary, affected group/exercise ids, validation warnings, and next revision. |
POST /api/v1/workspaces/{workspaceId}/program-draft-days/{dayId}/duplicate-workout | Copy the selected full-day workout to matching same-day slots. | Expected revision, duplication scope: ALL_WEEKS, EVEN_WEEKS, ODD_WEEKS, or NEXT_WEEK_ONLY. | Copied target week/day ids, skipped targets with reasons, validation warnings, and next revision. |
POST /api/v1/workspaces/{workspaceId}/program-drafts/{draftId}/validate | Run review validation before publish or assignment. | Expected revision and validation mode. | ProgramValidationResultDto with blocking errors, warnings, and navigation anchors. |
POST /api/v1/workspaces/{workspaceId}/program-drafts/{draftId}/publish | Create immutable version snapshot. | Expected revision, template id or create-template intent, publish note. | Published ProgramVersionDto, checksum, version number, and assignability result. |
POST /api/v1/workspaces/{workspaceId}/program-versions/{versionId}/assignments | Assign published program to clients. | Relationship ids, start date, timezone, notification options, optional relationship-specific personalization overrides, optional supersedeExistingAssignedProgramId, idempotency key. | Created assigned programs, rejected relationship ids with reasons such as ACTIVE_PRIMARY_PROGRAM_EXISTS, and emitted task references. |
PATCH /api/v1/workspaces/{workspaceId}/assigned-programs/{assignedProgramId}/status | Pause, resume, complete, cancel, or supersede an assignment. | Next status, reason, optional replacement version id, effective date. | Updated assigned program, revision audit id, and DailyExecution sync effect. |
GET /api/v1/client/assigned-programs/current | Client reads active assigned program summary. | Client actor, optional relationship id. | Current assigned program summary, date range, program calendar overview, and cache metadata. |
GET /api/v1/client/assigned-programs/{assignedProgramId}/workouts/{week}/{day} | Client or DailyExecution reads one workout snapshot. | Client actor, week number, day number, locale. | Workout snapshot with groups, exercises, prescriptions, alternatives, media refs, and offline cache checksum. |
Key Code Snippets
Implementation sketches for the TypeScript, NestJS, Drizzle, and OpenAPI modular monolith.
Drizzle schema shape
apps/api/src/programs-exercise/db/program-builder.schema.ts
ts
export const programTemplates = pgTable("program_templates", {
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id").notNull(),
ownerUserId: uuid("owner_user_id").notNull(),
title: text("title").notNull(),
description: text("description"),
goal: text("goal"),
experienceLevel: text("experience_level").$type<ExperienceLevel | null>(),
equipmentTags: text("equipment_tags").array().notNull().default([]),
status: text("status").$type<"ACTIVE" | "ARCHIVED">().notNull().default("ACTIVE"),
latestVersionId: uuid("latest_version_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
archivedAt: timestamp("archived_at", { withTimezone: true }),
cleanupEligibleAt: timestamp("cleanup_eligible_at", { withTimezone: true }),
}, (table) => ({
workspaceStatusUpdatedIdx: index("program_templates_workspace_status_updated_idx")
.on(table.workspaceId, table.status, table.updatedAt),
}));
export const programDrafts = pgTable("program_drafts", {
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id").notNull(),
templateId: uuid("template_id").references(() => programTemplates.id),
sourceVersionId: uuid("source_version_id"),
relationshipId: uuid("relationship_id"),
ownerUserId: uuid("owner_user_id").notNull(),
sourceType: text("source_type").$type<ProgramDraftSource>().notNull(),
status: text("status").$type<ProgramDraftStatus>().notNull().default("DRAFT"),
title: text("title").notNull(),
durationWeeks: integer("duration_weeks").notNull(),
daysPerWeek: integer("days_per_week").notNull(),
builderRevision: integer("builder_revision").notNull().default(1),
validationSummary: jsonb("validation_summary").$type<ProgramValidationSummary>(),
lastSavedAt: timestamp("last_saved_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
cleanupEligibleAt: timestamp("cleanup_eligible_at", { withTimezone: true }),
}, (table) => ({
ownerStatusIdx: index("program_drafts_owner_status_idx")
.on(table.workspaceId, table.ownerUserId, table.status),
durationWeeksCheck: check("program_drafts_duration_weeks_check",
sql`${table.durationWeeks} between 1 and 8`),
}));
export const programDraftExercises = pgTable("program_draft_exercises", {
id: uuid("id").primaryKey().defaultRandom(),
draftGroupId: uuid("draft_group_id").notNull(),
exerciseCatalogItemId: uuid("exercise_catalog_item_id").notNull(),
exerciseSourceType: text("exercise_source_type").notNull(),
providerKey: text("provider_key"),
providerExerciseId: text("provider_exercise_id"),
exerciseSnapshot: jsonb("exercise_snapshot").$type<ExerciseSelectionSnapshot>().notNull(),
prescription: jsonb("prescription").$type<ExercisePrescription>().notNull(),
trainingMethod: text("training_method").$type<TrainingMethod>().notNull().default("STRAIGHT_SET"),
trainingMethodGroupId: uuid("training_method_group_id"),
trainingMethodMetadata: jsonb("training_method_metadata").$type<TrainingMethodMetadata>().notNull().default({}),
sortOrder: integer("sort_order").notNull(),
coachNotes: text("coach_notes"),
clientCues: text("client_cues"),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
}, (table) => ({
groupOrderIdx: index("program_draft_exercises_group_order_idx")
.on(table.draftGroupId, table.sortOrder)
.where(sql`${table.deletedAt} is null`),
trainingMethodGroupIdx: index("program_draft_exercises_method_group_idx")
.on(table.trainingMethodGroupId),
}));
export const assignedPrograms = pgTable("assigned_programs", {
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id").notNull(),
relationshipId: uuid("relationship_id").notNull(),
programVersionId: uuid("program_version_id").notNull(),
assignedByUserId: uuid("assigned_by_user_id").notNull(),
status: text("status").$type<AssignedProgramStatus>().notNull(),
startDate: date("start_date").notNull(),
endDate: date("end_date").notNull(),
timezone: text("timezone").notNull(),
assignmentSnapshot: jsonb("assignment_snapshot").$type<ProgramAssignmentSnapshot>().notNull(),
personalizationOverrides: jsonb("personalization_overrides").$type<AssignmentPersonalizationOverrides>().notNull().default({}),
supersededById: uuid("superseded_by_id"),
canceledAt: timestamp("canceled_at", { withTimezone: true }),
cleanupEligibleAt: timestamp("cleanup_eligible_at", { withTimezone: true }),
}, (table) => ({
relationshipStatusStartIdx: index("assigned_programs_relationship_status_start_idx")
.on(table.relationshipId, table.status, table.startDate),
onePrimaryProgramUq: uniqueIndex("assigned_programs_one_primary_uq")
.on(table.relationshipId)
.where(sql`${table.status} in ('SCHEDULED', 'ACTIVE', 'PAUSED')`),
}));Add exercise use case
apps/api/src/programs-exercise/use-cases/add-program-exercise.ts
ts
@Injectable()
export class AddProgramExerciseUseCase {
constructor(
private readonly drafts: ProgramDraftRepository,
private readonly exerciseLibrary: ExerciseSelectionSnapshotPort,
private readonly policy: ProgramBuilderPolicy,
private readonly tx: TransactionRunner,
) {}
async execute(command: AddProgramExerciseCommand, actor: Actor): Promise<ProgramDraftExerciseDto> {
return this.tx.run(async () => {
const draft = await this.drafts.getById(command.draftId);
await this.policy.assertCanEditDraft(actor, draft);
draft.assertRevision(command.expectedRevision);
const snapshot = await this.exerciseLibrary.createSelectionSnapshot({
workspaceId: draft.workspaceId,
exerciseCatalogItemId: command.exerciseCatalogItemId,
locale: command.locale,
programDraftId: draft.id,
}, actor);
const exercise = draft.addExercise({
groupId: command.groupId,
snapshot,
prescription: ExercisePrescription.defaults(command.prescriptionDefaults),
sortOrder: command.sortOrder,
idempotencyKey: command.idempotencyKey,
});
await this.drafts.save(draft);
return ProgramDraftExerciseDto.fromDomain(exercise, draft.builderRevision);
});
}
}Publish draft use case
apps/api/src/programs-exercise/use-cases/publish-program-draft.ts
ts
@Injectable()
export class PublishProgramDraftUseCase {
constructor(
private readonly drafts: ProgramDraftRepository,
private readonly templates: ProgramTemplateRepository,
private readonly versions: ProgramVersionRepository,
private readonly validator: ProgramDraftValidator,
private readonly outbox: OutboxPort,
private readonly tx: TransactionRunner,
) {}
async execute(command: PublishProgramDraftCommand, actor: Actor): Promise<ProgramVersionDto> {
return this.tx.run(async () => {
const draft = await this.drafts.getById(command.draftId);
draft.assertRevision(command.expectedRevision);
draft.assertEditableBy(actor.userId);
const validation = this.validator.validateForPublish(draft);
if (validation.hasBlockingErrors()) {
return ProgramVersionDto.withValidationErrors(validation);
}
const template = await this.templates.getOrCreateForDraft(draft, command.templateIntent, actor);
const version = ProgramVersion.publish({
template,
draft,
validation,
publishedByUserId: actor.userId,
});
await this.versions.save(version);
template.recordPublishedVersion(version.id);
await this.templates.save(template);
await this.outbox.publish(version.pullDomainEvents());
return ProgramVersionDto.fromDomain(version);
});
}
}Assign program use case
apps/api/src/programs-exercise/use-cases/assign-program.ts
ts
@Injectable()
export class AssignProgramUseCase {
constructor(
private readonly versions: ProgramVersionRepository,
private readonly assignments: AssignedProgramRepository,
private readonly relationshipAccess: CoachClientRelationshipPolicyPort,
private readonly policy: ProgramBuilderPolicy,
private readonly outbox: OutboxPort,
) {}
async execute(command: AssignProgramCommand, actor: Actor): Promise<AssignProgramResultDto> {
const version = await this.versions.getById(command.programVersionId);
this.policy.assertCanAssignVersion(actor, version);
const results: AssignProgramRelationshipResult[] = [];
for (const relationshipId of command.relationshipIds) {
await this.relationshipAccess.assertCoachCanAssignProgram(actor, relationshipId);
const currentPrimary = await this.assignments.findNonTerminalPrimary(relationshipId);
if (currentPrimary && !command.supersedes(currentPrimary.id)) {
results.push(AssignProgramRelationshipResult.rejected(relationshipId, "ACTIVE_PRIMARY_PROGRAM_EXISTS"));
continue;
}
const assigned = AssignedProgram.assign({
version,
relationshipId,
startDate: command.startDate,
timezone: command.timezone,
personalizationOverrides: command.personalizationOverridesFor(relationshipId),
supersedesAssignedProgramId: currentPrimary?.id,
assignedByUserId: actor.userId,
idempotencyKey: command.idempotencyKeyFor(relationshipId),
});
if (currentPrimary) currentPrimary.supersedeWith(assigned.id, actor.userId);
if (currentPrimary) await this.assignments.save(currentPrimary);
await this.assignments.save(assigned);
await this.outbox.publish(assigned.pullDomainEvents());
results.push(AssignProgramRelationshipResult.created(assigned));
}
return AssignProgramResultDto.fromResults(results);
}
}Events & Background Jobs
Program Builder emits stable events; DailyExecution, notifications, analytics, and mobile sync consume them.
Events
ProgramDraftCreated: optional analytics and draft autosave tracking.ProgramExerciseAdded: invalidate draft summary/read model and recent exercise list.ProgramTrainingMethodUpdated: invalidate draft summary and validation state for affected supersets or drop sets.ProgramVersionPublished: update template catalog, searchable metadata, and assignability read model.ProgramAssigned: DailyExecution materializes a rolling 14-day window of upcoming workout tasks; Communication can notify the client.ProgramPausedandProgramResumed: DailyExecution pauses or resumes future workout tasks.ProgramSuperseded: closes future tasks from the old assignment and starts tasks from the replacement.ProgramCompleted: ProgressAnalytics can include final adherence and performance summary.
Jobs
- Draft cleanup marks abandoned unpublished drafts cleanup eligible after 7 days without activity, then soft-deletes or purges eligible unreferenced rows.
- Archive cleanup marks archived templates and canceled assignments cleanup eligible after 7 days, then purges only records that are not referenced by versions, logs, analytics, notifications, or audit rows.
- Template search projection rebuilds filterable title, goal, equipment, duration, days/week, and muscle focus fields.
- Assignment materialization creates or refreshes DailyExecution workout tasks for a rolling 14-day window from the assignment start date or current local date, never beyond assignment end date.
- A daily materialization job extends the window at the relationship timezone boundary; if an assignment starts more than 14 days in the future, task creation waits until it enters the window.
- Mobile sync hint generation marks assigned program snapshots that client mobile should download for offline use.
- Program analytics projection links templates, assigned programs, adherence, PRs, and progress outcomes.
Note
Keep workout logging events in DailyExecution. Program Builder should not record completed sets, performed loads, missed workouts, PRs, or client notes from executed sessions.
Permissions & Access Rules
Program data is workspace-scoped until assigned, then relationship-scoped. Provider exercise access still follows Exercise Library licensing policy.
Coach
- Coach must have active workspace membership and
programs.writepermission to create, edit, publish, archive, or assign programs. - Coach can edit only drafts in their workspace and according to ownership/collaboration rules.
- Coach can assign only to active or explicitly allowed coach-client relationships in the same workspace.
- Coach cannot assign a second non-terminal primary training program to the same relationship unless the request supersedes or cancels the existing assignment.
- Coach can read assigned programs for relationships they are authorized to access.
- Coach cannot mutate published version snapshots; editing starts a new draft from the version.
Client, Admin & System
- Client can read only assigned program snapshots for their own active or historically permitted relationships.
- Client cannot browse templates, drafts, full exercise catalog, or coach-private notes from program rows.
- System workers can materialize tasks, generate mobile sync hints, and build analytics projections with scoped service credentials.
- Admin/support read access to archived templates or canceled assignments must be explicit and audited.
- Exercise media in assigned snapshots must respect provider license and signed/proxied URL policy.
Web, Coach Mobile, Client Mobile
Coach web and coach mobile share builder contracts. Client mobile receives assigned workout snapshots optimized for offline execution.
Coach Web
- Primary dense builder with setup panel, scrollable week calendar, selected-day Exercise Library page or pane, prescription editor, review drawer, and assignment modal.
- Calendar viewport shows no more than four weeks at once and renders day workouts as note-style summaries with the first three exercise names partially shown.
- Selected day actions support deleting the full-day workout and duplicating it to the same day across all weeks, even weeks, odd weeks, or next week only.
- Add-exercise flow exposes inline custom exercise creation, then inserts the new Exercise Library snapshot into the selected day without leaving Program Builder.
- Prescription editor supports straight sets, supersets, and drop sets in the first release.
- Uses optimistic autosave with expected revision and clear conflict recovery when another session changes the draft.
- Supports keyboard reordering, focused search, quick prescription editing, validation anchors, and publish/assign actions.
- Shows provider/degraded exercise states without losing draft edits.
Coach Mobile
- Step-based builder: setup, week/day list, selected day detail, focused search, prescription sheet, review, publish, and assign.
- Can create a custom exercise inline from the selected-day add-exercise flow and insert it after Exercise Library validation succeeds.
- Supports configuring straight sets, supersets, and drop sets with mobile-friendly controls.
- Caches active drafts, taxonomy, and recently selected exercises for continuity.
- Offline edits can remain local drafts, but publish and assignment require backend validation before becoming active.
- Swipe day navigation and reorder handles must preserve stable ids so sync does not duplicate rows.
Client Mobile
- Reads active assigned program summaries and dated workout snapshots through client endpoints or DailyExecution tasks.
- Downloads workout snapshots, exercise media refs, alternatives, and prescription metadata for offline workout mode.
- Logs completed sets, load, reps, notes, and RPE/RIR in DailyExecution workout session logs.
- Does not mutate program drafts, templates, or assigned snapshots during execution.
Test Scenarios & Confirmed Decisions
Validate structural rules, publish immutability, assignment stability, Exercise Library integration, DailyExecution materialization, and cross-surface sync before implementation.
Tests
- Domain: duration accepts 1 through 8 weeks and rejects values outside Public V1 cap.
- Domain: changing duration or days per week creates/removes draft week/day slots through a command and keeps valid ordering.
- Domain: duplicating a selected full-day workout respects all, even, odd, and next-week-only same-day scopes.
- Domain: duplicating a selected full-day workout preserves straight set, superset, and drop-set metadata in copied exercises.
- Domain: draft validation blocks publishing with empty days, empty groups, missing prescriptions, invalid ranges, and unavailable exercises.
- Domain: publishing freezes calendar snapshot and increments template version without mutating earlier versions.
- Domain: assigned program snapshot remains stable after template version, provider exercise, or draft changes.
- Domain: relationship-specific assignment personalization does not create a reusable template version unless the coach explicitly saves a template update.
- Domain: assignment creation rejects a relationship with an existing scheduled, active, or paused primary program unless the request supersedes it.
- API: stale expected revision returns conflict and does not overwrite another session's draft changes.
- API/security: coach cannot edit drafts, publish versions, or assign programs outside their workspace/relationship permissions.
- Integration: adding an exercise requests a valid Exercise Library selection snapshot and stores source attribution for provider and coach-created exercises.
- Integration: inline custom exercise creation calls Exercise Library validation, creates the custom exercise, then inserts its selection snapshot into the selected draft group.
- Integration:
ProgramAssignedcreates DailyExecution tasks and mobile sync hints idempotently for the rolling 14-day materialization window. - Integration: cleanup marks abandoned drafts, archived unreferenced templates, and canceled unreferenced assignments cleanup eligible after 7 days.
- Web E2E: coach creates draft, scrolls calendar beyond four visible weeks, selects a day, adds exercises from Exercise Library, duplicates the day workout, validates, publishes, and assigns.
- Coach mobile E2E: coach resumes a draft, edits prescription, handles a revision conflict, reviews, and publishes.
- Client mobile E2E: assigned workout snapshot downloads for offline mode and logging works without querying builder APIs.
- Localization/RTL: Arabic exercise labels, day labels, notes, and prescription fields render correctly without breaking reorder controls.
Confirmed Decisions
- V1 baseline maximum program duration is 8 weeks.
- Each coach-client relationship can have only one non-terminal primary training program at a time.
- Assignment-specific personalization is allowed on the assignment snapshot or assignment revision and does not create a reusable template version unless the coach explicitly saves reusable template changes.
- Supersets and drop sets ship in the first builder release alongside straight sets.
- Program Builder exposes custom exercise creation inline from the add-exercise flow while Exercise Library remains the owner of custom exercise validation and lifecycle.
- Abandoned drafts, archived unreferenced templates, and canceled unreferenced assignments become cleanup eligible after 7 days.
- DailyExecution materializes workout tasks for a rolling 14-day window, extended daily in the relationship timezone and capped by the assignment end date.
Future Enhancements
Find a clear way for coaches to distinguish between templates when assigning them to clients.
- Template labels by goal, experience level, equipment, and duration.
- Preview cards showing duration, days/week, and target muscle focus.
- Search and filters for templates.
- Coach-owned naming conventions or folders.