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
-
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 sharedrealtimeSubscribeSSE client). No second EventSource, no new store pattern — the consumer feed mirrors the roster-progress slice, and the session list usesstore-kit. -
Producer via a pure diff, not scattered network calls. Rather than hook the ~8 set-mutation sites in the large, fragile
ExerciseDetailView, a singleuseLiveSetBroadcaster(sessions)inTrainingSession.tsxdiffs 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. -
"Device is in a live session" =
workoutLiveSessionID. The coach assigns devices viaparticipatingDeviceIDs; the tracking guard reads the resultingworkoutLiveSessionIDviauseOwnDeviceSession(). The backend resolves the session from thedevice-idheader, so the producer sends no id. -
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-AppShellroute, present mode is a client view under/dashboard/livethat 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-awareuseKeepAwake, so the web portal doesn't pull Capacitor into its bundle. -
Purpose-built feed view model. The consumer reduces the heavy set DTO to a
LiveSetview 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_dashboardattempt-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);LiveViewModenow spansleaderboard | 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_dashboarddisplayToken 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/isSwitchableModeinview-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; theonOpenLeaderboardprops onCompetitionView/AttemptDetailSheetwere 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.tsxrenders the single configured mode and no longer offers aSegmentedPicker(or a "Competition" chip). The view is chosen only in the session editor's "Live view" section (now the first section of the drawer, aCellPicker— the same icon-over-label cells the Insights chart-type switcher uses, shared viacomponents/cell-picker.tsx), andpage.tsxremountsLiveViewon 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 | nullfield 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.tshydrates 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 (PUTsentconfig{"v":1,"viewMode":"stream"}, echoed back). - Present mode became a fully interactive fullscreen kiosk.
use-fullscreengained cross-browserwebkitfallbacks and now surfaces (never swallows) request rejections; a sharedoverlayPortalHost()(containment.tsx) portals sheets/drawers intodocument.fullscreenElement ?? document.body, so the athlete drawer / attempt inspector / lift-leaderboard overlay all paint over the fullscreen present root. The!presentinteraction 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.