Bike trainer control and ride tracking
See the codeBike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 and Zwift Cog, with standards-based support for Bluetooth FTMS trainers such as the Elite DIRETO XR-T and initial support for Zwift Click V2.
/gpx/:provider/:collection/:route opens the terrain-workout tray, reusable route browser, map, and requested prepared route; /workouts/:workoutId opens and centers the requested workout; /sessions/:sessionId opens the complete saved-session detail; /devices opens the paired-devices tray; and /profile opens the rider-and-bike profile tray. Provider and collection links open the corresponding route browser, prior /bikegpx/:routeId links remain compatible, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface.+ Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, direct Cancel pairing and Stop connecting actions during stalled attempts, immediate local removal when Forget is chosen even if the Bluetooth link is failing, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Cancelling invalidates the pending attempt so a late browser selection or GATT completion cannot restore it. Ride Control currently exposes only the reliable + controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical + button shifts up, and the blue Y button shifts down; the controller row briefly identifies those inputs as + and − while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than 1.2.0 with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices panel is open stay in setup and do not shift the ride.+ Click controller from one browser permission snapshot after a reload. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Offline remembered devices keep retrying while the page remains open, with bounded attempts so a stale browser request cannot stall the loop; background heart-rate probes use a shorter timeout so a monitor that wakes up gets a fresh connection attempt promptly. Starting a new session re-arms every remembered device that is not already connected, while Disconnect, Stop connecting, and closing the page cancel current retry work. Trainers, heart-rate monitors, and the active Click controller share advertisement discovery through the GATT handshake so Chrome can rediscover remembered hardware as it broadcasts. Bounded direct GATT retries remain the fallback when advertisements are not delivered or watching is unavailable or fails. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication./profile?tab=personal and /profile?tab=bikes link directly to each section, browser history follows tab changes, and plain /profile safely defaults to Personal details. Switching tabs preserves every unsaved form edit. Profile data remains in IndexedDB on the current device and includes name, profile image, rider weight, an inclusive free-form sex or gender identity field that remembers saved custom entries in a separately labelled, removable suggestion group without relying on browser autofill, the app-wide Imperial or Metric display preference, and multiple named bikes. Every bike can store its own prepared image, manufacturer, model, color, purchase date, weight, front-chainring teeth, and rear-cassette teeth; rider and bike images share the same JPEG/PNG/WebP validation, browser-side resizing and compression, 32 MB source ceiling, 512-pixel edge, and 512 KB prepared-image ceiling. Removing a bike, profile image, or bike image requires explicit confirmation. 1×11, 1×12, 2×, and other valid drivetrains are supported up to 24 total combinations. Selecting the active bike immediately supplies that bike's mass and ordered virtual gear ratios to trainer physics. Existing single-bike and multi-bike profiles migrate automatically. Every actual rider-weight change is timestamped in the profile without adding duplicates for unchanged saves or unit conversions; the tray graphs the complete series with current weight and net change while retaining the complete local history for future encrypted sync. Weight follows the selected pounds or kilograms display while calculations use canonical kilograms, and the browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight plus the active bike's identity, weight, chainrings, and cassette before recording begins, preserves it through active-session recovery and continuation, and round-trips it through Ride Control TCX files so later bike edits do not rewrite historical settings. Those physics fields and the active-bike selection lock after recording begins and unlock when the session ends; names, images, identity, display units, and descriptive bike metadata remain editable. Identity, rider name, and images never affect workout calculations or enter session history. Future cloud storage and synchronization will be offered as a premium feature.+ Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, Up Arrow or Return for a harder gear, and Down Arrow or Right Shift for an easier gear remain usable. The physical + button shifts up and its blue Y button shifts down. The configured chainrings and cassette determine the number of positions—11 for a 1×11, 12 for a 1×12, and up to 24 total—and define the drivetrain's easiest, neutral, and hardest ratios. Positions use equal percentage load steps on either side of the middle neutral gear, while the control identifies the selected physical chainring/cassette combination, its ratio, and its calibrated load multiplier, such as 53/15 · 3.53:1 · 2.21× load. The progress meter retains visible fill in the easiest gear and increases at every higher position, so gear 1 remains represented while gear 2 is visibly farther along. The prepared route grade produces one stable terrain target, then the calibrated load curve progressively unloads gears below neutral and adds load above it: the middle gear preserves the terrain target at 1.00×, gear 1 provides the easiest configured ratio, and the final gear provides the hardest configured ratio. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer resistance.bun install
bun run dev
Open http://localhost:4200 in current Chrome.
Open this repository directory as an editor project root so Biome uses the installed version and
biome.jsonc, rather than an editor-bundled fallback. When editing the sibling backend as well,
add it as a separate project root. For Zed, run zed --new . ../backend from this directory instead
of opening their shared parent directory.
bun run ci checks Biome diagnostics, Tailwind CSS diagnostics, tests, types, and the production
build. The Biome configuration explicitly enables Tailwind directives and rejects !important;
chart tooltip presentation is stylesheet-owned while TanStack retains its native interaction and
positioning behavior.
Ride session data is held in a per-app TanStack Store and changed through atomic domain actions.
The useSession adapter owns recording timers and periodic IndexedDB checkpoints while exposing the
existing session controller API to the interface. Active-session metadata is stored separately from
chunked 100-sample records, while saved rides and immutable workout snapshots remain in their
existing IndexedDB stores. A gated one-time migration moves the former localStorage recovery record
and deletes it only after a successful write. Each Bluetooth hook exposes one explicit
connection phase instead of independently managed status flags. A single remembered-device catalog
loads browser-authorized devices once, then the trainer, heart-rate, and Click adapters start their
independent reconnections together. One GATT coordinator deduplicates overlapping requests
to the same physical device while allowing the trainer, heart-rate monitor, and controllers to
connect in parallel. Each device sequences its own service setup, so a sleeping device cannot block
an awake one and concurrent ATT requests cannot fight over one connection.
Shared bounded connection probes, required service and notification
setup, persistent advertisement-driven wake detection, retry backoff, scheduling, and notification subscriptions live in
plain domain controllers, while device-specific adapters own their GATT services. Bluetooth objects
and timers stay outside shared application state. The application component coordinates focused
dashboard regions,
uses an explicit overlay state for mutually exclusive trays, renders every side tray through one
animated and accessible shell, and delegates save/discard/start/continue transitions to a
store-backed session workflow. Temporary form inputs remain local React state.
Terrain workouts are an independent course domain layered over session distance. Each bundled
course keeps its editable metadata, map geometry, and elevation in an
individual JSON definition under src/workouts; shared factories derive the normalized runtime
course geometry and terrain behavior. Course geometry
produces grade, elevation, current and completed course counts, and map position. A shared terrain
engine turns grade into one bounded resistance target, independent of which route supplied it.
Virtual gearing applies a calibrated load curve across the active bike's modeled drivetrain ratios—including
1×11 and 1×12 setups—without feeding
the trainer's resulting speed, power, and cadence back into the same resistance target, so terrain
changes ramp smoothly while button-driven gear changes remain immediate. An optional IndexedDB
profile supplies total rider-and-bike mass and the configured drivetrain ratios to that calculation,
and retains a timestamped history whenever the rider weight actually changes;
the existing 53/39 chainrings, 12-speed 12–24 cassette, and neutral reference mass remain the
defaults. Recorded grade, resistance,
and elevation appear alongside the other session graphs for the full ride, and the course profile
repeats on every loop or out-and-back trip while point-to-point routes stop at their finish; route
progress, selected gear, and applied resistance stay portable in saved sessions and TCX files;
standard ride metrics also stay portable through FIT files.
Saved-session writes also maintain a versioned IndexedDB analytics cache containing compact
per-session contributions plus daily, weekly, monthly, yearly, and all-time rollups. New sessions
update those totals incrementally; replacement and deletion subtract the prior contribution, and
only a removed personal-best holder requires a scan of the compact contribution store to select
the next peak. Existing saved rides are indexed and backfilled once when the analytics stores are
created, so opening the calendar or statistics view never walks complete telemetry histories.
Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation,
and repeated dialog and keyboard behavior so those rules stay consistent across views and exports.
Pull requests and pushes to main run the complete bun run ci suite in GitHub Actions. After
CI succeeds on main, a separate workflow runs bun run build and deploys the generated dist
assets to a Cloudflare Worker at ridecontrol.xyz. Each build emits
version.json beside those static assets. Running clients revalidate that marker with the browser
cache at most once per hour, so unchanged checks can use Cloudflare's asset ETag without invoking
dynamic Worker code or transferring the application bundle again.
Ride Control currently tests Web Bluetooth only in desktop Chrome. Bluetooth does not work in Brave.
Persistent Web Bluetooth permissions are disabled by default in current Chromium builds. To allow the app to reconnect after a page reload:
chrome://flags/#enable-web-bluetooth-new-permissions-backend.The paired-devices panel detects Chrome's persistent reconnect capability and replaces these setup steps with a configured confirmation when it is available.
Remembered permission alone does not guarantee that Chrome can connect to a device: it may still need advertisement discovery to refresh that device's availability. In a live TRACKR HR recovery, direct GATT retries repeatedly reported “Bluetooth Device is no longer in range” until advertisement watching was enabled on the same remembered device; it then connected and delivered heart-rate measurements without a refresh or re-pair. Heart-rate monitors therefore use the same discovery policy as trainers, alongside bounded direct GATT retries that do not depend on an advertisement arriving or watching succeeding. This recovery was observed on that device in Chrome, not verified across all heart-rate hardware.
Copyright (C) 2026 Ride Control.
Ride Control is licensed under version 3 of the GNU General Public License. See LICENSE for the complete license terms.
TypeScript
98.6%
CSS
1.4%
Bike trainer control and ride tracking
See the codeBike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 and Zwift Cog, with standards-based support for Bluetooth FTMS trainers such as the Elite DIRETO XR-T and initial support for Zwift Click V2.
/gpx/:provider/:collection/:route opens the terrain-workout tray, reusable route browser, map, and requested prepared route; /workouts/:workoutId opens and centers the requested workout; /sessions/:sessionId opens the complete saved-session detail; /devices opens the paired-devices tray; and /profile opens the rider-and-bike profile tray. Provider and collection links open the corresponding route browser, prior /bikegpx/:routeId links remain compatible, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface.+ Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, direct Cancel pairing and Stop connecting actions during stalled attempts, immediate local removal when Forget is chosen even if the Bluetooth link is failing, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Cancelling invalidates the pending attempt so a late browser selection or GATT completion cannot restore it. Ride Control currently exposes only the reliable + controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical + button shifts up, and the blue Y button shifts down; the controller row briefly identifies those inputs as + and − while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than 1.2.0 with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices panel is open stay in setup and do not shift the ride.+ Click controller from one browser permission snapshot after a reload. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Offline remembered devices keep retrying while the page remains open, with bounded attempts so a stale browser request cannot stall the loop; background heart-rate probes use a shorter timeout so a monitor that wakes up gets a fresh connection attempt promptly. Starting a new session re-arms every remembered device that is not already connected, while Disconnect, Stop connecting, and closing the page cancel current retry work. Trainers, heart-rate monitors, and the active Click controller share advertisement discovery through the GATT handshake so Chrome can rediscover remembered hardware as it broadcasts. Bounded direct GATT retries remain the fallback when advertisements are not delivered or watching is unavailable or fails. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication./profile?tab=personal and /profile?tab=bikes link directly to each section, browser history follows tab changes, and plain /profile safely defaults to Personal details. Switching tabs preserves every unsaved form edit. Profile data remains in IndexedDB on the current device and includes name, profile image, rider weight, an inclusive free-form sex or gender identity field that remembers saved custom entries in a separately labelled, removable suggestion group without relying on browser autofill, the app-wide Imperial or Metric display preference, and multiple named bikes. Every bike can store its own prepared image, manufacturer, model, color, purchase date, weight, front-chainring teeth, and rear-cassette teeth; rider and bike images share the same JPEG/PNG/WebP validation, browser-side resizing and compression, 32 MB source ceiling, 512-pixel edge, and 512 KB prepared-image ceiling. Removing a bike, profile image, or bike image requires explicit confirmation. 1×11, 1×12, 2×, and other valid drivetrains are supported up to 24 total combinations. Selecting the active bike immediately supplies that bike's mass and ordered virtual gear ratios to trainer physics. Existing single-bike and multi-bike profiles migrate automatically. Every actual rider-weight change is timestamped in the profile without adding duplicates for unchanged saves or unit conversions; the tray graphs the complete series with current weight and net change while retaining the complete local history for future encrypted sync. Weight follows the selected pounds or kilograms display while calculations use canonical kilograms, and the browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight plus the active bike's identity, weight, chainrings, and cassette before recording begins, preserves it through active-session recovery and continuation, and round-trips it through Ride Control TCX files so later bike edits do not rewrite historical settings. Those physics fields and the active-bike selection lock after recording begins and unlock when the session ends; names, images, identity, display units, and descriptive bike metadata remain editable. Identity, rider name, and images never affect workout calculations or enter session history. Future cloud storage and synchronization will be offered as a premium feature.+ Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, Up Arrow or Return for a harder gear, and Down Arrow or Right Shift for an easier gear remain usable. The physical + button shifts up and its blue Y button shifts down. The configured chainrings and cassette determine the number of positions—11 for a 1×11, 12 for a 1×12, and up to 24 total—and define the drivetrain's easiest, neutral, and hardest ratios. Positions use equal percentage load steps on either side of the middle neutral gear, while the control identifies the selected physical chainring/cassette combination, its ratio, and its calibrated load multiplier, such as 53/15 · 3.53:1 · 2.21× load. The progress meter retains visible fill in the easiest gear and increases at every higher position, so gear 1 remains represented while gear 2 is visibly farther along. The prepared route grade produces one stable terrain target, then the calibrated load curve progressively unloads gears below neutral and adds load above it: the middle gear preserves the terrain target at 1.00×, gear 1 provides the easiest configured ratio, and the final gear provides the hardest configured ratio. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer resistance.bun install
bun run dev
Open http://localhost:4200 in current Chrome.
Open this repository directory as an editor project root so Biome uses the installed version and
biome.jsonc, rather than an editor-bundled fallback. When editing the sibling backend as well,
add it as a separate project root. For Zed, run zed --new . ../backend from this directory instead
of opening their shared parent directory.
bun run ci checks Biome diagnostics, Tailwind CSS diagnostics, tests, types, and the production
build. The Biome configuration explicitly enables Tailwind directives and rejects !important;
chart tooltip presentation is stylesheet-owned while TanStack retains its native interaction and
positioning behavior.
Ride session data is held in a per-app TanStack Store and changed through atomic domain actions.
The useSession adapter owns recording timers and periodic IndexedDB checkpoints while exposing the
existing session controller API to the interface. Active-session metadata is stored separately from
chunked 100-sample records, while saved rides and immutable workout snapshots remain in their
existing IndexedDB stores. A gated one-time migration moves the former localStorage recovery record
and deletes it only after a successful write. Each Bluetooth hook exposes one explicit
connection phase instead of independently managed status flags. A single remembered-device catalog
loads browser-authorized devices once, then the trainer, heart-rate, and Click adapters start their
independent reconnections together. One GATT coordinator deduplicates overlapping requests
to the same physical device while allowing the trainer, heart-rate monitor, and controllers to
connect in parallel. Each device sequences its own service setup, so a sleeping device cannot block
an awake one and concurrent ATT requests cannot fight over one connection.
Shared bounded connection probes, required service and notification
setup, persistent advertisement-driven wake detection, retry backoff, scheduling, and notification subscriptions live in
plain domain controllers, while device-specific adapters own their GATT services. Bluetooth objects
and timers stay outside shared application state. The application component coordinates focused
dashboard regions,
uses an explicit overlay state for mutually exclusive trays, renders every side tray through one
animated and accessible shell, and delegates save/discard/start/continue transitions to a
store-backed session workflow. Temporary form inputs remain local React state.
Terrain workouts are an independent course domain layered over session distance. Each bundled
course keeps its editable metadata, map geometry, and elevation in an
individual JSON definition under src/workouts; shared factories derive the normalized runtime
course geometry and terrain behavior. Course geometry
produces grade, elevation, current and completed course counts, and map position. A shared terrain
engine turns grade into one bounded resistance target, independent of which route supplied it.
Virtual gearing applies a calibrated load curve across the active bike's modeled drivetrain ratios—including
1×11 and 1×12 setups—without feeding
the trainer's resulting speed, power, and cadence back into the same resistance target, so terrain
changes ramp smoothly while button-driven gear changes remain immediate. An optional IndexedDB
profile supplies total rider-and-bike mass and the configured drivetrain ratios to that calculation,
and retains a timestamped history whenever the rider weight actually changes;
the existing 53/39 chainrings, 12-speed 12–24 cassette, and neutral reference mass remain the
defaults. Recorded grade, resistance,
and elevation appear alongside the other session graphs for the full ride, and the course profile
repeats on every loop or out-and-back trip while point-to-point routes stop at their finish; route
progress, selected gear, and applied resistance stay portable in saved sessions and TCX files;
standard ride metrics also stay portable through FIT files.
Saved-session writes also maintain a versioned IndexedDB analytics cache containing compact
per-session contributions plus daily, weekly, monthly, yearly, and all-time rollups. New sessions
update those totals incrementally; replacement and deletion subtract the prior contribution, and
only a removed personal-best holder requires a scan of the compact contribution store to select
the next peak. Existing saved rides are indexed and backfilled once when the analytics stores are
created, so opening the calendar or statistics view never walks complete telemetry histories.
Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation,
and repeated dialog and keyboard behavior so those rules stay consistent across views and exports.
Pull requests and pushes to main run the complete bun run ci suite in GitHub Actions. After
CI succeeds on main, a separate workflow runs bun run build and deploys the generated dist
assets to a Cloudflare Worker at ridecontrol.xyz. Each build emits
version.json beside those static assets. Running clients revalidate that marker with the browser
cache at most once per hour, so unchanged checks can use Cloudflare's asset ETag without invoking
dynamic Worker code or transferring the application bundle again.
Ride Control currently tests Web Bluetooth only in desktop Chrome. Bluetooth does not work in Brave.
Persistent Web Bluetooth permissions are disabled by default in current Chromium builds. To allow the app to reconnect after a page reload:
chrome://flags/#enable-web-bluetooth-new-permissions-backend.The paired-devices panel detects Chrome's persistent reconnect capability and replaces these setup steps with a configured confirmation when it is available.
Remembered permission alone does not guarantee that Chrome can connect to a device: it may still need advertisement discovery to refresh that device's availability. In a live TRACKR HR recovery, direct GATT retries repeatedly reported “Bluetooth Device is no longer in range” until advertisement watching was enabled on the same remembered device; it then connected and delivered heart-rate measurements without a refresh or re-pair. Heart-rate monitors therefore use the same discovery policy as trainers, alongside bounded direct GATT retries that do not depend on an advertisement arriving or watching succeeding. This recovery was observed on that device in Chrome, not verified across all heart-rate hardware.
Copyright (C) 2026 Ride Control.
Ride Control is licensed under version 3 of the GNU General Public License. See LICENSE for the complete license terms.
TypeScript
98.6%
CSS
1.4%