Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Ride Control

Bike trainer control web app using Web Bluetooth. Tested with Wahoo Kickr Core 2 with Zwift Cog.
Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 and Zwift Cog, with initial support for Zwift Click V2.

[Open Ride Control](https://ridecontrol.xyz)

## Features

- Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; the welcome screen remains available from the Ride Control footer link.
- Connects to compatible bike trainers through Web Bluetooth, remembers authorized devices, and automatically reconnects when possible.
- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices panel, with a blue activity indicator while devices connect and a green indicator once every paired device is ready; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, and continuously retries saved Click connections after a refresh or controller sleep; stalled attempts can be retried immediately, and Click presses made while this panel is open stay in setup and do not shift the ride.
- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and automatically reconnects when possible.
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts while recording resistance changes alongside the other ride metrics.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics.
- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance.
- Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives.
- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and resistance, with focused and combined chart views.
- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with focused and combined chart views.
- Saves completed sessions to browser-managed IndexedDB storage, including optional comments and how the ride felt, and requests persistent browser storage when supported.
- Organizes saved sessions by local date and time in a slide-out history tray with paginated loading, detailed metrics and charts, keyboard navigation, and permanent deletion.
- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance, calories, ride feeling, and comments for upload to Strava and other cycling services.
- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation, and permanent deletion.
- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services.
- Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time.
- Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session.
- Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions.
Expand Down
195 changes: 155 additions & 40 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { ConnectionControl } from './components/connection-control';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DevicePairingButton, DevicePairingPanel } from './components/device-pairing';
import { GearControl } from './components/gear-control';
import { Icon } from './components/icon';
import { KeyboardShortcutsDialog } from './components/keyboard-shortcuts-dialog';
import { Metric, SmallMetric } from './components/metrics';
Expand All @@ -9,18 +10,28 @@ import { SessionChart } from './components/session-chart';
import { SessionHistory } from './components/session-history';
import { SessionSaveDialog } from './components/session-save-dialog';
import { WelcomeDialog } from './components/welcome-dialog';
import { useGearControl } from './hooks/use-gear-control';
import { useHeartRateMonitor } from './hooks/use-heart-rate-monitor';
import { useSession } from './hooks/use-session';
import { useTrainer } from './hooks/use-trainer';
import { useZwiftClick } from './hooks/use-zwift-click';
import { formatAggregateAverage, formatDuration } from './lib/format';
import { type AppShortcut, appShortcutForKey } from './lib/keyboard';
import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard';
import {
createSavedSession,
requestPersistentSessionStorage,
saveSession,
} from './lib/saved-sessions';
import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session';
import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome';
import type { RoutePoint, SavedSession, SessionMetadata, SpeedUnit } from './types';
import type {
ControlMode,
Metrics,
RoutePoint,
SavedSession,
SessionMetadata,
SpeedUnit,
} from './types';

const EMPTY_ROUTE: RoutePoint[] = [];

Expand All @@ -35,23 +46,62 @@ function shouldIgnoreShortcut(event: KeyboardEvent) {
);
}

function metricsWithHeartRate(metrics: Metrics, connected: boolean, heartRate: number): Metrics {
if (!connected) {
return metrics;
}
return { ...metrics, heartRate };
}

function shiftHandlerUnlessBlocked(handler: (change: number) => void, blocked: boolean) {
return blocked ? () => undefined : handler;
}

function controlModeForClick(paired: boolean): ControlMode {
return paired ? 'gear' : 'resistance';
}

