src/features/workouts/presentation and src/features/workouts/components. The route files only mount them.
Routes
The day route reads three search params with
useLocalSearchParams:
Workouts tab
WorkoutsScreen reads two queries from src/features/workouts/hooks/useWorkouts.ts: useWorkouts() (key ['workouts', 'overview'], staleTime 60 seconds) and useWorkoutHistory() (key ['workouts', 'history'], staleTime 30 seconds). Pull to refresh refetches both.
The screen picks selectedPlan from local state and falls back to the first plan. With more than one plan it renders PlanSwitcher. The body then has three shapes:
- Loading. Skeleton rows plus
WorkoutHistorySection. - File plan. When
isFilePlan(selectedPlan)is true the plan has no days. The screen shows the plan name, a badge (link or file, decided byfilePlanSource(selectedPlan.pdfUrl)), a mark done button andFilePlanViewat a height ofmax(320, round(windowHeight * 0.58))(FILE_PLAN_MIN_HEIGHTandFILE_PLAN_HEIGHT_RATIO). - Regular plan. A featured card, the remaining days as a row list, the movement cards and the history section.
Card states
ProgramCardState in components/ProgramCard.tsx is 'normal' | 'up-next' | 'in-progress' | 'done'. WorkoutsScreen derives it per day:
The featured day is the in-progress day, or the up-next day when nothing is in progress.
ProgramCard renders FeaturedWorkoutCard for up-next and in-progress, and ProgramRow for the other two states. The rules behind nextWorkoutId and performedThisWeek are on Workout rules.
Both card types have two targets. Pressing the card calls openWorkout(id) and opens the day in info mode. Pressing the play control calls startWorkout(id), which adds start: '1'.
The header pill shows weeklyWorkoutProgress(...) as done and total. now comes from useWorkoutWeek(), which refreshes when the app becomes active and at the next week start, so the done state resets without a reload.
WorkoutsOverview.week is always an empty array today (getOverview returns week: []), so the WeekStrip component and its section never render.History section
WorkoutHistorySection lists history.data.items. Each row shows the label, the date (formatWorkoutWhen), minutes, set count and volume, opens /workout/history/[id], and has a delete button that confirms with Alert.alert and calls useDeleteWorkoutLog().
Workout day screen
WorkoutDayScreen loads the day with useLiveWorkout(dayId, planId) (key ['workouts', 'live', id, planId ?? null], staleTime 60 seconds, placeholderData: keepPreviousData).
Info mode and workout mode
The screen has two modes and passes the current one toWorkoutTopBar as mode:
1
Opening the day creates an unstarted session
An effect calls
start(data) on useWorkoutSession as soon as the query has data. The session has started: false and startedAt: 0. Nothing is persisted yet.2
Info mode shows the preview
Each exercise renders as
ExercisePreviewBlock, a collapsible row with the prescription per set (reps label and target weight), tempo, and chips for instructions, coach notes and substitutes. The top bar shows exercise count, set count and the estimated minutes. No set can be logged.3
Start begins the workout
The start button calls
handleStart, which calls start(data) then begin(). begin() sets started: true and startedAt: Date.now(). If a different workout is already started, handleStart first shows a discard confirmation, and on confirm calls restStop(), reset() and then begins.4
Workout mode shows the logger
Exercises are grouped with
groupExercises and render as ExerciseBlock or SupersetCard. The top bar shows an elapsed clock, completed sets over total sets and a finish button.reset() when the session matches this day and is not started. A started session survives navigation, which is what the minimise button relies on.
The start=1 deep link
When start equals 1, a second effect calls begin() once the data is loaded and the matching session is not started. Callers that use it:
- The play control on the workouts tab (
startWorkoutinWorkoutsScreen). - The home screen workout card (
src/features/home/presentation/ClassicHomeScreen.tsxandthemes/useHomeModel.ts), which sendsidandstartbut noplan.
target: 'activeWorkout' do not use start. src/lib/notificationRouting.ts pushes /workout/[id] with the current session’s dayId and programId, or /workouts when there is no session.
Loading and error states
showSkeleton is true when there is no session and no data and the query is loading. showError is true when there is no content and the query failed. The error state has a retry button that calls refetch().
Logging a set
ExerciseBody renders the header (image tile, name, completed over total sets, muscle), action pills and one SetRow per set. Columns are: set marker, check, previous, weight, optional RIR, reps.
- Set marker. Working sets show their index among working sets. Warm-up and drop sets show a short tag, coloured
#ea8a23and#7c3aed(SET_TYPE_COLOR). - Previous.
lastRepsandlastWeightfrom the last workout, or a dash placeholder with no history. - Weight and reps. Local drafts in
SetRowcommit to the store on blur, and are flushed before the check toggles. - Reps picker. A pan gesture with
activateAfterLongPress(400)(REPS_HOLD_MS) opens the floating list fromsrc/features/workouts/store/repsPicker.ts. The values come fromparseRepRange. Dragging selects, release commits. The list closes when the scroll view starts dragging. - Check. Calls
handleToggleSeton the screen, covered on Workout session engine.
completeExercise.
When the toggled set is a new record the screen shows a toast, a success haptic and Confetti.
RIR column
The column only renders when the day has it turned on:rirColumn comes from TrainingDayDto.rirColumn and is copied to the session by toSession. The cell opens the same picker store in tappable mode with RIR_OPTIONS = ['0', '1', '2', '3', '4+']. Tapping the already selected value clears it. When nothing is chosen the cell shows the coach’s prescribed RIR (exercise.rir) as a hint, and only until the set is done. An untouched RIR is logged as null.
Supersets
groupExercises in src/features/workouts/lib/supersets.ts groups by supersetParentId. A row whose parent id points at an earlier lead row joins that lead’s unit. SupersetCard renders the members inside one card with one shared rest chip taken from the lead (restChipLabel(lead)), and passes showRestChip={false} to each ExerciseBody.
Rest is shared too. After a set is checked, the screen starts the rest timer only when every member of the group has that same set number done, and it uses the lead’s restSeconds and name.
Exercise sheet and media
ExerciseSheet is a slide-up Modal with two modes, 'notes' and 'instructions'.
The header is a drag handle. A drag of under 6 points counts as a tap and toggles between collapsed and expanded. Otherwise the sheet snaps to expanded when the drag went up more than 30 points or the height passed the midpoint.
ExerciseMediaView picks a renderer from the video URL:
isPlayableVideo(video)(extensionmp4,mov,m4vorwebm):LoopingVideowithexpo-video, muted, looping,audioMixingMode = 'mixWithOthers', paused in the background.isHostedVideoEmbed(video):LoopingEmbed, aWebViewon the embed URL with injected JavaScript that mutes and loops every media element every 400 ms, andpointerEvents="none".- Otherwise the image with a watch video button that opens the URL with
Linking.openURL.
DEFAULT_MEDIA_ASPECT_RATIO (16 by 9) as the fallback.
Substitutes
The substitute pill shows whenexercise.alternatives.length > 0. SubstituteSheet lists exerciseOptions(exercise) and marks the coach’s exercise. Choosing one calls handleSubstitute:
- If any set of the exercise is done, confirm first, because the swap clears entered values.
- Call
substitute(exerciseId, option)on the store. - Open
SubstituteSaveDialog, which asks whether to keep the swap for next time. Saving callsPOST /v1/trainee/workouts/substitutions. The dialog is skipped when there is noprogramIdor whenIS_PREVIEWis true.
Technique videos
Each exercise carriestechniquePrompt with mode: 'none' | 'free' | 'coachDemand', required, reason ('FIRST_TIME' | 'EVERY_N') and completedCount.
TechniquePillinExerciseBodyis hidden formode: 'none'. It turns into a sent chip oncevideoSentis true.- The reminder popup (
TechniquePromptDialog) opens on its own whenshouldPromptTechniqueis true: mode iscoachDemand,requiredis true, the video was neither sent nor skipped, and exactly one set of the exercise is done. - The popup never blocks finishing the exercise. Skip sets
videoSkippedforcoachDemandrows. useTechniqueVideoinsrc/features/workouts/hooks/useTechniqueVideo.tsoffers record or pick from library throughexpo-image-picker(quality: 0.7), uploads withuploadFile, then callsPOST /v1/trainee/technique-videoswithurland optionalexerciseId.- Coach feedback on an earlier video shows as a card on the exercise. Dismissing it calls
dismissCoachFeedbackandPOST /v1/trainee/technique-videos/:videoId/feedback-seen.
Cardio inside a workout
When the day hascardio, WorkoutCardioCard renders under the exercises in both modes. Before the workout starts it only shows a hint. Once started it lets the trainee pick an activity and start a cardio timer linked to this day. The timer itself is described on Cardio session.
Finishing and discarding
handleFinish finishes straight away when every set is done. Otherwise it opens IncompleteWorkoutDialog with the count of missing sets and two choices: finish without them, or return. The discard button opens DiscardWorkoutDialog, which calls restStop(), reset() and goes back.
On a successful log the screen stores the summary in useShareStore, settles any linked cardio timer, resets the session and replaces the route with /workout/complete. That screen is covered on Share to story.
History detail
WorkoutHistoryScreen loads useWorkoutHistoryDetail(id) and shows one card per exercise with a line per set from formatWorkoutSet (weight and reps, reps only, weight only, or a done label, plus the RIR when present). It has two actions:
- Create a workout photo: builds a
WorkoutSummarywithsummaryFromHistory(detail)and pushes/workout/complete. History summaries carry no records (prCount: 0,prs: []). - Delete: confirms, then calls
useDeleteWorkoutLog()and goes back.
Floating workout bar
GlobalWorkoutBar is mounted once in src/app/(app)/_layout.tsx. It shows on the tab screens (/home, /workouts, /nutrition, /content, /profile) while a workout is started or a cardio session exists.
- It mounts 420 ms after becoming active (
REVEAL_DELAY_MS), so it does not appear under the closing workout sheet. It waits to mount instead of fading in because a translucent ancestor disables the glass effect. - Tap reopens
/workout/[id]with the session’sdayIdandprogramId. Long press offers to discard. - It shows the title, the next exercise from
nextExercise(session)and completed sets over total sets. - With a cardio session it also shows
CardioMiniBar. The workout row is hidden when the cardio session is linked to the same workout.