A live workout is held in one Zustand store, useWorkoutSession, in src/features/workouts/store/workoutSession.ts. The rest timer has its own store in src/features/workouts/store/restTimer.ts. Nothing reaches the server until the trainee finishes. All paths are relative to the mobile repo root.

Data in

src/features/workouts/data/workoutsApi.ts reads everything from one endpoint, GET /v1/trainee/workouts, which returns TrainingProgramsDto (plans and optional movement). Two mappers build view models from it:
  • getOverview() builds WorkoutsOverview for the tab.
  • getLiveWorkout(dayId, planId) finds the day with findDay and maps it with dayToLive into a LiveWorkout.
dayToLive turns each StrengthDto row into a WorkoutExercise:
  • restSeconds comes from restToSeconds(row.rest), which accepts m:ss or a plain number of seconds.
  • sets comes from makeSets. It uses the coach’s setRows when present. Otherwise it creates firstNumber(row.sets) || 3 working sets.
  • Every set arrives with empty weight, reps, rir and rm. The prescription is only a target.
  • lastWeight and lastReps come from row.lastSets, matched by setNumber, falling back to the last entry in the list.
If the day cannot be found, getLiveWorkout returns an empty LiveWorkout with programId: null and no exercises.

Session state

origin holds the prescribed exercise while a substitute is active, so the swap can be undone. Each ExerciseSet holds setNumber, type ('warmup' | 'working' | 'drop'), targetReps, targetRepsLabel, targetWeight, the entered strings weight, reps, rir, rm, the reference strings lastWeight, lastReps, and the flags done and isPr. Entered values are kept as raw strings so editing stays smooth. An empty string means “use the placeholder”.

Helpers

sessionStats computes volumeKg as the rounded sum of weight times reps over done sets, using the placeholder when the field is empty and skipping sets without a positive weight and reps.

Actions

Checking a set

toggleSet handles both directions:
  • Unchecking sets done: false on the set and also done: false on the exercise.
  • Checking commits the row. Empty weight and reps take their placeholder, rir and rm are trimmed, and done becomes true. Only the checked row is filled. Other rows keep what the trainee typed.
After either direction the store runs recomputePrFlags on the exercise and markStarted, so the first checked set also starts a session that was still in info mode. completeExercise applies the same placeholder commit to every set of the exercise.

What the screen adds

handleToggleSet in src/features/workouts/presentation/WorkoutDayScreen.tsx wraps the store call:
  1. Calls toggleSet.
  2. If the set was turned on and is a record, celebrates it.
  3. If the set was turned on, decides whether to start the rest timer (see below).
  4. Opens the technique popup when shouldPromptTechnique is true.
  5. Calls completeExercise when every set of the exercise is now done.

Rest timer

useRestTimerStore holds running, remaining, totalSeconds and exerciseName. It does not own the end time. The end time lives in module state inside src/features/workouts/live-activity/workoutActivityController.ts (restStartedAt, restEndsAt), so the Live Activity, the persisted snapshot and the in-app bar all read one value. src/app/(app)/_layout.tsx calls resume() on mount and every time AppState becomes active.

When a rest starts

The screen starts a rest after a checked set when all of these hold:
  • The lead exercise of the group has restSeconds > 0.
  • Every member of the superset group has that set number done (supersetGroup). A single exercise is its own group.
  • The workout is not fully done.
The rest uses the lead’s restSeconds and name. WorkoutRestBar shows the countdown, a progress bar of remaining / totalSeconds and a skip button that calls stop().
adjust and REST_STEP_SECONDS exist in the store, but no component calls them today. The rest bar only offers skip.

Rest-over alert

The alert is a local notification scheduled for the moment the rest ends.
1

Build the content

useRestNotificationConfig() reads GET /v1/trainee/notification-configs (query key ['notification-configs'], staleTime 10 minutes) and takes the entry with type: 'REST_TIMER'. It returns enabled, titleTemplate and bodyTemplate, with app strings as fallbacks and enabled defaulting to true. The screen fills the templates with applyTemplate(template, vars), which replaces {{ exercise }} with the lead exercise name. When enabled is false no alert is passed.
2