export function App() {
const trainer = useTrainer();
const session = useSession(
const [devicesOpen, setDevicesOpen] = useState(false);
const clickShiftRef = useRef<(change: number) => void>(() => undefined);
const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []);
const click = useZwiftClick(handleClickShift, trainer.setNotice, devicesOpen);
const heartRate = useHeartRateMonitor(trainer.setNotice);
const liveMetrics = metricsWithHeartRate(
trainer.metrics,
trainer.resistance,
trainer.lastPedalingAt,
trainer.trainerReportsDistance
heartRate.connected,
heartRate.heartRate
);
const { connected } = trainer;
const { isRiding, manuallyPaused } = session;
const sessionIsSaved = Boolean(session.savedSessionId);
const [speedUnit, setSpeedUnit] = useState<SpeedUnit>(() =>
localStorage.getItem('speed-unit') === 'kmh' ? 'kmh' : 'mph'
);
const [historyOpen, setHistoryOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const [welcomeOpen, setWelcomeOpen] = useState(shouldShowWelcome);
const dashboardKeyboardEnabled = !(devicesOpen || historyOpen || shortcutsOpen || welcomeOpen);
const gearControl = useGearControl({
active: click.paired,
connected: trainer.connected,
keyboardEnabled: dashboardKeyboardEnabled,
onResistanceChange: trainer.shiftResistanceBy,
resistance: trainer.resistance,
setNotice: trainer.setNotice,
});
clickShiftRef.current = shiftHandlerUnlessBlocked(gearControl.shiftGear, devicesOpen);
const session = useSession(
liveMetrics,
{
gear: gearControl.gear,
mode: controlModeForClick(click.paired),
resistance: trainer.resistance,
},
trainer.lastPedalingAt,
trainer.trainerReportsDistance
);
const { isRiding, manuallyPaused } = session;
const sessionIsSaved = Boolean(session.savedSessionId);
const [saveDialogOpen, setSaveDialogOpen] = useState(() => session.ended && !sessionIsSaved);
const [saving, setSaving] = useState(false);
const [startAfterSave, setStartAfterSave] = useState(false);
Expand Down Expand Up @@ -134,8 +184,14 @@ export function App() {
}, [warnBeforeUnload]);

useEffect(() => {
trainer.setKeyboardControlsEnabled(!(historyOpen || shortcutsOpen || welcomeOpen));
}, [historyOpen, shortcutsOpen, trainer.setKeyboardControlsEnabled, welcomeOpen]);
trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled);
trainer.setGearControlsEnabled(click.paired);
}, [
click.paired,
dashboardKeyboardEnabled,
trainer.setGearControlsEnabled,
trainer.setKeyboardControlsEnabled,
]);

