From a629b5a8195ec04d4b0a72baf9a28a5318352a5e Mon Sep 17 00:00:00 2001 From: Ride Control Date: Thu, 23 Jul 2026 15:31:11 -0700 Subject: [PATCH 1/4] Add rider profiles to session history --- README.md | 2 +- src/app.tsx | 10 ++-- src/components/dashboard-tools.tsx | 18 ------ src/components/profile-dialog.tsx | 92 ++++++++++++++++++++++++++++-- src/hooks/use-session.ts | 15 ++++- src/lib/profile.ts | 69 ++++++++++++++++++---- src/lib/saved-sessions.ts | 2 + src/lib/session.ts | 10 +--- src/lib/tcx-import.ts | 17 ++++++ src/lib/tcx.ts | 16 +++++- src/lib/unload.ts | 6 ++ src/stores/session-store.ts | 39 ++++++++++++- src/types.ts | 3 + tests/components.test.tsx | 27 ++++++++- tests/fixtures/saved-session.ts | 6 ++ tests/profile.test.ts | 38 ++++++++++++ tests/session-store.test.ts | 52 +++++++++++++++++ tests/session.test.ts | 20 ++++++- tests/tcx-import.test.ts | 1 + tests/tcx.test.ts | 7 +++ 20 files changed, 399 insertions(+), 51 deletions(-) create mode 100644 src/lib/unload.ts diff --git a/README.md b/README.md index 3b02412..b284ee3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,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. - 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. Trainers and the active Click controller keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. 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. - Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes. -- Keeps an optional rider profile in IndexedDB with name, profile image, rider and bike weight, an inclusive free-form sex or gender identity field, and editable front-chainring and rear-cassette tooth counts. Weight follows the dashboard's pounds or kilograms display, while calculations use canonical kilograms. Rider and bike mass scale terrain load, and the configured tooth counts define up to 24 ordered virtual gear ratios; identity, name, and image never affect workout calculations. +- Keeps an optional rider profile in IndexedDB with name, profile image, rider and bike weight, an inclusive free-form sex or gender identity field, editable front-chainring and rear-cassette tooth counts, and the app-wide MPH or KM/H display preference. Weight follows the profile's selected pounds or kilograms display, while calculations use canonical kilograms. The browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight, bike 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 historical rides remain attributable to the settings that produced them. Those physics fields lock after recording begins and unlock when the session ends; name, image, identity, and display units remain editable. Rider and bike mass scale terrain load, and the configured tooth counts define up to 24 ordered virtual gear ratios; identity, name, and image never affect workout calculations or enter session history. - 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, 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. - 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 with every valid route point preserved. The map-first BikeGPX browser lists only fully prepared public routes, searches them by name, place, distance, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, shows the selected route's elevation profile beside its finalized statistics, and imports it in one click. Every visible route already includes finalized distance, climbing, maximum grade, and difficulty, 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, renaming, removing, and vertical drag reordering with persistent order. The terrain tray and BikeGPX browser remember their open state, scroll positions, searches, filters, and selected route across reloads. Missing descriptions use a cached starting-city lookup that is saved with the workout. diff --git a/src/app.tsx b/src/app.tsx index 0d053c8..3d2f924 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -60,7 +60,8 @@ import { unreachable } from './lib/errors'; import { maximumGear, resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { profileTotalMassKg, type RiderProfile } from './lib/profile'; -import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; +import { sessionNeedsUnloadWarning } from './lib/session'; +import { requestUnloadConfirmation } from './lib/unload'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; import { workoutDashboardPreview, @@ -306,7 +307,8 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored }, trainer.lastPedalingAt, trainer.trainerReportsDistance, - initialSession + initialSession, + riderProfile.ready ? riderProfile.profile : undefined ); const dashboardWorkout = workoutDashboardPreview({ distance: session.rideDistance, @@ -557,9 +559,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored onOpenDevices={() => setActiveOverlay(APP_OVERLAY.DEVICES)} onOpenHistory={() => setActiveOverlay(APP_OVERLAY.HISTORY)} onOpenShortcuts={() => setActiveOverlay(APP_OVERLAY.SHORTCUTS)} - onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit} pairedDeviceCount={pairedDeviceCount} - speedUnit={speedUnit} /> setActiveOverlay(undefined)} onSave={riderProfile.save} + onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit} open={activeOverlay === APP_OVERLAY.PROFILE} + physicsSettingsLocked={warnBeforeUnload} profile={riderProfile.profile} speedUnit={speedUnit} storageError={riderProfile.storageError} diff --git a/src/components/dashboard-tools.tsx b/src/components/dashboard-tools.tsx index 74244eb..ea9d52c 100644 --- a/src/components/dashboard-tools.tsx +++ b/src/components/dashboard-tools.tsx @@ -1,5 +1,3 @@ -import { SPEED_UNIT_OPTIONS } from '../lib/units'; -import type { SpeedUnit } from '../types'; import { DevicePairingButton } from './device-pairing'; export function DashboardTools({ @@ -8,18 +6,14 @@ export function DashboardTools({ onOpenDevices, onOpenHistory, onOpenShortcuts, - onSelectSpeedUnit, pairedDeviceCount, - speedUnit, }: { connectedDeviceCount: number; devicesConnecting: boolean; onOpenDevices: () => void; onOpenHistory: () => void; onOpenShortcuts: () => void; - onSelectSpeedUnit: (unit: SpeedUnit) => void; pairedDeviceCount: number; - speedUnit: SpeedUnit; }) { return (
@@ -30,18 +24,6 @@ export function DashboardTools({ > Sessions -
- {SPEED_UNIT_OPTIONS.map((option) => ( - - ))} -
+ ))} +
+

+ Controls speed, distance, elevation, and weight units. +

+ + {physicsSettingsLocked ? ( +

+ Weight and drivetrain settings are locked after a ride starts so its + recorded profile remains accurate. End the session to edit them for your + next ride. +

+ ) : null}

