Cardio and steps live in src/features/movement. The feature has three parts: a read-only movement summary from the server, one persisted stopwatch that runs across the whole app, and a cardio log. All paths are relative to the mobile repo root.

Movement requirement

The server attaches a movement object to GET /v1/trainee/workouts. The workouts feature passes it through as WorkoutsOverview.movement and LiveWorkout.movement. The types are in src/features/movement/data/types.ts.
MovementSteps has dailyGoal, todaySteps, weeklyGoal, weeklySteps and met. The app does no goal maths of its own beyond progress bars. goalProgress(done, target) clamps done / target to 0 to 1, and remaining(done, target) is max(0, target - done) (src/features/movement/lib/movementTheme.ts). Cardio uses CARDIO_COLOR = '#2563eb' and steps use STEPS_COLOR = '#059669', matching the coach builder.

Which cards show where

MovementStack takes a surface of 'training' or 'home'. Screens call hasMovementCards(movement, surface) first so an empty stack does not leave a blank row. A per-workout cardio target normally lives inside the workout, next to its own stopwatch. It only gets a card on the training tab when steps are owed too, so the trainee can see both goals. The compensation note also renders on home, where the cardio card is not drawn.

Steps

StepsGoalCard shows todaySteps over dailyGoal, the remaining steps and a progress bar. It is display only. The step count comes back from the server in movement.steps. How the device step count gets to the server is covered on Health integration.

Activities

src/features/movement/lib/cardioActivities.ts defines the fixed list:
offeredActivities(approved) filters the coach’s list to known activities. If nothing is left it returns the full list. A coach who named activities gets exactly those, with no other option. CardioActivityGrid renders the result as radio tiles. When only one activity is offered, the screens preselect it.

The cardio timer store

useCardioSession in src/features/movement/store/cardioSession.ts holds at most one session for the whole app.
The stopwatch is two numbers, not a ticking counter. accumulatedMs is the time banked before the last pause. runningSince is the timestamp of the last resume, or null while paused.
MAX_CARDIO_MINUTES is 600. cardioElapsedMinutes floors the result to whole minutes. Because elapsed time is derived from timestamps, it stays correct through backgrounding and app restarts. useCardioElapsed(session) in src/features/movement/hooks/useCardioSession.ts returns the live elapsed milliseconds for a component. It ticks every 1000 ms only while the session is running, and refreshes when the app becomes active.

Persistence

The file is written through src/lib/storage/jsonFileStore.ts as { version, session }, into the same document directory as the workout snapshot. A store subscription calls persist on every session change, with no throttle, and removes the file when the session is cleared. load() deletes the file when the version differs, isSession fails, or the session is older than 6 hours. Nothing is read or written when IS_PREVIEW is true. Bump this file’s SNAPSHOT_VERSION when CardioSession changes shape. The Android notification module reads the same file natively and depends on the keys session, accumulatedMs and runningSince, so renaming those needs a matching change in modules/cardio-notification. A session started from inside a workout carries workout: { dayId, programId }. cardioLinkedTo(session, dayId, programId) is true when the day ids match and the program ids match or one side has none. The store watches the workout store:
A linked cardio session is cleared as soon as its workout is no longer the started session, for example when the workout is discarded.

Starting cardio

There are two entry points and they configure the session differently.
WorkoutCardioCard (in src/features/workouts/components) shows when the day has cardio (minutes and activities). It only offers the start button once the workout is started.
For a per-workout target, total is cardio.minutes and done is movement.todayMinutes, so the target is what is still owed today. If a cardio session from elsewhere is already running, the card shows a button to open /cardio/session instead. Once the goal is met the card shows a completed state and no start button.
A target only renders when scope === 'perWorkout' and targetMinutes > 0. Reaching the target does not stop the timer.

Finishing and logging

useFinishCardio() returns finish, settleWithWorkout and pending. finish() is the explicit save, used by the session screen and by CardioLiveBanner on the workout day screen.
  1. Reads the session. Under 1 whole minute it shows an error toast and returns false. Nothing is saved or cleared.
  2. Pauses the session.
  3. Posts the log. On success it clears the session, plays a haptic and shows a toast with the minutes. On failure it shows an error and leaves the paused session in place.
settleWithWorkout(dayId, programId) runs when a workout log succeeds.
  1. Returns when the cardio session is not linked to that workout.
  2. Takes a paused copy and clears the session straight away.
  3. Under 1 minute it stops there. Otherwise it posts the log in the background.
  4. If the post fails it calls keepUnsaved(snapshot), which restores the session paused and unlinked, and shows an error toast. The minutes are not lost, and the trainee can save them from /cardio/session.

API

src/features/movement/data/movementApi.ts:
logInput sets source to WORKOUT when the session is linked and MANUAL otherwise, and adds dayId and programId from the link. Minutes are whole minutes, floored. useLogCardio and useDeleteCardio invalidate the ['cardio'] prefix, the ['workouts'] prefix and the home query, because every surface that quotes cardio minutes reads them from /home or /workouts.

Routes

/cardio

CardioScreen opens from CardioGoalCard. It shows:
  • A summary card from overview.movement.cardio: a ring with the weekly percentage when weeklyTargetMinutes > 0, minutes done over target, and the number of sessions this week.
  • The history from useCardioHistory(90) (CARDIO_HISTORY_MAX_DAYS; the hook’s default is 7 days), grouped by groupCardioByWeek.
  • A footer button to /cardio/session. While a session exists its label shows the live clock.
src/features/movement/lib/cardioWeeks.ts groups entries by week. weekStartOf(iso) moves an ISO date back to its Sunday, using noon UTC to avoid day shifts. CardioWeekSection titles a week as this week, last week or a date range through weeksBetween, and each entry has a delete button. Entries with source: 'WORKOUT' are labelled as done inside a workout.

/cardio/session

CardioSessionScreen has two states. With no session it shows the activity grid and a start button. With a session it shows CardioLiveBanner (activity, live or paused, the clock, a pause and resume control) and a footer with finish and discard. Discard confirms, then calls clear(). The header control minimises the screen and leaves the timer running. A weekly progress card adds the live elapsed minutes to weeklyMinutes.

Where the running timer shows

src/app/(app)/_layout.tsx subscribes to the store and calls both sync functions on every session change. The two native surfaces are described on Live Activity and widgets.