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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- 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.
- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, and out-and-back routes; an out-and-back follows the supplied path to its turnaround, then reverses the same location and elevation data back to the start before repeating. Prairie Roll adds a non-intersecting, curving 15-mile loop of repeated gradual climbs and descents. Granite Switchbacks adds a sustained four-mile ascent whose hairpin corners briefly get steeper before immediately returning to the steady climbing grade, followed by a ridge and a descending sequence of five recovery rollers. Ridgeline Time Trial is a ten-mile out-and-back with a gradual five-mile, roughly 300-foot hillclimb to the turnaround and the identical terrain in reverse on the return. Every course begins flat without giving nearly level routes an unnecessarily long rollout: low-climb courses use about 0.4 km, moderate rollers use about 0.8 km, and climbing-focused courses retain a 1.5 km rollout. The course then automatically adjusts trainer resistance from the current grade, tracks the rider in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route path and pulsing position markers while pedaling, and uses clear mid-contrast preview lines with a shared elevation scale so gentle rollers remain visibly low beside genuinely mountainous routes. It shows course distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. A workout can be selected before riding or planned while viewing a completed session; ending a workout keeps it selected and previews it at 0% for the next session unless the rider clears or replaces it. A newly planned workout immediately replaces the prior course on the dashboard at 0% progress without changing the completed ride's recorded data. It then remains locked from the moment riding begins until that session ends; definition refreshes for that same workout remain allowed without opening a path to switch courses. Routes contain geometry and elevation rather than an arbitrary resistance baseline; one shared terrain engine derives the load from grade before virtual gearing is applied.
- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, and out-and-back routes; an out-and-back follows the supplied path to its turnaround, then reverses the same location and elevation data back to the start before repeating. Prairie Roll adds a non-intersecting, curving 15-mile loop of repeated gradual climbs and descents. Granite Switchbacks adds a sustained four-mile ascent whose hairpin corners briefly get steeper before immediately returning to the steady climbing grade, followed by a ridge and a descending sequence of five recovery rollers. Ridgeline Time Trial is a ten-mile out-and-back with a gradual five-mile, roughly 300-foot hillclimb to the turnaround and the identical terrain in reverse on the return. Every course begins flat without giving nearly level routes an unnecessarily long rollout: low-climb courses use about 0.4 km, moderate rollers use about 0.8 km, and climbing-focused courses retain a 1.5 km rollout. The course then automatically adjusts trainer resistance from the current grade, tracks the rider in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route path and pulsing position markers while pedaling, and uses clear mid-contrast preview lines with a shared elevation scale so gentle rollers remain visibly low beside genuinely mountainous routes. It shows course distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. A workout can be selected before riding or planned while viewing a completed session; ending a workout keeps it selected and previews it at 0% for the next session unless the rider clears or replaces it. A newly planned workout immediately replaces the prior course on the dashboard at 0% progress without changing the completed ride's recorded data. It then remains locked from the moment riding begins until that session ends; definition refreshes for that same workout remain allowed without opening a path to switch courses. Routes contain geometry and elevation rather than an arbitrary resistance baseline; one shared terrain engine derives the load from grade before virtual gearing is applied. Point-to-point finish grades use the actual finish elevation, so starting the next lap cannot create an invisible climb or descent in the last 150 meters.
- Lets a saved ride start a linked course extension on a later day. The new session inherits the exact map and elevation-profile position while resetting time, distance, calories, climbing, samples, averages, and maxima, so each day remains independently measurable. Every extension records its journey and previous-session lineage; session history can switch between the selected part and a combined full-journey view whose totals, averages, maxima, charts, and course progress span the complete linked chain. Ride Control TCX export and import preserve this continuation metadata.
- Downloads terrain workouts as standard GPX 1.1 files with ordinary geographic and elevation data plus Ride Control metadata for stable ids, difficulty, exact distance, starting location, and route type. Valid GPX tracks or routes can be imported through the file picker or by dropping a file anywhere in the workout tray, then saved into the current device's custom library. Before processing, Ride Control offers an ephemeral enhanced Worker path that removes invalid and duplicate points, rejects large coordinate gaps, smooths elevation noise and implausible grade spikes, calculates climbing and difficulty, and creates normalized map geometry without storing the uploaded file. Riders can decline and process the route entirely on the current device instead; a final dialog summarizes the imported route, processing path, and any errors or fallback warnings. Both paths enforce a 2 MiB file ceiling, a 25,000-point ceiling, safe XML restrictions, and bounded route validation. The map-first route browser can switch between providers and collections, including BikeGPX public routes and yearly Tour de France stages. BikeGPX is linked and thanked beside its collection description, with a note that Ride Control heavily processes and cleans the original route data. The browser searches by name, place, distance, group, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, displays available stage imagery, shows finalized elevation statistics, downloads GPX, and imports a course in one click. Every visible route already includes finalized distance, climbing, maximum grade, difficulty, and map data; routes with unusable coordinate or elevation data never appear, and the first matching route previews automatically. Imported route descriptions can open an in-app map with start and finish markers and an animated bicycle; routes with genuinely nearby endpoints become loops while other routes remain point-to-point. Stable fingerprints prevent duplicate imports. The workout library supports immediate filtering by name, difficulty, or an approximate distance in the selected Imperial or Metric units, plus renaming, confirmed removal, and vertical drag reordering with persistent order. The terrain tray and route browser remember their open state, collection-specific scroll positions, searches, filters, provider, collection, and selected route across reloads.
- Replaces direct resistance controls with a focused virtual shifting interface whenever the `+` 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.
Expand Down
86 changes: 86 additions & 0 deletions docs/bikegpx-finish-grade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# BikeGPX finish grade investigation

