src/features/workouts/store/sessionPersistence.ts. All paths are relative to the mobile repo root.
Where the snapshot lives
The file is read and written through
src/lib/storage/jsonFileStore.ts:
- On native it is a file in the app’s document directory,
new File(Paths.document, name)fromexpo-file-system, using the synchronoustextSync(),write()anddelete()calls. - On web it is a
localStorageentry under the same name. - Every helper (
readJsonSync,writeJsonSync,removeJsonSync) swallows errors. A failed read returnsnull, a failed write does nothing.
Snapshot shape
session is the whole WorkoutSession object from src/features/workouts/store/workoutSession.ts, including every exercise, every set and the entered values. rest describes a running rest timer: absolute start and end timestamps, the exercise name, the alert content and the id of the scheduled notification.
Writing
The store file ends with one subscription:saveWorkoutSession(session) behaves as follows:
- No session, or a session that is not started. If a snapshot exists it is cleared from the cache and the file is removed straight away. An info-mode preview is therefore never persisted.
- A started session. The in-memory
cachedsnapshot is replaced, keeping the currentrestvalue, and a write is scheduled.
saveWorkoutRest(rest) updates only the rest part and schedules a write. It does nothing when there is no snapshot. It is called from persistRest() in src/features/workouts/live-activity/workoutActivityController.ts whenever a rest starts, is adjusted, gets its notification id, or ends.
Throttling
schedule() writes at most once every 400 ms. If the last write was longer ago it flushes immediately. Otherwise it sets one timer for the remaining time. Typing in a weight field therefore does not write on every keystroke.
A pending write is not lost when the app goes to the background:
flush() writes the cached snapshot, or removes the file when the cache is null.
Loading and validation
readWorkoutSnapshot() loads once and caches the result in module state. load() rejects the file, and deletes it, when any of these is true:
raw.version !== SNAPSHOT_VERSIONisStartedSession(raw.session)fails. It requires a non-empty stringdayId,started === true, a numericstartedAtgreater than zero, and anexercisesarray.Date.now() - raw.session.startedAt > MAX_SESSION_AGE_MS
rest is kept only when isRest passes (numeric startedAt and endsAt, string exerciseName). Otherwise it loads as null.
When to bump SNAPSHOT_VERSION
Bump SNAPSHOT_VERSION in the same change whenever you alter the persisted shape in a way old data does not satisfy:
- Adding, renaming or removing a field on
ExerciseSet,SessionExercise,WorkoutExerciseorWorkoutSessionthat code reads without a fallback. Store actions call.trim()onweight,reps,rirandrm, so a missing string field throws. - Changing the type or meaning of an existing field, for example a unit change.
- Changing
RestSnapshotfields thatisRestdoes not check.
load() deletes snapshots with another version. That is the intended trade: losing one live workout is better than restoring a session the new code cannot read. A purely additive field that every reader treats as optional does not need a bump.
The constant is not exported. It is only used inside sessionPersistence.ts.
Restore flow
1
The store starts with the snapshot
The initial state is
session: readWorkoutSnapshot()?.session ?? null. This runs when the store module is first imported, synchronously, so the first render already has the session.2
The layout restores the rest timer
The effect in
src/app/(app)/_layout.tsx calls restoreWorkoutActivity() first. It runs once. If the snapshot has a rest whose endsAt is still in the future, it copies startedAt, endsAt, exerciseName, alert and alertId back into the controller’s module state. A rest that already ended is ignored. It also sets adoptionAllowed when a workout or cardio snapshot exists, which lets the controller adopt a Live Activity that is still running from before the kill.3
The Live Activity is synced
syncWorkoutActivity(session) is called with the restored session when it is started.4
The rest bar resumes
useRestTimerStore.getState().resume() reads the restored end time. If time is left it sets running, remaining, totalSeconds and exerciseName and restarts the 250 ms interval. The id of the notification scheduled before the kill was restored too, so skip and adjust can still cancel it.5
The trainee gets back to the workout
No screen is pushed automatically.
GlobalWorkoutBar appears on the tab screens because the session is started, and the workouts tab shows the day as in progress. Opening the day calls start(data), which is a no-op because sessionMatches is true, so the restored values are kept and not replaced by fresh server data.Clearing
The snapshot is removed when the session becomesnull or not started:
- After a successful log,
finishWorkoutcallsreset(). - Discarding from the day screen, the floating bar or the “another workout is running” prompt calls
reset(). - Leaving an info-mode day calls
reset(), which only matters if a snapshot existed.
Preview mode
WhenIS_PREVIEW from src/lib/preview.ts is true, load() returns null and both save functions return early. The embedded preview never reads or writes a snapshot.
Related snapshot
The cardio timer has its own file,perform-active-cardio.json, with its own SNAPSHOT_VERSION and the same 6 hour limit. See Cardio session.