Appearance
DailyExecution / Client Mobile / Weak-Signal Training
Offline Workout Mode keeps daily workout tasks executable without network access.
The V1 baseline offline mode downloads assigned workout snapshots before training, lets the client start and finish workout sessions entirely from SQLite, queues deterministic sync operations, and reconciles accepted work back into Today, workout history, coach review, adherence, and progress projections when connection returns.
Page status
- Client mobile
- SQLite first
- Daily workout tasks
- Outbox sync
- Conflict receipts
Boundary decision
Boundary decision: Today View owns dated task projection. Program Builder owns assigned program snapshots. Workout Logging owns session, exercise, set, effort, skip, substitution, submit, correction, and review records. Offline Workout Mode owns the mobile cache envelope, offline package manifest, local draft lifecycle, sync ordering, retry state, and conflict UX that make those contracts reliable in weak-signal gyms.
Note
Offline mode must not invent workout data or silently mark a task complete. The client can show local completion immediately, but the Today task becomes accepted history only after the backend accepts the corresponding workout session revision.
Offline Workout Flows
The flow starts before the client reaches the gym and ends only when server receipts reconcile local state.
Prefetch Daily Workout Tasks
- Client mobile opens Today and requests the configured date window for the active relationship, local date, timezone, and cached sync cursor.
- API returns workout task summaries plus offline cache hints for assigned program day snapshots, exercise snapshots, approved alternatives, input schemas, and previous-session hints.
- App downloads missing offline workout packages while on a usable connection and stores manifest checksum, source revisions, task ids, and expiration policy in SQLite.
- Downloaded packages are scoped to relationship and device. Logout, relationship removal, device revoke, or account deletion wipes the local encrypted cache.
- Today renders a workout as offline-ready only when the package includes the required task snapshot, exercise metadata, prescription schema, and sync cursor.
Train Without Network
- Client starts a workout from Today or a cached workout detail screen and the app creates a local session draft with stable client-generated ids.
- Set edits, skipped exercises, approved alternatives, notes, rest timers, RPE, and RIR write to SQLite before the UI updates.
- Each meaningful edit appends or coalesces an outbox operation with monotonic client revision, operation type, local timestamp, and snapshot checksum.
- Client can background, close, or crash the app and resume the same local draft from SQLite without contacting the server.
- Submit freezes the draft revision, queues a submit operation, and shows the workout task as queued rather than accepted.
Reconnect And Sync
- Network monitor marks eligible workout operations for sync only after auth session, relationship access, and local database health checks pass.
- Sync worker sends operations ordered by relationship, session, client revision, and local sequence number so the server can reconstruct intent.
- Backend validates task ownership, snapshot checksum, expected revision, idempotency keys, assignment status, and workout logging rules.
- Accepted operations return canonical ids, accepted server revision, updated Today task state, adherence summary, and coach-review flags.
- Client persists receipts, maps canonical ids onto local rows, removes accepted outbox operations, and keeps stable client ids for future idempotent retries.
Conflicts And Recovery
- If today's assignment changed while the client was offline, server decides whether the visible snapshot is still acceptable, superseded, or blocked.
- Acceptable old-snapshot submissions remain valid history and the coach sees that the client completed the version visible at training time.
- Blocked or stale operations return conflict receipts with a reason, affected records, latest package cursor, and whether the client can resubmit.
- Client keeps conflicted workout drafts recoverable until the user resolves, discards, or support retention rules expire.
- Duplicate retries, app restarts during sync, and partial batch failures must never duplicate sessions, sets, task completions, or adherence events.
Domain Model
Offline mode is a mobile-first coordination layer around DailyExecution task projection and workout session logging.
Aggregates & Entities
OfflineWorkoutPackage: immutable mobile-safe payload for one workout task or compact task window, including source references, snapshot checksum, input schema, alternatives, and cache policy.OfflineWorkoutManifest: relationship/device cache cursor listing package ids, source revisions, local dates, checksums, and invalidation hints.LocalWorkoutSessionDraft: client-side session aggregate persisted in SQLite until accepted, conflicted, discarded, or corrected.MobileWorkoutOutboxOperation: local command record for session start, edit, skip, substitution, submit, correction, or discard intent.WorkoutSyncBatch: server-side reconciliation request envelope containing ordered operations from one device.WorkoutSyncReceipt: accepted, rejected, duplicate, or conflicted server receipt stored on both server and device.WorkoutSyncConflict: explicit conflict record with type, blocking rule, latest server context, local draft reference, and client resolution options.DeviceSyncState: client relationship/device cursor for manifest version, last successful sync, retry backoff, and outbox health.
Value Objects & Rules
OfflinePackageChecksum: stable hash across task snapshot, program day snapshot, exercise snapshots, prescription schema, and action schema.ClientRevision: monotonically increasing per local session draft; every submit or correction references the latest frozen revision.OutboxOperationKind:START_SESSION,UPSERT_EXERCISE,UPSERT_SET,SKIP_EXERCISE,USE_ALTERNATIVE,SUBMIT_SESSION,CORRECT_SESSION.SyncConflictKind:SNAPSHOT_SUPERSEDED,TASK_CANCELED,SESSION_ALREADY_SUBMITTED,STALE_REVISION,ACCESS_REVOKED,PAYLOAD_INVALID.RetryPolicy: bounded exponential backoff by operation type, auth status, network class, and server response.- A device can have only one active local draft for the same daily workout task unless the server accepts multiple execution attempts for that task.
- Outbox operations are idempotent by relationship id, device id, client operation id, and operation payload checksum.
- Local drafts can be optimistic; accepted history cannot. Server receipts decide canonical ids, completion state, and projection updates.
| Model | Owned By | Important Methods | Emits |
|---|---|---|---|
OfflineWorkoutPackage | DailyExecution read model | buildFromTask(), assertCompleteForOffline(), checksum(), toMobileDto() | OfflineWorkoutPackagePrepared |
LocalWorkoutSessionDraft | Client mobile | start(), applyEdit(), freezeForSubmit(), markQueued(), markAccepted(), markConflicted() | Local UI state changes and outbox operations. |
MobileWorkoutOutboxOperation | Client mobile | queue(), coalesce(), markSyncing(), accept(), reject(), retryAfter() | MobileOutboxOperationQueued |
WorkoutSyncBatch | DailyExecution API | deduplicate(), validateOrder(), reconcile(), toReceipts() | WorkoutSyncBatchAccepted, WorkoutSyncBatchPartiallyRejected |
WorkoutSyncConflict | DailyExecution API | raise(), explain(), resolveByRefresh(), resolveByDiscard() | WorkoutSyncConflictRaised, WorkoutSyncConflictResolved |
Table Structure
Server tables record sync envelopes and receipts; SQLite stores the executable workout subset and local draft state.
| Table | Store | Purpose | Important Columns | Constraints & Indexes |
|---|---|---|---|---|
mobile_workout_cache_manifests | PostgreSQL | Optional generated manifest metadata for relationship/device cache reconciliation. | id, relationship_id, device_id, date_window_start, date_window_end, manifest_checksum, package_count, sync_cursor, generated_at, expires_at | Index (relationship_id, device_id, generated_at desc); old manifests can be pruned after receipt retention. |
mobile_workout_sync_batches | PostgreSQL | Bulk sync request envelope from one client device. | id, relationship_id, device_id, client_batch_id, operation_count, batch_status, received_at, processed_at, failure_reason | Unique (relationship_id, device_id, client_batch_id); index by (relationship_id, received_at desc). |
mobile_workout_sync_receipts | PostgreSQL | Per-operation receipt used for idempotent retries and mobile reconciliation. | id, sync_batch_id, relationship_id, device_id, client_operation_id, operation_type, receipt_status, accepted_revision, canonical_record_id, payload_checksum, rejection_reason, created_at | Unique (relationship_id, device_id, client_operation_id); accepted receipts are retained for the offline retry window. |
mobile_workout_sync_conflicts | PostgreSQL | Conflict detail for operations that need client refresh, resubmit, discard, or support review. | id, sync_receipt_id, relationship_id, conflict_kind, local_reference jsonb, server_reference jsonb, resolution_options jsonb, resolved_at | Index (relationship_id, resolved_at); never mutates workout logs until an explicit accepted resolution. |
offline_workout_packages | SQLite | Cached executable task package for one workout task or task window. | package_id, relationship_id, task_id, local_date, manifest_checksum, snapshot_checksum, payload_json, downloaded_at, expires_at | Unique (relationship_id, task_id); delete when invalidated, expired, logged out, or storage pressure requires it and no draft depends on it. |
local_workout_session_drafts | SQLite | Client-side session header and sync status. | client_session_id, relationship_id, task_id, package_id, local_date, draft_status, current_client_revision, frozen_submit_revision, server_session_id, guided_position jsonb, come_back_queue jsonb, last_saved_at | Unique active draft per (relationship_id, task_id); accepted or discarded drafts can be pruned after receipt retention. Guided position and come-back queue are presentation state — never included in sync payloads. |
local_workout_outbox_operations | SQLite | Ordered offline commands waiting for sync or retry. | client_operation_id, client_batch_id, relationship_id, client_session_id, operation_kind, client_revision, payload_json, payload_checksum, sync_status, attempt_count, next_attempt_at | Unique (relationship_id, client_operation_id); index unsynced by (sync_status, next_attempt_at). |
local_workout_sync_receipts | SQLite | Compact copy of server receipts for idempotent retry and UI state. | client_operation_id, relationship_id, receipt_status, server_revision, canonical_id_map_json, conflict_json, received_at | Unique (relationship_id, client_operation_id); retained longer than accepted outbox rows. |
Note
SQLite also mirrors local exercise and set logs from the Workout Logging page. Offline mode should not store full coach program builder state, full exercise provider catalogs, or live media blobs unless a package explicitly includes a bounded offline media policy.
Lifecycle Policy
Offline data is temporary execution state, but local drafts are user work and must survive weak signal, app restarts, and retries.
Cache Lifecycle
- Client mobile prefetches Today plus the configured future workout window and refreshes packages when manifest checksum or source revision changes.
- Future unstarted packages can be replaced when assignments change. In-progress and submitted local drafts keep the exact package visible when the client trained.
- Expired packages are hidden from starting new sessions but retained if a local draft, outbox operation, or conflict receipt still references them.
- Logout, device revocation, relationship removal, account deletion, or explicit cache reset wipes packages, drafts without accepted history, receipts, and queued operations after warning where product allows.
Draft And Outbox Lifecycle
- Draft statuses:
LOCAL_IN_PROGRESS,QUEUED_FOR_SYNC,SYNCING,ACCEPTED,CONFLICTED,DISCARDED. - Accepted drafts keep a compact local history row and receipt map until the history refresh confirms the canonical server session is cached.
- Rejected validation errors keep the draft editable when possible; blocked conflicts keep the draft read-only with resubmit, refresh, or discard choices.
- Outbox pruning removes accepted operations only after receipt persistence, canonical id mapping, and projection refresh have succeeded.
Note
Missed-task jobs must account for local queued workout submissions. Server-side jobs cannot see unsynced work, so client UI must explain when a workout is locally done but not yet accepted, and backend grace windows should avoid penalizing near-term sync delays where product policy allows.
API Contracts
Contracts separate package download from workout session mutation while sharing the same idempotent sync receipts.
| Endpoint | Purpose | Request | Response |
|---|---|---|---|
GET /api/v1/client/workouts/offline-manifest?from=YYYY-MM-DD&to=YYYY-MM-DD | Return package metadata for workout tasks in a local date window. | Date window, timezone, device id, optional cached manifest checksum. | Manifest checksum, package list, task ids, source revisions, stale package ids, next sync cursor. |
GET /api/v1/client/workout-tasks/{taskId}/offline-package | Fetch the complete executable package for one workout task. | Task id, device id, cached package checksum. | Workout task snapshot, assigned program day snapshot, exercise snapshots, alternatives, input schema, previous hints, cache policy. |
GET /api/v1/client/workouts/offline-delta?cursor={cursor} | Fetch compact cache invalidations and changed packages since the last successful sync. | Device id, relationship id, sync cursor. | Changed package metadata, invalidated task ids, superseded source refs, deleted package ids, next cursor. |
POST /api/v1/client/workout-sessions/sync | Bulk reconcile offline start, edit, submit, and correction operations. | Device id, client batch id, ordered operations, client ids, revisions, package checksums, payload checksums. | Batch status, accepted receipts, duplicate receipts, rejected operations, conflicts, canonical id map, Today refresh hints. |
GET /api/v1/client/workout-sessions/sync/{clientBatchId} | Recover a batch result after timeout, app restart, or partial network failure. | Device id and client batch id. | Known batch status, operation receipts, conflict details, latest retry hint. |
POST /api/v1/client/workout-sessions/conflicts/{conflictId}/resolve | Apply a supported client conflict resolution. | Resolution choice, retained local draft reference, optional resubmitted operation ids. | Updated conflict status, receipts, package refresh hints, task/session state. |
GET /api/v1/coach/relationships/{relationshipId}/workout-sync-issues | Coach reads workout sync issues that may need follow-up. | Date range, status, conflict kind filter. | Conflict summaries, affected workout tasks, submitted old-snapshot explanations, and messaging hints. |
Key Code Snippets
Implementation sketches define the package contract, local outbox shape, mobile sync loop, and server reconciliation boundary.
Offline package contract
packages/contracts/src/daily-execution/offline-workout.ts
ts
export interface OfflineWorkoutPackageDto {
packageId: string;
relationshipId: string;
taskId: string;
localDate: string;
timezone: string;
snapshotChecksum: string;
sourceRevision: number;
cachePolicy: {
expiresAt: string;
canStartOffline: boolean;
requiresRefreshBeforeStart: boolean;
};
workoutTask: DailyWorkoutTaskDto;
workoutSnapshot: AssignedWorkoutDaySnapshotDto;
inputSchema: WorkoutInputSchemaDto;
approvedAlternatives: ApprovedAlternativeDto[];
previousSessionHints: PreviousSessionHintDto[];
}Local outbox record
apps/mobile-client/src/workouts/offline-workout-outbox.ts
ts
export interface LocalWorkoutOutboxOperation {
clientOperationId: string;
clientBatchId?: string;
deviceId: string;
relationshipId: string;
taskId: string;
clientSessionId: string;
operationKind: WorkoutOutboxOperationKind;
clientRevision: number;
packageChecksum: string;
payloadChecksum: string;
payload: Record<string, unknown>;
syncStatus: "QUEUED" | "SYNCING" | "ACCEPTED" | "REJECTED" | "CONFLICTED";
attemptCount: number;
nextAttemptAt?: string;
createdAt: string;
}Mobile sync loop
apps/mobile-client/src/workouts/sync-workout-outbox.ts
ts
export async function syncWorkoutOutbox(deps: SyncDeps): Promise<void> {
if (!deps.network.canSync() || !deps.auth.hasValidSession()) return;
const operations = await deps.outbox.nextWorkoutBatch({
relationshipId: deps.relationshipId,
limit: 50,
});
if (operations.length === 0) return;
const batch = WorkoutSyncBatchDto.fromLocalOperations(operations);
await deps.outbox.markSyncing(batch.clientBatchId, operations);
const result = await deps.api.syncWorkoutSessions(batch);
await deps.db.transaction(async () => {
await deps.receipts.store(result.receipts);
await deps.idMap.applyCanonicalIds(result.canonicalIdMap);
await deps.outbox.applyReceipts(result.receipts);
await deps.today.refreshDates(result.todayRefreshHints);
await deps.conflicts.store(result.conflicts);
});
}Server reconciliation use case
apps/api/src/daily-execution/use-cases/sync-offline-workout-batch.ts
ts
@Injectable()
export class SyncOfflineWorkoutBatchUseCase {
constructor(
private readonly batches: WorkoutSyncBatchRepository,
private readonly sessions: WorkoutSessionRepository,
private readonly tasks: DailyWorkoutTaskRepository,
private readonly reconciler: WorkoutSyncReconciler,
private readonly policy: OfflineWorkoutPolicy,
private readonly tx: TransactionRunner,
private readonly outbox: OutboxPort,
) {}
async execute(command: SyncWorkoutBatchCommand, actor: Actor): Promise<WorkoutSyncResultDto> {
this.policy.assertClientDeviceCanSync(actor, command.relationshipId, command.deviceId);
return this.tx.run(async () => {
const duplicate = await this.batches.findByClientBatch(command.batchKey());
if (duplicate) return WorkoutSyncResultDto.fromBatch(duplicate);
const taskIds = command.operations.map((operation) => operation.taskId);
const tasks = await this.tasks.getManyForUpdate(taskIds);
const result = await this.reconciler.reconcile(command, tasks);
await this.sessions.saveAll(result.changedSessions);
await this.tasks.saveAll(result.changedTasks);
await this.batches.save(result.batch);
await this.outbox.publish(result.domainEvents);
return WorkoutSyncResultDto.fromResult(result);
});
}
}Conflict policy
apps/api/src/daily-execution/policies/offline-workout.policy.ts
ts
export class OfflineWorkoutPolicy {
classifySnapshotConflict(task: DailyWorkoutTask, operation: WorkoutSyncOperation): SyncConflictKind | null {
if (!task.isClientVisible()) return "TASK_CANCELED";
if (task.snapshotChecksum === operation.packageChecksum) return null;
if (task.acceptsHistoricalSnapshot(operation.packageChecksum)) return null;
return "SNAPSHOT_SUPERSEDED";
}
assertClientDeviceCanSync(actor: Actor, relationshipId: string, deviceId: string): void {
actor.requireRole("CLIENT");
actor.requireRelationshipParticipant(relationshipId);
actor.requireRegisteredDevice(deviceId);
}
}Events & Background Jobs
Offline mode listens to task and program changes, then emits package, sync, conflict, and projection events.
Consumed Events
TodayAgendaProjected: prompts client mobile to prefetch or refresh workout packages for visible daily tasks.DailyWorkoutTaskMaterialized: makes a task eligible for offline package generation.ProgramAssigned,ProgramSuperseded,ProgramPaused, andProgramResumed: invalidate or refresh future package manifests.ExerciseMediaUpdated: updates optional media refs without rewriting executable workout history.AuthDeviceRevokedandCoachClientRelationshipEnded: invalidate package access and require local cache wipe.
Emitted Events And Jobs
OfflineWorkoutPackagePrepared: records manifest metadata and sends mobile sync hints.MobileWorkoutSyncBatchAccepted: updates receipts, Today projections, and client history refresh hints.WorkoutSyncConflictRaised: creates client conflict UI state and optional coach visibility when work may be lost or delayed.WorkoutSessionSubmitted: emitted by Workout Logging after accepted offline submit and consumed by adherence, PR, risk, and coach-review projectors.- Rolling prefetch job prepares today plus the configured future window for active clients with workouts.
- Outbox health job detects stuck sync batches, repeated conflicts, and receipt-retention mismatches for support diagnostics.
- Cache cleanup job prunes expired manifests and receipts after ensuring accepted session history is already canonical.
Permissions & Access Rules
Offline packages contain health and performance context, so access is scoped by relationship, actor, and registered device.
Client And Device
- Client can download offline packages only for relationships where they are the participant and the relationship is active or historically visible.
- Sync accepts operations only from registered devices associated with the authenticated client session.
- Local cache is encrypted at rest where platform support is available, and sensitive package data is wiped on logout, token revoke, or device revoke.
- Client cannot sync operations for tasks that were never visible to that relationship/device package window.
Coach And Support
- Coach can see accepted workout history and sync issues for relationships they manage, but not raw local draft payloads that never synced.
- Coach can understand old-snapshot completions and conflicts through review surfaces, not by editing mobile outbox rows.
- Support/admin access to receipts, conflict payloads, device ids, and cache diagnostics requires explicit permission and an audited reason.
- Ended relationships follow CoachClientManagement retention and visibility rules before offline package metadata is hidden or anonymized.
Web, Coach Mobile, Client Mobile
Offline workout mode is client-mobile-first; coach surfaces focus on accepted history and exceptional sync states.
Coach Web
- Timeline and workout review show accepted offline submissions with prescription versus actual values, snapshot version, and sync acceptance time.
- Client risk and task inbox surfaces highlight repeated sync conflicts, pain notes, skipped exercises, and stale unsynced warnings where server can know them.
- Program changes explain whether a client completed a superseded but visible package or the latest assignment.
Coach Mobile
- Coach mobile reads the same accepted workout summaries as web and can follow up from conflict or pain-note indicators.
- Coach mobile does not need an offline workout editing surface in Public V1, but cached review summaries can be read offline if already downloaded.
- Any coach action that changes future workouts goes through Program Builder and invalidates future packages through events.
Client Mobile
- Workout task cards show offline readiness, package age, queued submit state, sync failure state, and accepted state without confusing one for another.
- The workout logger works from SQLite for start, resume, set entry, copy previous values, skip, alternative, notes, RPE/RIR, and submit.
- Resume restores the guided-session position and come-back queue from the local draft, so a force-close or crash never loses the current set or deferral order (see Workout Logging for the guided presentation rules).
- Conflict screens preserve the local draft, explain the server reason, and offer only valid actions: retry, refresh, resubmit when allowed, discard, or contact coach.
- The UI supports Arabic/English, RTL set rows, dark mode, dynamic type, screen-reader status labels, and large touch targets for gym use.
Test Scenarios & Confirmed V1 Decisions
Offline workout mode needs strong durability, idempotency, conflict, and mobile UX coverage because dropped logs break trust quickly.
Tests
- Domain unit tests: package completeness, checksum comparison, stale package classification, retry policy, conflict classification, and outbox operation coalescing.
- API integration tests: manifest delta responses, package fetch access, duplicate batch retry, partial batch rejection, canonical id mapping, and receipt persistence.
- Workout logging integration tests: offline submit completes Today once, emits adherence once, preserves old visible snapshots, and rejects invalid set payloads without losing local draft state.
- Mobile persistence tests: start workout offline, log sets, force-close app, resume draft, submit offline, restart during sync, retry, and reconcile accepted receipts.
- Conflict tests: coach supersedes workout while client is offline, task is canceled, access is revoked, same session submitted from another device, and stale correction is rejected.
- Storage and security tests: encrypted cache wipe on logout/device revoke, low-storage package pruning, no draft deletion while unsynced work exists, and receipt retention after accepted sync.
- Localization tests: Arabic/English labels, RTL exercise/set layout, long exercise names, dynamic type, accessible queued/syncing/conflicted announcements, and dark mode contrast.
Confirmed V1 Decisions
- Public V1 keeps the offline prefetch window intentionally small: Today caches the current local day, and Workout Logging caches the current workout plus one next materialized workout package when available. Seven-day and coach-configured relationship windows are Post-V1.
- Exercise videos are not downloaded for offline playback in Public V1. Offline packages cache exercise snapshots, written cues, approved alternatives, local thumbnails, and safe media references; provider video playback requires an online runtime media session.
- Old-snapshot submissions are accepted when the package was visible and valid when the client started training. Coach and client history must label the completion as performed from a superseded visible snapshot. Submissions are blocked only for canceled tasks, revoked access, invalid payloads, or snapshots that policy marks non-resubmittable.
- Multiple devices may create local drafts, but Public V1 accepts one canonical submitted session per daily workout task. The first accepted submit locks the task; later device submissions return a
SESSION_ALREADY_SUBMITTEDconflict and keep the local draft recoverable for review or discard. - Conflicted local drafts remain recoverable for 30 days after the latest conflict receipt, unless logout, device revocation, relationship removal, account deletion, or explicit cache reset requires immediate wipe. Low-storage pruning may remove only packages that no draft, outbox operation, or receipt still references.
- Missed-workout adherence uses the fixed one-local-day grace window from Today View. A workout submitted and accepted within that window suppresses the missed penalty; accepted submissions after the window remain valid history but are labeled late/offline for adherence and coach review.