Skip to content

fix(web): three different trainer-pairing UIs, and the page named for pairing cannot pair a trainer #1000

Description

@janlauber

Pairing a trainer looks and behaves differently depending on which screen a rider happens to be standing on, and the one screen named for pairing cannot pair the device the ride depends on.

Mockups: https://claude.ai/code/artifact/9f434188-6178-46d0-8461-96468e579ac2

Three UIs for one job

surface component shape can pair a trainer
/pair — "Sensors" DeviceSlot (116 lines) a slot grid no
room Lounge TrainerButton (83 lines) bare buttons yes
/ride, /ramp, room Training SensorOverview (207 lines) device cards yes

Three vocabularies, three layouts, three places an error can appear, for one question a rider asks once: is my trainer connected?

The page named for pairing cannot pair

web/src/routes/pair/+page.svelte:57 writes the trainer slot's help text as:

"Pairing lives in the room — open one and pair from the Lounge or the Training place."

and :126 disables it outright:

supported={supported && !!ride}     <!-- ride = roomConnection.current?.ride -->

So the trainer slot is dead unless the rider already has a room open — on the screen they opened specifically to set up their equipment. That is the confusion, and it is a one-line symptom of a two-component cause.

The machinery already exists. /ride pairs a trainer with no room involved: solo.pair(new FtmsTrainer()) (web/src/routes/ride/+page.svelte:497). /pair was simply never wired to it.

The logic was never the problem

web/src/lib/room/sensor-status.ts already unifies the state machine — trainerState(), sensorState(), pairedElsewhere(), pairedElsewhereAll() — and all three surfaces call it. They then draw the answer three different ways. Only the rendering forked.

One card, everywhere

Keep SensorOverview. Delete DeviceSlot. Turn TrainerButton into SensorOverview's compact variant.

SensorOverview wins because three of the four surfaces already use it and it is the only one with a place for the live reading, the fault hint, the inline error and the simulate affordance — all things DeviceSlot has no slot for and /pair therefore cannot show.

It renders the states the shared function already returns — idle, connecting, connected, failed, plus fault: 'silent' (#520) and pairedElsewhere (#610):

┌ 🔌 TRAINER                                not paired ┐
│    Required — the workout's targets need it          │
│    FTMS · 0x1826                                     │
│    [ Pair trainer ]                                  │
└──────────────────────────────────────────────────────┘

┌ 🔌 TRAINER                                    paired ┐   ← --color-watt border
│    KICKR CORE 8A2F   214 W · 88 rpm                  │
│    [ Forget ]                                        │
└──────────────────────────────────────────────────────┘

┌ ⚠ TRAINER                           paired · silent ┐
│    KICKR CORE 8A2F                                   │
│    No watts yet — turn the cranks                    │
└──────────────────────────────────────────────────────┘

┌ 📱 TRAINER                             on your phone ┐   ← dimmed, no action
│    Paired on another of your screens                 │
└──────────────────────────────────────────────────────┘

The error travels with the card. Today ride.error is a muted paragraph under the slot on /pair, room.rideError is a danger-coloured line beside the button in the Lounge, and error is a prop in SensorOverview — the same failure reports itself three ways. Ride-critical, so it stays put rather than toasting past a rider three metres away (errors.md).

Compact is a density prop, not a fourth shape. The Lounge drops the protocol line and the explanatory copy and keeps name, state and action in the same places, so a rider recognises the card they saw on the pre-ride screen.

One simulation gate

Three gates exist, each documented as deliberately unlike the others:

where rule
/ride dev || url.searchParams.has('sim') — a URL any rider can type
room (TrainerButton, RoomSensorOverview) dev || account.providers.includes('dev')
/pair dev — heart-rate only, no trainer simulation at all

Simulated watts reach medals, XP and streaks, so this is #123's fairness rule, not a dev convenience — and a rule with three implementations is one rule and two bugs. Collapse to one canSimulate() helper using the room's rule: a dev build, or a server that offers the dev sign-in door (WATTROOM_DEV_LOGIN). That is the strict one, and #418 chose it precisely because it survives a production build.

The CI trap — read this before deleting ?sim=1

WATTROOM.md locks one Playwright flow on every PR: "simulator trainer → ride 2-min workout → .fit produced". It rides a production build, where dev is false — which is the entire reason ?sim=1 exists (ride/+page.svelte:75).

The strict gate still admits CI, because CI's server offers the dev door. But the solo e2e has to sign in through that door rather than typing ?sim=1. Migrate the e2e in the same PR; do not delete the parameter and discover this from a red pipeline on the one flow WATTROOM.md requires.

?replay=<fixture> (#54) is gated behind the same flag and must keep working — it is how a deterministic capture is ridden.

Acceptance criteria

  • /pair pairs a trainer with no room open, using the same path /ride already uses.
  • DeviceSlot is deleted; TrainerButton is gone as a separate component; SensorOverview renders every surface, compact in the Lounge.
  • sensor-status.ts is unchanged — if the rewrite needs to change the state machine, say so in a comment explaining why, because it is the one part that was already right.
  • Every state renders in every surface: idle, connecting, connected, failed, silent, paired-elsewhere, and no-Web-Bluetooth. Each carries a reason rather than a dead control (ux.md: never render a button that will fail).
  • The pairing error appears in the same place, in the same colour, on every surface.
  • One canSimulate() helper; ?sim=1 removed; ?replay= still works behind the unified gate.
  • The Playwright flow WATTROOM.md requires is migrated in this PR and green.
  • The two dev pages that use DeviceSlot (dev/pairing, dev/components) are updated, not left broken.
  • Phones still see no trainer question at all — the spectator gate in TrainerButton moves to the card rather than being lost (feat(web): the phone spectator view takes the redesign's shape #412).
  • Vitest covers the state→render mapping for all seven states; existing sensor-status tests still pass untouched.
  • make ci green.

Verify it for real

The verify skill, and this one wants real hardware for at least one pass — docs/HARDWARE-SESSIONS.md has the protocol. Pair from /pair with no room open, then from the Lounge, then from the solo pre-ride screen, and confirm the card reads identically in all three. Then pair on a second screen and confirm the first shows "on your phone" rather than offering a pairing the hub would refuse (#610).

Deliberately not in scope

  • A first-run walkthrough for a rider who has never paired anything — worth doing, its own issue.
  • Changing what any sensor does, the BLE layer, or the arbitration in arbitrate.ts.
  • The ?sim=1 fairness question beyond consolidating the gate: if simulated solo rides should be excluded from streaks and trophies, that is a separate issue.

Related

#565 (why /pair can see the room's trainer at all), #610 (the device claim across screens), #611 (why the solo pre-ride screens grew the grid), #520 (the "paired but silent" fault), #123 (simulators are dev-flag equipment), #418 (why the room's gate is a server gate), #412 (phones are spectators), #54 (?replay=), WATTROOM.md (the locked Playwright flow).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bleBLE trainer/sensor layerbugSomething isn't workingdesignDesign related stuff

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions