Skip to content

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.

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);

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.

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

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}
/>
))}
</>
);
}

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 null

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.

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.

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 workoutprogress.currentTargets (the active interval’s bundle, or null), present on the running, paused, and resting phases so a target tile keeps rendering through a rest.
  • At results timeresult.plan is the executed ResolvedPlan, so each plan.intervals[i].targets (and a free workout’s plan.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.

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. The aborted phase then carries a non-null partialResult. (The default keep: 'discard' gives partialResult: null.)
  • An aborted or failed session from a console HOME press, Bluetooth off, lost devices, or an engine fault carries partialResult with 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 />;
}

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.