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()buildsWorkoutsOverviewfor the tab.getLiveWorkout(dayId, planId)finds the day withfindDayand maps it withdayToLiveinto aLiveWorkout.
dayToLive turns each StrengthDto row into a WorkoutExercise:
restSecondscomes fromrestToSeconds(row.rest), which acceptsm:ssor a plain number of seconds.setscomes frommakeSets. It uses the coach’ssetRowswhen present. Otherwise it createsfirstNumber(row.sets) || 3working sets.- Every set arrives with empty
weight,reps,rirandrm. The prescription is only a target. lastWeightandlastRepscome fromrow.lastSets, matched bysetNumber, falling back to the last entry in the list.
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: falseon the set and alsodone: falseon the exercise. - Checking commits the row. Empty
weightandrepstake their placeholder,rirandrmare trimmed, anddonebecomes true. Only the checked row is filled. Other rows keep what the trainee typed.
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:
- Calls
toggleSet. - If the set was turned on and is a record, celebrates it.
- If the set was turned on, decides whether to start the rest timer (see below).
- Opens the technique popup when
shouldPromptTechniqueis true. - Calls
completeExercisewhen 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.
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.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.jsondeclaresandroid.permission.SCHEDULE_EXACT_ALARM.- The
rest-timerchannel is created byensureChannelwithAndroidImportance.MAX, default sound and vibration pattern[0, 250, 250, 250]. presentRestAlertNow(scheduledId, title, body)is an Android-only backstop. Whensync()sees the rest hit zero while the app is running,workoutActivityRestElapsedcalls it. It cancels the scheduled alert, returns if a rest alert is already presented, and otherwise posts one immediately on therest-timerchannel.
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.
programId, dayId, label (the day label), durationSec from elapsedSeconds(startedAt, Date.now()), completed: true, and one entry per exercise:
reps: for a done set, the entered or placeholder reps, ortargetRepswhen both are empty.nullfor a set that was not done.weight: for a done set with a value, the number. Otherwisenull. A done set with no weight logsnull, which is also how a bodyweight set is logged.rir: the chosen string for a done set, otherwisenull.targetReps: the prescription when positive, otherwisenull.- 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.['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.