Skip to content

Building a workout

start() takes one value — a StartWorkoutInput. Everything a Rogue console can run is expressible in that single input, so a workout builder is just a UI that assembles the input and calls start(). This guide shows the exact input for each of the six workout types, the way a builder screen produces them.

The companion Executing workouts guide covers what the SDK does with the input (the mode discriminator, the three rest behaviors, rounds expansion, and target tiers); this guide is the authoring side — how a consumer turns builder state into each shape. The example app ships exactly this as a reusable, pure model (src/screens/builder/builderState.ts and the controlled WorkPicker / RestPicker / TargetsEditor / IntervalEditor / RoundsPicker / ModeToggle components).

Keep the builder’s UI state pure and project it to a StartWorkoutInput in one place. devices is either a single DeviceId (the N=1 convenience) or a modality-keyed RoleAssignment you build from the connected fleet with roleMap — it takes the useDevices() Device objects, not ids.

import { roleMap, useDevices, useWorkoutControls } from "@rogue/console-sdk";
import type { Device, StartWorkoutInput } from "@rogue/console-sdk";
function useStart() {
const machines = useDevices({ connected: true });
const { start } = useWorkoutControls();
return async function run(input: StartWorkoutInput) {
const ok = await start(input); // false on failure; never rejects
return ok;
};
}
// One console → the bare-id form. Two or more → roleMap(machines).
function devicesField(selected: readonly Device[]) {
return selected.length === 1 ? selected[0]!.id : roleMap(selected);
}

start() never rejects — it routes failure into the snapshot. But a builder usually wants to disable Start and show why before the user taps it. Two value-returning helpers give you that without a try/catch or copying the SDK’s rules:

  • tryRoleMap(devices) — the non-throwing companion to roleMap: { ok: false, conflict } when two consoles share a modality, so you can render “which rower?” instead of catching.
  • validateStartInput(input, modalityOf) — a dry-run of the exact resolution start() uses. It returns { ok: true, plan } | { ok: false, error } so you can validate mode/role coverage and the leg/round ceiling (MAX_EXPANDED_LEGS) against the SDK itself — no drift from copied magic numbers.
import { validateStartInput } from "@rogue/console-sdk";
const check = validateStartInput(input, (id) => deviceById(id)?.modality ?? null);
const canStart = check.ok; // else show check.error.message

A free workout with an optional whole-workout WorkoutTarget — omit target for an open “just row/ride/ski” — plus an optional splitEvery boundary.

// distance, with a split every 500 m
await start({
kind: "free",
devices: rowerId,
target: { kind: "distance", meters: 2000 },
splitEvery: { kind: "distance", meters: 500 },
});
// time (drift-free app clock)
await start({
kind: "free",
devices: rowerId,
target: { kind: "time", seconds: 1200 },
});
// calories
await start({
kind: "free",
devices: rowerId,
target: { kind: "calories", calories: 100 },
});
// open — omit the target entirely
await start({ kind: "free", devices: rowerId });

One IntervalSpec repeated rounds times. A single-entry program is the compact constantInterval form — pass the spec once with a rounds count, not N copies.

await start({
kind: "intervals",
devices: rowerId,
intervals: [
{
work: { kind: "time", seconds: 60 },
rest: { kind: "timed", seconds: 30 },
},
],
rounds: 8, // 1–254, or "unlimited" (AMRAP) — single-entry only
mode: "broadcast",
});

A multi-entry list, each entry independently editable, run for rounds rounds. The SDK materializes the rounds (tiling the legs) so every round runs — you author the list and the count, nothing else.

await start({
kind: "intervals",
devices: rowerId,
rounds: 2,
mode: "broadcast",
intervals: [
{
work: { kind: "distance", meters: 250 },
rest: { kind: "timed", seconds: 30 },
},
{
work: { kind: "distance", meters: 500 },
rest: { kind: "timed", seconds: 45 },
},
{
work: { kind: "distance", meters: 250 },
rest: { kind: "untilUserContinues" },
},
],
});

Each work interval can carry a PerformanceTargets bundle — a pace target (the only one the firmware enforces) plus app-scored watts / calories-per-hour / stroke-rate / HR-zone. A builder mirrors the reference one-pacer convention (one of pace / watts / cal, with stroke-rate and HR-zone as independent siblings):

{
work: { kind: "time", seconds: 60 },
rest: { kind: "timed", seconds: 30 },
targets: { pace: { per500mSeconds: 110 }, strokeRate: 28, hrZone: 3 },
}

Role-less intervals across the connected consoles, mode: "broadcast" — every machine runs the same program at once (two-athletes-racing). Build devices from the fleet with roleMap.

await start({
kind: "intervals",
mode: "broadcast",
devices: roleMap(machines), // { rower: rowerId, bike: bikeId }
intervals: [
{
work: { kind: "calories", calories: 20 },
rest: { kind: "timed", seconds: 30 },
},
],
rounds: 3,
});

In broadcast mode intervals must be role-less — a builder strips any per-row role before it emits the input.

Role-scoped intervals with mode: "sequential" — one athlete moves machine-to-machine, only the active console runs. Every interval carries a role, and every role must be filled by a connected console.

await start({
kind: "intervals",
mode: "sequential",
devices: roleMap(machines), // { bike, rower, ski }
intervals: [
{
role: "bike",
work: { kind: "distance", meters: 1000 },
rest: { kind: "untilUserContinues" },
},
{
role: "rower",
work: { kind: "distance", meters: 500 },
rest: { kind: "untilUserContinues" },
},
{
role: "ski",
work: { kind: "distance", meters: 500 },
rest: { kind: "untilUserContinues" },
},
],
});

Rest is a property of a work interval, not a separate entry, and every interval gets its own Rest — so “rests everywhere” is just authoring a rest kind on each row. A builder exposes the three kinds as a per-interval Timed / Hold / None selector:

await start({
kind: "intervals",
devices: rowerId,
rounds: 1,
mode: "broadcast",
intervals: [
// timed: a fixed countdown, then auto-advance
{
work: { kind: "distance", meters: 500 },
rest: { kind: "timed", seconds: 45 },
},
// hold: waits for the athlete (never auto-advances)
{
work: { kind: "distance", meters: 500 },
rest: { kind: "untilUserContinues" },
},
// none: immediate, back-to-back with no wait
{ work: { kind: "distance", meters: 500 }, rest: { kind: "none" } },
],
});

An omitted rest defaults to untilUserContinues (hold). The three behaviors and how each executor honors them are in Executing workouts → Rests.

Mikko’s Triangle is a named 3-machine relay: each round is three 60-second time legs in order bike → rower → ski with no rest between machines (none), and a 60-second timed rest between rounds attached to the ski leg. It is a sequential plan with rounds = 3 (Mini) or 10 (the ~39-minute full variant); the SDK trims the final round’s trailing rest so the workout ends on work. A builder ships it as a one-tap preset:

const WORK = { kind: "time", seconds: 60 } as const;
await start({
kind: "intervals",
mode: "sequential",
devices: roleMap(machines), // { bike, rower, ski }
rounds: variant === "mini" ? 3 : 10,
intervals: [
{ role: "bike", work: WORK, rest: { kind: "none" } }, // immediate → rower
{ role: "rower", work: WORK, rest: { kind: "none" } }, // immediate → ski
{ role: "ski", work: WORK, rest: { kind: "timed", seconds: 60 } }, // between rounds
],
});

start() never rejects — a bad plan lands in startError as a typed session/invalid-plan. A good builder catches the same conditions up front and shows a friendly message instead:

  • Mode ↔ role coherence — broadcast plans are role-less; sequential plans give every interval a role; every role is filled by a connected console.
  • One device per modalityroleMap throws session/invalid-plan if two consoles share a modality; surface that as a disabled state.
  • Leg ceilings — a single-entry program allows 1–254 rounds (or unlimited); a multi-entry program’s expanded leg count (entries × rounds) must stay ≤ 255, and unlimited is not allowed for it.

On success, navigate into your session screen and render useWorkoutSession() — the same screen serves every type, at any machine count.

The example ships the whole thing as one screen — src/screens/builder/WorkoutBuilderScreen.tsx — that is a thin composition shell: a useReducer(builderReducer), a device picker over useDevices({ connected: true }), the controlled pickers, a presets row (4 × 500 m, a pyramid, and Mikko’s Triangle), and a Start button. The one projection is called once for the live preview and again as the Start guard:

const [state, dispatch] = useReducer(builderReducer, undefined, initialBuilderState);
const machines = useDevices({ connected: true });
const { start, isStarting, startError } = useWorkoutControls();
// The single seam: builder state → a valid input, or a reason to show first.
const build = toStartWorkoutInput(state, machines);
async function onStart() {
if (!build.ok) return; // the button is disabled; render build.reason inline
const ok = await start(build.input);
if (ok) navigation.navigate("ActiveSession"); // reuse the shared session screen
}

Every control is (value, onChange) over the reducer, so the screen never holds workout state itself — it renders the reducer and dispatches actions. start()’s startError is surfaced inline alongside the pre-start() validation reason.