Exercise Library¶
Overview¶
The exercise library is the central exercise catalog for the Okta-Health platform. Two user roles interact with it differently:
- Therapists manage the library: browse, create, edit, favorite, and select exercises when building patient workout plans.
- Patients see exercises within their assigned workouts, watch demo videos, and provide feedback (pain, exertion, notes) after each exercise.
Written for both human developers and AI assistants working in this codebase.
Therapist Experience¶
Exercise Library Page¶
File: OktaPT-FE/pages/doctor/exercise-library.tsx
- Header: "Exercise Library" title and subtitle
- Three tabs: All Exercises (full catalog), My Private (exercises the doctor created as non-public), Favorites (starred exercises for quick access)
- View toggle: Grid (card layout via
VirtuosoGrid) vs List (sortable table viaExerciseTable) - Search bar: Real-time text search by exercise name
- Filter button: Opens
TagFilterDrawerwith advanced filters (category, difficulty, muscle group, primary mover, creator, media type, tags). Shows badge with active filter count - Tag filter: Supports AND mode ("all selected tags must match") and OR mode ("any selected tag matches"). Active tags shown as removable chips below the toolbar
- Create New button: Opens the
CreateExerciseFormdialog
Exercise Card (Grid View)¶
File: OktaPT-FE/components/exerciseLibrary/ExerciseGrid.tsx
What each card displays in the grid:
- Thumbnail: Priority fallback — Kinescope poster > Cloudflare thumbnail > YouTube thumbnail > uploaded photo > placeholder icon. Play icon overlay if video exists
- Exercise name: Truncated with ellipsis if long (
truncateclass) - Creator info:
UserCircleIcon+ name (or "System" for platform exercises) - Tags: Up to 2 colored
TagChipcomponents, "+N more" indicator for additional tags - Favorite star: Appears on hover (desktop) / always visible if favorited. Click to toggle without page reload
- Hover effect: Subtle scale (
1.02) + shadow elevation
Exercise Detail Dialog¶
File: OktaPT-FE/components/exerciseLibrary/ExerciseDetailDialog.tsx
What doctors see when clicking an exercise:
- Header: Exercise name, difficulty badge (color-coded via
getDifficultyColor()), action buttons: - Star icon: toggle favorite
- Pencil icon: edit (only if
createdById === userId) - Trash icon: delete (only if
createdById === userId) - Left column: Media display
- If both video + photo:
Tab.Groupinterface to switch between them - If one: Full display of available media
- Supports Cloudflare Stream (
@cloudflare/stream-react), Kinescope iframe, YouTube iframe - Right column: Exercise metadata
- Tags displayed as colored
TagChipcomponents - Description text (or "No description available")
- "Created by" credit line
- Lazy loading: If exercise data is incomplete (e.g. AI-generated), fetches full data via
GET /v2/exercises/exercise/:id
Exercise Selector (Workout Creation)¶
File: OktaPT-FE/components/ExerciseSelector/ExerciseLibraryDialog.tsx
How therapists select exercises when building a workout:
- Therapist clicks "Add Exercise" in workout builder → modal opens
- Search + filter bar at top (search input, tag filter button, folder dropdown)
- Folder dropdown: All Exercises / My Exercises / Favorites
- "Show Favorites First" checkbox: Reorders with favorites sorted to top
- Therapist clicks an exercise card → exercise added to workout with default parameters (sets/reps) → modal closes
- "Create New" button (bottom, dashed border): Opens create form inline, allowing immediate creation and selection
Creating and Editing Exercises¶
Create/Edit Form¶
File: OktaPT-FE/components/CreateExerciseForm.tsx
Two-column layout: Left = basic info + tags, Right = media uploads + external URLs
Form Fields¶
| Field | Required | Input Type | Validation | Notes |
|---|---|---|---|---|
| Name | Yes | Text input | required, aria-required="true" |
Pre-populated on edit |
| Description | No | Textarea (4 rows) | None | Free-text exercise instructions |
| Category | Yes | Dropdown | Auto-populated from GET /exercise-categories |
First category auto-selected if empty |
| Public | No | Checkbox | None | Hidden for PHYSIOTHERAPISTRU, OTERA, OKTA brands via NEXT_PUBLIC_BRAND_NAME |
| Favorite | No | Checkbox | None | Shown for both new and existing exercises |
| Tags | No | Tag picker modal | Via TagAssignmentModal |
Shows selected count and removable TagChip components |
Media Upload¶
Two types of media, each with a two-phase upload process:
Video upload (accept="video/*"):
1. New exercises: File stored as local preview (object URL via URL.createObjectURL), uploaded AFTER exercise creation via signed S3 URL
2. Existing exercises: Uploaded immediately on file selection via handleMediaUpload()
3. Backend generates signed S3 upload URL → frontend uploads via XMLHttpRequest with progress tracking (0-100%)
4. After S3 upload: webhook token triggers Cloudflare Stream processing via POST /exercises/upload-complete/:token, storing cloudflareStreamId
Photo upload (accept="image/*"):
- Same two-phase process as video
- Direct S3 storage, no Cloudflare processing
- uploadedPhotoKey stored in database
External URLs (separate from file uploads):
- Optional type="url" fields for YouTube video URL and external photo URL
- Stored as videoUrl and photoUrl respectively
Delete media: Per-type delete button → DELETE /exercises/{id}/media/{type}
Create vs Edit Flow¶
- Create:
POST /exercises→ upload pending media → save tags viaPUT /exercise/:id/tags→ callback - Edit:
PATCH /exercises/{id}→ toggle favorite if changed → upload media → save tags → callback - Tag saving always replaces all tags with current selection (
replaceExerciseTags) - Media upload errors are non-blocking — exercise saves even if upload fails
- Upload Warning Modal: If user cancels during active upload, shows progress and offers "Wait" or "Cancel Upload"
Patient Experience¶
Exercise Preview (Before Workout)¶
File: OktaPT-FE/components/patient/ExerciseThumbnailRow.tsx
Patients see their upcoming exercises in a thumbnail row:
- 84x84px thumbnails with play icon overlay if video exists
- Exercise name + block tag (color-coded: Warm-up orange, Main blue, Supplemental purple, Cool-down green)
- Sets/reps specification (e.g., "3x12 reps" or "3x30s" for time-based)
- Tappable to preview video before starting (disabled if no video source)
- Thumbnail priority: Personalized Video > Kinescope > Cloudflare > YouTube > photoUrl > placeholder
Exercise Execution (During Workout — Mobile)¶
Full-screen video playback with HUD overlay (see mobile-workout-flow.md):
- Autoplay video when exercise becomes active
- Doctor prescriptions overlay (if set by therapist):
- Pain tolerance level (0-10, color-coded green/yellow/red)
- Physical exertion level (0-10, color-coded blue/purple/orange)
- Doctor's pre-exercise notes
- Timer: Elapsed time in MM:SS
- Info icon: Opens bottom sheet with full exercise details, description, and doctor guidelines
- "Finish Exercise" button: Advances to next exercise
Patient Feedback (After Each Exercise — Mobile)¶
Two-stage feedback modal:
Stage 1 — Sets Completed: Grid of buttons (1 set, 2 sets, ... up to planned count). Auto-advances to Stage 2.
Stage 2 — How Did It Feel?: - Pain Level (0-10): 10-button grid, color-coded (0-3 green, 4-6 yellow, 7-10 red). Shows doctor's target if set - Exertion Level (0-10): Same layout, color-coded (0-3 blue, 4-6 purple, 7-10 orange) - Notes (optional): Free-text field ("How did the exercise feel?") - "Complete Exercise" button saves all feedback
Exercise Card in Web Workout View¶
File: OktaPT-FE/components/WorkoutExerciseCard.tsx
Shows exercises in a queue:
- Numbered badge (gray=upcoming, brand-color=active, green=completed)
- 96x96px thumbnail + name + sets/reps + description
- Action buttons when active: "View Demo" (opens video), "Do Exercise with/without Recording"
Data Model¶
Exercise¶
Source: OktaPT-API/prisma/schema.prisma:398-448
model Exercise {
id Int @id @default(autoincrement())
name String
exerciseNameEnglish String?
description String?
descriptionEnglish String?
videoUrl String? // YouTube URL
photoUrl String? // External photo URL
difficultyLevel DifficultyLevel?
targetMuscleGroup String?
primaryMover String?
secondaryMuscle String?
tertiaryMuscle String?
equipment String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categoryId ExerciseCategory
isActive Boolean @default(true) // Soft-delete flag
isPublic Boolean @default(false) // Shared in tenant
createdById Int?
uploadedVideoKey String? // S3 key
uploadedPhotoKey String? // S3 key
cloudflareStreamId String? // From S3→Cloudflare pipeline
kinescopeVideoId String? // Legacy video provider
isAIExercise Boolean @default(false) // AI-generated placeholder
tenantId Int @default(1) // Multi-tenant scope
// Canonicalization fields
nameNormalized String?
canonicalExerciseId Int?
mergedAt DateTime?
// Relations
createdBy User? @relation(...)
workouts ExerciseInWorkout[]
templateExercises TemplateExercise[]
favoritedBy UserFavoriteExercise[]
tags ExerciseTag[]
personalizedVideos PersonalizedExerciseVideo[]
}
Key enums:
ExerciseCategory(12 values):UPPER_BODY,LOWER_BODY,CORE,CARDIO,STRETCHING,BALANCE,MOBILITY,REHABILITATION,MIDSECTION,FULL_BODY,OTHER,NOT_SPECIFIEDDifficultyLevel(10 values):BEGINNER,NOVICE,INTERMEDIATE,ADVANCED,EXPERT,MASTER,GRAND_MASTER,LEGENDARY,OTHER,NOT_SPECIFIED
Canonicalization Fields¶
nameNormalized, canonicalExerciseId, mergedAt — see exercise-canonicalization.md
Related Models¶
| Model | Key Fields | Purpose |
|---|---|---|
UserFavoriteExercise |
doctorId, exerciseId (@@unique) |
Per-doctor favorites list |
ExerciseInWorkout |
workoutId, exerciseId, planned/actual sets/reps/weight/duration, patient feedback, doctor notes |
Actual exercise instance in a patient workout |
TemplateExercise |
templateId, exerciseId, planned params, exerciseBlock, orderInTemplate |
Exercise placed in a reusable workout template |
CompletedExerciseSet |
exerciseInWorkoutId, setNumber, reps, weight, duration, poseData |
Per-set tracking data within a workout exercise |
PersonalizedExerciseVideo |
patientId, exerciseId, doctorId, cloudflareStreamId, status |
Therapist-recorded per-patient exercise demos |
Tag System¶
Source: OktaPT-API/prisma/schema.prisma:450-498
model Tag {
id Int @id @default(autoincrement())
key String // Language-agnostic identifier (e.g., "lower_body", "no_equipment")
type TagType
color String? // Hex color for UI display (e.g., "#3B82F6")
isGlobal Boolean @default(false)
tenantId Int? // Null for global tags, specific ID for tenant-custom tags
isActive Boolean @default(true)
translations TagTranslation[]
exercises ExerciseTag[]
@@unique([key, tenantId])
}
Tag Types¶
| TagType | Purpose | Examples |
|---|---|---|
REGION |
Body region | Shoulders, Arms, Back, Legs, Chest |
MUSCLE_GROUP |
Specific muscle | Quads, Hamstrings, Glutes |
MOVEMENT_PATTERN |
Movement type | Push, Pull, Squat, Lunge, Rotation |
EQUIPMENT |
Equipment required | No Equipment, Dumbbells, Resistance Band |
OBJECTIVE |
Training goal | Strength, Flexibility, Balance, Endurance |
SPECIALTY |
Clinical specialty | Post ACL, Post Knee Surgery, Shoulder Rehab |
POSITION |
Starting position | Standing, On Ground, Seated, Lying Down |
DIFFICULTY_CONTEXT |
Difficulty qualifier | Beginner Friendly, Advanced Technique |
CUSTOM |
Tenant-specific | Any custom tags created by a tenant |
Multi-Language Support¶
TagTranslation model provides one translation per language per tag (ISO 639-1 codes: "en", "es", "ru", "de"). The lang query param on tag endpoints controls which translation is returned. Falls back to the key field if no translation exists for the requested language.
model TagTranslation {
tagId Int
language String // ISO 639-1
name String // Translated tag name
description String?
@@unique([tagId, language])
}
Global vs Custom Tags¶
isGlobal: true+tenantId: null= system-wide tags available to all tenantsisGlobal: false+tenantId: N= tenant-specific custom tags@@unique([key, tenantId])prevents duplicate tag keys within the same tenant
Tag Filtering (AND/OR)¶
From OktaPT-FE/lib/utils/exercise.ts:86-98:
if (filters.tags && filters.tags.length > 0 && exercise.tags) {
const exerciseTagKeys = exercise.tags.map((tag) => tag.key);
if (filters.tagMode === "all") {
// All selected tags must be present (AND)
matchesTags = filters.tags.every((tagKey) =>
exerciseTagKeys.includes(tagKey)
);
} else {
// At least one selected tag must be present (OR)
matchesTags = filters.tags.some((tagKey) =>
exerciseTagKeys.includes(tagKey)
);
}
}
API Endpoints¶
Exercise CRUD¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v2/exercises |
Required | List exercises (query: q, lang, limit, offset) |
| POST | /v2/exercises |
Required | Create exercise (triggers auto-canonicalization) |
| GET | /v2/exercises/exercise/:exerciseId |
None | Get single exercise |
| PATCH | /exercises/:exerciseId |
Required | Update exercise (creator only) |
| DELETE | /exercises/:exerciseId |
Required | Soft-delete exercise (creator only) |
Media¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /exercises/:id/media/upload-url |
Required | Generate signed S3 upload URL (creator only) |
| POST | /exercises/upload-complete/:token |
None (JWT token in path) | Trigger Cloudflare Stream processing after S3 upload |
| DELETE | /exercises/:id/media/:type |
Required | Delete exercise media (creator only) |
| GET | /exercises/:id/media |
Required | Get signed download URLs for exercise media |
Favorites¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /exercises/favorites |
Required | List user's favorite exercises |
| POST | /exercises/:id/favorite |
Required | Add exercise to favorites |
| DELETE | /exercises/:id/favorite |
Required | Remove exercise from favorites |
Tags¶
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v2/exercises/tags |
Required | Get all tags (query: type, lang, includeCustom) |
| GET | /v2/exercises/tags/:key |
Required | Get specific tag by key |
| POST | /v2/exercises/tags |
Required | Create custom tenant-specific tag |
| GET | /v2/exercises/tag-types |
Required | Get all available tag type enum values |
Exercise Tags¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v2/exercise/:exerciseId/tags |
Required | Add tags to exercise |
| DELETE | /v2/exercise/:exerciseId/tags/:tagId |
Required | Remove a tag from exercise |
| GET | /v2/exercise/:exerciseId/tags |
Required | Get all tags for an exercise |
| PUT | /v2/exercise/:exerciseId/tags |
Required | Replace all tags for exercise |
Patient Feedback¶
| Method | Path | Auth | Description |
|---|---|---|---|
| PATCH | /v2/exercises-in-workout/:id/patient-feedback |
Required | Individual exercise feedback (pain 0-10, exertion 0-10, notes) |
| PATCH | /v2/workouts/:workoutId/exercises/patient-feedback |
Required | Bulk patient feedback per workout |
Doctor Notes¶
| Method | Path | Auth | Description |
|---|---|---|---|
| PATCH | /v2/exercises-in-workout/:id/doctor-post-notes |
Required | Individual post-exercise notes (max 1000 chars) |
| PATCH | /v2/workouts/:workoutId/exercises/doctor-post-notes |
Required | Bulk doctor post-notes per workout |
Security¶
Authentication¶
- All exercise routes (except
GET /v2/exercises/exercise/:exerciseId) require JWT viaauthenticationmiddleware - Token sourced from
Authorization: Bearerheader, fallback toaccessTokencookie - JWT validated against
JWT_SECRET+ active refresh token verified in database requireDoctormiddleware checksuserType === "DOCTOR"for therapist-only actions
Source: OktaPT-API/src/middleware/auth.ts
Authorization & Ownership¶
| Operation | Who | Enforcement |
|---|---|---|
| Browse/search | Any authenticated user | buildExerciseWhere("PUBLIC_OR_OWNER") |
| Create | Authenticated doctor | requireDoctor, createdById auto-set from JWT |
| Edit | Creator only | createdById check → 403 |
| Delete | Creator only | createdById check → 403 |
| Upload/delete media | Creator only | createdById check → 403 |
| Add/remove tags | Any authenticated user | Exercise must be visible per scope |
| Patient feedback | Assigned patient only | workout.patientId === req.user.userId check |
| Doctor notes | Assigned doctor only | workout.doctorId === req.user.userId check |
Tenant Isolation¶
runWithTenant()sets AsyncLocalStorage per-request context from JWTtenantId- Domain-based fallback for unauthenticated requests (lookup
Tenantby hostname) - Prisma middleware auto-injects
tenantIdinto all queries - Cross-ref exercise-scope-service.md
Source: OktaPT-API/src/middleware/tenant.ts
Input Validation¶
| Field | Rule | Source |
|---|---|---|
patientRecordedPainLevel |
0-10 integer | patientFeedback.ts |
patientRecordedPhysicalExertion |
0-10 integer | patientFeedback.ts |
doctorNotesPostExercise |
Required string, max 1000 chars | doctorFeedback.ts |
| Tag type | Must be valid TagType enum |
tags.ts |
Search limit |
Capped at 100 | exerciseService.ts |
Media Security¶
- Private S3 bucket (ACL:
"private") - Signed upload/download URLs (1-hour expiration via
getSignedUrl(..., { expiresIn: 3600 })) - Videos processed through Cloudflare Stream (not served from S3 directly)
- S3 key format:
exercise-media/{exerciseId}/{type}-{timestamp}{ext} - Frontend file type enforcement via
accept="video/*"andaccept="image/*"attributes
Search and Filtering¶
Backend Search¶
searchExercises() in OktaPT-API/src/services/exerciseService.ts:
- Prisma
containswithmode: "insensitive"(case-insensitive) - Offset pagination via
skip/take - Results capped at 100 per request
- Scope:
buildExerciseWhere("PUBLIC_OR_OWNER")— only public exercises and exercises created by the requesting user
Frontend Filtering¶
filterExercises() in OktaPT-FE/lib/utils/exercise.ts:
| Dimension | Logic |
|---|---|
| Name | Case-insensitive includes |
| Category | Case-insensitive includes on categoryId |
| Difficulty | Case-insensitive includes on difficultyLevel |
| Created by | Match on firstName + lastName |
| Media | video (has videoUrl), photo (has photoUrl), none (neither), all (any) |
| Tags | AND mode: all selected tags must be present. OR mode: at least one selected tag |
Exercise Visibility¶
PUBLIC_OR_OWNER— public exercises + exercises created by the user (default for library browsing)PUBLIC_ONLY— only public exercisesPUBLIC_OR_HAS_VIDEO— public exercises + any exercise with a video source
Cross-ref exercise-scope-service.md
Exercise Blocks and Types¶
Exercise Types¶
Source: OktaPT-API/prisma/schema.prisma:602-605
| Type | Measurement | Tracked Fields |
|---|---|---|
REPS_BASED |
Repetitions | plannedSets/actualSets, plannedReps/actualReps, plannedWeight/actualWeight |
TIME_BASED |
Duration (seconds) | plannedSets/actualSets, plannedDuration/actualDuration |
Exercise Blocks¶
Source: OktaPT-API/prisma/schema.prisma:607-612
| Block | Order | Purpose |
|---|---|---|
WARM_UP |
0 | Preparation exercises |
MAIN_SESSION |
1 | Core workout exercises |
SUPPLEMENTAL_WORK |
2 | Accessory exercises |
COOL_DOWN |
3 | Recovery exercises |
Block ordering enforced by validateExerciseReorder() in OktaPT-FE/lib/utils/exerciseBlockValidation.ts — prevents drag-and-drop moves that would violate block sequence (e.g., placing a Cool-down before a Warm-up).
Exercise-to-Workout Pipeline¶
Three-level chain:
Exercise (catalog entry)
→ TemplateExercise (in reusable WorkoutTemplate)
→ ExerciseInWorkout (in actual patient Workout)
- Exercise: Shared library entry, reusable across templates and workouts
- TemplateExercise: Exercise placed in a
WorkoutTemplatewith planned params (sets/reps/weight/duration),exerciseBlock, doctor pre-notes, and prescribed tolerance levels - ExerciseInWorkout: Actual instance in a patient workout — tracks planned vs actual values, patient feedback, doctor post-notes, per-set data via
CompletedExerciseSet
Prescription & Feedback Fields¶
| Group | Fields | Scale |
|---|---|---|
| Doctor Prescription | prescribedPainToleranceLevel, prescribedPhysicalExertionLevel, doctorNotesPreExercise |
0-10 |
| Patient Feedback | patientRecordedPainLevel, patientRecordedPhysicalExertion, patientNotes |
0-10 |
| Doctor Post-Notes | doctorNotesPostExercise, doctorNotesPostExerciseAt |
max 1000 chars |
Cross-ref patient-workout-management.md
Related Documentation¶
| Document | Covers |
|---|---|
| exercise-canonicalization.md | AI-powered duplicate detection and merge pipeline |
| exercise-media-rendering.md | Video/thumbnail priority fallback across providers |
| exercise-scope-service.md | Visibility filtering (buildExerciseWhere, applyExerciseScope) |
| personalized-exercise-videos.md | Therapist-recorded per-patient exercise demos |
| patient-workout-management.md | Workout creation, editing, and plan management |
| mobile-workout-flow.md | Mobile workout execution and exercise feedback |
Key Files¶
| File | Purpose |
|---|---|
OktaPT-API/prisma/schema.prisma |
Exercise, Tag, ExerciseTag, ExerciseInWorkout, TemplateExercise, CompletedExerciseSet models |
OktaPT-API/src/services/exerciseService.ts |
searchExercises(), createExerciseShared() |
OktaPT-API/src/services/exerciseScopeService.ts |
buildExerciseWhere() visibility helpers |
OktaPT-API/src/routes/exercises/exercises.ts |
V2 exercise list and create routes |
OktaPT-API/src/routes/exercises/tags.ts |
Tag CRUD routes |
OktaPT-API/src/routes/exercises/exerciseTags.ts |
Exercise-tag association routes |
OktaPT-API/src/routes/exercises/getExercise.ts |
Single exercise GET (public) |
OktaPT-API/src/routes/exercises/patientFeedback.ts |
Patient feedback routes (individual + bulk) |
OktaPT-API/src/routes/exercises/doctorFeedback.ts |
Doctor post-exercise notes routes |
OktaPT-API/src/routes/legacy.ts |
Exercise CRUD, media upload/delete, favorites |
OktaPT-API/src/middleware/auth.ts |
authentication, requireDoctor middleware |
OktaPT-API/src/middleware/tenant.ts |
tenantMiddleware — per-request tenant context |
OktaPT-FE/pages/doctor/exercise-library.tsx |
Exercise library page (tabs, filters, views) |
OktaPT-FE/components/CreateExerciseForm.tsx |
Create/edit form with media upload |
OktaPT-FE/components/exerciseLibrary/ExerciseGrid.tsx |
Grid view with VirtuosoGrid |
OktaPT-FE/components/exerciseLibrary/ExerciseDetailDialog.tsx |
Exercise detail dialog |
OktaPT-FE/components/ExerciseSelector/ExerciseLibraryDialog.tsx |
Exercise selector for workout creation |
OktaPT-FE/components/patient/ExerciseThumbnailRow.tsx |
Patient exercise preview thumbnails |
OktaPT-FE/components/WorkoutExerciseCard.tsx |
Web workout exercise card |
OktaPT-FE/lib/utils/exercise.ts |
filterExercises(), thumbnail/video utilities |
OktaPT-FE/lib/utils/exerciseBlockValidation.ts |
validateExerciseReorder(), BLOCK_ORDER |