diff --git a/README.md b/README.md index c566ab5..399eb39 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app.tsx b/src/app.tsx index 4e8973a..3364253 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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'; @@ -9,10 +10,13 @@ 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, @@ -20,7 +24,14 @@ import { } 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[] = []; @@ -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(() => 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); @@ -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 void> = { @@ -176,7 +232,7 @@ export function App() { }, }; const handleShortcut = (event: KeyboardEvent) => { - if (welcomeOpen || shouldIgnoreShortcut(event)) { + if (devicesOpen || welcomeOpen || shouldIgnoreShortcut(event)) { return; } if (historyOpen) { @@ -190,6 +246,7 @@ export function App() { window.addEventListener('keydown', handleShortcut); return () => window.removeEventListener('keydown', handleShortcut); }, [ + devicesOpen, endSession, handleNewSessionShortcut, historyOpen, @@ -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) { @@ -377,14 +443,11 @@ export function App() { > ? - setDevicesOpen(true)} + pairedCount={pairedDeviceCount} /> @@ -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)} /> @@ -445,28 +508,46 @@ export function App() { />
-

Resistance control

- - {trainer.resistance} - % - +

+ {click.paired ? 'Virtual shifting' : 'Resistance control'} +

+
+ + {click.paired ? gearControl.gear : trainer.resistance} + + {click.paired ? 'of 24' : '%'} + + +
- + {click.paired ? ( + + ) : ( + + )}
@@ -518,7 +599,41 @@ export function App() { open={historyOpen} speedUnit={speedUnit} /> - + 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, + }} + /> + ); diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx new file mode 100644 index 0000000..228ecdb --- /dev/null +++ b/src/components/device-pairing.tsx @@ -0,0 +1,354 @@ +import { useEffect } from 'react'; +import { Icon } from './icon'; + +interface DeviceSlot { + allowRetryWhileBusy?: boolean; + battery?: number; + busy: boolean; + connected: boolean; + name?: string; + onDisconnect: () => void; + onForget: () => void | Promise; + onPair: () => void | Promise; + onReconnect: () => void | Promise; + paired: boolean; + status: string; +} + +interface ClickController { + active: boolean; + connected: boolean; + connecting: boolean; + id: string; + label: string; +} + +interface ClickSlot extends DeviceSlot { + connectedCount: number; + controllers: ClickController[]; + onForgetController: (deviceId: string) => void | Promise; + pairedCount: number; + pairing: boolean; +} + +function clickControllerOrder(controller: ClickController) { + if (controller.label.startsWith('+')) { + return 0; + } + if (controller.label.startsWith('−')) { + return 1; + } + return 2; +} + +function StatusDot({ connected, busy }: { connected: boolean; busy: boolean }) { + let statusClass = 'bg-slate-600'; + if (busy) { + statusClass = 'animate-pulse bg-yellow-300'; + } else if (connected) { + statusClass = 'bg-mint shadow-[0_0_10px_rgba(173,245,189,.55)]'; + } + return ( +