From 45c185a65626b5ab32c7c8e9d2e6f3ca5089c9e1 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 10:05:51 -0700 Subject: [PATCH 1/5] Add terrain-aware virtual shifting --- README.md | 12 ++++++---- src/app.tsx | 28 ++++++++++++++++++---- src/components/session-detail.tsx | 7 ++++++ src/components/workout-progress.tsx | 7 +++++- src/hooks/use-gear-control.ts | 14 ++++------- src/hooks/use-session.ts | 17 ++++--------- src/hooks/use-trainer.ts | 29 +++++++++++++++++----- src/hooks/use-workout.ts | 6 ++--- src/lib/gears.ts | 29 ++++++++++++++++------ src/stores/session-store.ts | 3 +-- tests/components.test.tsx | 5 +++- tests/gears.test.ts | 37 +++++++++++++++++++++-------- tests/session-store.test.ts | 29 +++++++++++++++++++++- 13 files changed, 159 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 766d511..992ca79 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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 with matching button feedback, 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 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 long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. 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 on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap or ridden-this-trip 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 percentage, current grade, and terrain resistance directly on the map, 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. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; 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. Paired shift controllers can remain connected and are ignored while a workout controls resistance. Workout terrain is modeled separately from trainer control so virtual shifting can be integrated later without changing course data. +- 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 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 long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. 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 on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap or ridden-this-trip 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 percentage, current grade, and effective trainer resistance directly on the map, 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. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; 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. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior. - Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, exact workout distance, and loop or out-and-back course type. Valid GPX tracks or routes from other tools can be imported into a custom library saved only on the current device; closed paths become loops automatically, while open paths become out-and-back workouts with a generated return leg. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor. -- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired and no terrain workout is active; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then 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. +- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired, including during terrain workouts. Shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows use an evenly spaced proportional gear curve with matching visual feedback; gear 12 preserves the terrain load, gear 24 is roughly twice as hard, and gear 1 is roughly half as hard. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record and graph the gear together with workout elevation and progress. - 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. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects. - Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop. - Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. @@ -55,9 +55,11 @@ course keeps its editable metadata, map geometry, elevation, and resistance base 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, map position, and a bounded resistance -target. Recorded elevation appears alongside the other session graphs for the full ride and repeats -the course profile on every loop or out-and-back trip, while route progress stays portable in saved sessions and TCX -files and leaves room to compose the same terrain with virtual gearing later. +target. Virtual gearing applies one shared proportional ratio curve to that target, so terrain +changes ramp smoothly while button-driven gear changes remain immediate. Recorded elevation appears +alongside the other session graphs for the full ride and repeats the course profile on every loop or +out-and-back trip, while route progress and selected gear stay portable in saved sessions and TCX +files. 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. diff --git a/src/app.tsx b/src/app.tsx index 50785b1..f5a4087 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -26,6 +26,7 @@ import { useZwiftClick } from './hooks/use-zwift-click'; import { APP_OVERLAY, type AppOverlay } from './lib/app-overlay'; import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; +import { resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; @@ -81,11 +82,17 @@ export function App() { const workoutLibrary = useWorkoutLibrary(); const virtualShiftingReady = trainer.connected && click.connectedCount === MAX_CLICK_CONTROLLERS; + const gearResistanceRef = useRef<(fromGear: number, toGear: number) => void>( + trainer.shiftResistanceForGears + ); + const handleGearChange = useCallback( + (fromGear: number, toGear: number) => gearResistanceRef.current(fromGear, toGear), + [] + ); const gearControl = useGearControl({ active: click.paired, - onResistanceChange: trainer.shiftResistanceBy, + onGearChange: handleGearChange, ready: virtualShiftingReady, - resistance: trainer.resistance, setNotice: trainer.setNotice, }); const session = useSession( @@ -108,16 +115,26 @@ export function App() { const workoutTerrain = dashboardWorkout.workout ? workoutTerrainAtDistance(dashboardWorkout.workout.course, dashboardWorkout.distance) : undefined; + const virtualShiftingActive = click.paired; + let workoutResistance = workoutTerrain?.resistance; + if (workoutTerrain && virtualShiftingActive) { + workoutResistance = resistanceForVirtualGear(workoutTerrain.resistance, gearControl.gear); + } + gearResistanceRef.current = workoutTerrain + ? (_fromGear, toGear) => + trainer.updateProgramShiftResistance( + resistanceForVirtualGear(workoutTerrain.resistance, toGear) + ) + : trainer.shiftResistanceForGears; useWorkoutResistance({ active: !session.ended, connected: trainer.connected, onResistanceChange: trainer.updateProgramResistance, onRestoreResistance: trainer.restoreManualResistance, - terrain: workoutTerrain, + resistance: workoutResistance, }); const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide); const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen; - const virtualShiftingActive = click.paired && !dashboardWorkout.workout; clickShiftRef.current = shiftHandlerUnlessBlocked( gearControl.shiftGear, !(dashboardKeyboardEnabled && virtualShiftingActive) @@ -305,6 +322,7 @@ export function App() { elevationTotals={dashboardWorkout.elevationTotals} isRiding={session.isRiding} speedUnit={speedUnit} + targetResistance={workoutResistance} terrain={workoutTerrain} workout={dashboardWorkout.workout} /> @@ -320,7 +338,7 @@ export function App() { speedUnit={speedUnit} workout={session.workout} /> - {workoutTerrain ? null : ( + {workoutTerrain && !virtualShiftingActive ? null : ( sample.gear !== undefined)?.gear; + const workoutResistance = + workoutTerrain && finalGear !== undefined + ? resistanceForVirtualGear(workoutTerrain.resistance, finalGear) + : undefined; const controlMetric = usesGear ? { accent: 'mint', @@ -208,6 +214,7 @@ export function SessionDetail({ elevationTotals={session.elevationTotals} isRiding={false} speedUnit={speedUnit} + targetResistance={workoutResistance} terrain={workoutTerrain} workout={session.workout} /> diff --git a/src/components/workout-progress.tsx b/src/components/workout-progress.tsx index eb160d9..0fdb8a7 100644 --- a/src/components/workout-progress.tsx +++ b/src/components/workout-progress.tsx @@ -41,6 +41,7 @@ function WorkoutStats({ export function WorkoutProgress({ elevationTotals, isRiding, + targetResistance, speedUnit, terrain, workout, @@ -48,6 +49,7 @@ export function WorkoutProgress({ elevationTotals: ElevationTotals; isRiding: boolean; speedUnit: SpeedUnit; + targetResistance?: number; terrain: WorkoutTerrain; workout: SessionWorkout; }) { @@ -65,7 +67,10 @@ export function WorkoutProgress({ label: 'Grade', value: formatGrade(terrain.grade), }, - { label: 'Resistance', value: `${terrain.resistance}%` }, + { + label: 'Resistance', + value: `${Math.round(targetResistance ?? terrain.resistance)}%`, + }, ]; return (
diff --git a/src/hooks/use-gear-control.ts b/src/hooks/use-gear-control.ts index 9151690..99e5aa3 100644 --- a/src/hooks/use-gear-control.ts +++ b/src/hooks/use-gear-control.ts @@ -3,8 +3,6 @@ import { CONTROL_FLASH_MS } from '../constants'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { GEAR_STORAGE_KEY, - gearForResistance, - resistanceChangeForGears, SHIFTING_CONNECTION_MESSAGE, shiftedGear, storedGear, @@ -14,18 +12,16 @@ import type { ResistanceAdjustmentDirection } from '../types'; export function useGearControl({ active, - onResistanceChange, + onGearChange, ready, - resistance, setNotice, }: { active: boolean; - onResistanceChange: (change: number) => void; + onGearChange: (fromGear: number, toGear: number) => void; ready: boolean; - resistance: number; setNotice: (notice: string) => void; }) { - const [gear, setGear] = useState(() => storedGear(localStorage, gearForResistance(resistance))); + const [gear, setGear] = useState(() => storedGear()); const [shiftFlash, setShiftFlash] = useState(); const gearRef = useRef(gear); const keyboardControlsEnabled = useRef(true); @@ -51,9 +47,9 @@ export function useGearControl({ gearRef.current = next; setGear(next); localStorage.setItem(GEAR_STORAGE_KEY, String(next)); - onResistanceChange(resistanceChangeForGears(previous, next)); + onGearChange(previous, next); }, - [onResistanceChange, ready, setNotice] + [onGearChange, ready, setNotice] ); useEffect(() => { diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 35acdbb..2563cd8 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -1,7 +1,6 @@ import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { RECORDING_PAUSE_DELAY_MS } from '../constants'; -import { CONTROL_MODE } from '../lib/control-mode'; import { loadStoredSession, SESSION_STORAGE_KEY } from '../lib/session'; import { MILLISECONDS_PER_SECOND, secondsForMilliseconds } from '../lib/units'; import { @@ -61,13 +60,6 @@ interface SessionController { workout?: SessionWorkout; } -function controlForWorkout( - control: SessionControlState, - workout?: SessionWorkout -): SessionControlState { - return workout ? { ...control, mode: CONTROL_MODE.RESISTANCE } : control; -} - export function useSession( metrics: Metrics, control: SessionControlState, @@ -76,9 +68,8 @@ export function useSession( ): SessionController { const store = useMemo(() => createSessionStore(loadStoredSession()), []); const state = useSelector(store); - const effectiveControl = controlForWorkout(control, state.workout); const latestMetrics = useRef(metrics); - const latestControl = useRef(effectiveControl); + const latestControl = useRef(control); const lastTrainerDistance = useRef(undefined); useEffect(() => { @@ -87,9 +78,9 @@ export function useSession( }, [metrics, store]); useEffect(() => { - latestControl.current = effectiveControl; - store.actions.observeControlMode(effectiveControl.mode); - }, [effectiveControl, store]); + latestControl.current = control; + store.actions.observeControlMode(control.mode); + }, [control, store]); useEffect(() => { persistSessionState(store.get()); diff --git a/src/hooks/use-trainer.ts b/src/hooks/use-trainer.ts index 8954d12..8315597 100644 --- a/src/hooks/use-trainer.ts +++ b/src/hooks/use-trainer.ts @@ -4,6 +4,7 @@ import { CONTROL_FLASH_MS } from '../constants'; import { deviceConnectionView } from '../lib/device-connection'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { errorMessage } from '../lib/errors'; +import { resistanceAfterGearShift } from '../lib/gears'; import { scheduleNoticeDismissal } from '../lib/notification'; import { clamp } from '../lib/numbers'; import { @@ -145,14 +146,13 @@ export function useTrainer() { [connection.connected, queueResistance] ); - const shiftResistanceBy = useCallback( - (change: number) => { - const next = clampResistance(resistanceTarget.current + change); + const applyResistanceImmediately = useCallback( + (value: number, remember: boolean) => { + const next = clampResistance(value); window.clearTimeout(resistanceTimer.current); window.clearTimeout(resistanceRampTimer.current); resistanceTarget.current = next; appliedResistance.current = next; - rememberedResistance.current = next; setResistance(next); setResistanceRamp({ current: next, @@ -161,13 +161,29 @@ export function useTrainer() { progress: 1, to: next, }); - localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); + if (remember) { + rememberedResistance.current = next; + localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); + } trainerConnection .sendResistance(next) .catch((error: unknown) => setNotice(errorMessage(error))); }, [setNotice, setResistance, setResistanceRamp, trainerConnection.sendResistance] ); + const shiftResistanceForGears = useCallback( + (fromGear: number, toGear: number) => { + applyResistanceImmediately( + resistanceAfterGearShift(resistanceTarget.current, fromGear, toGear), + true + ); + }, + [applyResistanceImmediately] + ); + const updateProgramShiftResistance = useCallback( + (value: number) => applyResistanceImmediately(value, false), + [applyResistanceImmediately] + ); useEffect(() => { const handleKeys = (event: KeyboardEvent) => { @@ -245,9 +261,10 @@ export function useTrainer() { setKeyboardControlsEnabled, setNotice, settleAfterRide, - shiftResistanceBy, + shiftResistanceForGears, trainerReportsDistance: trainerConnection.trainerReportsDistance, updateProgramResistance, + updateProgramShiftResistance, updateResistance, }; } diff --git a/src/hooks/use-workout.ts b/src/hooks/use-workout.ts index a0f45c9..dfa7de3 100644 --- a/src/hooks/use-workout.ts +++ b/src/hooks/use-workout.ts @@ -1,20 +1,18 @@ import { useEffect, useRef } from 'react'; -import type { WorkoutTerrain } from '../types'; export function useWorkoutResistance({ active, connected, onResistanceChange, onRestoreResistance, - terrain, + resistance, }: { active: boolean; connected: boolean; onResistanceChange: (resistance: number) => void; onRestoreResistance: () => void; - terrain?: WorkoutTerrain; + resistance?: number; }) { - const resistance = terrain?.resistance; const automatedResistance = useRef(false); useEffect(() => { diff --git a/src/lib/gears.ts b/src/lib/gears.ts index 590c6bf..85a45ba 100644 --- a/src/lib/gears.ts +++ b/src/lib/gears.ts @@ -1,21 +1,20 @@ import { clamp } from './numbers'; +import { clampResistance } from './resistance'; export const MIN_GEAR = 1; export const MAX_GEAR = 24; export const DEFAULT_GEAR = 12; -export const RESISTANCE_POINTS_PER_GEAR = 3; export const GEAR_STORAGE_KEY = 'trainer-virtual-gear'; export const SHIFTING_CONNECTION_MESSAGE = 'Connect the trainer and controllers before shifting gears.'; +const GEAR_STEPS_PER_RESISTANCE_DOUBLING = 12; +const RESISTANCE_PRECISION = 10; + export function clampGear(gear: number): number { return clamp(Math.round(gear), MIN_GEAR, MAX_GEAR); } -export function gearForResistance(resistance: number): number { - return clampGear(resistance / RESISTANCE_POINTS_PER_GEAR + 1); -} - export function storedGear( storage: Pick = localStorage, fallback = DEFAULT_GEAR @@ -28,6 +27,22 @@ export function shiftedGear(current: number, change: number): number { return clampGear(current + change); } -export function resistanceChangeForGears(from: number, to: number): number { - return (clampGear(to) - clampGear(from)) * RESISTANCE_POINTS_PER_GEAR; +export function virtualGearRatio(gear: number): number { + return 2 ** ((clampGear(gear) - DEFAULT_GEAR) / GEAR_STEPS_PER_RESISTANCE_DOUBLING); +} + +function roundedResistance(resistance: number): number { + return Math.round(clampResistance(resistance) * RESISTANCE_PRECISION) / RESISTANCE_PRECISION; +} + +export function resistanceForVirtualGear(baseResistance: number, gear: number): number { + return roundedResistance(baseResistance * virtualGearRatio(gear)); +} + +export function resistanceAfterGearShift( + resistance: number, + fromGear: number, + toGear: number +): number { + return roundedResistance(resistance * (virtualGearRatio(toGear) / virtualGearRatio(fromGear))); } diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index 7eef6af..2e05b04 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -66,7 +66,6 @@ function selectActiveWorkout( } return { ...current, - controlMode: course ? CONTROL_MODE.RESISTANCE : current.controlMode, workout: course ? { course } : undefined, }; } @@ -245,7 +244,7 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { return { ...emptySession, aggregates: emptySession.aggregates, - controlMode: workout ? CONTROL_MODE.RESISTANCE : controlMode, + controlMode, isRiding: false, manuallyPaused: false, maximums: emptyMetrics, diff --git a/tests/components.test.tsx b/tests/components.test.tsx index aa2dbe0..6323e06 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -532,11 +532,13 @@ describe('view components', () => { expect(lockedPanel.match(/disabled=""/g)).toHaveLength(6); const terrain = workoutTerrainAtDistance(course, course.distance * 2 + 2); + const shiftedWorkoutResistance = 42.4; const progress = render( @@ -559,7 +561,8 @@ describe('view components', () => { expect(progress).toContain('Grade'); expect(progress).toContain(formatGrade(terrain.grade)); expect(progress).toContain('Resistance'); - expect(progress).toContain(`${terrain.resistance}%`); + expect(progress).toContain(`${Math.round(shiftedWorkoutResistance)}%`); + expect(progress).not.toContain(`${terrain.resistance}%`); expect(progress.match(/sm:text-4xl/g)).toHaveLength(3); expect(progress.match(/sm:text-2xl/g)).toHaveLength(3); expect(progress).toContain('sm:text-lg'); diff --git a/tests/gears.test.ts b/tests/gears.test.ts index 857e899..0808950 100644 --- a/tests/gears.test.ts +++ b/tests/gears.test.ts @@ -2,12 +2,13 @@ import { describe, expect, test } from 'bun:test'; import { clampGear, DEFAULT_GEAR, - gearForResistance, MAX_GEAR, MIN_GEAR, - resistanceChangeForGears, + resistanceAfterGearShift, + resistanceForVirtualGear, shiftedGear, storedGear, + virtualGearRatio, } from '../src/lib/gears'; describe('virtual gears', () => { @@ -25,15 +26,31 @@ describe('virtual gears', () => { expect(storedGear({ getItem: () => '0' })).toBe(DEFAULT_GEAR); }); - test('starts near the trainer resistance when no virtual gear is remembered', () => { - expect(gearForResistance(0)).toBe(1); - expect(gearForResistance(10)).toBe(4); - expect(gearForResistance(100)).toBe(24); - expect(storedGear({ getItem: () => null }, gearForResistance(10))).toBe(4); + test('starts in the neutral gear when no virtual gear is remembered', () => { + expect(storedGear({ getItem: () => null })).toBe(DEFAULT_GEAR); }); - test('maps each shift to a quick three-point resistance change', () => { - expect(resistanceChangeForGears(12, 13)).toBe(3); - expect(resistanceChangeForGears(12, 10)).toBe(-6); + test('uses evenly spaced ratios across the 24-gear range', () => { + expect(virtualGearRatio(DEFAULT_GEAR)).toBe(1); + expect(virtualGearRatio(MAX_GEAR)).toBe(2); + expect(virtualGearRatio(MIN_GEAR)).toBeCloseTo(0.53, 2); + expect(virtualGearRatio(13) / virtualGearRatio(12)).toBeCloseTo( + virtualGearRatio(12) / virtualGearRatio(11), + 10 + ); + }); + + test('scales terrain resistance around neutral gear and clamps trainer targets', () => { + expect(resistanceForVirtualGear(30, DEFAULT_GEAR)).toBe(30); + expect(resistanceForVirtualGear(30, MAX_GEAR)).toBe(60); + expect(resistanceForVirtualGear(30, MIN_GEAR)).toBe(15.9); + expect(resistanceForVirtualGear(80, MAX_GEAR)).toBe(100); + }); + + test('applies the same ratio curve to consecutive free-ride shifts', () => { + const harder = resistanceAfterGearShift(30, 12, 13); + expect(harder).toBe(31.8); + expect(resistanceAfterGearShift(harder, 13, 12)).toBeCloseTo(30, 1); + expect(resistanceAfterGearShift(3, 12, 1)).toBe(1.6); }); }); diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts index 99cd7c7..a8ac184 100644 --- a/tests/session-store.test.ts +++ b/tests/session-store.test.ts @@ -168,6 +168,33 @@ describe('session store', () => { ); }); + test('records virtual gears and workout terrain in the same ride samples', () => { + const workout = WORKOUT_COURSES.find((course) => course.id === 'cedar-circuit'); + if (!workout) { + throw new Error('Expected a built-in workout course'); + } + const store = createSessionStore(restoredSession({ controlMode: CONTROL_MODE.GEAR }), 1000); + store.actions.selectWorkout(workout); + expect(store.get().controlMode).toBe(CONTROL_MODE.GEAR); + store.actions.syncRiding(true); + store.actions.recordTick({ + control: { gear: 8, mode: CONTROL_MODE.GEAR, resistance: 20 }, + distanceDelta: 1, + metrics: liveMetrics, + seconds: 1, + }); + + expect(store.get().history[0]).toMatchObject({ + gear: 8, + workoutDistance: 1, + workoutLap: 1, + }); + expect(store.get().history[0]?.elevation).toBeGreaterThan(0); + expect(store.get().history[0]?.resistance).toBeUndefined(); + expect(store.get().aggregates.gear).toEqual({ count: 1, maximum: 8, sum: 8 }); + expect(store.get().aggregates.resistance).toEqual({ count: 0, sum: 0 }); + }); + test('replaces an unstarted workout when its saved definition is revised', () => { const workout = WORKOUT_COURSES.find((course) => course.id === 'cedar-circuit'); if (!workout) { @@ -274,7 +301,7 @@ describe('session store', () => { store.actions.reset(CONTROL_MODE.GEAR, 5000); expect(store.get().ended).toBe(false); - expect(store.get().controlMode).toBe(CONTROL_MODE.RESISTANCE); + expect(store.get().controlMode).toBe(CONTROL_MODE.GEAR); expect(store.get().workout?.course.id).toBe(plannedCourse.id); expect(store.get().plannedWorkout).toBeUndefined(); }); From 5199917d94f06f197c87a3603b988e2a8a9397f7 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 10:06:03 -0700 Subject: [PATCH 2/5] Fix footer repository link --- src/components/app-footer.tsx | 2 +- tests/components.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/app-footer.tsx b/src/components/app-footer.tsx index 8abf41d..9bb72e6 100644 --- a/src/components/app-footer.tsx +++ b/src/components/app-footer.tsx @@ -13,7 +13,7 @@ export function AppFooter({ onOpenWelcome }: { onOpenWelcome: () => void }) { diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 6323e06..6535163 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -643,7 +643,7 @@ describe('view components', () => { 'href="https://github.com/lookfirst/RideControl/pulls?q=is%3Apr+is%3Aclosed"' ); expect(html).toContain('