Live Hub
The Live Hub broadcasts a training live. A coach builds and manages live sessions in the portal; athlete devices in the tracking app push their set operations to the backend as they train; the portal renders three live views over the same feed — the Leaderboard, the weightlifting/powerlifting-style Competition dashboard (three attempts per lift), and the remote-training Live Stream. It is a cross-app feature: tracking is the producer, the portal is the consumer.
Data flow
tracking (athlete device) backend (Vapor) portal (coach / display)
set completed/edited/deleted ──POST/PUT/DELETE──► /workout_live_session_sets
│ broadcast (SSE) + persist
(only when the device's ▼
UserDeviceSession.workoutLiveSessionID is set) realtime + GET /reduced/:id ──► live views / present mode
- A device is "in a live session" when its
UserDeviceSession.workoutLiveSessionIDis set — the coach assigns devices viaparticipatingDeviceIDson the session. - The tracking producer is best-effort: a failed push is reported quietly and never disturbs the authoritative local session (which is still mirrored to IndexedDB and bulk-uploaded at finish).
Backend contract (wired in @enode/core)
/api/workout_live_sessions(JWT) — coach CRUD + logo (GET /:id/logo, ETag). DTOs + api inpackages/core/src/api/{dtos/,}live-sessions.ts. The Create/Update/Return DTOs carry an opaqueconfig?: string | nullthe backend persists verbatim (added 2026-07-17); the portal stores its versioned view config there (see "Server-backed view config")./api/workout_live_session_sets(JWT +device-id) — device push (POST), tracked update/delete (PUT|DELETE /tracked/:currentPersistentSetID), and feed reads (GET /reduced|complete/:liveSessionID).currentPersistentSetIDis the tracking app's ownWorkoutSet.id— the backend's correlation key./api/competition_dashboard(displayToken, no JWT) — the public gym-display attempt-board path (the standalone comp-dashboard app). The portal's own Competition view (below) derives the attempt board from the live set feed instead; joining the displayToken path stays a later phase.
Core domain — packages/core/src/live-session/
| Concern | Files |
|---|---|
| Session list (coach) | sessions-store.ts, sessions-hooks.ts (store-kit) |
| Producer (tracking push) | producer.ts, broadcast-diff.ts, use-broadcast.ts |
| Feed (consumer) | feed-types.ts, feed-schema.ts (zod), feed-reducers.ts, feed-store.ts, feed-stream.ts, feed-hooks.ts |
The tracking producer is wired with a single line in TrainingSession.tsx
(useLiveSetBroadcaster(sessions)) — a pure diff of completed sets over the
existing sessions state, so no per-set network call is scattered through the
set handlers. The consumer feed loads a REST snapshot (the complete read — reduced
currently strips set measurements, which the leaderboard ranks by), joins the
realtime stream,
and re-reads the snapshot only on tab return/reconnect or the manual Refresh
action — push-only, no interval polling (feed-hooks.ts).
Portal — apps/portal/src/app/dashboard/live/
page.tsx is a client state machine (management grid ⇄ editor drawer ⇄ live
view ⇄ present mode) — no dynamic route segments (static-export safe).
live-view.tsx renders the session's ONE configured view inside the standard
PageShell; present-mode.tsx presents that view full-screen for a gym
display — Fullscreen API (@enode/ui/use-fullscreen) plus a screen wake-lock
(@enode/ui/use-screen-wake-lock), without leaving the authenticated
AppShell — behind a shareable ?present=<id>&view=<mode> deep link. All
three views accept variant="present" (display scale, header control surfaces
trimmed) but stay fully interactive (see "Present mode" below). The nav
entry lives in the Workspace group of components/sidebar.tsx.
Present mode
Present mode (present-mode.tsx) is a chrome-less, authenticated gym-display
overlay that fills the viewport and offers OS Fullscreen + a screen wake-lock.
Two v4 fixes make it a real kiosk surface:
- Fullscreen works cross-browser.
@enode/ui/use-fullscreengainedwebkit-prefixed fallbacks (Safari < 16.4 / iOS expose only the prefixed API) for support detection, the request/exit calls, and the change event, and now surfaces (never swallows) the promise rejection browsers throw outside a user gesture — aconsole.warnin dev, the error engine's reporter in production. - Overlays paint over the fullscreen root. When an element is in the
Fullscreen API, only its descendants render — so a
document.body-portaled drawer/sheet would be invisible under the present root.overlayPortalHost()(packages/ui/src/containment.tsx) returnsdocument.fullscreenElement ?? document.body;packages/ui/src/sheet.tsxanddrawer-stack.tsxportal into it, so the athlete drawer, attempt inspector, and lift-leaderboard overlay all paint over the fullscreen display. Outside fullscreenfullscreenElementisnull, so behaviour is identical everywhere else.
Present is fully interactive: the !present interaction gates were dropped
across the views (leaderboard/exercise-pane, competition, stream/stream-detail).
Rows expand, the athlete drawer / attempt inspector / lift-leaderboard overlay
all open, and the masthead athlete switcher works — only the header control
surfaces are trimmed for the display scale.
View modes — one view per session, chosen in the editor
view-config.ts owns the mode system: LiveViewMode is
"leaderboard" | "competition" | "stream". A live session runs in ONE
view, and there is no in-view mode switcher — no SegmentedPicker tabs, no
"Competition" chip. live-view.tsx renders the single configured mode with the
PageShell header kept out of the way (Present is the only primary action;
Edit / Refresh / How it works live in the "…" menu). The mode is a per-session
setting; SWITCHABLE_VIEW_MODES / isSwitchableMode remain in view-config.ts
only to describe which modes could be toggled freely, but no UI toggles them.
The session editor (live-session-drawer.tsx) is the only place the view is
chosen:
- The "Live view" section is the FIRST section of the form (top of the drawer), above Basic Information and Devices.
- It offers all three modes as a
CellPicker— the same icon-over-label "cells" the Insights chart-type switcher uses (apps/portal/src/components/cell-picker.tsx, shared withdata/insights/chart-type-meta.tsxso the two stay identical): a segmented grid with the active cell in the red-orange tint, and a single hint line below that shows the selected mode's one-line description. - Saving writes the chosen mode to both the synchronous local cache
(
setStoredViewMode) and the serverconfigblob (see the next section).
After an in-view editor save, page.tsx bumps a viewRevision counter that
keys the mounted LiveView (the React key combines the session id and the
revision), remounting it so a new choice takes effect immediately — the live
view seeds its mode once on mount from storedViewMode.
Other entrances resolve the same per-session value:
- Opening a session from the hub, presenting from the hub, and a
?present=<id>link WITHOUT a (valid)viewparam all resolve the stored view viastoredViewMode(fallback:"leaderboard").?view=streamis a valid deep-link override. - The hub cards (
live-hub.tsx) show the configured view — icon + label — in their meta line, so a card says what kind of board opening it enters.
Server-backed view config — the config round-trip
The coach's view choice is persisted server-side and travels across
browsers, devices, and kiosks. The backend persists an opaque
config?: string | null on the live-session Create / Update / Return DTOs
(packages/core/src/api/dtos/live-sessions.ts, added on backdev 2026-07-17);
the backend never parses it — the portal owns the shape.
session-config.tsis a versioned JSON codec:parseSessionConfig/serializeSessionConfig(stampsSESSION_CONFIG_VERSION),viewModeFromConfig(reads theviewModefield), andconfigWithViewMode(merges a mode into an existing blob at the raw-JSON level so unknown future fields survive a plain view-mode edit). It is tolerant: a missing/blank/malformed blob yields an empty config rather than throwing, so a live session never fails to load over a badconfig.- The editor writes the mode into
configon save —configWithViewModeon the Create/Update DTO. Verified live on backdev: thePUTsentconfig{"v":1,"viewMode":"stream"}, echoed back on the Return DTO. - Hydration on load:
view-config.tsgainedstoredViewModeRaw,hydrateViewModeFromConfig, andresolveViewMode.page.tsxrunshydrateViewModeFromConfig(session.id, session.config)for each loaded session (first-sight only — a just-made local choice is never stomped), seeding the local cache from the server value so a fresh browser opens in the coach's saved mode.resolveViewModereads local-then-server as a pure read for render-time labels. - localStorage remains the synchronous cache/fallback (
storedViewMode, read on render; the live view + present mode seed from it). The durable copy is the serverconfig; the drawer save writes both, a hydration seeds local from server. See the localStorage-key table below.
Leaderboard — leaderboard-view.tsx, exercise-pane.tsx, derive-leaderboard.ts, leaderboard-export.tsx
The leaderboard is exercise-scoped: ExercisePane is the per-exercise slice
(exercise identity header over a pinned attempt analysis, then the scrolling
ranked field); the view owns the feed, the pane orchestration, the shared
controls, and the loading/empty states. It is mobile-responsive: below sm
the shared control cluster collapses into a per-pane "Modify" ActionMenu
(BoardModifyMenu in exercise-pane.tsx) holding Ranked by… / Keep leader on
top / Export PDF, and the pinned attempt analysis collapses to a compact attempt
card that scrolls with the board instead of staying pinned.
- Ranking (
derive-leaderboard.ts, pure + tested): athletes rank by their session value of the primary rank metric — per-set (or best-rep) values rolled up by the selected aggregation (max/min/avg/latest) — with the most recently arrived athlete highlighted (Latest tag). Ties on the value go to the newer athlete: two athletes at the same top load get distinct ranks, the more recently active one taking the better#(set count is only a final fallback) — so the ranking "applies to the latest". - Rank-movement arrows (per-athlete, persistent): every
LeaderboardRowcarriespreviousRank/rankDelta— the athlete's CURRENT rank vs their rank on the board recomputed WITHOUT that athlete's own newest-arrived set(s) ("what did my latest set do for me"). Because the comparison is per-athlete, the arrows persist between arrivals instead of flashing only for the athlete who just finished. Rendered as a small up-arrow (green) / down-arrow (red) next to the rank in both the windowed table and the present rows. - Pin leader: a global coach toggle wearing the trophy glyph (the same
glyph as the LEADER tag; localStorage
enode.live.pinLeader) keeps rank 1's row on top of the field even under user-applied column sorting — via the genericpinnedRowKeysprop on the sharedDataTable(components/data-table-view.tsx). - Per-row pins: a visible per-row pin affordance (trophy pin icon) lets the
coach keep ANY athlete on top, not just the leader — additively to the
auto-pinned leader. The pinned user set (
pinnedUserIDs) feeds the sameDataTablepinnedRowKeys. It is in-memory session-scoped state (userIDs don't outlive the session), so it is NOT a localStorage key; a new set dropping in clears manual pins via a render-time sync. - PDF export (
leaderboard-export.tsx,useLeaderboardExport+LeaderboardExportButton): a branded, multi-page canvas → PDF of the current exercise pane. Page 1 is the ranked summary table (rank · athlete · metric columns) with no red-orange "Latest" badge — the export is a document, not a live board. The pages after it carry the expanded per-athlete set breakdown: every set behind each athlete's board numbers, in board rank order, with all metric values and a neutral "Warmup" marker on non-work sets. - Open pane model (N panes): a "+" action in the control cluster adds
ExercisePanes — up to 4, capped by the number of distinct exercises in the feed — tiled per a literal grid map (stacked belowlg, side by side above). Pane 0 auto-follows the latest drop-in's exercise; every added pane is purely manual (seeded with the most recently active exercise not on screen) and carries its own quiet × remove control. The pure helpersexerciseKeysByRecency(keys by newest arrival first, the seed order) andresolvePaneKeys(keeps panes on distinct exercises; pane 0 holds its key rather than auto-following onto one another pane shows; panes with no distinct exercise left are dropped) own the orchestration. The pane COUNT persists per session (localStorageenode.live.leaderboardSplit:<sessionID>— see the key table). - Shared rank-metric selection: the stored selection list is ONE app-wide
list shared by every pane and the rank-metrics sheet —
use-rank-selections.tssyncs hook instances through a module-level version counter +useSyncExternalStore, so a change from any pane re-renders all. In multi-pane mode the offered option catalogue spans the whole feed (the union of all panes); single-pane it stays scoped to pane 0's exercise. - Clicking a row selects that athlete for the pinned attempt analysis; the expandable row lists every set behind the athlete's board numbers and carries an "Athlete profile" action into the athlete drawer (below) — the attempt card's identity block is the other drawer entrance (windowed, the block is a quiet click-through button). Rows themselves carry no extra icon.
Competition dashboard — competition-view.tsx, derive-competition.ts, lift-leaderboard-overlay.tsx
The platform view for weightlifting/powerlifting sessions — chosen in the
session editor's "Live view" section (see "View modes" above), modelled on the
standalone comp-dashboard's attempt board: the athlete who lifted last owns the
board (auto-follows the next arrival), EVERY exercise in the feed lists as a
lift with three attempt slots (work sets only), and a value-less work set is a
failed attempt (the closest thing to a no-lift the wire can express
— there is no judgement flag, and the live create DTO carries no rep valid
field, so invalid reps are withheld from the broadcast entirely rather than sent
unmarked; see invalid-reps.md. Backend wishlist: add
valid to WorkoutLiveSessionRepCreateDto so they can be sent and muted like
everywhere else). The board is fully interactive in present mode: the
masthead athlete switcher, the per-card rank chips (→ lift overlay), and
card → attempt inspector all work on a gym display.
- Board masthead (
BoardMasthead): the identity band over the attempt cards IS the athlete switcher — the athlete block opens the anchored switcher popover (MastheadAthleteSwitcher), the one place an athlete is picked. The masthead names the shown athlete + lift and carries THE single live signal — "On the platform" (red-orange) when the shown athlete owns the feed's newest arrival, a neutral "Viewing" line otherwise. Windowed, the identity also links into the athlete drawer. On the right it carries a leaderboard trophy — the samesize-9circular trophy the stream detail panel uses, so opening the lift ranking reads identically across the two views (it opens the sameLiftLeaderboardOverlaythe per-card rank chips do). - Sidebar — the lift rail: ONLY lift navigation now (the athlete switcher
moved into the masthead): every lift as a visually quiet group with mini
attempt tiles, the selected tile wearing a platinum-green left accent + tint
that the masthead echoes as a thin hairline (a subtle
w-0.5reduced-opacity rule between the athlete and the lift, not a heavy divider) — one connected "this is what you're looking at" accent language. - Attempt labeling: an eyebrow over the slot grid says exactly what's
shown —
Last {n} attempts, extended withof {total} totalonce attempts scrolled off the windowed slots — and each filled card saysAttempt {n} of {total}(pure helpertotalAttemptCountinderive-competition.ts: the slots keep chronological attempt numbers, so the highest filled number IS the athlete's true attempt count on the lift). - Failed attempts get the error-surface treatment (feedback-error border + surface tint, a FAILED chip) and state "No lift" in place of the missing score, instead of near-empty space.
- Lift leaderboard overlay (
LiftLeaderboardOverlay,lift-leaderboard-overlay.tsx): data depth WITHOUT navigating away — a centred, windowed card over whatever is open showing the current lift's top 3 by the primary rank metric, plus the focused athlete's own rank and gap context when they sit outside the podium. Opened from the per-card rank chips and from the attempt inspector's rank bridge chip (#{rank} of {total} in {lift}); Escape closes only the topmost sheet (module-level LIFO escape stack), so dismissing the overlay lands back on an open attempt sheet. This overlay replaced the earlier cross-mode jump into the Leaderboard view (theonOpenLeaderboardprops are gone). - Chip/trophy discipline (pure helpers
filledSlotCount/scoredAttemptCountinderive-competition.ts): BEST chips and trophies only appear once at least two attempts scored (a lone reading is trivially "best"); the CURRENT chip only appears on the feed's latest arrival with at least two filled slots (the masthead already says live otherwise). On a tied best the newer attempt wins the badge — equal top loads mark only the later set, both on the board (isBest) and in the leaderboard's per-athlete breakdown table trophy (exercise-pane.tsx). - Open slots render as ghost cards (dashed border, low-emphasis copy) that keep their grid position and continue the attempt numbering.
- Bar-path previews (
trajectory-preview.tsx): each attempt card embeds the realTechniqueTrajectoryChartframeless and non-interactive withhideScaleLabels(numeric tick labels off, light gridlines stay — the board is a glance surface). Clicking a card opens the attempt inspector (attempt-detail-sheet.tsx) with the full chart (sagittal + frontal, force vectors, per-rep picker), decoded on demand from the live reps'dataPackages(live-session/technique.ts+ the portal'suse-decoded-live-repscache).
Live Stream — stream-view.tsx, stream-filters.tsx, stream-detail.tsx, derive-stream.ts
The remote-training view: the whole session as ONE chronological set stream, split into a fixed LIVE surface and a browsing area.
- A KPI summary strip on top: sets (with work-set count), athletes,
exercises, last activity (
deriveStreamSummary). It is responsive — onsm+the four fullSummaryCards; on phones a single tight four-upCompactStatrow (number/time over a terse label,FitValue-scaled so a wide clock time never overflows) so the header stops crowding out the feed. The present variant always keeps the full cards. - Filter dropdowns (
StreamFilters,stream-filters.tsx): two multi-select dropdown popovers in the portal's control-pill chrome — Athletes with avatar rows, Exercises with the full exercise identity (animation + variation, joined to the catalogue via the newStreamExercise.definitionID) — plus a quiet clear-all once anything is filtered. Empty selection = everything. - Live vs Browse: a pinned red-orange "Live" card always shows the
session's newest arrival (
newestStreamSet, unfiltered — it is the live signal, not a browse result) above the scrollable Browse feed, newest arrival first (orderByArrivalDesc); warm-ups stay muted. The Browse feed excludes the live set (browseFeedSetsdrops the pinned newest arrival), so the list below is earlier sets only — the live set is never shown twice, and the "Browse" count reflects those earlier sets. When a filter hides the newest set it isn't in the filtered feed anyway, so nothing extra is dropped. The pinned Live card is the view's only red-orange treatment. The detail panel holds the coach's pick: it adopts the newest set once on first render, then only a real feed-row click changes it — arrivals move the Live card, never the panel. A deliberate pick wears the row's platinum-green accent; re-clicking an already-selected browse row deselects back to the live set (follow live again). (This replaced the earlier follow-live toggle and itsenode.live.streamFollow:*persistence.) The present variant defaults the detail to the live set but a deliberate pick still holds — present stays interactive. - The detail panel (
StreamDetailPanel,stream-detail.tsx) shows the selected set in full, read rep by rep: an identity header with no "Latest" chip, then a rep-pill row (RepPills, shared with the attempt inspector; accent = the primary metric's colour — red-orange stays reserved for "live"). Unpicked, the selection follows the newest chartable rep as the set streams (resolveChartedRepinuse-decoded-live-reps.ts); picking a pill pins that rep, and re-clicking the pinned pill un-pins back to follow — the set-level "deselect follows live" pattern one level down. Below the pills, ONE merged values bar (TechniqueValuesHeader+leadingStats): the set's normatives lead as set context, coloured by their insights category colour (categoryColorForMeasurement), then a "Rep n/total" divider cell fronts the per-rep section — any normative the charted rep carries itself resolves per rep (repMetricValueOf), and the crosshair metric cells chart the same rep. Then every available technique chart of the charted rep at once as a drag-to-reorder board (bar path / velocity & accel / power & force — the attempt inspector's chart vocabulary, rebuilt locally on therep-chart-servicepipeline from the decoded live reps; reps decode in a deterministic order — created, then id — so numbering holds across whole-set upserts). The bar path wears the attempt inspector's on-path peak dots (peakMarkersOf, shared): velocity / power / force peaks placed on actual trajectory samples. (The earlier per-rep endpoint dots — foreign reps' endpoints drawn on the charted rep's axis frame, which made them jump on live updates — were removed with the rep-based rework.) - Lift-leaderboard bridge (
LiftLeaderboardOverlay): the selected set's exercise ranked across the whole field, opened as the same centred overlay the competition board uses — depth without navigating away from the stream.
Athlete drawer — athlete-drawer.tsx, athlete-drawer-sections.tsx, use-athlete-pair.ts
A per-athlete-per-exercise info drawer, opened from the leaderboard's
expanded-row "Athlete profile" action and attempt-card identity block, the
competition masthead and attempt inspector, and the stream detail panel — both
windowed and in present mode (it paints over the fullscreen root via the shared
overlayPortalHost(), see "Present mode" above). Callers pass only
wire-carried ids (LiveAthleteDrawerTarget); use-athlete-pair.ts resolves
the athlete's own exercise INSTANCE from the definition id via a cached
getRelevantExercises read. Tabs:
- Overview — the instance's current (global) 1RM plus the athlete's recent sessions of this exercise.
- Analysis — the four pair analyses (the Performance page's tiles), in one column.
- Charts (only with the
writeCustomChartsprivilege) — the coach's saved Insights charts filtered client-side to the athlete × exercise pair (chartMatchesPair: the chart config must list BOTH ids), plus the embeddedChartBuilderDrawerpre-seeded with the pair's user and exercise-definition ids. Saved charts land in the normal Insights library.
Shared rank metrics
All three views read the coach's ordered rank-metric selection
(use-rank-selections.ts): the primary metric ranks the leaderboard and scores
the competition attempts; the other picks become the leaderboard's extra
columns, the attempt cards' metric rows, and the stream rows' leading value.
localStorage keys
All Live Hub client-side preferences, read lazily and SSR-guarded. The
session's view mode is now durably stored server-side in the session's
config blob (session-config.ts) — its localStorage key below is the
synchronous cache/fallback, seeded from the server value by
hydrateViewModeFromConfig; the others are local-only preferences.
| Key | Scope | Meaning |
|---|---|---|
enode.live.viewMode:<sessionID> | per session | Synchronous cache of the session's configured view (view-config.ts); the durable copy is the server config blob. Set by the editor's "Live view" choice (which writes both) and seeded from the server on load. |
enode.live.rankMetricKeys | app-wide | Ordered rank-metric selections, JSON [{key, agg}] (legacy enode.live.rankMetricKey read as fallback). |
enode.live.pinLeader | app-wide | Keep-leader-on-top board toggle ("1" / absent). |
enode.live.leaderboardSplit:<sessionID> | per session | Leaderboard pane COUNT ("2"…"4"; the legacy split flag "1" reads as 2 panes; a single pane clears the key). |
Testing
Pure logic is unit-tested (Vitest, node):
- Core:
broadcast-diff,producer,feed-reducers,feed-store,device-eligibility,mappers/live-sessions. - Portal (109 tests across 6 files in the live suite):
derive-leaderboard.test.ts(grouping, set/rep metric resolution, session aggregation, arrival ordering, per-athlete rank movement, recency ordering + pane resolution (exerciseKeysByRecency/resolvePaneKeys), attempt analysis),derive-competition.test.ts(board derivation + slot/attempt-count helpers),derive-stream.test.ts(summary, filters, ordering, newest-set selection),use-athlete-pair.test.ts(instance matching, 1RM read, pair-chart matching),view-config.test.ts(mode guards, the switchable-mode split, and the config hydration/resolve helpers), andsession-config.test.ts(theconfigcodec — parse/serialize round-trip,viewModeFromConfig,configWithViewModefield-preserving merge, malformed-input tolerance).
The Debug page has a Live leaderboard fixture (live-session-fixture.tsx,
synthetic feed, no device) to drive the consumer→derive→view path locally: it
pushes deterministic sets for four athletes across TWO exercises in blocks of
five (every fourth set a warm-up) and offers one Present button per view
mode.
Open items (confirm with backend)
- Realtime topic + event-kind names for the live-set stream are not pinned in
the spec — defaults live in
feed-stream.ts(workout_live_session:sets:<id>+live_session_set.{created,updated,deleted}), swappable in one place; the REST snapshot + the manual Refresh cover it until confirmed. - Dashboard-hub push bridge (
dashboard-bridge.ts): the portal live view joins/competition_dashboardlike the standalone comp-dashboard app (displayToken in the URL, native EventSource) and re-reads the snapshot on everystateevent. Verified live: the hub broadcasts onPUT /:id/state— but the set-POST emit is missing on backdev (the spec promises it), so new sets currently still need refresh/refocus. Backend ticket: broadcast onPOST /workout_live_session_sets(or authorize a generic live-set topic). GET /workout_live_sessionscurrently 500s on backdev — the frontend shows the correct error state; the endpoint needs to come up.- Logo mime: the editor uploads JPEG (the shared cropper's output); the backend spec lists PNG/SVG — confirm JPEG is accepted or add a PNG output.
- An offline queue for producer pushes is a later phase.
The per-session view choice is no longer a wishlist: the backend added the
opaque config field on the live-session DTOs (2026-07-17), so the editor's
"Live view" choice now travels across browsers/displays (see "Server-backed
view config" above).