Skip to content

Patient Workout Simplification (2026-08-19)

Cross-repo rebuild of the patient workout completion experience (branches patient-workout-simplification in OktaPT-API, Okta-Mobile, OktaPT-FE; API PR #411, FE PR #582, Mobile PRs #58/#60). Goal: minimum taps to finish a session, quick-finish from the dashboard, and server-backed resume. Includes a large automated-review triage pass (authorization, races, input validation, a11y).

Feature-level documentation: Patient Workout Completion, Resume & Quick-Finish and Mobile Workout Flow.

API (OktaPT-API)

Schema (prisma/schema.prisma, migration 20260819043656_add_workout_session_feedback_and_progress)

Additive columns on Workout (no new models):

Column Purpose
patientRecordedPainLevel / patientRecordedPhysicalExertion (Int?) Session-level 0–10 feedback
patientSessionNotes (String?) Session-level patient note
progressState (Json?) Resume envelope; null = no session in flight
progressUpdatedAt (DateTime?) Last progress write
quickCompleted (Boolean, default false) Distinguishes dashboard quick-finishes

Shared completion service (src/services/workoutCompletionService.ts)

The six completion side-effects (app-entrance cancellation, streaks, celebration notifications, AI next-plan generation, RTM recalculation, therapist feedback notification) were extracted verbatim from the legacy route into runCompletionSideEffects so every completion path fires them exactly once. Each effect is best-effort — an outage never turns a durable completion into a 500. Guard added for patientId == null (templates).

  • completeWorkoutWithClientActuals — legacy path: guards missing completedSets/poseData (previously 500s), scopes exercise writes to the workout, filters NaN/foreign exercise ids, claims completion via conditional updateMany (concurrent completions can't double-run side effects), clears progressState. Retries are no-ops returning the existing row.
  • quickCompleteWorkout — single transaction: marks not-yet-completed exercises done at planned values (app-entrance mirror convention; in-app-completed exercises keep real actuals), applies session + per-exercise feedback, sets quickCompleted, clears progressState. Backdates a null-or-stale (>3h) timeWorkoutStart by estimateWorkoutDurationMinutes (planned sets/reps/durations + rest, clamped 5–60 min) so streak weekly-minutes aren't zero.
  • canModifyWorkout helper — patient owner, assigning doctor, or a connected doctor (the org-wide verifyDoctorPatientAccess branch only checks the patient's connection, so the fallback is gated on caller type DOCTOR; same fix applied to the mirrored legacy exercise PATCH).

New patient endpoints (src/routes/workouts/patientSession.ts)

  • POST /v2/workouts/:id/quick-complete — { sessionFeedback?, exerciseFeedback? }, all optional; 0 is a valid pain level (explicit !== undefined validation, not truthiness); foreign exercise ids → 400 with invalidExerciseIds; 409 when already completed; runs all six side-effects after commit (feedback persisted before the notification re-reads the row).
  • PATCH /v2/workouts/:id/progress — resume envelope (version: 1, playbackMode, step-indexed pointer, currentExerciseInWorkoutId checksum, completedSets, cameraEnabled; ≤20KB) plus optional completedExercise (actuals validated as scalars — Prisma atomic-operator objects like {increment: …} are rejected —, durationSeconds → exerciseDuration, feedback). First write sets timeWorkoutStart (replacing the page-load PATCH that inflated durations). 409 once completed.

Legacy route + notifications

  • POST /workouts/:id/complete — now authorized (canModifyWorkout), validates the id, tolerates missing body fields, and skips side effects on repeats/races. External contract unchanged; old app builds keep working.
  • feedbackNotificationService — session-level feedback participates with the same thresholds (pain ≥ 7 / exertion ≥ 9 / notes); metadata gains an additive session block; the exercise-scoped headline only claims high pain when an exercise itself reported it.

~40 new Jest tests across five suites (complete-legacy, quick-complete, progress, completion service, feedback notification).

Mobile (Okta-Mobile)

  • State machine (hooks/useWorkoutFlow.ts): WORKOUT_SETUP → EXERCISE_ACTIVE → FEEDBACK → … (no-record) / … → RECORD_SETUP → RECORDING → FEEDBACK → … (record) → COMPLETION. Setup skipped when moot or resuming; camera denial falls back to a no-record session with a notice instead of dead-ending. Reaching the summary auto-persists completion (persistWorkoutCompletion, deduped, 409-tolerant); Done only navigates or retries.
  • Merged active screen (ExerciseActiveScreen, replacing the deleted WorkoutExecutionScreen + per-exercise setup screen): arrival = started; time-based target shown beside the timer; optional QuickRatingSheet pre-fills feedback.
  • Single-screen feedback (PatientFeedbackModal): sets pre-selected to planned, shared PillRatingRow (measured layout, staggered two-row grid on narrow screens, ≥44pt targets), collapsed note, one button, double-press lock.
  • Progress queue (services/workoutProgressService.ts): per-workout FIFO with save-state (saving/offline/error) surfaced in the UI, offline retry, transient-error retention, in-flight-head eviction protection, and a discarded-feedback recovery list drained through the batch feedback endpoint at completion.
  • Dashboard (WorkoutCard, ExerciseThumbnailRow, new ExerciseDetailSheet): name and thumbnail both open the detail sheet (works without video; VideoPreviewModal deleted); real Start/Resume/Mark-as-finished buttons replace the fake Start pill + whole-card navigation; Resume shows "Exercise N of M" + Start over.
  • Quick-finish (app/(patient)/(workout)/quick-finish/[id].tsx): modal route; session scales + note + expandable per-exercise editors; success state; 409 treated as success.
  • Plumbing: side-edges-only SafeAreaView (fixes double bottom inset on iOS — buttons sat ~34px high); sheets apply their own insets; httpClient gains HttpError, a 15s timeout, and single-read empty-body handling; useWorkoutData effect cancellation; route params normalized; empty-workout screen; extensive a11y labels/states; EN/RU keys for everything.

Web (OktaPT-FE)

  • Flow hook (hooks/useWorkoutFlow.ts): same architecture as mobile — beginStepAt (arrival = started on the quick path), per-boundary persistProgress with pointer-coalescing queue + online-event retry, resumeFromSaved with version/checksum validation, per-exercise duration accumulation across circuit visits, drain + 409-tolerant completion on the last feedback submit.
  • Player page: setup modal skipped on resume or when no camera device exists; single Start Recording button on the camera path; quick-mode video is muted (required for autoplay); floating glass bottom bar restored; upload retry actually re-uploads (lastBlobsRef); stale-fetch cancellation with per-id entry reset; exercise-list jumps allowed any time in regular mode with recording teardown.
  • Feedback modal: single screen, sets pre-selected, 0–10 scales via shared PainExertionScale (fixes the old 0–9 grid where 10 was unreachable).
  • Dashboard: ExerciseDetailModal (Sheet-based, all-media preview) replaces VideoPreviewModal; QuickFinishSheet with double-submit guard and no-dismiss-while-saving; Resume pill + Start over.
  • Foundations: border-brand-*/ring-brand-* utilities added to global.css (used in ~40 files but previously undefined — silently rendered nothing); describeResumePosition helper hardened against malformed persisted data.

Review triage highlights

Post-merge automated review produced ~45 findings; ~34 were fixed (authorization gating, atomic-operator injection, completion race, queue eviction/data-loss bugs, a11y batch, RU wording) and the rest declined with reasons (wrong-premise endpoint claims, refactors-not-fixes, Tailwind-config suggestion conflicting with the runtime color-mix() brand system).