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 missingcompletedSets/poseData(previously 500s), scopes exercise writes to the workout, filters NaN/foreign exercise ids, claims completion via conditionalupdateMany(concurrent completions can't double-run side effects), clearsprogressState. 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, setsquickCompleted, clearsprogressState. Backdates a null-or-stale (>3h)timeWorkoutStartbyestimateWorkoutDurationMinutes(planned sets/reps/durations + rest, clamped 5–60 min) so streak weekly-minutes aren't zero.canModifyWorkouthelper — patient owner, assigning doctor, or a connected doctor (the org-wideverifyDoctorPatientAccessbranch only checks the patient's connection, so the fallback is gated on caller typeDOCTOR; 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!== undefinedvalidation, not truthiness); foreign exercise ids → 400 withinvalidExerciseIds; 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,currentExerciseInWorkoutIdchecksum,completedSets,cameraEnabled; ≤20KB) plus optionalcompletedExercise(actuals validated as scalars — Prisma atomic-operator objects like{increment: …}are rejected —,durationSeconds→exerciseDuration, feedback). First write setstimeWorkoutStart(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 additivesessionblock; 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 deletedWorkoutExecutionScreen+ per-exercise setup screen): arrival = started; time-based target shown beside the timer; optionalQuickRatingSheetpre-fills feedback. - Single-screen feedback (
PatientFeedbackModal): sets pre-selected to planned, sharedPillRatingRow(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, newExerciseDetailSheet): name and thumbnail both open the detail sheet (works without video;VideoPreviewModaldeleted); 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;
httpClientgainsHttpError, a 15s timeout, and single-read empty-body handling;useWorkoutDataeffect 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-boundarypersistProgresswith pointer-coalescing queue + online-event retry,resumeFromSavedwith 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) replacesVideoPreviewModal;QuickFinishSheetwith double-submit guard and no-dismiss-while-saving; Resume pill + Start over. - Foundations:
border-brand-*/ring-brand-*utilities added toglobal.css(used in ~40 files but previously undefined — silently rendered nothing);describeResumePositionhelper 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).