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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Shows each deployment's build time in the viewer's local timezone and links it to the GitHub pull request that produced the build, falling back to the closed pull-request list when no associated PR is available. A tiny static deployment marker is revalidated at most once per hour; when it differs from the running bundle, a persistent notice offers to reload into the latest version without automatically interrupting a ride.
- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and restores the trainer, heart-rate monitor, and `+` 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.
- Reports a heart-rate monitor ready only after its first valid measurement. If readings stop for 10 seconds, the app closes the stale connection and retries the remembered monitor without requiring it to be forgotten or paired again. Disconnects during setup abort the attempt, and canceled or late connection work cannot tear down its replacement.
- Opening the Devices tray refreshes advertisement discovery for a remembered heart-rate monitor that is already reconnecting. It preserves the retry schedule and any connection attempt in progress, leaves connected devices alone, and does not resume a manually stopped or disconnected monitor.
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Opens the linkable Profile view as a slide-out tray with shared, keyboard-accessible tabs that separate Personal details from Bikes while leaving room for future sections such as Premium and Teams. `/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.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback: Up Arrow or Return increases resistance, while Down Arrow or Right Shift decreases it. The control shows smoothing progress inside the slider thumb and records resistance changes alongside the other ride metrics.
Expand Down Expand Up @@ -50,6 +51,16 @@ 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.

## Architecture

Ride session data is held in a per-app TanStack Store and changed through atomic domain actions.
Expand Down
12 changes: 11 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
"indentWidth": 4,
"lineWidth": 100
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"javascript": {
"globals": ["WebSocketPair"],
"formatter": {
Expand All @@ -37,6 +42,9 @@
"a11y": {
"noAutofocus": "error"
},
"complexity": {
"noImportantStyles": "error"
},
"performance": {
// Reconnect attempts must be sequential, and these leaf event handlers are not memoized consumers.
"noAwaitInLoops": "off",
Expand All @@ -46,7 +54,9 @@
// Bluetooth FTMS and CSC payloads are defined as packed bit fields.
"noBitwiseOperators": "off",
// Mutable React refs can change outside Biome's local control-flow analysis.
"noUnnecessaryConditions": "off"
"noUnnecessaryConditions": "off",
// Retain the rule moved from Ultracite core to its opt-in type-aware preset.
"useArraySortCompare": "error"
}
}
}
Expand Down
Loading