Skip to content

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 pain row with severity='unknown' / action='dismissed' when the modal closes without a chosen action (need new enum values), OR
  • Treat the paused_for_pain event 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):

  1. SessionStage: on every timeupdate, throttle-save {runId, exerciseIdx, videoTime} to sessionStorage (or localStorage if you want it to survive a browser-tab close).
  2. SessionStage: on mount, read the saved value; if runId + exerciseIdx match the current state, set videoEl.currentTime = savedTime after loadedmetadata fires.
  3. 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_ended fires 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 via BroadcastChannel); the older tab receives it, fires onDeposed, 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 a stopTelemetry action that's distinct from flushTelemetryOnDone (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_progress runs when feedback POSTs)
  • Idempotent clinical-event ingest (client_event_id UUID + UNIQUE index + ON CONFLICT DO NOTHING)
  • IndexedDB-backed retry queue (online-aware, run-scoped drain ticker)
  • Server-synthesized abandoned events 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 carries terminated_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 new startAtIdx config 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}/pain and /exercise-event. New ratelimit.URLParamKey("runId") extractor plus two policies wired through sessions.PatientMountOpts. Defaults: 50 pain / 200 exercise per hour per run. Caps a buggy client loop without ever blocking realistic clinical use. Returns 429 with Retry-After headers.

  • #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 refreshToken callback; on 401 it calls the callback (single-flighted), swaps the in-memory token, retries the batch once. Provider supplies a refreshTelemetryTokenAction that re-fetches /payload for a fresh JWT.

  • #8 long-pause / cron-race on manual resume. New getSessionRunStatusAction lightweight 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-closed explains 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/abandoned counts now COUNT DISTINCT (run_id, session_exercise_id) so a refresh-replayed event doesn't double- count. paused_for_pain stays raw COUNT(*) — 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 for ended_explicit/ended_naturally (small wall time is more honest than "—" when the patient briefly interacted).
  • Activity-log StatusBadge now 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 to ended_naturally; other terminal kinds keep their existing labels.
  • Dead TrendLine component deleted from clinic (zero consumers after the multi-program scatter swap). Portal still has its own copy under apps/portal/components/progress/, untouched.

This chat (commit 849034c + commit 3dd3a6c):

  • Telemetry aggregator: per-media ended_at reflects 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 using FeedbackPoint.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).
  • SynthesizeAbandonedEvents RLS ordering (synthesize FIRST, then terminate — was failing with 42501 because policy requires status = 'in_progress').
  • AutoCloseRun cron'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 canonical PainAction enum names. Severe + end_session events were silently dropping out of the Patient's Response breakdown.