Checked on October 1, 2026 against Ride Control's public prepared-route catalog.

## Cause and correction

`workoutTerrainAtDistance` looks up to 150 meters ahead to calculate trainer grade.
For point-to-point courses, it shortens that distance to the finish. The point lookup
interprets the exact course distance as the start of the next lap, however, so the
calculation substituted the starting elevation for the finishing elevation. The error
grows as the remaining distance shrinks. The displayed profile uses the actual course
geometry and therefore does not show this artificial hill or descent.

The terrain engine now samples the actual final point when point-to-point lookahead
reaches the finish. Normal lap repetition and loop/out-and-back lookahead are unchanged.
This applies to existing imported workouts as well as future imports; it does not
require changing provider data or reimporting routes.

## Cykelbana Godby - Finby (2635)

The current prepared course is 12.403419 km long. Its starting elevation is 11.4 m,
and its finishing elevation is 0.347576 m. The final stretch descends gently.

| Distance to finish | Previous grade | Corrected grade | Previous base resistance | Corrected base resistance |
| --- | --- | --- | --- | --- |
| 150 m | +6.97% | -0.40% | 28% | 11% |
| 100 m | +10.62% | -0.44% | 36% | 11% |
| 50 m | +15.00% | -0.45% | 46% | 11% |
| 10 m | +15.00% | -0.43% | 46% | 11% |

Resistance values precede virtual gearing. The previous grade hits the terrain
engine's +15% cap despite the descent visible in the profile.

The regression fixture retains the real starting point and the final 300 meters
plus their preceding interpolation point. Tests cover the 150-meter boundary,
positions down to 10 cm before the finish, and repeated laps. Synthetic courses
also cover flat, climbing, and descending finishes with both higher and lower starts.

## Other routes and audit limits

The catalog contained 6,124 routes. Complete prepared geometry was retrieved for the
first 95 routes in catalog order before the public API returned HTTP 429. These are
not a random sample, and the counts below must not be extrapolated to the full catalog.

- 94 point-to-point routes and one loop were valid after the application's normal restoration.
- 41 point-to-point routes had artificial increases in finish resistance.
- 53 point-to-point routes had artificial decreases in finish resistance.
- All 2,177 endpoint checks against the corrected engine passed, including repeated laps.
- Other affected routes include Lago del Desierto - Candelario Mancilla (682),
Eastern Dandenong Ranges Trail (11781), Brisbane Valley Rail Trail (3600),
Darwin Rail Trail (8139), and Mascot - Darlinghurst (4979).

The remaining 6,029 routes have **not** had their geometry checked. Authenticated
bulk access to the stored public route data was unavailable because the Cloudflare
CLI had no active authentication token. Completing that inventory requires restoring
the existing Cloudflare login or supplying an export of the prepared-route records.

The code correction covers the common endpoint-wrap defect for every point-to-point
course. It does not establish that every route is free of unrelated source-data issues.

## Queued production audit

The backend now implements a dedicated, resumable audit on the existing
`ridecontrol-gpx-processing` Queue. After deploying both repositories, run
`bun run gpx:audit:production start` from the backend checkout. The production job has
not been started. See the backend's `docs/gpx-audit.md` for status, export, and retries.

The job snapshots the stored catalog, checks every route directly in KV (including
already prepared routes), and records per-route geometry findings and exposure to the
old finish-wrap defect. The latter uses the exact endpoint-elevation difference, avoiding
any duplicate implementation of frontend interpolation. This is an audit of stored data;
frontend regression tests exercise the corrected live terrain calculation.

A paginated finalizer accounts for every catalog ID before creating a completed summary.
Storage failures remain pending for retries, and resubmitting the same run ID resumes
unfinished work. Audit records expire seven days after the original start, so they do not
accumulate indefinitely. Valid route geometry is preserved. Reprocessing is reserved for
separate data defects found during the full audit.

The audit code was also checked locally against the same 95 downloaded routes: 41 exposed
to a harder finish, 53 to an easier finish, and one unaffected loop. All 95 passed its
geometry checks. This is not a substitute for the full production run.

Validation: `bun run ci` passed Biome, Tailwind diagnostics, 418 unit tests, TypeScript,
and the production build. Browser verification of the fix was unavailable because no
Ride Control frontend development process was running; no server was started.
8 changes: 7 additions & 1 deletion src/lib/workouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,13 @@ export function workoutTerrainAtDistance(
course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT
? Math.min(lookAheadDistance, course.distance - distance)
: lookAheadDistance;
const ahead = coursePointAtDistance(course, distance + gradeDistance);
// The finish is a real elevation sample. Looking it up as a lap distance wraps
// to the start and turns unequal endpoint elevations into an invisible hill.
const ahead =
course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT &&
distance + gradeDistance >= course.distance
? (course.points.at(-1) ?? point)
: coursePointAtDistance(course, distance + gradeDistance);
const grade =
gradeDistance > 0
? clamp(((ahead.elevation - point.elevation) / (gradeDistance * 1000)) * 100, -15, 15)
Expand Down
Loading
Loading