Rider and bike weight adjust terrain load. Chainring and cassette sizes define the virtual gear ratios. Your profile stays in IndexedDB on this diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 9d078d2..3cc5488 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { RECORDING_PAUSE_DELAY_MS } from '../constants'; import { createActiveSessionWriter } from '../lib/active-session'; import { CONTROL_MODE, trainingControlMode } from '../lib/control-mode'; +import type { RiderPhysicsProfile } from '../lib/profile'; import { MILLISECONDS_PER_SECOND, secondsForMilliseconds } from '../lib/units'; import { createSessionStore, @@ -52,6 +53,7 @@ interface SessionController { markDiscarded: () => void; markSaved: (id: string) => void; maximums: Metrics; + profileSnapshot?: RiderPhysicsProfile; rideCalories: number; rideDistance: number; savedSessionId?: string; @@ -69,7 +71,8 @@ export function useSession( control: SessionControlState, lastPedalingAt: ActivityRef, trainerReportsDistance: FlagRef, - initialSession: StoredSession + initialSession: StoredSession, + profileSnapshot?: RiderPhysicsProfile ): SessionController { const store = useMemo(() => createSessionStore(initialSession), [initialSession]); const persistActive = useMemo(() => createActiveSessionWriter(), []); @@ -83,6 +86,7 @@ export function useSession( : control; const latestMetrics = useRef(metrics); const latestControl = useRef(activeControl); + const latestProfileSnapshot = useRef(profileSnapshot); const lastTrainerDistance = useRef(undefined); useEffect(() => { @@ -95,6 +99,13 @@ export function useSession( store.actions.observeControlMode(activeControl.mode); }, [activeControl, store]); + useEffect(() => { + latestProfileSnapshot.current = profileSnapshot; + if (profileSnapshot) { + store.actions.observeProfileSnapshot(profileSnapshot); + } + }, [profileSnapshot, store]); + useEffect(() => { const checkpoint = () => { persistActive(storedSessionFromState(store.get())).catch(() => undefined); @@ -149,6 +160,7 @@ export function useSession( control: liveControl, distanceDelta, metrics: live, + profileSnapshot: latestProfileSnapshot.current, seconds, }); }, MILLISECONDS_PER_SECOND); @@ -213,6 +225,7 @@ export function useSession( markDiscarded, markSaved, maximums: state.maximums, + profileSnapshot: state.profileSnapshot, rideCalories: state.calories, rideDistance: state.distance, savedSessionId: state.savedSessionId, diff --git a/src/lib/profile.ts b/src/lib/profile.ts index 80e90da..07a6605 100644 --- a/src/lib/profile.ts +++ b/src/lib/profile.ts @@ -45,12 +45,15 @@ export interface VirtualDrivetrain { rearCassetteTeeth: readonly number[]; } -export interface RiderProfile extends VirtualDrivetrain { +export interface RiderPhysicsProfile extends VirtualDrivetrain { bikeWeightKg: number; + riderWeightKg: number; +} + +export interface RiderProfile extends RiderPhysicsProfile { identity: string; image?: Blob; name: string; - riderWeightKg: number; } interface StoredRiderProfile extends RiderProfile { @@ -115,23 +118,21 @@ function isProfileImage(value: unknown): value is Blob { return value instanceof Blob && PROFILE_IMAGE_TYPES.some((type) => type === value.type); } -export function profileFromStoredValue(value: unknown): RiderProfile | undefined { - if (!(isRecord(value) && value.version === PROFILE_VERSION)) { +export function riderPhysicsProfileFromStoredValue( + value: unknown +): RiderPhysicsProfile | undefined { + if (!isRecord(value)) { return; } const frontChainringTeeth = numericArray(value.frontChainringTeeth); const rearCassetteTeeth = numericArray(value.rearCassetteTeeth); if ( !( - isString(value.name) && - isString(value.identity) && isFiniteNumber(value.riderWeightKg) && isFiniteNumber(value.bikeWeightKg) && frontChainringTeeth && rearCassetteTeeth ) || - value.name.length > MAXIMUM_PROFILE_NAME_LENGTH || - value.identity.length > MAXIMUM_PROFILE_IDENTITY_LENGTH || value.riderWeightKg < MINIMUM_RIDER_WEIGHT_KG || value.riderWeightKg > MAXIMUM_RIDER_WEIGHT_KG || value.bikeWeightKg < MINIMUM_BIKE_WEIGHT_KG || @@ -141,15 +142,61 @@ export function profileFromStoredValue(value: unknown): RiderProfile | undefined ) { return; } - const image = isProfileImage(value.image) ? value.image : undefined; return { bikeWeightKg: value.bikeWeightKg, frontChainringTeeth, + rearCassetteTeeth, + riderWeightKg: value.riderWeightKg, + }; +} + +export function snapshotRiderPhysicsProfile(profile: RiderPhysicsProfile): RiderPhysicsProfile { + return { + bikeWeightKg: profile.bikeWeightKg, + frontChainringTeeth: [...profile.frontChainringTeeth], + rearCassetteTeeth: [...profile.rearCassetteTeeth], + riderWeightKg: profile.riderWeightKg, + }; +} + +export function sameRiderPhysicsProfile( + left: RiderPhysicsProfile | undefined, + right: RiderPhysicsProfile +): boolean { + return ( + left?.bikeWeightKg === right.bikeWeightKg && + left.riderWeightKg === right.riderWeightKg && + left.frontChainringTeeth.length === right.frontChainringTeeth.length && + left.rearCassetteTeeth.length === right.rearCassetteTeeth.length && + left.frontChainringTeeth.every( + (tooth, index) => tooth === right.frontChainringTeeth[index] + ) && + left.rearCassetteTeeth.every((tooth, index) => tooth === right.rearCassetteTeeth[index]) + ); +} + +export function profileFromStoredValue(value: unknown): RiderProfile | undefined { + if (!(isRecord(value) && value.version === PROFILE_VERSION)) { + return; + } + const physicsProfile = riderPhysicsProfileFromStoredValue(value); + if ( + !( + physicsProfile && + isString(value.name) && + isString(value.identity) && + value.name.length <= MAXIMUM_PROFILE_NAME_LENGTH && + value.identity.length <= MAXIMUM_PROFILE_IDENTITY_LENGTH + ) + ) { + return; + } + const image = isProfileImage(value.image) ? value.image : undefined; + return { + ...physicsProfile, identity: value.identity, image, name: value.name, - rearCassetteTeeth, - riderWeightKg: value.riderWeightKg, }; } diff --git a/src/lib/saved-sessions.ts b/src/lib/saved-sessions.ts index 3262c06..1bc18f6 100644 --- a/src/lib/saved-sessions.ts +++ b/src/lib/saved-sessions.ts @@ -10,6 +10,7 @@ import type { import { IMPORTED_FIT_ID_PREFIX } from './activity-file'; import { restoreElevationTotals } from './elevation'; import { indexedDbRequestResult, indexedDbTransactionComplete } from './indexed-db'; +import { riderPhysicsProfileFromStoredValue } from './profile'; import { aggregateGear, aggregateResistance, @@ -470,6 +471,7 @@ export function normalizeSavedSession(session: SavedSession): SavedSession { controlMode: controlModeForHistory(session.history, session.controlMode), elevationTotals: restoreElevationTotals(session.elevationTotals, session.history), importedAt: normalizedImportedAt(session.importedAt), + profileSnapshot: riderPhysicsProfileFromStoredValue(session.profileSnapshot), workout: restoreSessionWorkout(session.workout), }; } diff --git a/src/lib/session.ts b/src/lib/session.ts index ebcf42f..a3e4fe1 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -11,6 +11,7 @@ import { CONTROL_MODE, type ControlMode, isControlMode } from './control-mode'; import { restoreElevationTotals } from './elevation'; import { clampGear, MAX_GEAR, MIN_GEAR } from './gears'; import { clamp, nonNegativeNumber } from './numbers'; +import { riderPhysicsProfileFromStoredValue } from './profile'; import { clampResistance, DEFAULT_RESISTANCE, MAX_RESISTANCE, MIN_RESISTANCE } from './resistance'; import { isFiniteNumber, isRecord, isString } from './type-guards'; import { restoreSessionWorkout } from './workouts'; @@ -33,6 +34,7 @@ export function sessionContinuation(snapshot: SessionSnapshot): StoredSession { endedAt: 0, history: snapshot.history, maximums: snapshot.maximums, + profileSnapshot: snapshot.profileSnapshot, startedAt: snapshot.startedAt, workout: snapshot.workout, }; @@ -42,13 +44,6 @@ export function sessionNeedsUnloadWarning(ended: boolean, elapsedSeconds: number return !ended && elapsedSeconds > 0; } -export function requestUnloadConfirmation( - event: Pick -): void { - event.preventDefault(); - event.returnValue = true; -} - export function addAggregate( aggregate: MetricAggregate, value: number, @@ -216,6 +211,7 @@ export function restoreStoredSession(value: unknown): StoredSession { speed: nonNegativeNumber(maximums.speed), }, plannedWorkout: restoreSessionWorkout(parsed.plannedWorkout), + profileSnapshot: riderPhysicsProfileFromStoredValue(parsed.profileSnapshot), savedSessionId: isString(parsed.savedSessionId) ? parsed.savedSessionId : undefined, startedAt: nonNegativeNumber(parsed.startedAt), workout: restoreSessionWorkout(parsed.workout), diff --git a/src/lib/tcx-import.ts b/src/lib/tcx-import.ts index 487b7f5..c11ffd7 100644 --- a/src/lib/tcx-import.ts +++ b/src/lib/tcx-import.ts @@ -12,6 +12,7 @@ import { CONTROL_MODE } from './control-mode'; import { elevationTotalsForSamples } from './elevation'; import { clampGear } from './gears'; import { nonNegativeNumber } from './numbers'; +import { parsedTeeth, riderPhysicsProfileFromStoredValue } from './profile'; import { clampResistance } from './resistance'; import { addMetricAggregates } from './session'; import { IMPORTED_TCX_ID_PREFIX, isRideControlTcxExtensionNamespace } from './tcx-schema'; @@ -157,6 +158,21 @@ function activityWorkout(activity: Element): SessionWorkout | undefined { }); } +function activityProfileSnapshot(activity: Element) { + const profile = descendants(activity, 'ProfileSnapshot').find((element) => + isRideControlTcxExtensionNamespace(element.namespaceURI) + ); + if (!profile) { + return; + } + return riderPhysicsProfileFromStoredValue({ + bikeWeightKg: numberValue(child(profile, 'BikeWeightKilograms')), + frontChainringTeeth: parsedTeeth(text(child(profile, 'FrontChainrings'))), + rearCassetteTeeth: parsedTeeth(text(child(profile, 'RearCassette'))), + riderWeightKg: numberValue(child(profile, 'RiderWeightKilograms')), + }); +} + function fallbackAggregate(laps: Element[], names: string[]): MetricAggregate { const values = laps .map((lap) => @@ -300,6 +316,7 @@ function parseActivity(activity: Element): SavedSession { KILOMETERS_PER_HOUR_PER_METER_PER_SECOND ), }, + profileSnapshot: activityProfileSnapshot(activity), startedAt, workout: activityWorkout(activity), }; diff --git a/src/lib/tcx.ts b/src/lib/tcx.ts index 95773b2..85de0b3 100644 --- a/src/lib/tcx.ts +++ b/src/lib/tcx.ts @@ -8,6 +8,7 @@ import { CONTROL_MODE } from './control-mode'; import { downloadBrowserFile } from './download'; import { aggregateAverage, aggregateMaximum } from './format'; import { nonNegativeNumber } from './numbers'; +import type { RiderPhysicsProfile } from './profile'; import { RIDECONTROL_TCX_EXTENSION_NAMESPACE, TCX_ACTIVITY_EXTENSION_NAMESPACE, @@ -93,6 +94,19 @@ function workoutSummaryXml(workout?: SessionWorkout): string { `; } +function profileSummaryXml(profile?: RiderPhysicsProfile): string { + if (!profile) { + return ''; + } + return ` + + ${profile.riderWeightKg.toFixed(3)} + ${profile.bikeWeightKg.toFixed(3)} + ${profile.frontChainringTeeth.join('/')} + ${profile.rearCassetteTeeth.join('/')} + `; +} + export function sessionToTcx(session: SavedSession): string { const startedAt = new Date(session.startedAt).toISOString(); const distances = sessionSampleDistances(session); @@ -169,7 +183,7 @@ export function sessionToTcx(session: SavedSession): string { ${xmlEscape(session.id)} ${nonNegativeNumber(session.elevationTotals.ascent).toFixed(2)} ${nonNegativeNumber(session.elevationTotals.descent).toFixed(2)} - ${controlSummary}${workoutSummaryXml(session.workout)} + ${controlSummary}${profileSummaryXml(session.profileSnapshot)}${workoutSummaryXml(session.workout)} ${ diff --git a/src/lib/unload.ts b/src/lib/unload.ts new file mode 100644 index 0000000..d07a9bc --- /dev/null +++ b/src/lib/unload.ts @@ -0,0 +1,6 @@ +export function requestUnloadConfirmation( + event: Pick +): void { + event.preventDefault(); + event.returnValue = true; +} diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index cb3fb4a..ae1802f 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -2,6 +2,11 @@ import { createStore } from '@tanstack/react-store'; import { emptyMetrics, emptySession } from '../constants'; import { CONTROL_MODE, type ControlMode } from '../lib/control-mode'; import { estimatedCyclingCalories } from '../lib/cycling-energy'; +import { + type RiderPhysicsProfile, + sameRiderPhysicsProfile, + snapshotRiderPhysicsProfile, +} from '../lib/profile'; import { addMetricAggregates, sessionContinuation } from '../lib/session'; import { kilometersTraveled } from '../lib/units'; import { @@ -25,6 +30,7 @@ interface RecordSessionTick { }; distanceDelta?: number; metrics: Metrics; + profileSnapshot?: RiderPhysicsProfile; seconds: number; } @@ -89,6 +95,16 @@ function elevationTotalsAfterTick(current: SessionStoreState, distance: number) : current.elevationTotals; } +function profileSnapshotAfterTick( + current: RiderPhysicsProfile | undefined, + observed: RiderPhysicsProfile | undefined +): RiderPhysicsProfile | undefined { + if (current) { + return current; + } + return observed ? snapshotRiderPhysicsProfile(observed) : undefined; +} + export function sessionSnapshotFromState(state: SessionStoreState): SessionSnapshot { return { aggregates: state.aggregates, @@ -100,6 +116,7 @@ export function sessionSnapshotFromState(state: SessionStoreState): SessionSnaps endedAt: state.endedAt, history: state.history, maximums: state.maximums, + profileSnapshot: state.profileSnapshot, startedAt: state.startedAt, workout: state.workout, }; @@ -119,6 +136,7 @@ export function storedSessionFromState(state: SessionStoreState): StoredSession history: state.history, maximums: state.maximums, plannedWorkout: state.plannedWorkout, + profileSnapshot: state.profileSnapshot, savedSessionId: state.savedSessionId, startedAt: state.startedAt, workout: state.workout, @@ -181,7 +199,22 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { return { ...current, maximums }; }); }, - recordTick: ({ control, distanceDelta, metrics, seconds }: RecordSessionTick) => { + observeProfileSnapshot: (profile: RiderPhysicsProfile) => { + setState((current) => + current.elapsedSeconds === 0 && + !current.ended && + !sameRiderPhysicsProfile(current.profileSnapshot, profile) + ? { ...current, profileSnapshot: snapshotRiderPhysicsProfile(profile) } + : current + ); + }, + recordTick: ({ + control, + distanceDelta, + metrics, + profileSnapshot, + seconds, + }: RecordSessionTick) => { setState((current) => { if (!current.isRiding) { return current; @@ -227,6 +260,10 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { elapsedSeconds, elevationTotals, history: [...current.history, sample], + profileSnapshot: profileSnapshotAfterTick( + current.profileSnapshot, + profileSnapshot + ), }; }); }, diff --git a/src/types.ts b/src/types.ts index 955b7bb..2ac4833 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { ControlMode } from './lib/control-mode'; +import type { RiderPhysicsProfile } from './lib/profile'; import type { WorkoutDescriptionAttribution } from './lib/workout-description'; import type { WorkoutDifficulty, WorkoutRouteType } from './lib/workout-schema'; @@ -120,6 +121,7 @@ export interface StoredSession { history: MetricSample[]; maximums: Metrics; plannedWorkout?: SessionWorkout; + profileSnapshot?: RiderPhysicsProfile; savedSessionId?: string; startedAt: number; workout?: SessionWorkout; @@ -142,6 +144,7 @@ export interface SessionSnapshot { endedAt: number; history: MetricSample[]; maximums: Metrics; + profileSnapshot?: RiderPhysicsProfile; startedAt: number; workout?: SessionWorkout; } diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 786525d..62b33c4 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -854,7 +854,8 @@ describe('view components', () => { expect(html).not.toContain('fixed right-4 bottom-3 left-4'); expect(html).toContain('rounded-2xl border border-line bg-panel p-4'); expect(html).toContain('xl:grid-cols-[1.45fr_.55fr]'); - expect(html.indexOf('KM/H')).toBeLessThan(html.indexOf('Show keyboard controls')); + expect(html).not.toContain('>KM/H'); + expect(html).not.toContain('>MPH'); expect(html).toMatch(enabledEndSessionButton); }); @@ -939,7 +940,9 @@ describe('view components', () => { undefined} onSave={async () => undefined} + onSelectSpeedUnit={() => undefined} open={false} + physicsSettingsLocked={false} profile={profile} speedUnit="mph" storageError="" @@ -950,7 +953,9 @@ describe('view components', () => { undefined} onSave={async () => undefined} + onSelectSpeedUnit={() => undefined} open + physicsSettingsLocked={false} profile={profile} speedUnit="mph" storageError="" @@ -963,12 +968,32 @@ describe('view components', () => { expect(html).toContain('value="Non-binary"'); expect(html).toContain('value="Two-Spirit"'); expect(html).toContain('never used in workout calculations'); + expect(html).toContain('Display units'); + expect(html).toContain('aria-pressed="true"'); + expect(html).toContain('>KM/H'); + expect(html).toContain('>MPH'); + expect(html).toContain('Controls speed, distance, elevation, and weight units.'); expect(html).toContain('Your weight (lb)'); expect(html).toContain('Bike weight (lb)'); expect(html).toContain('value="53/39"'); expect(html).toContain('This setup creates 24 virtual gears'); expect(html).toContain('IndexedDB'); expect(html).toContain('aria-label="Close profile"'); + const lockedHtml = render( + undefined} + onSave={async () => undefined} + onSelectSpeedUnit={() => undefined} + open + physicsSettingsLocked + profile={profile} + speedUnit="mph" + storageError="" + /> + ); + expect(lockedHtml).toContain('Weight and drivetrain settings are locked'); + expect(lockedHtml).toContain('id="profile-rider-weight"'); + expect(lockedHtml).toContain('disabled=""'); }); test('shows manual virtual shifting for a terrain workout without Click controllers', async () => { diff --git a/tests/fixtures/saved-session.ts b/tests/fixtures/saved-session.ts index bfae91b..afb3361 100644 --- a/tests/fixtures/saved-session.ts +++ b/tests/fixtures/saved-session.ts @@ -46,5 +46,11 @@ export const savedSessionFixture: SavedSession = { power: 210, speed: 30, }, + profileSnapshot: { + bikeWeightKg: 9, + frontChainringTeeth: [53, 39], + rearCassetteTeeth: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24], + riderWeightKg: 75, + }, startedAt: SAVED_SESSION_STARTED_AT, }; diff --git a/tests/profile.test.ts b/tests/profile.test.ts index b7ecbef..406277d 100644 --- a/tests/profile.test.ts +++ b/tests/profile.test.ts @@ -12,6 +12,9 @@ import { poundsForKilograms, profileFromStoredValue, profileTotalMassKg, + riderPhysicsProfileFromStoredValue, + sameRiderPhysicsProfile, + snapshotRiderPhysicsProfile, } from '../src/lib/profile'; describe('rider profile', () => { @@ -41,6 +44,41 @@ describe('rider profile', () => { expect(kilogramsForPounds(poundsForKilograms(84))).toBeCloseTo(84, 10); }); + test('validates and independently copies the physics-only session profile', () => { + const profile = riderPhysicsProfileFromStoredValue({ + bikeWeightKg: 8.5, + frontChainringTeeth: [50, 34], + rearCassetteTeeth: [11, 13, 15, 17], + riderWeightKg: 68, + }); + expect(profile).toEqual({ + bikeWeightKg: 8.5, + frontChainringTeeth: [50, 34], + rearCassetteTeeth: [11, 13, 15, 17], + riderWeightKg: 68, + }); + if (!profile) { + return; + } + const snapshot = snapshotRiderPhysicsProfile(profile); + expect(snapshot).toEqual(profile); + expect(snapshot.frontChainringTeeth).not.toBe(profile.frontChainringTeeth); + expect(snapshot.rearCassetteTeeth).not.toBe(profile.rearCassetteTeeth); + expect(sameRiderPhysicsProfile(snapshot, profile)).toBe(true); + expect( + sameRiderPhysicsProfile(snapshot, { + ...profile, + riderWeightKg: 69, + }) + ).toBe(false); + expect( + riderPhysicsProfileFromStoredValue({ + ...profile, + riderWeightKg: -1, + }) + ).toBeUndefined(); + }); + test('validates a versioned IndexedDB profile record', () => { const image = new Blob(['profile'], { type: 'image/png' }); expect( diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts index fa42e0e..49a3964 100644 --- a/tests/session-store.test.ts +++ b/tests/session-store.test.ts @@ -33,7 +33,59 @@ const liveMetrics: Metrics = { speed: 36, }; +const firstProfile = { + bikeWeightKg: 8, + frontChainringTeeth: [50, 34], + rearCassetteTeeth: [11, 13, 15, 17], + riderWeightKg: 68, +}; + +const secondProfile = { + bikeWeightKg: 9, + frontChainringTeeth: [53, 39], + rearCassetteTeeth: [12, 14, 16, 18], + riderWeightKg: 75, +}; + describe('session store', () => { + test('captures current physics settings and freezes them once recording begins', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.observeProfileSnapshot(firstProfile); + expect(store.get().profileSnapshot).toEqual(firstProfile); + expect(store.get().profileSnapshot?.frontChainringTeeth).not.toBe( + firstProfile.frontChainringTeeth + ); + + store.actions.observeProfileSnapshot(secondProfile); + expect(store.get().profileSnapshot).toEqual(secondProfile); + store.actions.syncRiding(true); + store.actions.recordTick({ + control: { gear: 4, mode: 'gear', resistance: 30 }, + metrics: liveMetrics, + profileSnapshot: secondProfile, + seconds: 1, + }); + store.actions.observeProfileSnapshot(firstProfile); + expect(store.get().profileSnapshot).toEqual(secondProfile); + expect(sessionSnapshotFromState(store.get()).profileSnapshot).toEqual(secondProfile); + expect(storedSessionFromState(store.get()).profileSnapshot).toEqual(secondProfile); + + store.actions.reset('gear', 2000); + expect(store.get().profileSnapshot).toBeUndefined(); + }); + + test('captures a profile on the first tick when none was observed before recording', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + store.actions.recordTick({ + control: { gear: 4, mode: 'gear', resistance: 30 }, + metrics: liveMetrics, + profileSnapshot: firstProfile, + seconds: 1, + }); + expect(store.get().profileSnapshot).toEqual(firstProfile); + }); + test('records a complete ride tick as one atomic update', () => { const store = createSessionStore(restoredSession(), 1000); store.actions.syncRiding(true); diff --git a/tests/session.test.ts b/tests/session.test.ts index e561a40..9629598 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -6,12 +6,12 @@ import { addMetricAggregates, aggregateResistance, loadStoredSession, - requestUnloadConfirmation, restoreAggregate, sessionContinuation, sessionNeedsUnloadWarning, storedResistance, } from '../src/lib/session'; +import { requestUnloadConfirmation } from '../src/lib/unload'; import { WORKOUT_COURSES } from '../src/lib/workouts'; const storageWith = (value: string | null) => ({ @@ -46,6 +46,12 @@ describe('session utilities', () => { }, ], maximums: emptySession.maximums, + profileSnapshot: { + bikeWeightKg: 8, + frontChainringTeeth: [50, 34], + rearCassetteTeeth: [11, 13, 15, 17], + riderWeightKg: 68, + }, startedAt: 1000, }; const continued = sessionContinuation(snapshot); @@ -165,6 +171,12 @@ describe('session utilities', () => { ], maximums: { cadence: 95, heartRate: 160, power: 250, speed: 35 }, plannedWorkout: { course: workout }, + profileSnapshot: { + bikeWeightKg: 8, + frontChainringTeeth: [50, 34], + rearCassetteTeeth: [11, 13, 15, 17], + riderWeightKg: 68, + }, savedSessionId: 'saved-session', startedAt: 1000, workout: { course: workout }, @@ -189,6 +201,12 @@ describe('session utilities', () => { expect(session.aggregates.power).toEqual({ count: 1, maximum: 200, sum: 200 }); expect(session.aggregates.resistance).toEqual({ count: 1, maximum: 42, sum: 42 }); expect(session.maximums.speed).toBe(35); + expect(session.profileSnapshot).toEqual({ + bikeWeightKg: 8, + frontChainringTeeth: [50, 34], + rearCassetteTeeth: [11, 13, 15, 17], + riderWeightKg: 68, + }); const { plannedWorkout } = session; if (!plannedWorkout) { throw new Error('Expected the planned workout to be restored'); diff --git a/tests/tcx-import.test.ts b/tests/tcx-import.test.ts index 4faad1e..9f69882 100644 --- a/tests/tcx-import.test.ts +++ b/tests/tcx-import.test.ts @@ -50,6 +50,7 @@ describe('TCX import', () => { expect(imported.feeling).toBe('good'); expect(imported.comments).toBe('Imported ride notes'); expect(imported.aggregates.gear.maximum).toBe(10); + expect(imported.profileSnapshot).toEqual(session.profileSnapshot); }); test('imports every TCX trackpoint from a ride longer than the former sample limit', () => { diff --git a/tests/tcx.test.ts b/tests/tcx.test.ts index 0d9c18f..7ffdbaa 100644 --- a/tests/tcx.test.ts +++ b/tests/tcx.test.ts @@ -22,6 +22,13 @@ describe('TCX export', () => { expect(tcx).toContain('0.00'); expect(tcx).toContain('42.5'); expect(tcx).toContain('45.0'); + expect(tcx).toContain(''); + expect(tcx).toContain('75.000'); + expect(tcx).toContain('9.000'); + expect(tcx).toContain('53/39'); + expect(tcx).toContain( + '12/13/14/15/16/17/18/19/20/21/22/24' + ); expect(tcx).toContain('Comments: Hard & fun <again>'); expect(tcx).not.toContain('NaN'); }); From ffa8e13b15ece06703cb5cd33e5c740dd660a001 Mon Sep 17 00:00:00 2001 From: Ride Control Date: Thu, 23 Jul 2026 18:30:15 -0700 Subject: [PATCH 2/4] Migrate dialogs to TanStack Form and Zod --- README.md | 2 +- bun.lock | 10 + package.json | 4 +- src/components/form-field-error.tsx | 19 + src/components/profile-dialog.tsx | 620 ++++++++++++----------- src/components/rename-workout-dialog.tsx | 78 ++- src/components/session-save-dialog.tsx | 117 +++-- src/components/welcome-dialog.tsx | 71 ++- src/lib/form-errors.ts | 11 + src/lib/profile-form.ts | 226 +++++++++ src/lib/profile-image.ts | 147 ++++++ src/lib/rename-workout-form.ts | 15 + src/lib/session-save-form.ts | 36 ++ src/lib/welcome-form.ts | 11 + tests/form-schemas.test.ts | 167 ++++++ tests/profile-image.test.ts | 110 ++++ vite.config.ts | 2 +- 17 files changed, 1245 insertions(+), 401 deletions(-) create mode 100644 src/components/form-field-error.tsx create mode 100644 src/lib/form-errors.ts create mode 100644 src/lib/profile-form.ts create mode 100644 src/lib/profile-image.ts create mode 100644 src/lib/rename-workout-form.ts create mode 100644 src/lib/session-save-form.ts create mode 100644 src/lib/welcome-form.ts create mode 100644 tests/form-schemas.test.ts create mode 100644 tests/profile-image.test.ts diff --git a/README.md b/README.md index b284ee3..33b8357 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,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. - 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. Trainers and the active Click controller keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. 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. - Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes. -- Keeps an optional rider profile in IndexedDB with name, profile image, rider and bike weight, an inclusive free-form sex or gender identity field, editable front-chainring and rear-cassette tooth counts, and the app-wide MPH or KM/H display preference. Weight follows the profile's selected pounds or kilograms display, while calculations use canonical kilograms. The browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight, bike 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 historical rides remain attributable to the settings that produced them. Those physics fields lock after recording begins and unlock when the session ends; name, image, identity, and display units remain editable. Rider and bike mass scale terrain load, and the configured tooth counts define up to 24 ordered virtual gear ratios; identity, name, and image never affect workout calculations or enter session history. +- Keeps an optional rider profile in IndexedDB with name, profile image, rider and bike weight, an inclusive free-form sex or gender identity field, editable front-chainring and rear-cassette tooth counts, and the app-wide MPH or KM/H display preference. Profile images are resized and compressed to a compact WebP in the browser before storage. Weight follows the profile's selected pounds or kilograms display, while calculations use canonical kilograms. The browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight, bike 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 historical rides remain attributable to the settings that produced them. Those physics fields lock after recording begins and unlock when the session ends; name, image, identity, and display units remain editable. Rider and bike mass scale terrain load, and the configured tooth counts define up to 24 ordered virtual gear ratios; identity, name, and image never affect workout calculations or enter session history. - 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, 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. - 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 with every valid route point preserved. The map-first BikeGPX browser lists only fully prepared public routes, searches them by name, place, distance, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, shows the selected route's elevation profile beside its finalized statistics, and imports it in one click. Every visible route already includes finalized distance, climbing, maximum grade, and difficulty, 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, renaming, removing, and vertical drag reordering with persistent order. The terrain tray and BikeGPX browser remember their open state, scroll positions, searches, filters, and selected route across reloads. Missing descriptions use a cached starting-city lookup that is saved with the workout. diff --git a/bun.lock b/bun.lock index efeaebc..8c28ff6 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@dnd-kit/sortable": "10.0.0", "@garmin/fitsdk": "^21.208.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-form": "^1.33.2", "@tanstack/react-router": "^1.170.18", "@tanstack/react-store": "^0.11.0", "@tanstack/react-virtual": "^3.14.7", @@ -19,6 +20,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", + "zod": "^4.4.3", }, "devDependencies": { "@biomejs/biome": "^2.5.5", @@ -310,8 +312,16 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.4", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw=="], + + "@tanstack/form-core": ["@tanstack/form-core@1.33.2", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-F60zJd15bGrXKonc1kpRYnNRNfiES7F+hgvrPMrsZznPLqZtO2DIg76OU6R25kCYkqYQY5xvuKteuWcUsc587A=="], + "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], + "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="], + + "@tanstack/react-form": ["@tanstack/react-form@1.33.2", "", { "dependencies": { "@tanstack/form-core": "1.33.2", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-nEfayOu+27q5cZ5E0G5dmnddqLcLjdFCatbL/LCs/iLD469a1o1yYJlr8RISV3GfnqsBpm0hf+8kM4okh5fPCw=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.18", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.15", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ=="], "@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], diff --git a/package.json b/package.json index 0b6ae8b..a73ab0d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@dnd-kit/sortable": "10.0.0", "@garmin/fitsdk": "^21.208.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-form": "^1.33.2", "@tanstack/react-router": "^1.170.18", "@tanstack/react-store": "^0.11.0", "@tanstack/react-virtual": "^3.14.7", @@ -28,7 +29,8 @@ "leaflet": "^1.9.4", "react": "^19.2.8", "react-dom": "^19.2.8", - "tailwindcss": "^4.3.3" + "tailwindcss": "^4.3.3", + "zod": "^4.4.3" }, "devDependencies": { "@biomejs/biome": "^2.5.5", diff --git a/src/components/form-field-error.tsx b/src/components/form-field-error.tsx new file mode 100644 index 0000000..6ae22a6 --- /dev/null +++ b/src/components/form-field-error.tsx @@ -0,0 +1,19 @@ +import type { AnyFieldApi } from '@tanstack/react-form'; +import { formErrorMessage } from '../lib/form-errors'; + +export function FormFieldError({ + className = 'mt-1.5 text-rose-300 text-xs leading-5', + field, +}: { + className?: string; + field: AnyFieldApi; +}) { + if (field.state.meta.errors.length === 0) { + return null; + } + return ( +

+ {field.state.meta.errors.map(formErrorMessage).join(' ')} +

+ ); +} diff --git a/src/components/profile-dialog.tsx b/src/components/profile-dialog.tsx index 85119d0..0635d88 100644 --- a/src/components/profile-dialog.tsx +++ b/src/components/profile-dialog.tsx @@ -1,72 +1,53 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useForm, useSelector } from '@tanstack/react-form'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useBodyScrollLock, useCloseOnEscape, useDialogInitialFocus, } from '../hooks/use-dialog-behavior'; +import { errorMessage } from '../lib/errors'; import { drivetrainGearCount, - formattedTeeth, - kilogramsForPounds, MAXIMUM_BIKE_WEIGHT_KG, - MAXIMUM_DRIVETRAIN_TEETH, MAXIMUM_PROFILE_IDENTITY_LENGTH, MAXIMUM_PROFILE_NAME_LENGTH, MAXIMUM_RIDER_WEIGHT_KG, - MAXIMUM_VIRTUAL_GEARS, MINIMUM_BIKE_WEIGHT_KG, - MINIMUM_DRIVETRAIN_TEETH, MINIMUM_RIDER_WEIGHT_KG, PROFILE_IDENTITY_SUGGESTIONS, PROFILE_IMAGE_ACCEPT, - PROFILE_IMAGE_TYPES, parsedTeeth, - poundsForKilograms, type RiderProfile, } from '../lib/profile'; +import { + profileFormSchema, + profileFormValues, + profileFormValuesForSpeedUnit, + profileImageSchema, + profileWeightRange, + profileWeightUnit, + riderProfileFromFormValues, +} from '../lib/profile-form'; +import { prepareProfileImage } from '../lib/profile-image'; import { SPEED_UNIT_OPTIONS } from '../lib/units'; import { requestUnloadConfirmation } from '../lib/unload'; import type { SpeedUnit } from '../types'; +import { FormFieldError } from './form-field-error'; const fieldClass = 'mt-2 w-full rounded-xl border border-line bg-[#10151a] px-3 py-2.5 text-sm outline-none placeholder:text-slate-600 focus:border-mint'; const labelClass = 'block font-semibold text-slate-200 text-sm'; const helpClass = 'mt-1.5 text-slate-500 text-xs leading-5'; -function displayWeight(kilograms: number, speedUnit: SpeedUnit): string { - const value = speedUnit === 'mph' ? poundsForKilograms(kilograms) : kilograms; - return value.toFixed(1); -} - -function storedWeight(value: string, speedUnit: SpeedUnit): number { - const numericValue = Number(value); - return speedUnit === 'mph' ? kilogramsForPounds(numericValue) : numericValue; +function profileInitial(name: string): string { + return name.trim().charAt(0).toLocaleUpperCase() || 'R'; } -function weightRange( - speedUnit: SpeedUnit, - minimumKilograms: number, - maximumKilograms: number -): { maximum: number; minimum: number } { - if (speedUnit === 'kmh') { - return { maximum: maximumKilograms, minimum: minimumKilograms }; +function profileSaveButtonLabel(imagePreparing: boolean, isSubmitting: boolean): string { + if (imagePreparing) { + return 'Preparing image…'; } - return { - maximum: Number(poundsForKilograms(maximumKilograms).toFixed(1)), - minimum: Number(poundsForKilograms(minimumKilograms).toFixed(1)), - }; -} - -function validTeeth(teeth: readonly number[]): boolean { - return ( - teeth.every( - (tooth) => tooth >= MINIMUM_DRIVETRAIN_TEETH && tooth <= MAXIMUM_DRIVETRAIN_TEETH - ) && new Set(teeth).size === teeth.length - ); -} - -function profileInitial(name: string): string { - return name.trim().charAt(0).toLocaleUpperCase() || 'R'; + return isSubmitting ? 'Saving…' : 'Save profile'; } export function ProfileDialog({ @@ -91,38 +72,53 @@ export function ProfileDialog({ const closeButtonRef = useDialogInitialFocus(open); useCloseOnEscape(open, onClose); useBodyScrollLock(open); - const [selectedSpeedUnit, setSelectedSpeedUnit] = useState(speedUnit); - const [name, setName] = useState(profile.name); - const [identity, setIdentity] = useState(profile.identity); - const [riderWeight, setRiderWeight] = useState(() => - displayWeight(profile.riderWeightKg, speedUnit) - ); - const [bikeWeight, setBikeWeight] = useState(() => - displayWeight(profile.bikeWeightKg, speedUnit) - ); - const [frontChainrings, setFrontChainrings] = useState(() => - formattedTeeth(profile.frontChainringTeeth) - ); - const [rearCassette, setRearCassette] = useState(() => - formattedTeeth(profile.rearCassetteTeeth) + const [saveError, setSaveError] = useState(''); + const [imageError, setImageError] = useState(''); + const [imagePreparing, setImagePreparing] = useState(false); + const imagePreparationGeneration = useRef(0); + const form = useForm({ + defaultValues: profileFormValues(profile, speedUnit), + onSubmit: async ({ formApi, value }) => { + setSaveError(''); + try { + const nextProfile = riderProfileFromFormValues(value); + const image = nextProfile.image + ? await prepareProfileImage(nextProfile.image) + : undefined; + await onSave({ ...nextProfile, image }); + onSelectSpeedUnit(value.speedUnit); + formApi.reset({ ...value, image }); + onClose(); + } catch { + setSaveError('Your profile could not be saved in this browser. Please try again.'); + } + }, + validators: { + onChange: profileFormSchema, + onSubmit: profileFormSchema, + }, + }); + const values = useSelector(form.store, (state) => state.values); + const canSubmit = useSelector(form.store, (state) => state.canSubmit); + const isDirty = useSelector(form.store, (state) => state.isDirty); + const isSubmitting = useSelector(form.store, (state) => state.isSubmitting); + const imageUrl = useMemo( + () => (values.image ? URL.createObjectURL(values.image) : undefined), + [values.image] ); - const [image, setImage] = useState(profile.image); - const [error, setError] = useState(''); - const [saving, setSaving] = useState(false); - const imageUrl = useMemo(() => (image ? URL.createObjectURL(image) : undefined), [image]); - const weightUnit = selectedSpeedUnit === 'mph' ? 'lb' : 'kg'; - const riderRange = weightRange( - selectedSpeedUnit, + const weightUnit = profileWeightUnit(values.speedUnit); + const riderRange = profileWeightRange( + values.speedUnit, MINIMUM_RIDER_WEIGHT_KG, MAXIMUM_RIDER_WEIGHT_KG ); - const bikeRange = weightRange( - selectedSpeedUnit, + const bikeRange = profileWeightRange( + values.speedUnit, MINIMUM_BIKE_WEIGHT_KG, MAXIMUM_BIKE_WEIGHT_KG ); - const parsedFront = parsedTeeth(frontChainrings); - const parsedRear = parsedTeeth(rearCassette); + const parsedFront = parsedTeeth(values.frontChainrings); + const parsedRear = parsedTeeth(values.rearCassette); const gearCount = parsedFront && parsedRear ? drivetrainGearCount({ @@ -130,31 +126,19 @@ export function ProfileDialog({ rearCassetteTeeth: parsedRear, }) : 0; - const profileChanged = - selectedSpeedUnit !== speedUnit || - name !== profile.name || - identity !== profile.identity || - riderWeight !== displayWeight(profile.riderWeightKg, selectedSpeedUnit) || - bikeWeight !== displayWeight(profile.bikeWeightKg, selectedSpeedUnit) || - frontChainrings !== formattedTeeth(profile.frontChainringTeeth) || - rearCassette !== formattedTeeth(profile.rearCassetteTeeth) || - image !== profile.image; useEffect(() => { if (!open) { + imagePreparationGeneration.current += 1; + setImagePreparing(false); return; } - setSelectedSpeedUnit(speedUnit); - setName(profile.name); - setIdentity(profile.identity); - setRiderWeight(displayWeight(profile.riderWeightKg, speedUnit)); - setBikeWeight(displayWeight(profile.bikeWeightKg, speedUnit)); - setFrontChainrings(formattedTeeth(profile.frontChainringTeeth)); - setRearCassette(formattedTeeth(profile.rearCassetteTeeth)); - setImage(profile.image); - setError(''); - setSaving(false); - }, [open, profile, speedUnit]); + imagePreparationGeneration.current += 1; + form.reset(profileFormValues(profile, speedUnit)); + setImageError(''); + setImagePreparing(false); + setSaveError(''); + }, [form, open, profile, speedUnit]); useEffect( () => () => { @@ -166,7 +150,7 @@ export function ProfileDialog({ ); useEffect(() => { - if (!(open && profileChanged)) { + if (!(open && (imagePreparing || isDirty))) { return; } const confirmUnsavedProfileExit = (event: BeforeUnloadEvent) => { @@ -174,84 +158,53 @@ export function ProfileDialog({ }; window.addEventListener('beforeunload', confirmUnsavedProfileExit); return () => window.removeEventListener('beforeunload', confirmUnsavedProfileExit); - }, [open, profileChanged]); + }, [imagePreparing, isDirty, open]); if (!open) { return null; } const selectSpeedUnit = (unit: SpeedUnit) => { - if (unit === selectedSpeedUnit) { + if (unit === values.speedUnit) { return; } - const riderWeightKg = storedWeight(riderWeight, selectedSpeedUnit); - const bikeWeightKg = storedWeight(bikeWeight, selectedSpeedUnit); - if (Number.isFinite(riderWeightKg)) { - setRiderWeight(displayWeight(riderWeightKg, unit)); - } - if (Number.isFinite(bikeWeightKg)) { - setBikeWeight(displayWeight(bikeWeightKg, unit)); - } - setSelectedSpeedUnit(unit); + const converted = profileFormValuesForSpeedUnit(values, unit); + form.setFieldValue('bikeWeight', converted.bikeWeight); + form.setFieldValue('riderWeight', converted.riderWeight); + form.setFieldValue('speedUnit', unit); }; - const submitProfile = async (event: React.FormEvent) => { - event.preventDefault(); - const riderWeightKg = storedWeight(riderWeight, selectedSpeedUnit); - const bikeWeightKg = storedWeight(bikeWeight, selectedSpeedUnit); - if ( - !Number.isFinite(riderWeightKg) || - riderWeightKg < MINIMUM_RIDER_WEIGHT_KG || - riderWeightKg > MAXIMUM_RIDER_WEIGHT_KG - ) { - setError( - `Enter a rider weight between ${riderRange.minimum.toFixed(0)} and ${riderRange.maximum.toFixed(0)} ${weightUnit}.` - ); + const selectProfileImage = async ( + file: File, + onPrepared: (image: Blob | undefined) => void + ) => { + const result = profileImageSchema.safeParse(file); + if (!result.success) { + setImageError(result.error.issues[0]?.message ?? 'Choose a valid profile image.'); return; } - if ( - !Number.isFinite(bikeWeightKg) || - bikeWeightKg < MINIMUM_BIKE_WEIGHT_KG || - bikeWeightKg > MAXIMUM_BIKE_WEIGHT_KG - ) { - setError( - `Enter a bike weight between ${bikeRange.minimum.toFixed(0)} and ${bikeRange.maximum.toFixed(0)} ${weightUnit}.` - ); + if (!result.data) { + setImageError('Choose a valid profile image.'); return; } - if (!(parsedFront && parsedRear && validTeeth(parsedFront) && validTeeth(parsedRear))) { - setError( - `Enter unique whole-number drivetrain teeth between ${MINIMUM_DRIVETRAIN_TEETH} and ${MAXIMUM_DRIVETRAIN_TEETH}, separated by slashes.` - ); - return; - } - if (parsedFront.length > 3) { - setError('Enter no more than three front chainrings.'); - return; - } - if (gearCount > MAXIMUM_VIRTUAL_GEARS) { - setError( - `This drivetrain creates ${gearCount} gears. Ride Control supports up to ${MAXIMUM_VIRTUAL_GEARS}.` - ); - return; - } - setSaving(true); - setError(''); + const generation = imagePreparationGeneration.current + 1; + imagePreparationGeneration.current = generation; + setImagePreparing(true); + setImageError(''); + setSaveError(''); try { - await onSave({ - bikeWeightKg, - frontChainringTeeth: parsedFront, - identity: identity.trim(), - image, - name: name.trim(), - rearCassetteTeeth: parsedRear, - riderWeightKg, - }); - onSelectSpeedUnit(selectedSpeedUnit); - onClose(); - } catch { - setError('Your profile could not be saved in this browser. Please try again.'); - setSaving(false); + const image = await prepareProfileImage(result.data); + if (imagePreparationGeneration.current === generation) { + onPrepared(image); + } + } catch (preparationError) { + if (imagePreparationGeneration.current === generation) { + setImageError(errorMessage(preparationError)); + } + } finally { + if (imagePreparationGeneration.current === generation) { + setImagePreparing(false); + } } }; @@ -277,103 +230,138 @@ export function ProfileDialog({ × -
-
-
- {imageUrl ? ( - {name.trim() - ) : ( - - )} -
-
- - { - const [file] = Array.from(event.target.files ?? []); - if (file) { - if ( - PROFILE_IMAGE_TYPES.some((type) => type === file.type) - ) { - setImage(file); - setError(''); - } else { - setError('Choose a JPEG, PNG, or WebP profile image.'); - } - } - event.target.value = ''; - }} - type="file" - /> - {image ? ( - - ) : null} -

- JPEG, PNG, or WebP. Stored only in this browser. -

-
-
+ { + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( +
+
+ {imageUrl ? ( + { + ) : ( + + )} +
+
+ + { + const [file] = Array.from(event.target.files ?? []); + if (file) { + selectProfileImage(file, (image) => + field.handleChange(image) + ); + } + event.target.value = ''; + }} + type="file" + /> + {field.state.value ? ( + + ) : null} +

+ JPEG, PNG, or WebP. Resized and compressed in this browser + before storage. +

+ {imageError ? ( +

+ {imageError} +

+ ) : null} + +
+
+ )} +
- - + + {(field) => ( + + )} + + + {(field) => ( + + )} +
Display units
{SPEED_UNIT_OPTIONS.map((option) => (
- - - - + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + {(field) => ( + + )} +
{physicsSettingsLocked ? ( @@ -461,15 +473,15 @@ export function ProfileDialog({ define the virtual gear ratios. Your profile stays in IndexedDB on this device.

- {error || storageError ? ( + {saveError || storageError ? (

- {error || storageError} + {saveError || storageError}

) : null}
diff --git a/src/components/rename-workout-dialog.tsx b/src/components/rename-workout-dialog.tsx index 6ac7057..0a4ef88 100644 --- a/src/components/rename-workout-dialog.tsx +++ b/src/components/rename-workout-dialog.tsx @@ -1,8 +1,11 @@ -import { type FormEvent, useState } from 'react'; +import { useForm, useSelector } from '@tanstack/react-form'; +import { useEffect, useState } from 'react'; import { useCloseOnEscape, useDialogInitialFocus } from '../hooks/use-dialog-behavior'; import { errorMessage } from '../lib/errors'; +import { renameWorkoutFormSchema } from '../lib/rename-workout-form'; import { MAX_WORKOUT_NAME_LENGTH } from '../lib/workout-file'; import type { WorkoutCourse } from '../types'; +import { FormFieldError } from './form-field-error'; export function RenameWorkoutDialog({ course, @@ -13,21 +16,33 @@ export function RenameWorkoutDialog({ onClose: () => void; onRename: (courseId: string, name: string) => void; }) { - const [name, setName] = useState(course.name); const [renameError, setRenameError] = useState(''); const nameInputRef = useDialogInitialFocus(); useCloseOnEscape(true, onClose); + const form = useForm({ + defaultValues: { name: course.name }, + onSubmit: ({ value }) => { + setRenameError(''); + try { + const validated = renameWorkoutFormSchema.parse(value); + onRename(course.id, validated.name); + onClose(); + } catch (error) { + setRenameError(errorMessage(error)); + } + }, + validators: { + onChange: renameWorkoutFormSchema, + onSubmit: renameWorkoutFormSchema, + }, + }); + const canSubmit = useSelector(form.store, (state) => state.canSubmit); + const isSubmitting = useSelector(form.store, (state) => state.isSubmitting); - const submitRename = (event: FormEvent) => { - event.preventDefault(); + useEffect(() => { + form.reset({ name: course.name }); setRenameError(''); - try { - onRename(course.id, name); - onClose(); - } catch (error) { - setRenameError(errorMessage(error)); - } - }; + }, [course.name, form]); return (
@@ -50,19 +65,32 @@ export function RenameWorkoutDialog({ ×
-
- - setName(event.target.value)} - placeholder="Name this workout" - ref={nameInputRef} - value={name} - /> + { + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => ( + + )} + {renameError ? (

{renameError} @@ -78,7 +106,7 @@ export function RenameWorkoutDialog({ - ))} - - + + {(field) => ( +

+ How did it feel? +
+ {SESSION_FEELING_OPTIONS.map((option) => ( + + ))} +
+ +
+ )} + - -