Reading results
A completed workout hands you a value, not a record. end() resolves — and
session.result carries — a complete, reconciled WorkoutResult including the
full metric sample series. The SDK persists nothing: rendering, storing, or
uploading the result is your app’s business. There is no useSaveWorkout, no
history hook, and no durable storage anywhere in the surface. When you have the
value, the SDK is done.
Where the result comes from
Section titled “Where the result comes from”result exists on exactly one phase — completed — and the union shape is the
guard (see executing workouts). Narrow to it, or
take the value end() resolves:
import { useWorkoutSession, useWorkoutControls } from "@rogue/console-sdk";
// From the snapshot — result exists ONLY on the completed variant.const session = useWorkoutSession();if (session.phase === "completed") { handle(session.result);}
// Or imperatively from end() — the bare verb resolves the value or null on failure.const { end } = useWorkoutControls();const result = await end();if (result) handle(result);Charting straight off the value
Section titled “Charting straight off the value”The 0.5 s capture series is already in memory on result.metrics — one series
per device, complete at completed. There is nothing to fetch, reconstruct, or
decompress; a chart draws from series.samples directly.
import { useWorkoutControls } from "@rogue/console-sdk";import type { WorkoutResult } from "@rogue/console-sdk";
function SummaryScreen({ result }: { result: WorkoutResult }) { const { dismiss } = useWorkoutControls(); return ( <> <ScoreCard totals={result.totals} perDevice={result.devices} /> {result.metrics.perDevice.map((series) => ( <PaceChart key={series.deviceId} samples={series.samples} /> // no fetch, no decompress ))} <Button title="Done" onPress={() => { onWorkoutComplete(result); // your app's function: persist, upload, or drop dismiss(); // release the session-scoped streams }} /> </> );}onWorkoutComplete is entirely yours. The Rogue app’s own sync pipeline is
exactly such a consumer — it reads this value and builds its upload payloads from
it. The SDK does not know or care what you do next.
WorkoutResult anatomy
Section titled “WorkoutResult anatomy”The result is a flat, self-contained value:
| Field | What it is |
|---|---|
id |
A stable WorkoutId, assigned at session start — a key for your storage, not because the SDK stores anything |
performedAt |
{ startedAt, endedAt } epoch ms |
timing |
The timing spine — start/end plus pause spans, with elapsedSeconds derived |
plan |
The normalized plan that was executed (ResolvedPlan) |
totals |
Merged, firmware-reconciled PerformanceTotals across all devices |
devices |
Per-device attribution (DeviceResult[]) — N=1 ⇒ one entry |
heartRate |
The HR track (HeartRateSample[]) recorded during the session |
metrics |
The full 0.5 s capture (WorkoutMetrics), per device, in memory |
flags |
{ aborted, partial } terminal-state flags |
reconciliation |
Provenance — where the totals came from |
Per-device attribution
Section titled “Per-device attribution”result.devices is one DeviceResult per session device, each carrying its own
firmware-reconciled totals and committed splits. At N=1 there is exactly one
entry; at N=2 there are two — the same array, grown. Render a per-machine
scorecard by mapping it:
import type { WorkoutResult } from "@rogue/console-sdk";
function PerMachineScores({ result }: { result: WorkoutResult }) { return ( <> {result.devices.map((d) => ( <MachineScore key={d.deviceId} modality={d.modality} consoleId={d.consoleId} distance={d.totals.distanceMeters} calories={d.totals.calories} splits={d.splits} /> ))} </> );}The metric series
Section titled “The metric series”result.metrics is { sampleRateSeconds, perDevice }, where each
WorkoutMetricSeries is an ordered array of WorkoutMetricSample. Every metric
on a sample is nullable — a field is null when the device did not report it
(or across a null-filled frame gap), so a chart plots gaps honestly:
// WorkoutMetricSample:// t seconds from session start// pace average pace, seconds per 500m, or null// instantPace instantaneous pace, seconds per 500m, or null// power watts, or null// strokeRate strokes/revolutions per minute, or null// distance cumulative meters, or null// calories cumulative calories, or null// heartRate bpm, or nullReconciliation provenance
Section titled “Reconciliation provenance”result.reconciliation tells you how much to trust the totals:
source: 'console-records'— the totals came from the console’s own workout records (the authoritative case).source: 'app-estimated'— records were unavailable, so the totals are estimated from the live stream.splitsMatched— whether the reconciled splits matched the console’s records.
Surface it when it matters (a coach view, an export) rather than hiding an estimate behind an authoritative-looking number.
The heart-rate track
Section titled “The heart-rate track”result.heartRate is the HeartRateSample[] recorded during the session, fed by
useHeartRateBroadcast (see
live metrics & controls). If you never
bridged a watch, it is an empty array — not an error.
Targets are yours to score
Section titled “Targets are yours to score”The PerformanceTargets each
interval carried are inert values, not something the SDK scores. Only pace
was ever enforced by the firmware; watts, caloriesPerHour, strokeRate, and
hrZone were app-scored — surfaced but never written to the machine and
never graded by the SDK. That contract holds at results time too: the SDK ships
no scoring or color helper.
They are readable in two places, both pass-through:
- During the workout —
progress.currentTargets(the active interval’s bundle, ornull), present on therunning,paused, andrestingphases so a target tile keeps rendering through a rest. - At results time —
result.planis the executedResolvedPlan, so eachplan.intervals[i].targets(and a free workout’splan.targets) is right there for a post-workout “hit / missed target” breakdown.
Compare the target against result.metrics (or a device’s committed splits) with
your own scoring — the reference apps score pace lower-is-better, watts and
stroke rate higher-is-better, and HR zone in-band, but that logic lives in your
app, exactly like the live-workout scoring.
Partial results
Section titled “Partial results”Not every session finishes cleanly, and a partial result is still a value, not an exception. Two paths produce one:
abandon({ keep: 'partial' })— walk away but keep whatever reconciled. Theabortedphase then carries a non-nullpartialResult. (The defaultkeep: 'discard'givespartialResult: null.)- An
abortedorfailedsession from a console HOME press, Bluetooth off, lost devices, or an engine fault carriespartialResultwith whatever the engine could reconcile.
A partial WorkoutResult is shaped identically to a complete one, with
flags.partial: true (and flags.aborted: true for the abort paths). Render it
the same way — the chart, the totals, and the per-device attribution all work
over whatever was captured:
import { useWorkoutSession } from "@rogue/console-sdk";
const session = useWorkoutSession();if (session.phase === "aborted" || session.phase === "failed") { const partial = session.partialResult; // WorkoutResult | null if (partial) return <SummaryScreen result={partial} />; return <EndedWithNoData />;}The handoff: the value is yours
Section titled “The handoff: the value is yours”Once you hold the WorkoutResult, the SDK’s job is over. Persist it to your
database, upload it to your backend, drop it, or keep it in memory for a summary
screen — the id is there so your own storage has a stable key. Call dismiss()
when you are done to return the session to idle and release its streams (or let
the next start() dismiss it implicitly). There is nothing to save through the
SDK, because the SDK saves nothing.
Reference
Section titled “Reference”WorkoutResult·PerformanceTotals·DeviceResult·DeviceTotals·Split·TimingSnapshotWorkoutMetrics·WorkoutMetricSeries·WorkoutMetricSample·HeartRateSampleResolvedPlan·PerformanceTargets·SessionProgressuseWorkoutSession·useWorkoutControls·WorkoutControls- Executing workouts · Multi-console