Skip to main content

0006 — Live Performance Hub

Status: Implemented (MVP) · Date: 2026-07-15

Context

We want to broadcast a training live — a leaderboard, a competition dashboard, a live stream — that a coach configures in the portal and a gym display can show. The data source is athletes training in the tracking app. The backend (workout_live_session + workout_live_session_sets controllers) exists, and a device already carries a latent UserDeviceSessionReturnDto.workoutLiveSessionID back-reference — but nothing was wired in @enode/core, and the tracking app does no per-set network calls today (it mutates an in-memory session tree and bulk-uploads at finish).

Decision

  1. Cross-app producer/consumer over the existing realtime infra. Tracking is the producer (/workout_live_session_sets), the portal is the consumer (REST snapshot + the shared realtimeSubscribe SSE client). No second EventSource, no new store pattern — the consumer feed mirrors the roster-progress slice, and the session list uses store-kit.

  2. Producer via a pure diff, not scattered network calls. Rather than hook the ~8 set-mutation sites in the large, fragile ExerciseDetailView, a single useLiveSetBroadcaster(sessions) in TrainingSession.tsx diffs successive completed-set snapshots (broadcast-diff.ts, pure + tested) and fires the best-effort producer. This keeps the network out of the critical set handlers and is fully unit-testable. Pushes are best-effort: a failure is reported quietly and never disturbs the local session.

  3. "Device is in a live session" = workoutLiveSessionID. The coach assigns devices via participatingDeviceIDs; the tracking guard reads the resulting workoutLiveSessionID via useOwnDeviceSession(). The backend resolves the session from the device-id header, so the producer sends no id.

  4. Present mode without a new route. A chrome-less, authenticated gym display is net-new ground (everything authenticated lives inside AppShell; the only sidebar-less route is the unauthenticated /). Instead of an authenticated-outside-AppShell route, present mode is a client view under /dashboard/live that goes full-screen via the Fullscreen API + a screen wake-lock, with a shareable ?present=<id> query deep link (static-export safe). The wake-lock/fullscreen hooks are added web-only to @enode/ui — the tracking app keeps its Capacitor-aware useKeepAwake, so the web portal doesn't pull Capacitor into its bundle.

  5. Purpose-built feed view model. The consumer reduces the heavy set DTO to a LiveSet view model; ranking policy (derive-leaderboard.ts) is a presentation concern kept in the portal, so richer competition metrics change only that file.

Consequences

  • One line wires the tracking producer; the diff is testable and low-risk.
  • The exact realtime topic + event-kind names are not yet confirmed by the backend — they are isolated in feed-stream.ts (swappable), and the REST snapshot plus event-driven revalidation (tab return / reconnect / manual Refresh) bridge the gap meanwhile — deliberately push-only, no polling.
  • The MVP ships the Leaderboard + present mode. The public /competition_dashboard attempt-board display and the Live Stream are stubbed behind the view switch and deferred, as is an offline queue for producer pushes.

Amendment — 2026-07-17

  • The Live Stream shipped behind the view switch as designed (stream-view.tsx + derive-stream.ts); LiveViewMode now spans leaderboard | competition | stream (view-config.ts), and the mode switch is a labelled tab row in the page header.
  • Each session's default view is persisted client-side (localStorage, view-config.ts) pending a backend field on the live-session entity — the session DTO carries no config field today, so the choice does not travel across browsers (backend wishlist in the docs page).
  • The public /competition_dashboard displayToken path and the producer offline queue remain deferred.

Amendment — 2026-07-17 (second pass, user-feedback driven)

  • Competition became a per-session TYPE, not a switchable tab. The header tabs now toggle only Leaderboard ↔ Stream (SWITCHABLE_VIEW_MODES / isSwitchableMode in view-config.ts); Competition is chosen in the session editor's "Live view" section, and a competition session shows a non-interactive "Competition" chip plus a "Change live view" action-menu entry back into the editor. Rationale: a competition runs as a dedicated platform board for a whole event — flipping into it mid-watch was never a real workflow, and the tab row misrepresented it as one.
  • An in-view lift-leaderboard overlay replaced the cross-mode jump. The competition board's rank chips and the attempt inspector's rank bridge chip open LiftLeaderboardOverlay (lift-leaderboard-overlay.tsx — the lift's top 3 plus the focused athlete's rank/gaps) instead of navigating into the Leaderboard view; the onOpenLeaderboard props on CompetitionView / AttemptDetailSheet were removed. With Competition no longer a sibling tab, a mode jump would have contradicted the session-type model — the overlay gives the ranking depth without leaving the board.

Amendment — 2026-07-17 (v4)

  • One view per session, chosen once in the editor — no in-view switcher. The header tab row is gone: live-view.tsx renders the single configured mode and no longer offers a SegmentedPicker (or a "Competition" chip). The view is chosen only in the session editor's "Live view" section (now the first section of the drawer, a CellPicker — the same icon-over-label cells the Insights chart-type switcher uses, shared via components/cell-picker.tsx), and page.tsx remounts LiveView on save so a new choice applies at once. Rationale: mid-watch view flipping was never a real coach workflow, and a single configured board matches how a display is set up once and left running.
  • The view choice is now persisted server-side via a new opaque config?: string | null field on the live-session Create/Update/Return DTOs (packages/core/src/api/dtos/live-sessions.ts). The portal owns a versioned JSON codec (session-config.ts); view-config.ts hydrates its localStorage cache from the server value on load. This supersedes the earlier "client-side until a backend field exists" note (first amendment above) — the choice now travels across browsers, devices, and kiosks. Verified live on backdev (PUT sent config {"v":1,"viewMode":"stream"}, echoed back).
  • Present mode became a fully interactive fullscreen kiosk. use-fullscreen gained cross-browser webkit fallbacks and now surfaces (never swallows) request rejections; a shared overlayPortalHost() (containment.tsx) portals sheets/drawers into document.fullscreenElement ?? document.body, so the athlete drawer / attempt inspector / lift-leaderboard overlay all paint over the fullscreen present root. The !present interaction gates were dropped across the views, so present rows expand and every overlay opens on a gym display. Rationale: a gym display that could not open a drawer or go OS fullscreen was a demo, not a product; the portal-host fix is a one-line seam that keeps behaviour identical outside fullscreen.

See docs/live-performance-hub.md for the runtime map and open items.