useEffect(() => {
const shortcutHandlers: Record<AppShortcut, (event: KeyboardEvent) => void> = {
Expand Down Expand Up @@ -176,7 +232,7 @@ export function App() {
},
};
const handleShortcut = (event: KeyboardEvent) => {
if (welcomeOpen || shouldIgnoreShortcut(event)) {
if (devicesOpen || welcomeOpen || shouldIgnoreShortcut(event)) {
return;
}
if (historyOpen) {
Expand All @@ -190,6 +246,7 @@ export function App() {
window.addEventListener('keydown', handleShortcut);
return () => window.removeEventListener('keydown', handleShortcut);
}, [
devicesOpen,
endSession,
handleNewSessionShortcut,
historyOpen,
Expand Down Expand Up @@ -275,12 +332,21 @@ export function App() {

const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1;
const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km';
const displayedSpeed = trainer.metrics.speed * unitFactor;
const displayedSpeed = liveMetrics.speed * unitFactor;
const displayedDistance = session.rideDistance * unitFactor;
const displayedMaximumSpeed = session.maximums.speed * unitFactor;
const averageSpeed =
session.elapsedSeconds > 0 ? session.rideDistance / (session.elapsedSeconds / 3600) : 0;
const displayedAverageSpeed = averageSpeed * unitFactor;
const connectedDeviceCount =
Number(trainer.connected) + Number(heartRate.connected) + click.connectedCount;
const pairedDeviceCount = Number(trainer.paired) + Number(heartRate.paired) + click.pairedCount;
const devicesConnecting = [
trainer.connectionBusy,
heartRate.busy,
click.busy,
click.pairing,
].some(Boolean);
let sessionControlLabel = 'Auto paused';
let sessionControlIcon = 'stop';
if (isRiding) {
Expand Down Expand Up @@ -377,14 +443,11 @@ export function App() {
>
?
</button>
<ConnectionControl
busy={trainer.connectionBusy}
connected={connected}
deviceName={trainer.deviceName}
onCancel={trainer.cancelConnection}
onConnect={trainer.connect}
onDisconnect={trainer.disconnect}
status={trainer.status}
<DevicePairingButton
connectedCount={connectedDeviceCount}
connecting={devicesConnecting}
onClick={() => setDevicesOpen(true)}
pairedCount={pairedDeviceCount}
/>
</div>
</div>
Expand All @@ -406,7 +469,7 @@ export function App() {
label="POWER"
maximum={String(Math.round(session.maximums.power))}
unit="watts"
value={String(trainer.metrics.power)}
value={String(liveMetrics.power)}
/>
<Metric
accent="violet"
Expand All @@ -415,7 +478,7 @@ export function App() {
label="CADENCE"
maximum={String(Math.round(session.maximums.cadence))}
unit="rpm"
value={String(Math.round(trainer.metrics.cadence))}
value={String(Math.round(liveMetrics.cadence))}
/>
<Metric
accent="rose"
Expand All @@ -424,7 +487,7 @@ export function App() {
label="HEART RATE"
maximum={String(Math.round(session.maximums.heartRate))}
unit="bpm"
value={String(trainer.metrics.heartRate || '—')}
value={String(liveMetrics.heartRate || '—')}
/>
</section>

Expand All @@ -445,28 +508,46 @@ export function App() {
/>
</div>
<SessionChart
controlMode={session.controlMode}
history={session.history}
keyboardEnabled={!(historyOpen || shortcutsOpen || welcomeOpen)}
keyboardEnabled={dashboardKeyboardEnabled}
route={EMPTY_ROUTE}
speedUnit={speedUnit}
/>
</div>
<div className="self-start rounded-2xl border border-line bg-panel p-4 sm:p-5">
<div className="flex items-center justify-between gap-4">
<h2 className="font-bold text-lg">Resistance control</h2>
<output className="font-bold text-3xl text-mint tabular-nums tracking-tight">
{trainer.resistance}
<span className="ml-0.5 text-lg">%</span>
</output>
<h2 className="font-bold text-lg">
{click.paired ? 'Virtual shifting' : 'Resistance control'}
</h2>
<div className="text-right">
<output className="font-bold text-3xl text-mint tabular-nums tracking-tight">
{click.paired ? gearControl.gear : trainer.resistance}
<span className="ml-1 text-slate-500 text-xs">
{click.paired ? 'of 24' : '%'}
</span>
</output>
</div>
</div>
<ResistanceControl
disabled={!connected}
max={100}
min={0}
onChange={trainer.updateResistance}
step={1}
value={trainer.resistance}
/>
{click.paired ? (
<GearControl
disabled={!connected}
gear={gearControl.gear}
onChange={gearControl.shiftGear}
shiftFlash={gearControl.shiftFlash}
/>
) : (
<ResistanceControl
disabled={!connected}
keyboardFlash={trainer.resistanceKeyFlash}
max={100}
min={0}
onChange={trainer.updateResistance}
ramp={trainer.resistanceRamp}
step={1}
value={trainer.resistance}
/>
)}
</div>
</section>
</div>
Expand Down Expand Up @@ -518,7 +599,41 @@ export function App() {
open={historyOpen}
speedUnit={speedUnit}
/>
<KeyboardShortcutsDialog onClose={closeShortcuts} open={shortcutsOpen} />
<DevicePairingPanel
click={{
...click,
onDisconnect: click.disconnect,
onForget: click.forget,
onForgetController: click.forgetDevice,
onPair: click.pair,
onReconnect: click.reconnect,
}}
heartRate={{
...heartRate,
onDisconnect: heartRate.disconnect,
onForget: heartRate.forget,
onPair: heartRate.pair,
onReconnect: heartRate.reconnect,
}}
onClose={() => setDevicesOpen(false)}
open={devicesOpen}
trainer={{
busy: trainer.connectionBusy,
connected: trainer.connected,
name: trainer.pairedDeviceName,
onDisconnect: trainer.disconnect,
onForget: trainer.forget,
onPair: trainer.connect,
onReconnect: trainer.reconnect,
paired: trainer.paired,
status: trainer.status,
}}
/>
<KeyboardShortcutsDialog
onClose={closeShortcuts}
open={shortcutsOpen}
shortcuts={click.paired ? gearingKeyboardShortcuts : undefined}
/>
<WelcomeDialog onClose={closeWelcome} open={welcomeOpen} />
</main>
);
Expand Down
Loading