Stats & session-player follow-ups
Backlog from the F9 stats + session-player audit work done up to 2026-05-24. Items here are open or deferred; the "Closed since" log at the bottom captures what shipped so the deltas read in context.
Each item carries enough detail to act on without re-reading the original conversation — file paths, suggested approach, and the clinical or technical risk that makes it worth doing.
Open clinical-data bugs
1. Pain modal closed without action → no pain row recorded
Where: apps/portal/components/session/session-stage.tsx (submitPain, painSheetOpen state).
Symptom: Patient hits the pain button (a paused_for_pain session_exercise_event lands) but then dismisses the modal — taps outside, hits browser back, or exits the session from a different control. No row is written to session_pain_events. The clinical signal "patient flagged pain" is lost; only the "patient paused" signal survives.
Why it matters: A pain tap with no follow-through is the most clinically interesting case — anxiety, indecision, or "it stopped hurting so I just played." All three are signal the specialist should see.
Suggested approach: Either
- Record a
painrow withseverity='unknown'/action='dismissed'when the modal closes without a chosen action (need new enum values), OR - Treat the
paused_for_painevent itself as the signal of record; surface the gap explicitly in the per-exercise table ("3 pain taps, 2 reports") so the discrepancy is visible.
The first is simpler but expands the action enum; the second avoids schema churn but requires UI work to surface the delta.
Status: Deferred to UI polish pass per user direction (2026-05-24).
Open session-player gotchas (latent)
(No open gotchas in this list right now — #6 closed; see below.)
Deferred features / taxonomy dependencies
These were discussed and explicitly parked. Each unlocks a chunk of the per-protocol stats placeholder cards or new stats surfaces.
11. target_regions taxonomy on exercises
Status: Deferred per user decision — coordinates with broader taxonomy work happening in another conversation. Don't start piecemeal.
Unlocks: Real body-map (replaces the PREVIEW card on per-protocol stats), cohort/population analytics by region, per-region filtering across all stats.
Shape when picked up: exercises.target_regions text[] (multi- valued — an exercise can hit lumbar + hip). Propagate via session/program aggregation. Add a column-classification entry in the same PR. Backfill via slug heuristics + manual catalog pass.
12. Pose-tracking pipeline (Class I MDR)
Status: Scoped per CLAUDE.md but not yet implemented.
Unlocks: Real ROM measurements (replaces the PREVIEW Mobility card on per-protocol stats). Joint-by-joint range-of-motion with delta-vs-baseline.
Constraint: Class I MDR — informational only, no treatment decisions claimed. Upgrade path to Class IIa is preserved by the swap-point interfaces but must not be designed against today.
13. Program phase metadata
Status: Depends on program-builder design (separate feature).
Unlocks: Real recovery-milestones timeline (replaces the PREVIEW Milestones card on per-protocol stats). Program-defined phases with gating, checkpoints, reassessment prompts.
Today: Programs are a flat list of sessions with no internal phase markers.
14. Validated questionnaires (Oswestry ODI, Roland-Morris)
Status: Not started. Not selected as a placeholder card. A full feature when scoped.
Unlocks: Clinical PROMs (patient-reported outcome measures) charted over time alongside VAS. The gold standard for evidence- based outcomes in lumbar rehab.
Effort estimate: Forms subsystem (definitions + scoring + administration cadence + UI). Multi-week feature.
15. Daily VAS check-ins
Status: Not started. Separate UX from per-session feedback.
Today: VAS only captured after a session run. Days where the patient didn't run a session have no pain data.
Unlocks: The denser daily-VAS chart pattern (screenshot from the external inspiration the user shared). Annotated with milestones ("Plank lateral introdus, ziua 8, durere temporar +1") for clinical context.
Needs: Portal entry surface for daily self-report, push- notification cadence, anti-spam logic (one entry per day).
16. Cohort / population analytics
Status: Not started. Different surface — clinic-admin tier, not per-patient stats.
Unlocks: Cross-patient scatter plots (the "118 patients · Pearson r=+0.93" inspiration screenshot). Adherence vs. recovery, cohort segmentation by program / region / age.
Constraint: Within-org only per CLAUDE.md cross-tenant rules. Within-org cohort analytics are fine.
Housekeeping
(No open housekeeping items.)
20. Mid-run resume restarts the current exercise from t=0
Where: apps/portal/components/session/session-stage.tsx.
Context: #7 (commit c9c54e9) closed the "patient reloads and restarts from exercise 1" bug — they now land on the right exercise. But the conductor's video position within that exercise isn't persisted across reload, so the exercise itself restarts from t=0.
Suggested fix (sessionStorage, ~30–60min):
- SessionStage: on every
timeupdate, throttle-save{runId, exerciseIdx, videoTime}to sessionStorage (or localStorage if you want it to survive a browser-tab close). - SessionStage: on mount, read the saved value; if
runId + exerciseIdxmatch the current state, setvideoEl.currentTime = savedTimeafterloadedmetadatafires. - Cleanup: on natural completion or end-early, clear the entry.
Edge cases to think through before shipping:
- Stale position trap: patient paused, walked away for an hour, came back. Resuming "exactly where you were" might be confusing — sometimes restarting the exercise is the better UX. Maybe gate the restore on "saved within last N minutes" or always restart if the video would resume at <2s before end.
- End-of-video boundary: patient was at 86s of an 87s video, resume lands at 86s,
video_endedfires almost immediately, conductor advances before patient reacts. The "always restart if <Ns left" gate above covers this. - Storage scoping: localStorage persists across runs — need to clean up old run_id entries to avoid bloat. Either cap the size or expire on age.
Open design questions
19. Per-program affordance on the patient overview pain card
The patient overview's PainCard still aggregates patient-wide (mixes pain from all of a patient's active programs). The per- protocol page is the per-program view, reached by clicking an adherence card.
Open question: Is "click adherence card to drill in" enough, or do you want a "switch to per-program view" tab/dropdown on the overview card itself? The current design assumes the drill-down flow is discoverable enough; this hasn't been user-tested.
Closed since 2026-05-24 (context log)
Brief record of what shipped, so the deltas above read in context.
Multi-tab takeover (commit pending at write-time):
- #6 closed. Patient opening the same run in a second tab no longer produces dueling conductors. The newer tab wins — it broadcasts a CLAIM (
restartix:run:<runId>channel viaBroadcastChannel); the older tab receives it, firesonDeposed, disposes the conductor, stops its telemetry heartbeat (without sending session_end — the new tab owns the run), and shows a "Sesiunea continuă în altă filă" screen with "Reia aici" / "Ieși din sesiune" actions. Reia aici =window.location.reload()so the page re-fetches /payload (mid-run resume picks the right exercise), re-mounts everything, and broadcasts a fresh CLAIM that flips the currently-active tab to the deposed screen. Scope is same-origin same-browser only — cross- device dueling (phone vs laptop) stays the pair-claim flow's concern. New hook:apps/portal/lib/session/use-run-takeover.ts; state provider gained astopTelemetryaction that's distinct fromflushTelemetryOnDone(no session_end emit).
Original session-player audit (commits 5145800, da3e527):
- Telemetry pagehide flush (10-min silence-sweep window)
- Telemetry backpressure with surgical retry scope (connection-error only, never on HTTP responses)
- Feedback handler backstop (auto-terminate stuck
in_progressruns when feedback POSTs) - Idempotent clinical-event ingest (
client_event_idUUID + UNIQUE index + ON CONFLICT DO NOTHING) - IndexedDB-backed retry queue (online-aware, run-scoped drain ticker)
- Server-synthesized
abandonedevents on non-natural termination - Position-preserving HLS re-attach on network recovery
- Offline-aware UI signal (badge color swap)
- Smart-TV dead-branch cleanup (TVs moved to
apps/tv/)
Mid-run resume (commit pending at write-time):
- #7 closed. Mid-run reload no longer restarts the patient at exercise 1. The payload response (
GET /v1/session-runs/{runId}/payload) now carriesterminated_session_exercise_ids[]— the subset of session_exercises that already have a terminal event (completed/skipped/abandoned) recorded for this run. The conductor's prime() reads it via a newstartAtIdxconfig field and starts at the first un-terminated index instead of always 0. SessionStage seeds startedRef/completedRef/skippedRef for those indices as defensive coverage (today the snapshot-effect doesn't visit pending exercises, so no double-emit risk; the seed protects against future conductor changes that might pre-prime earlier positions). Video position within the current exercise is still not persisted across reload — patient lands on the right exercise but restarts that one from t=0. Documented separately if anyone wants to chase it; sessionStorage keyed by run_id is the obvious approach.
Gotchas batch (commit dc7f33b):
#10 per-run rate limit on POST
/v1/session-runs/{runId}/painand/exercise-event. Newratelimit.URLParamKey("runId")extractor plus two policies wired throughsessions.PatientMountOpts. Defaults: 50 pain / 200 exercise per hour per run. Caps a buggy client loop without ever blocking realistic clinical use. Returns 429 withRetry-Afterheaders.#9 in_progress visibility in clinic activity log. Data layer already returned them; UI now gives the row an amber left-border and tint so stuck runs are visible at a glance without polluting the dominant view.
#5 telemetry token refresh via retry-on-401. Heartbeat config takes a
refreshTokencallback; on 401 it calls the callback (single-flighted), swaps the in-memory token, retries the batch once. Provider supplies arefreshTelemetryTokenActionthat re-fetches/payloadfor a fresh JWT.#8 long-pause / cron-race on manual resume. New
getSessionRunStatusActionlightweight preflight; the manual resume button awaits it and routes the patient out of the player when the run is terminal. Network failures fall through to the existing (broken) behavior — no regression on the happy path.#8 follow-on — auto-close explanation page. First pass routed auto-closed runs directly to /feedback, which was disorienting (the patient didn't end the session intentionally). New page at
/runs/[runId]/auto-closedexplains what happened, what's saved, and gives three next-step actions (submit feedback, start a new session, go home). Routing updated in three places: the manual resume preflight, the /runs/[runId] root, and the /play subroute — so reload + deep-link cases also land on the explanation page, not the bare feedback form. ended_explicit / ended_naturally (patient-initiated terminals) still route to /feedback.#7 audit only — confirmed the conductor restarts at exercise 1 on reload; documented the proper fix (per-position resume from event state) as scoped follow-up work. Did NOT implement.
Easy-wins batch (commit faf2c10):
- ListExerciseStats refresh-replay dedup:
started/completed/skipped/abandonedcounts nowCOUNT DISTINCT (run_id, session_exercise_id)so a refresh-replayed event doesn't double- count.paused_for_painstays rawCOUNT(*)— multiple pain pauses per exercise are real clinical occurrences. - Auto-closed runs with no exercise-started event now render
WallTimeSeconds = nil(UI shows "—") instead of falling back to(completed_at − started_at)which printed "47m" for runs where nothing ran. The pre-Start abandonment fallback is preserved forended_explicit/ended_naturally(small wall time is more honest than "—" when the patient briefly interacted). - Activity-log
StatusBadgenow has three completion tiers driven by the ratio of completed / total exercises: ≥80% = Completed (full credit), 50–79% = Partial (amber), <50% = Mostly skipped (red). Only applied toended_naturally; other terminal kinds keep their existing labels. - Dead
TrendLinecomponent deleted from clinic (zero consumers after the multi-program scatter swap). Portal still has its own copy underapps/portal/components/progress/, untouched.
This chat (commit 849034c + commit 3dd3a6c):
- Telemetry aggregator: per-media
ended_atreflects each media's own last event, not the run flush. - Activity row "notable observations" subline (network drops, manual pauses, pain pauses, abandoned).
- Session-detail drawer (full event + pain timeline per run, lazy- loaded via server action).
- Pain card severity × action 3×3 matrix.
- Per-program scoping end-to-end (
?program_id=filter on/overview,/exercises,/pain-summary; new per-protocol page at/patients/[id]/protocols/[protocolId]; adherence cards clickable; VAS + RPE on patient overview swapped to multi-program scatter usingFeedbackPoint.program_id). - VAS evolution card (severity zones, baseline-anchored MCID line, program-start marker, delta-vs-baseline badge).
- 3 PREVIEW placeholder cards on the per-protocol page (body map, ROM/Mobility, recovery milestones).
- Pain → end_session RLS race (drain IDB queue before flipping run terminal so the pain row lands while the per-patient INSERT policy still permits it).
SynthesizeAbandonedEventsRLS ordering (synthesize FIRST, then terminate — was failing with42501because policy requiresstatus = 'in_progress').AutoCloseRuncron's synthesis uses admin pool (no patient context available; admin bypasses RLS on the table owner).- Stats SQL filter values corrected:
'skip_exercise'and'end_session'(not'skip'/'stop') — the canonicalPainActionenum names. Severe + end_session events were silently dropping out of the Patient's Response breakdown.