Skip to main content

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:

  1. 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.
  2. Deferred upload. use-finish-training.ts calls postWorkoutSessions (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

StoreKeyHolds
inProgressSessionssession.id (index: workoutId, stationId)Live WorkoutSession tree (domain model), schemaVersion-stamped. Raw rep data stored as a gzipped encoded blob.
activeWorkoutsstationIdThe live draft WorkoutReturnDtoV2 (items, blocks/normatives, participants) — schemaVersion-stamped.
meta.activeWorkoutfixed keyStation roster + mode (which station UUIDs are active, single vs multi, sessionWeight, trackingDetailMode).
sensorAssignmentsstationIdPer-station sensor routing intent (deviceId, name, position, isSmartBar, unit pairing) — serializable fields only, no live adapter.
pendingUploadsbatchId (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 WorkoutSession tree so it also carries userExercise (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 the encodeRepData Float32 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's rawData for 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 (pendingSensorReps store, written by use-station-reps).
  • on set-completion (the rep+raw burst, one putMany, ~40 KB gzipped);
  • on visibilitychange: hidden (force-flush dirty records — iOS kills backgrounded apps; this is a web-standard event, not BLE). Subsumed 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; the onHide helper in connectivity.ts stays 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:

  • inProgressSessions is 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 via createInitialSessions. A full rebuild over recovered data is structurally impossible.
  • activeWorkouts is 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 pendingUploads entries and its inProgressSessions copies, 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 the WorkoutSession tree only — the convergence point downstream of however a rep was produced.
  • Live BleConnection / SensorAdapter are not serialized. sensorAssignments persists only the routing table; the BLE flow re-establishes connections on recovery via its own assignSensor / 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.

FormatBytes/rep
Raw SensorDataPoint[] objects~55 KBrejected — ~6× larger
Encoded Float32 blob~9 KBupload-ready
Gzipped encoded blob~4–5 KBchosen — 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.ts with fake-indexeddb). Plus thin wiring in use-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 logout wipeAll, so a coach cannot log out and still upload later. No per-user scoping needed. Revised 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 with baseUrl::userId in the meta store (ensureOfflineOwner, applied by offline/owner.ts awaited in login/register and fired at boot by AuthGuard): 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/drainVideos additionally 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.ts mid-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 via CompressionStream), db.ts (generic IndexedDB engine — store registry, typed CRUD, atomic tx), stores.ts (the registry + singleton), plus session-store.ts, shell-store.ts, upload-queue.ts, connectivity.ts, and orchestrator.ts (the app-facing composition). All unit-tested with fake-indexeddb.
  • Persistence is wired in TrainingSession (it owns the live draft + the session tree) rather than only use-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 in use-training-sessions.ts is an additive reconcile (missingSessionPairs + mergeNewSessions in training/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. persistSessions still 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 recoverStations pairs 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-reps mirrors its combined (restored + live) buffer to the pendingSensorReps store 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), and discardStation — 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's rawData in place. The feedback modes' scratchpad sets stay deliberately transient (no persistence passed). 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's WorkoutReturnDtoV2, indexed by dateKey), cachedUserExercises (the per-athlete ExerciseForChildUserReturnDto, keyed ${dateKey}|${defID}|${userID}), cachedExercises (per-exercise display metadata ExerciseMappedReturnDto by id — the workout view fetches this via getExercise and would otherwise error offline), cachedExerciseBases (per-exercise base by id — the displayBaseExerciseGroupID the block/normative editor filters its base picker on, fetched lazily via getExerciseBase; without it block editing is impossible offline), offlineDays (one manifest per ready day), and cachedReference (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 from workout.date: that field encodes a time-of-day on a templated date (often 1970 for recurring workouts — see workouts-today-list.tsx), so keying off it would never match the downloaded bundle.
  • day-bundle-store.ts owns 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 is getExercisesForWorkout; everything else createInitialSessions needs is global reference data.
  • reference-cache.ts snapshots 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 via loadWithCache (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 to loadDayWorkouts when 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-sessions reports uncoveredUserIDs).
  • A workout with NO covered pair at all can't be started (coversNoPaircoversNothingFor → the Start gate in WorkoutDetailsDrawer.tsx), and an uncovered exercise says so in place of the empty set list (ExerciseDetailView's offline prop). 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's exerciseID at upload time) is planned separately in handoffs/OFFLINE_ADHOC_TRAINING_PLAN.md.
  • Hygiene: past days' bundles are pruned on mount (pruneOfflineDaysBefore); the owner-mismatch wipe (wipeOfflineData via ensureOfflineOwner, see the amendment-2 consequence above — no longer run on logout) drops the whole DB, covering the new stores. Gated behind the reversible OFFLINE_READY_CHIP flag.

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 a videoID, keyed by video id and carrying its owning sessionId. The entries are derived from the finished sessions and enqueued by finishAndUpload before any network call, so an offline finish leaves them for the next drain (the trimmed .mp4 already sits on disk — the only copy).
  • video-queue.tsenqueueVideos / flushVideos / drainVideos. The backend creates the video record from the session POST (the set carries videoID), so a video is only eligible once its sessionId is no longer in pendingUploads (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 iOS ENVideoService.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 (handleDeleteSetdeleteLocalVideos), (3) the user discards the session (TrainingSession discard + recovered-session discard → deleteLocalVideos(videoIdsInSessions(...))). So a recorded video can never be lost without either a successful upload or an explicit user delete. Finish shares stopAndCleanup with discard but must NOT delete — only the discard call sites do. removeVideoEntries drops the outbox row for the user-initiated cases. Removing an athlete (applyAthleteChangeremoveSessionsForUsers) 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 through apiRequest (it's JSON-only); the file→URL transfer is a native plugin method (EnodeVideo.uploadVideo), and the one place the upload HTTP contract lives is packages/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 showSetSummary on (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 writeCloudStorage privilege + an automaticVideoUpload user setting; v1 of the web outbox always uploads. Wire an injected predicate when those land.
  • Android video upload. Recording + the native EnodeVideo plugin 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 uploadsresolved 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 isolationshipped 2026-07-12 (amendment 4). flushUploads classifies 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_FAILURES surface as stuckCount in 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. finishAndUpload no longer awaits the flush: the batch is durable before it returns; the detached send (sessions, then their now-eligible videos) is exposed as FinishOutcome.flushed for deterministic tests. Open with the backend: idempotency of POST /workout_sessions on 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 reconciliationshipped 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 discardStation lost 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:
    1. Atomic finish (ATOMIC_FINISH, orchestrator): a finish with a stationId stages the upload batch AND deletes the station's shell, session rows, and pending-rep buffers in ONE IndexedDB transaction (stagePendingUpload + the stageDelete* 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).
    2. 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.
    3. 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.
  • Plan Bshipped 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 useMedia path 404s offline), and exercise-detail charts for an athlete with no prior local history remain blank offline.