0002 — Offline training persistence & deferred upload
Status: Implemented · Decided: 2026-06-11 · Implemented: 2026-06-15
Context
Strength facilities frequently have no internet. Two needs follow:
- Crash safety. Today the entire in-session training state — sessions,
sets, reps, raw sensor data — lives only in React state
(
use-training-sessions.ts,useState<WorkoutSession[]>). An app crash, OS kill (common on backgrounded iOS apps), or refresh loses everything recorded so far. - Deferred upload.
use-finish-training.tscallspostWorkoutSessions(POST /workout_sessions) directly; on failure it only keeps the view open. There is no queue, so a finish with no connectivity has nowhere to go.
The only persistence primitives in use today are localStorage (auth token,
device id, a few UI flags). Installed natively: just @capacitor/core — no
Preferences/Filesystem/SQLite/Network plugin.
A second concern shapes the design: the workout being trained usually has no
durable backend counterpart. Templates picked via the FAB are cloned with a
fresh crypto.randomUUID() and never saved; recurring "today" instances are
client-side projections; empty/on-the-fly workouts are client-only; and all
mid-session edits (add athlete/exercise, edit block normatives) live solely in
workout-draft-context.tsx with no save endpoint (block normatives are
explicitly client-only — see dtos/workouts.ts). So the workout structure, not
just the recorded data, is unrecoverable after a crash unless persisted locally.
This ADR covers Plan A (crash-safe local persistence + deferred upload) and Plan B (download-the-day's-workouts for fully offline use), which reuses the same storage layer. Plan A shipped first (2026-06-11); Plan B followed (2026-06-15) — see "Implementation notes" → "Plan B".
Concurrency note
This work is being implemented alongside a separate effort on the BLE/sensor
connection flow (packages/core/src/ble/*, native plugins,
use-sensor-connection.ts, use-sensor-reps.ts, ConnectSensorSheet.tsx).
Plan A deliberately sits downstream of all BLE code — it observes the
WorkoutSession tree, never sensor events — so the two efforts share only one
file (use-training-sessions.ts) and otherwise do not overlap. See "The seam".
Decision
Storage engine: IndexedDB
Use IndexedDB, wrapped in a thin packages/core/src/offline/db.ts. It works
identically in web and the iOS/Android WebView with zero native code, is
async, handles large blobs, and keeps everything in the shared pure-TS layer —
off the native surface the BLE effort owns. SQLite (@capacitor-community/sqlite)
was rejected: a new native plugin on both platforms (colliding with the BLE
work) plus a wasm web fallback, for querying we don't need. localStorage is
disqualified by raw-data volume; Filesystem-JSON by clumsy incremental writes.
db.ts is flexible storage infrastructure, not a data model. It owns the
connection, versioning, a declarative store registry (names + keyPath +
indices), and generic typed CRUD (get/getAll/getAllByIndex/put/
putMany/delete/deleteMany/tx). Record shapes live in the feature
modules on top of it, so Plan B can later register more stores without touching
db.ts, and BLE-driven changes to WorkoutSession never reach the storage layer.
Object stores
| Store | Key | Holds |
|---|---|---|
inProgressSessions | session.id (index: workoutId, stationId) | Live WorkoutSession tree (domain model), schemaVersion-stamped. Raw rep data stored as a gzipped encoded blob. |
activeWorkouts | stationId | The live draft WorkoutReturnDtoV2 (items, blocks/normatives, participants) — schemaVersion-stamped. |
meta.activeWorkout | fixed key | Station roster + mode (which station UUIDs are active, single vs multi, sessionWeight, trackingDetailMode). |
sensorAssignments | stationId | Per-station sensor routing intent (deviceId, name, position, isSmartBar, unit pairing) — serializable fields only, no live adapter. |
pendingUploads | batchId (index: createdAt) | Wire-format payload (mapped SessionCreateDto[], gzipped+base64 data inline). |
What is persisted (and why these things specifically)
- Sessions/sets/reps/measurements — the recorded data; the core data-loss
risk. Stored as the full
WorkoutSessiontree so it also carriesuserExercise(1RM history) and per-set normatives, making recovery network-free. - Raw sensor data — the bulk. Persisted as a gzipped JSON blob of the
rep's
SensorDataPoint[], computed once at set-completion (raw is immutable after capture). Deviation from the original plan (which stored theencodeRepDataFloat32 wire blob): the wire encoder is lossy — jump mode resamples time and offsets vertical position by the first sample — and has no inverse, so a stored wire blob could not faithfully restore a rep'srawDatafor the on-device chart after a crash. Gzipped JSON round-trips the exact in-app representation; the upload path re-encodes from the restored data at send time (cheap, deterministic). Storage cost is somewhat higher than the packed blob but still negligible against the native WebView quota. - The workout structure (draft) — because it often has no backend counterpart and reflects mid-session edits. Persisted as the draft-merged workout (not the original fetched DTO), on the same continuous cadence as sessions.
- Station roster + sensor routing — to rebuild a multi-station screen and re-pair sensors after a crash.
Write strategy (A1)
Hook persistence into use-training-sessions.ts (the one shared file). Write:
- per rep-append (incremental, ~9 KB, reps land seconds apart) — crash
granularity is one rep. As shipped (amendment 3): the committed session
tree mirrors on set completion + edits (
TrainingSession), and the not-yet-completed set's reps mirror per rep via the pending-rep buffer (pendingSensorRepsstore, written byuse-station-reps). - on set-completion (the rep+raw burst, one
putMany, ~40 KB gzipped); onSubsumed by amendment 3: both mirrors write on every change with no debounce, so there is never a dirty window for a hidden-flush to save; thevisibilitychange: hidden(force-flush dirty records — iOS kills backgrounded apps; this is a web-standard event, not BLE).onHidehelper inconnectivity.tsstays unused by design.
encodeRepData + gzip run at set boundaries, not per sample, so the
BLE→state→disk path stays async and never blocks live recording.
Recovery (A2)
Two stores, different authority:
inProgressSessionsis authoritative for trained data — restore sessions directly from it. The hook's derive is an additive reconcile keyed on(workoutItemID, userID)(amended 2026-07-11): recovered pairs are found covered and left untouched; only genuinely missing pairs are created viacreateInitialSessions. A full rebuild over recovered data is structurally impossible.activeWorkoutsis authoritative for the shell — item ordering, block/superset grouping, header, not-yet-started items.
Hydrate shell from activeWorkouts, sessions from inProgressSessions,
re-associate by stationId + workoutItemID, then hand sensorAssignments to
the BLE reconnect flow. Recover with all drawers/sheets closed.
Deferred upload (A3)
Rework use-finish-training.ts: enqueue the wire payload under a fresh
batchId (persisted before any network call) → close the view (the user is
never blocked by connectivity) → flush. Because uploads are idempotent on
client-owned IDs and the server rolls back on any error/connection loss,
flush is simple:
- 2xx → delete the batch's
pendingUploadsentries and itsinProgressSessionscopies, in one transaction. - any error (network, 5xx, dropped connection) → the server wrote nothing; keep the batch and retry it verbatim.
Retry triggers: app launch (post-login), online event, visibility-regain, and
a manual "Upload now" button. flush is guarded against concurrent runs. A
persistent indicator surfaces pending count + manual retry.
Eviction & quota
Call navigator.storage.persist() at boot to move storage out of evictable
"best-effort" mode, and navigator.storage.estimate() to warn before a write
fails. On Capacitor native (the primary platform) WebView storage is already
durable, so these are hygiene rather than load-bearing; they matter more for the
rarely-offline web case.
Schema drift
pendingUploads stores wire format, so a future models.ts change cannot
break a queued upload — immune by construction. inProgressSessions and
activeWorkouts carry a schemaVersion stamp; on a mismatch at hydrate, the
record is discarded with a logged warning rather than migrated — this data
only lives for one training session, so migration code isn't worth it.
The seam (coordination with the BLE effort)
- Plan A touches no
ble/file, native plugin, or sensor hook. It reads theWorkoutSessiontree only — the convergence point downstream of however a rep was produced. - Live
BleConnection/SensorAdapterare not serialized.sensorAssignmentspersists only the routing table; the BLE flow re-establishes connections on recovery via its ownassignSensor/assignUnit. - Single shared file:
use-training-sessions.ts(persistence effect + hydrate path, distinct from the rep-intake region).
Sizing
Driven entirely by raw data (everything else is kilobytes). Encoder: ~62.5 Hz; strength = 12 floats/sample (48 B packed), ~190 samples per rep.
| Format | Bytes/rep | |
|---|---|---|
Raw SensorDataPoint[] objects | ~55 KB | rejected — ~6× larger |
| Encoded Float32 blob | ~9 KB | upload-ready |
| Gzipped encoded blob | ~4–5 KB | chosen — equals upload payload |
Worst-case multi-station workout (4 athletes × 6 exercises, ~1150 reps):
~5 MB gzipped — negligible against native WebView quota. Writes are
single-digit-ms putManys at set boundaries; hydration reads tens of ms;
chart decode is lazy per set viewed.
Scope
- In: the workout-driven in-session flow (single- and multi-station), including the draft workout structure and raw sensor data.
- Out: feedback-training mode — volatile by design, no recovery for now.
- In (Plan B, shipped 2026-06-15): download-for-offline of the day's
workouts + the per-athlete exercise instances, plus cold-start snapshots of
the global reference catalogues — all reusing
db.ts.
Consequences
- New module
packages/core/src/offline/(db.ts,session-store.ts,upload-queue.ts,connectivity.ts, co-located*.test.tswithfake-indexeddb). Plus thin wiring inuse-training-sessions.ts,use-finish-training.ts, and a launch/online flush + pending indicator at the today-page level. - Data survives crash, OS kill, and offline finishes; uploads retry until they land.
Erase-on-logout is accepted: the offline stores join the logoutRevised by amendment 2 (2026-07-11): the database survives logout and is owner-scoped instead. Logout — explicit or the involuntary 401 (there is no token refresh, so an expired token while backgrounded lands here) — clears only in-memory state. The database is stamped withwipeAll, so a coach cannot log out and still upload later. No per-user scoping needed.baseUrl::userIdin themetastore (ensureOfflineOwner, applied byoffline/owner.tsawaited inlogin/registerand fired at boot byAuthGuard): the same account's next login resumes recovery + queued uploads; a different account's login wipes first (intended — account switch removes the previous account's local data); an unstamped database is adopted (pre-amendment builds wiped on logout, so unstamped data can only belong to the signed-in account). Account deletion wipes explicitly.drainUploads/drainVideosadditionally refuse to send when the stamped owner doesn't match the caller's account, so a stale queue can never upload under another account's token.- A future breaking change to
models.tsmid-session discards in-progress recovery data (not queued uploads) — an accepted, narrow window.
Implementation notes (as shipped)
- Module
packages/core/src/offline/:compression.ts(native gzip viaCompressionStream),db.ts(generic IndexedDB engine — store registry, typed CRUD, atomictx),stores.ts(the registry + singleton), plussession-store.ts,shell-store.ts,upload-queue.ts,connectivity.ts, andorchestrator.ts(the app-facing composition). All unit-tested withfake-indexeddb. - Persistence is wired in
TrainingSession(it owns the live draft + the session tree) rather than onlyuse-training-sessions.ts; it writes the draft shell + the session set on every change (skipped while the initial session reconcile is still loading). A clean finish/discard clears the station's recovery state; a crash leaves it for recovery. - Sessions reconcile, never rebuild (amended 2026-07-11). Sessions are
owned local entities keyed by
(workoutItemID, userID): the derive inuse-training-sessions.tsis an additive reconcile (missingSessionPairs+mergeNewSessionsintraining/sessions.ts) that creates only missing pairs and never wipes or replaces existing ones. A workout reference that changes identity without changing content is a guaranteed no-op — the previous wipe-and-rebuild let a profile refetch on app resume destroy the live session state and (via the mirror) its recovery copy. - The disk mirror cannot destroy data.
persistSessionsstill replaces a station's set per write (rows absent from a non-empty mirror are pruned — that is how an explicit in-state removal, e.g. a removed athlete, reaches disk), but it refuses an empty write: an empty list is indistinguishable from an unhydrated one. Destruction is explicit only —deleteSessions/deleteStationSessions/deleteStationSessionRows(stationId, ids)(the orchestrator's athlete-removal path). - Per-station write lane (orchestrator). All disk mutations of a station's recovery state (mirror writes, explicit row deletes, the final discard) run on a per-station promise chain in call order, with mirror writes coalesced latest-wins. Without it, overlapping fire-and-forget writes could commit out of order (an older snapshot clobbering a newer one), and a discard could lose to an in-flight write and resurrect a finished session.
- Recovery is resume-in-place: on launch
recoverStationspairs each saved shell with its sessions, and a "Resume interrupted training?" prompt rebuilds the station(s) and injects the recovered sessions. The reconcile then finds every recovered pair covered (no backend re-derive over them); pairs the recovery did not cover — an empty shell, a partially-persisted athlete — are healed by creating them fresh. Decline → discard. - Pending-upload indicator: a floating pill (sidebar bottom-left chip in
multi-station; fixed bottom-left button in the single-station root) showing
the queued-batch count with a manual "Upload now", plus a near-quota storage
warning.
navigator.storage.persist()is requested at boot. - Live in-progress reps — CLOSED (amendment 3, 2026-07-12). The
not-yet-completed set's reps are durable per rep:
use-station-repsmirrors its combined (restored + live) buffer to thependingSensorRepsstore on every change, one record per (station, item, athlete) — reps belong to whoever was on the bar when they arrived, so an athlete switch PARKS the buffer instead of leaking it into the next athlete's set (the reset key includes the athlete), and switching back restores it. Recovery re-presents, never auto-commits: reopening the exercise/athlete shows the unsaved reps in the recording overlay; "Complete set" stays the commit point. Empty states are never written (hydration-gated, PR1 rule); emptying is explicit — save/dismiss (clearReps), athlete removal (deleteStationPendingRepsForUsers), anddiscardStation— all on the station write lane, including the hydrate READ (a restore must see a park still in flight). Raw data is gzipped WITHOUT session-store's identity-keyed memo: the continuation window mutates a pending rep'srawDatain place. The feedback modes' scratchpad sets stay deliberately transient (nopersistencepassed). Remaining narrow window: the Set-Summary draft path (flag default-off) clears the parked buffer when the summary opens, so a kill while the summary is on screen loses that one candidate set. - Reps arriving while backgrounded are a separate platform-capture question (native BLE keeps delivering; the JS pipeline may be suspended) — see the open questions.
Plan B — "make the day offline-ready" (shipped 2026-06-15)
- New stores (DB_VERSION → 4, additive):
cachedWorkouts(the day'sWorkoutReturnDtoV2, indexed bydateKey),cachedUserExercises(the per-athleteExerciseForChildUserReturnDto, keyed${dateKey}|${defID}|${userID}),cachedExercises(per-exercise display metadataExerciseMappedReturnDtoby id — the workout view fetches this viagetExerciseand would otherwise error offline),cachedExerciseBases(per-exercise base by id — thedisplayBaseExerciseGroupIDthe block/normative editor filters its base picker on, fetched lazily viagetExerciseBase; without it block editing is impossible offline),offlineDays(one manifest per ready day), andcachedReference(gzipped cold-start snapshots of the global catalogues). - Per-id lazy fetches (
getExercise,getExerciseBase) are the offline trap: they aren't part of any boot prefetch, so each needs both a download pre-warm AND a cache-on-load + offline-fallback in its store (exercise-bases/store.ts,use-workout-exercises.ts). The normatives themselves (bases/categories/metrics) ride the cached metrics snapshot. - The day key is always
todayDateKey()(dateKeyFromMs(Date.now())), used identically by the chip and by session-start. Critically, it is never derived fromworkout.date: that field encodes a time-of-day on a templated date (often 1970 for recurring workouts — seeworkouts-today-list.tsx), so keying off it would never match the downloaded bundle. day-bundle-store.tsowns the day records, the pure coverage helpers (requiredPairsForWorkout/missingPairs/uncoveredUserIDs), and a whole-day replace on re-download (prunes a dropped athlete/exercise). The one network dependency of session-start isgetExercisesForWorkout; everything elsecreateInitialSessionsneeds is global reference data.reference-cache.tssnapshots each reference store's payload (gzipped JSON) on every successful boot load and serves it back when the fetch fails — so a launch with no network still resolves names / metrics / equipment. Wired into all six stores vialoadWithCache(metrics, text-content, equipments, exercise-variations, tags, display-bases).- The chip (
OfflineReadyChip+use-offline-day.ts) sits above the schedule list and bundles exactly the workouts shown. The schedule page falls back toloadDayWorkoutswhen the fetch fails offline. - Uncovered athletes (a roster the bundle doesn't cover) are surfaced two
ways, both off the pure coverage check: an inline warning in the athlete
picker (
use-offline-coverage.ts) and a block-with-explanation banner at session start (use-training-sessionsreportsuncoveredUserIDs). - A workout with NO covered pair at all can't be started (
coversNoPair→coversNothingFor→ the Start gate inWorkoutDetailsDrawer.tsx), and an uncovered exercise says so in place of the empty set list (ExerciseDetailView'sofflineprop). Rationale: sessions — and therefore every set — only exist for a covered (exercise × athlete) pair, so such a training would silently record nothing. This is the all-or-nothing gate; a PARTIAL gap still starts and is merely named. It mirrors the pre-existing "all participants blocked" gate. Ad-hoc workouts built offline are the case this bites: the bundle covers only the day's scheduled workouts, so a freshly composed workout is never covered — the real fix (late binding of the session'sexerciseIDat upload time) is planned separately inhandoffs/OFFLINE_ADHOC_TRAINING_PLAN.md. - Hygiene: past days' bundles are pruned on mount (
pruneOfflineDaysBefore); the owner-mismatch wipe (wipeOfflineDataviaensureOfflineOwner, see the amendment-2 consequence above — no longer run on logout) drops the whole DB, covering the new stores. Gated behind the reversibleOFFLINE_READY_CHIPflag.
Implementation notes — set video upload (deferred outbox)
Recorded set videos upload through a second, parallel outbox that reuses this ADR's "persist before network, drain on connectivity" model. Videos are large binary and can't ride inside the JSON session payload, so they get their own store and a two-step gate:
pendingVideos(IndexedDB store, schema v5) — one entry per completed set with avideoID, keyed by video id and carrying its owningsessionId. The entries are derived from the finished sessions and enqueued byfinishAndUploadbefore any network call, so an offline finish leaves them for the next drain (the trimmed.mp4already sits on disk — the only copy).video-queue.ts—enqueueVideos/flushVideos/drainVideos. The backend creates the video record from the session POST (the set carriesvideoID), so a video is only eligible once itssessionIdis no longer inpendingUploads(its session has uploaded). Eligible videos then: request a presigned Cloudflare URL (GET /videos/upload_url?id=), stream the bytes natively (EnodeVideo.uploadVideo), and on a confirmed 200 move the file to the persistent directory (moveVideoToUploaded) and drop the entry. Mirrors iOSENVideoService.upload.- Unlike the session queue (strictly ordered — one failure stops the drain), a failed video doesn't block the others: each is an independent file, so the flush records the attempt and moves on.
- Cleanup invariant: a local video file is deleted at exactly three points,
never otherwise — (1) a confirmed upload (the local copy is dropped after a
200; reviewing an uploaded video re-fetches from the server), (2) the user
deletes its set (
handleDeleteSet→deleteLocalVideos), (3) the user discards the session (TrainingSessiondiscard + recovered-session discard →deleteLocalVideos(videoIdsInSessions(...))). So a recorded video can never be lost without either a successful upload or an explicit user delete. Finish sharesstopAndCleanupwith discard but must NOT delete — only the discard call sites do.removeVideoEntriesdrops the outbox row for the user-initiated cases. Removing an athlete (applyAthleteChange→removeSessionsForUsers) likewise deletes that athlete's sessions' videos, since their sets are dropped. - Triggers — the same launch /
online/ foreground opportunities (useUploadDrain) drain sessions first, then videos, so a now-eligible video goes on the very same connectivity event. The Cloudflare upload bytes never pass throughapiRequest(it's JSON-only); the file→URL transfer is a native plugin method (EnodeVideo.uploadVideo), and the one place the upload HTTP contract lives ispackages/core/src/video/upload.ts.
Open / deferred questions
- Background rep capture. Persistence (amendment 3) makes every rep the JS
pipeline processes durable — but while the app is backgrounded, whether raw
samples still reach JS is a platform question: iOS keeps native CoreBluetooth
alive (
bluetooth-central) while the WebView may suspend (bridge events queued or dropped — unverified on device); Android has no foreground service, so JS runs until the cached process is frozen/killed. To measure: mid-set, background for 60 s with the sensor streaming, foreground, inspect what arrived. If capture must be guaranteed: native ring-buffer + replay-on-resume in the BLE plugins (rep detection stays in shared TS) and an Android foreground service. - Set-Summary draft durability. With
showSetSummaryon (default off), opening the summary clears the parked pending-rep buffer while the candidate set exists only in memory — a kill during review loses it. Persist the draft set (or defer the buffer delete to accept/dismiss) if the flag ships on. - Set-video privilege/setting gate. iOS gates upload on the
writeCloudStorageprivilege + anautomaticVideoUploaduser setting; v1 of the web outbox always uploads. Wire an injected predicate when those land. - Android video upload. Recording + the native
EnodeVideoplugin are iOS-only today, so the upload/move methods exist only on iOS. Mirror them in the Android plugin when Android recording ships (the TS outbox is already cross-platform). - At-rest encryption (GDPR). IndexedDB is unencrypted; this is personal training data and the entity is EU-based. Device-level encryption is assumed sufficient for now — revisit if a stricter requirement lands.
- Retention of failed uploads — resolved as policy: never auto-delete an
unsendable batch (it is the only copy), but the indicator warns once the oldest
batch is > 7 days old or the queue exceeds ~50 MB (
uploadQueueHealth). Tune freely. - Poison-batch isolation — shipped 2026-07-12 (amendment 4).
flushUploadsclassifies failures (isTransientUploadError): transient (network / timeout / 5xx / 401 / 408 / 429 / a cancelled 310–312 dialog — and any unrecognized shape) stops the drain and retries later, exactly as before; a DETERMINISTIC 4xx rejection is logged, counted on the batch (failedAttempts/lastFailure), and SKIPPED so the healthy batches behind it still upload — no head-of-line blocking. The batch is never deleted and is re-attempted once per drain, so a server-side fix heals it with no client action. Batches rejected ≥STUCK_AFTER_FAILURESsurface asstuckCountin the queue health and get truthful pill copy ("can't be sent — retrying"). The session-upload POST carries its own 240 s deadline (SESSION_UPLOAD_TIMEOUT_MS,api/sessions.ts) — the default 15 s would systematically abort large raw-data batches on slow connections, which presents exactly like a poison batch.finishAndUploadno longer awaits the flush: the batch is durable before it returns; the detached send (sessions, then their now-eligible videos) is exposed asFinishOutcome.flushedfor deterministic tests. Open with the backend: idempotency ofPOST /workout_sessionson client ids (re-send after a lost 2xx), and that cross-batch arrival order carries no meaning (a skipped batch delivers late). - Atomic finish + launch reconciliation — shipped 2026-07-16 (amendment
5). Field report: after "Finish training" a coach could still edit the
workout (edits were silently dropped — the upload snapshots the sessions at
button press), and killing the app right after the view closed re-presented
a "resume training" prompt for a workout already on the server (the
fire-and-forget
discardStationlost the race with the kill; a shell can also outlive its uploaded sessions because the flush deletes rows but not shells). Three changes, each behind a reversible flag:- Atomic finish (
ATOMIC_FINISH, orchestrator): a finish with astationIdstages the upload batch AND deletes the station's shell, session rows, and pending-rep buffers in ONE IndexedDB transaction (stagePendingUpload+ thestageDelete*helpers), lane-ordered after in-flight mirror writes. At every kill point the DB holds either the full recovery state and no batch, or the batch and no recovery trace — never both, never neither. A failed transaction rolls back completely and the finish reports not-queued (the view stays open with Retry). - Launch reconciliation (
RECOVERY_RECONCILE,recoverStations): queue-covered sessions are hidden from the resume prompt (the queue owns them; their rows are deleted by the flush on upload success, never here); a shell with no session rows and no pending reps is a stale finish artifact and is deleted. Sessions are mirrored from training start, so a bare shell cannot be a legitimate crash remnant. This heals databases written before the atomic finish existed. - Finish interaction lock (
FINISH_LOCK,TrainingSession): while the finish maps/queues (seconds on Android), a full-station overlay blocks every interaction — set edits, Discard, sheet dismissal — so nothing can mutate state that the upload snapshot no longer contains.
- Atomic finish (
- Plan B — shipped 2026-06-15 (see "Implementation notes" → "Plan B").
Covers the day's workouts, per-athlete exercise instances, and the global
reference catalogues. Still open: media/Lottie assets are not yet cached
(the lazy
useMediapath 404s offline), and exercise-detail charts for an athlete with no prior local history remain blank offline.