All paths on this page are relative to the mobile repo root. The screens live in 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:
Day ids repeat across plans. Always pass plan together with id when you can. Links that cannot know the plan (the home card and the iOS widget) send only id, and findDay in src/features/workouts/data/workoutsApi.ts then scans the plans in order.

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 by filePlanSource(selectedPlan.pdfUrl)), a mark done button and FilePlanView at a height of max(320, round(windowHeight * 0.58)) (FILE_PLAN_MIN_HEIGHT and FILE_PLAN_HEIGHT_RATIO).
  • Regular plan. A featured card, the remaining days as a row list, the movement cards and the history section.
The mark done button on a file plan posts a log with no entries:

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 to WorkoutTopBar 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.
Leaving the screen in info mode drops the session: the effect cleanup calls reset() when the session matches this day and is not started. A started session survives navigation, which is what the minimise button relies on. 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 (startWorkout in WorkoutsScreen).
  • The home screen workout card (src/features/home/presentation/ClassicHomeScreen.tsx and themes/useHomeModel.ts), which sends id and start but no plan.
Notification taps with 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 #ea8a23 and #7c3aed (SET_TYPE_COLOR).
  • Previous. lastReps and lastWeight from the last workout, or a dash placeholder with no history.
  • Weight and reps. Local drafts in SetRow commit 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 from src/features/workouts/store/repsPicker.ts. The values come from parseRepRange. Dragging selects, release commits. The list closes when the scroll view starts dragging.
  • Check. Calls handleToggleSet on the screen, covered on Workout session engine.
An exercise with no sets shows the coach note and a single button that calls 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:
  1. isPlayableVideo(video) (extension mp4, mov, m4v or webm): LoopingVideo with expo-video, muted, looping, audioMixingMode = 'mixWithOthers', paused in the background.
  2. isHostedVideoEmbed(video): LoopingEmbed, a WebView on the embed URL with injected JavaScript that mutes and loops every media element every 400 ms, and pointerEvents="none".
  3. Otherwise the image with a watch video button that opens the URL with Linking.openURL.
Aspect ratio comes from the video track or image size, with DEFAULT_MEDIA_ASPECT_RATIO (16 by 9) as the fallback.

Substitutes

The substitute pill shows when exercise.alternatives.length > 0. SubstituteSheet lists exerciseOptions(exercise) and marks the coach’s exercise. Choosing one calls handleSubstitute:
  1. If any set of the exercise is done, confirm first, because the swap clears entered values.
  2. Call substitute(exerciseId, option) on the store.
  3. Open SubstituteSaveDialog, which asks whether to keep the swap for next time. Saving calls POST /v1/trainee/workouts/substitutions. The dialog is skipped when there is no programId or when IS_PREVIEW is true.
The session side of the swap is on Workout rules.

Technique videos

Each exercise carries techniquePrompt with mode: 'none' | 'free' | 'coachDemand', required, reason ('FIRST_TIME' | 'EVERY_N') and completedCount.
  • TechniquePill in ExerciseBody is hidden for mode: 'none'. It turns into a sent chip once videoSent is true.
  • The reminder popup (TechniquePromptDialog) opens on its own when shouldPromptTechnique is true: mode is coachDemand, required is 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 videoSkipped for coachDemand rows.
  • useTechniqueVideo in src/features/workouts/hooks/useTechniqueVideo.ts offers record or pick from library through expo-image-picker (quality: 0.7), uploads with uploadFile, then calls POST /v1/trainee/technique-videos with url and optional exerciseId.
  • Coach feedback on an earlier video shows as a card on the exercise. Dismissing it calls dismissCoachFeedback and POST /v1/trainee/technique-videos/:videoId/feedback-seen.

Cardio inside a workout

When the day has cardio, 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 WorkoutSummary with summaryFromHistory(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’s dayId and programId. 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.