Schedule it

workoutActivityRestStarted stores the content and calls scheduleRestEndAlert, which calls scheduleRestAlert(seconds, title, body) in src/lib/notifications.ts. That schedules a TIME_INTERVAL trigger on the rest-timer channel with interruptionLevel: 'timeSensitive', sound: true and data kind: 'rest-timer', target: 'activeWorkout'.
3

Reschedule or cancel

Adjusting the rest reschedules. Skipping or finishing cancels through cancelRestAlert. A generation counter (restAlertGeneration) discards the id of a schedule call that resolves after a newer one started.
Only one rest alert is ever visible. dismissRestAlerts removes presented rest alerts before a new one is scheduled, and a listener removes older ones when a new one arrives. Tapping the alert routes to the active workout through src/lib/notificationRouting.ts.

Android background behaviour

The in-app countdown is a JavaScript interval, so the alert cannot depend on it once the app leaves the foreground. The scheduled notification is the delivery path there, on both platforms. Three pieces in the code support it on Android:
  • app.json declares android.permission.SCHEDULE_EXACT_ALARM.
  • The rest-timer channel is created by ensureChannel with AndroidImportance.MAX, default sound and vibration pattern [0, 250, 250, 250].
  • presentRestAlertNow(scheduledId, title, body) is an Android-only backstop. When sync() sees the rest hit zero while the app is running, workoutActivityRestElapsed calls it. It cancels the scheduled alert, returns if a rest alert is already presented, and otherwise posts one immediately on the rest-timer channel.
The backstop only runs when the rest ended no more than 5 seconds ago (ELAPSED_ALERT_GRACE_MS). A sync() that runs late, for example on the first tick after the app returns to the foreground, therefore does not post an alert for a rest that ended long ago.

Blocked alerts

restAlertBlockReason() returns 'permission' when notification permission is not granted, 'channel' on Android when the rest-timer channel importance is NONE or MIN, and null otherwise. The screen checks it once per mount, the first time a rest with an alert starts, and shows an error toast when it returns a reason.

Idle and unfinished reminders

useWorkoutReminders() is mounted in src/app/(app)/_layout.tsx. It schedules two device-local notifications on the workout-reminders channel from the same notification configs: Both carry target: 'activeWorkout'. See Notifications for the rest of the notification setup.

Submitting the log

finishWorkout in WorkoutDayScreen builds the payload and calls useLogWorkout(), which posts to POST /v1/trainee/workouts/log.
The screen sends programId, dayId, label (the day label), durationSec from elapsedSeconds(startedAt, Date.now()), completed: true, and one entry per exercise:
Per-set rules:
  • reps: for a done set, the entered or placeholder reps, or targetReps when both are empty. null for a set that was not done.
  • weight: for a done set with a value, the number. Otherwise null. A done set with no weight logs null, which is also how a bodyweight set is logged.
  • rir: the chosen string for a done set, otherwise null.
  • targetReps: the prescription when positive, otherwise null.
  • Sets that were never performed are still sent with done: false, so the server sees the skip, but they never carry target or placeholder numbers.
rm is part of the set shape and the payload, but no input in SetRow writes it, so it is always sent as null.
On success the hook invalidates ['workouts', 'overview'], ['workouts', 'history'], the home query and the progress query, and calls useAuthStore.getState().refreshSession() because a first completed workout can start a waiting subscription. The screen then reads workoutNumber from the response, stores the share summary, calls cardioFinisher.settleWithWorkout(dayId, programId), resets the session and replaces the route with /workout/complete. On error it shows an alert and keeps the session.

Other workout endpoints

In saveExerciseSubstitution, a toExerciseId equal to fromExerciseId undoes a saved swap. useSaveSubstitution invalidates the whole ['workouts'] prefix, because the live day is cached under ['workouts', 'live', id, planId].

Side effects wired in the layout

src/app/(app)/_layout.tsx subscribes to the store once and forwards every change to syncWorkoutActivity, passing the session only when it is started. That drives the iOS Live Activity described on Live Activity and widgets. The store file itself subscribes saveWorkoutSession, described on Workout crash recovery.