From f75a0242ee60846d125baa27b67b70b89d87f82a Mon Sep 17 00:00:00 2001 From: Ride Control Date: Sat, 5 Sep 2026 12:40:24 -0700 Subject: [PATCH 1/3] fix(session): resume canceled finishes and isolate fresh rides --- src/components/session-controls.tsx | 2 +- src/components/session-save-dialog.tsx | 27 ++- src/hooks/use-session-workflow.ts | 134 ++++++--------- src/hooks/use-session.ts | 13 ++ src/lib/session-workflow.ts | 25 ++- src/stores/session-store.ts | 68 +++++++- tests/components.test.tsx | 21 +++ tests/session-store.test.ts | 146 +++++++++++++++++ tests/session-workflow.test.ts | 219 ++++++++++++++++++++++++- 9 files changed, 544 insertions(+), 111 deletions(-) diff --git a/src/components/session-controls.tsx b/src/components/session-controls.tsx index c755565..351ebfa 100644 --- a/src/components/session-controls.tsx +++ b/src/components/session-controls.tsx @@ -57,7 +57,7 @@ export function SessionControls({ onClick={onRequestNew} title={ workoutName - ? 'Start a fresh linked session from this course position' + ? 'Start a fresh session from the beginning of this course' : undefined } type="button" diff --git a/src/components/session-save-dialog.tsx b/src/components/session-save-dialog.tsx index 43cdb9c..0de55f8 100644 --- a/src/components/session-save-dialog.tsx +++ b/src/components/session-save-dialog.tsx @@ -1,5 +1,10 @@ import { useForm, useSelector } from '@tanstack/react-form'; import { useEffect } from 'react'; +import { + useBodyScrollLock, + useCloseOnEscape, + useDialogInitialFocus, +} from '../hooks/use-dialog-behavior'; import { unreachable } from '../lib/errors'; import { formatSessionTime, SESSION_FEELING_OPTIONS } from '../lib/saved-sessions'; import { MAXIMUM_SESSION_DESCRIPTION_LENGTH } from '../lib/session-description'; @@ -70,6 +75,10 @@ export function SessionSaveDialog({ }); const canSubmit = useSelector(form.store, (state) => state.canSubmit); const isSubmitting = useSelector(form.store, (state) => state.isSubmitting); + const busy = saving || isSubmitting; + const closeButtonRef = useDialogInitialFocus(open); + useCloseOnEscape(open && !busy, onClose); + useBodyScrollLock(open); useEffect(() => { if (open) { @@ -83,10 +92,18 @@ export function SessionSaveDialog({ return (
+
diff --git a/src/hooks/use-session-workflow.ts b/src/hooks/use-session-workflow.ts index 76906e8..7793e80 100644 --- a/src/hooks/use-session-workflow.ts +++ b/src/hooks/use-session-workflow.ts @@ -7,66 +7,33 @@ import { saveSession, } from '../lib/saved-sessions'; import { - finishRideSession, SESSION_WORKFLOW_INTENT, SESSION_WORKFLOW_PHASE, + type SessionWorkflow, type SessionWorkflowController, type SessionWorkflowIntent, sessionHistorySelectionAfterSave, } from '../lib/session-workflow'; import { createSessionWorkflowStore } from '../stores/session-workflow-store'; -import type { SavedSession, SessionMetadata, SessionSnapshot } from '../types'; +import type { SavedSession, SessionMetadata } from '../types'; export function useSessionWorkflow( session: SessionWorkflowController, setNotice: (notice: string) => void, settleTrainerResistance: () => void, onEndedSessionSaved: (sessionId: string) => void -) { +): SessionWorkflow { const sessionIsResolved = Boolean(session.savedSessionId) || session.discarded; const storeRef = useRef | undefined>(undefined); storeRef.current ??= createSessionWorkflowStore(session.ended && !sessionIsResolved); const store = storeRef.current; const state = useSelector(store); - const finishSession = useCallback( - () => finishRideSession(session.endSession, settleTrainerResistance), - [session.endSession, settleTrainerResistance] - ); - const { extendFrom: extendFromSession, selectedWorkout, startNew: resetSession } = session; - - const startFromCurrent = useCallback( - (sourceSession: SessionSnapshot, previousSessionId?: string) => { - const { workout: sourceWorkout } = sourceSession; - if ( - sourceWorkout && - selectedWorkout && - sourceWorkout.course.id === selectedWorkout.course.id - ) { - extendFromSession(sourceSession, previousSessionId); - } else { - resetSession(); - } - }, - [extendFromSession, resetSession, selectedWorkout] - ); const startNewSession = useCallback(() => { - if (session.elapsedSeconds > 0) { - startFromCurrent(session.snapshot, session.savedSessionId); - } else { - session.startNew(); - } + session.startNew(); store.actions.close(); setNotice('New session ready.'); - }, [ - session.elapsedSeconds, - session.savedSessionId, - session.snapshot, - session.startNew, - startFromCurrent, - setNotice, - store, - ]); + }, [session.startNew, setNotice, store]); const extendSession = useCallback( (savedSession: SavedSession) => { @@ -80,6 +47,8 @@ export function useSessionWorkflow( const completeIntent = useCallback( (intent: SessionWorkflowIntent, savedSession?: SavedSession) => { const historySelection = sessionHistorySelectionAfterSave(intent, savedSession); + session.endSession(); + settleTrainerResistance(); switch (intent.kind) { case SESSION_WORKFLOW_INTENT.EXTEND: session.extendFrom(intent.session, intent.session.id); @@ -89,19 +58,12 @@ export function useSessionWorkflow( : 'Course continuation ready with fresh ride metrics.' ); break; - case SESSION_WORKFLOW_INTENT.NEW: { - const sourceSession = savedSession || session.snapshot; - const previousSessionId = savedSession - ? savedSession.id - : session.savedSessionId; - startFromCurrent(sourceSession, previousSessionId); + case SESSION_WORKFLOW_INTENT.NEW: + session.startNew(); setNotice( - savedSession - ? 'Session saved. New linked session ready.' - : 'New linked session ready.' + savedSession ? 'Session saved. New session ready.' : 'New session ready.' ); break; - } case SESSION_WORKFLOW_INTENT.END: if (savedSession) { setNotice('Session saved.'); @@ -120,33 +82,45 @@ export function useSessionWorkflow( }, [ onEndedSessionSaved, + session.endSession, session.extendFrom, session.markDiscarded, - session.savedSessionId, - session.snapshot, + session.startNew, setNotice, - startFromCurrent, + settleTrainerResistance, store, ] ); + const openPrompt = useCallback( + (intent: SessionWorkflowIntent) => { + if (store.get().phase === SESSION_WORKFLOW_PHASE.SAVING) { + return; + } + session.prepareToEnd(); + store.actions.open(intent); + }, + [session.prepareToEnd, store] + ); + const endSession = useCallback(() => { - finishSession(); - store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END }); - }, [finishSession, store]); + openPrompt({ kind: SESSION_WORKFLOW_INTENT.END }); + }, [openPrompt]); const requestNewSession = useCallback(() => { + if (store.get().phase === SESSION_WORKFLOW_PHASE.SAVING) { + return; + } if (session.ended) { if (sessionIsResolved) { startNewSession(); } else { - store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); + openPrompt({ kind: SESSION_WORKFLOW_INTENT.NEW }); } return; } if (session.elapsedSeconds > 0) { - finishSession(); - store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); + openPrompt({ kind: SESSION_WORKFLOW_INTENT.NEW }); return; } startNewSession(); @@ -154,13 +128,16 @@ export function useSessionWorkflow( session.elapsedSeconds, session.ended, sessionIsResolved, - finishSession, + openPrompt, startNewSession, store, ]); const requestExtension = useCallback( (savedSession: SavedSession) => { + if (store.get().phase === SESSION_WORKFLOW_PHASE.SAVING) { + return; + } const currentNeedsSave = (session.ended && !sessionIsResolved) || (!session.ended && session.elapsedSeconds > 0); @@ -168,27 +145,18 @@ export function useSessionWorkflow( extendSession(savedSession); return; } - if (!session.ended) { - finishSession(); - } - store.actions.open({ kind: SESSION_WORKFLOW_INTENT.EXTEND, session: savedSession }); + openPrompt({ kind: SESSION_WORKFLOW_INTENT.EXTEND, session: savedSession }); }, - [ - extendSession, - session.elapsedSeconds, - session.ended, - sessionIsResolved, - finishSession, - store, - ] + [extendSession, session.elapsedSeconds, session.ended, sessionIsResolved, openPrompt, store] ); const saveCurrentSession = useCallback( async (metadata: SessionMetadata) => { - if (state.phase === SESSION_WORKFLOW_PHASE.CLOSED) { + const current = store.get(); + if (current.phase !== SESSION_WORKFLOW_PHASE.PROMPT) { return; } - const { intent } = state; + const { intent } = current; store.actions.startSaving(); try { const savedSession = createSavedSession(session.snapshot, metadata); @@ -200,19 +168,23 @@ export function useSessionWorkflow( setNotice(`Session could not be saved: ${errorMessage(error)}`); } }, - [completeIntent, session.markSaved, session.snapshot, setNotice, state, store] + [completeIntent, session.markSaved, session.snapshot, setNotice, store] ); const proceedWithoutSaving = useCallback(() => { - if (state.phase !== SESSION_WORKFLOW_PHASE.CLOSED) { - completeIntent(state.intent); + const current = store.get(); + if (current.phase === SESSION_WORKFLOW_PHASE.PROMPT) { + completeIntent(current.intent); } - }, [completeIntent, state]); - const closeSaveDialog = useCallback(() => store.actions.close(), [store]); - const openSaveDialog = useCallback( - () => store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END }), - [store] - ); + }, [completeIntent, store]); + const closeSaveDialog = useCallback(() => { + if (store.get().phase !== SESSION_WORKFLOW_PHASE.PROMPT) { + return; + } + session.cancelEnd(); + store.actions.close(); + }, [session.cancelEnd, store]); + const openSaveDialog = endSession; const requestPersistentStorage = useCallback( () => requestPersistentSessionStorage().catch(() => false), [] diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 3516f24..62c7a17 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -40,6 +40,7 @@ interface SessionControlState { interface SessionController { aggregates: SessionAggregates; + cancelEnd: () => void; continuation: StoredSession['continuation']; controlMode: ControlMode; discarded: boolean; @@ -54,6 +55,7 @@ interface SessionController { markDiscarded: () => void; markSaved: (id: string) => void; maximums: Metrics; + prepareToEnd: () => void; profileSnapshot?: RiderPhysicsProfile; rideCalories: number; rideDistance: number; @@ -180,6 +182,15 @@ export function useSession( store.actions.togglePause(recentlyPedaling); }, [lastPedalingAt, store]); + const prepareToEnd = useCallback(() => { + store.actions.prepareToEnd(Date.now()); + }, [store]); + + const cancelEnd = useCallback(() => { + lastTrainerDistance.current = latestMetrics.current.distance; + store.actions.cancelEnd(); + }, [store]); + const endSession = useCallback(() => { store.actions.endSession(Date.now()); }, [store]); @@ -226,6 +237,7 @@ export function useSession( return { aggregates: state.aggregates, + cancelEnd, continuation: state.continuation, controlMode: state.controlMode, discarded: state.discarded, @@ -240,6 +252,7 @@ export function useSession( markDiscarded, markSaved, maximums: state.maximums, + prepareToEnd, profileSnapshot: state.profileSnapshot, rideCalories: state.calories, rideDistance: state.distance, diff --git a/src/lib/session-workflow.ts b/src/lib/session-workflow.ts index 0de9065..19e7ddc 100644 --- a/src/lib/session-workflow.ts +++ b/src/lib/session-workflow.ts @@ -1,6 +1,7 @@ -import type { SavedSession, SessionSnapshot, SessionWorkout } from '../types'; +import type { SavedSession, SessionMetadata, SessionSnapshot } from '../types'; export interface SessionWorkflowController { + cancelEnd: () => void; discarded: boolean; elapsedSeconds: number; ended: boolean; @@ -8,17 +9,12 @@ export interface SessionWorkflowController { extendFrom: (snapshot: SessionSnapshot, previousSessionId?: string) => void; markDiscarded: () => void; markSaved: (id: string) => void; + prepareToEnd: () => void; savedSessionId?: string; - selectedWorkout?: SessionWorkout; snapshot: SessionSnapshot; startNew: () => void; } -export function finishRideSession(endSession: () => void, settleTrainerResistance: () => void) { - endSession(); - settleTrainerResistance(); -} - export const SESSION_WORKFLOW_INTENT = { END: 'end', EXTEND: 'extend', @@ -36,6 +32,21 @@ export type SessionWorkflowIntent = | { kind: typeof SESSION_WORKFLOW_INTENT.NEW } | { kind: typeof SESSION_WORKFLOW_INTENT.EXTEND; session: SavedSession }; +export interface SessionWorkflow { + closeSaveDialog: () => void; + endSession: () => void; + openSaveDialog: () => void; + proceedWithoutSaving: () => void; + requestExtension: (session: SavedSession) => void; + requestNewSession: () => void; + requestPersistentStorage: () => Promise; + saveCurrentSession: (metadata: SessionMetadata) => Promise; + saveDialogIntent: SessionWorkflowIntent['kind']; + saveDialogOpen: boolean; + saving: boolean; + sessionIsResolved: boolean; +} + export function sessionHistorySelectionAfterSave( intent: SessionWorkflowIntent, savedSession?: SavedSession diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index 055a147..68e0014 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -40,6 +40,10 @@ interface RecordSessionTick { export interface SessionStoreState extends StoredSession { isRiding: boolean; manuallyPaused: boolean; + pendingEnd?: { + manuallyPaused: boolean; + plannedWorkout?: SessionWorkout; + }; } function initialSessionState(restored: StoredSession, now: number): SessionStoreState { @@ -51,6 +55,19 @@ function initialSessionState(restored: StoredSession, now: number): SessionStore }; } +function endedSessionState(current: SessionStoreState, endedAt: number): SessionStoreState { + return current.ended + ? current + : { + ...current, + ended: true, + endedAt, + isRiding: false, + manuallyPaused: false, + plannedWorkout: current.workout, + }; +} + function sameWorkout(workout: SessionWorkout | undefined, course: WorkoutCourse | undefined) { return workout?.course === course; } @@ -171,14 +188,24 @@ export function storedSessionFromState(state: SessionStoreState): StoredSession export function createSessionStore(restored: StoredSession, now = Date.now()) { return createStore(initialSessionState(restored, now), ({ setState }) => ({ + cancelEnd: () => { + setState((current) => + current.pendingEnd + ? { + ...current, + ...current.pendingEnd, + ended: false, + endedAt: 0, + isRiding: false, + pendingEnd: undefined, + } + : current + ); + }, endSession: (endedAt: number) => { setState((current) => ({ - ...current, - ended: true, - endedAt, - isRiding: false, - manuallyPaused: false, - plannedWorkout: current.workout, + ...endedSessionState(current, endedAt), + pendingEnd: undefined, })); }, extendFrom: ( @@ -200,10 +227,20 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { })); }, markDiscarded: () => { - setState((current) => ({ ...current, discarded: true, savedSessionId: undefined })); + setState((current) => ({ + ...current, + discarded: true, + pendingEnd: undefined, + savedSessionId: undefined, + })); }, markSaved: (savedSessionId: string) => { - setState((current) => ({ ...current, discarded: false, savedSessionId })); + setState((current) => ({ + ...current, + discarded: false, + pendingEnd: undefined, + savedSessionId, + })); }, observeControlMode: (controlMode: ControlMode) => { setState((current) => @@ -244,6 +281,19 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { : current ); }, + prepareToEnd: (endedAt: number) => { + setState((current) => + current.ended + ? current + : { + ...endedSessionState(current, endedAt), + pendingEnd: { + manuallyPaused: current.manuallyPaused, + plannedWorkout: current.plannedWorkout, + }, + } + ); + }, recordTick: ({ control, distanceDelta, @@ -294,7 +344,7 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { }, reset: (controlMode: ControlMode, startedAt: number) => { setState((current) => { - const workout = current.plannedWorkout; + const workout = current.ended ? current.plannedWorkout : current.workout; return { ...emptySession, aggregates: emptySession.aggregates, diff --git a/tests/components.test.tsx b/tests/components.test.tsx index d878a29..c985235 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -72,6 +72,10 @@ const renderApp = async (initialSession?: StoredSession) => { return render(); }; const enabledEndSessionButton = /]*disabled)[^>]*>End session<\/button>/; +const disabledSaveDialogCloseButton = + /]*aria-label="Close save session dialog"[^>]*disabled=""/; +const disabledSaveDialogBackdrop = + /]*aria-label="Dismiss save session dialog"[^>]*disabled=""/; const gearProgressStyle = /style="width:([^"]+)"/; const noCustomWorkoutIds = new Set(); @@ -1878,6 +1882,23 @@ describe('view components', () => { expect(newSession).toContain('Save & start new'); }); + test('prevents dismissing the save dialog while a session is being saved', () => { + const html = render( + undefined} + onSave={async () => undefined} + onStartWithoutSaving={() => undefined} + open + saving + session={{ ...emptySession, maximums: emptyMetrics }} + speedUnit="kmh" + /> + ); + expect(html).toMatch(disabledSaveDialogCloseButton); + expect(html).toMatch(disabledSaveDialogBackdrop); + }); + test('places workout planning after starting a new session', () => { const html = render( { expect(store.get().savedSessionId).toBeUndefined(); }); + test('cancels a provisional finish without recording prompt time or losing the ride', () => { + const [course] = WORKOUT_COURSES; + if (!course) { + throw new Error('Expected a built-in workout course'); + } + const store = createSessionStore( + restoredSession({ + continuation: { + journeyId: 'journey', + previousSessionId: 'previous', + workoutStartDistance: 5, + }, + workout: { course }, + }), + 1000 + ); + const tick = { + control: { gear: 1, mode: CONTROL_MODE.RESISTANCE, resistance: 45 }, + metrics: liveMetrics, + seconds: 1, + }; + store.actions.syncRiding(true); + store.actions.recordTick(tick); + const before = sessionSnapshotFromState(store.get()); + + store.actions.prepareToEnd(2000); + store.actions.syncRiding(true); + store.actions.recordTick({ ...tick, seconds: 30 }); + expect(store.get()).toMatchObject({ ended: true, isRiding: false }); + store.actions.cancelEnd(); + expect(sessionSnapshotFromState(store.get())).toEqual(before); + expect(store.get()).toMatchObject({ + ended: false, + isRiding: false, + manuallyPaused: false, + plannedWorkout: undefined, + }); + + store.actions.syncRiding(true); + store.actions.recordTick(tick); + expect(store.get().elapsedSeconds).toBe(2); + expect(store.get().distance).toBeCloseTo(0.02); + expect(store.get().history.map((sample) => sample.elapsedSeconds)).toEqual([1, 2]); + expect(store.get().continuation).toEqual(before.continuation); + }); + + test('retains manual pause after repeated finish prompts are cancelled', () => { + const store = createSessionStore(restoredSession({ elapsedSeconds: 10 }), 1000); + store.actions.togglePause(true); + store.actions.prepareToEnd(2000); + store.actions.prepareToEnd(3000); + store.actions.cancelEnd(); + store.actions.syncRiding(true); + expect(store.get()).toMatchObject({ + elapsedSeconds: 10, + ended: false, + endedAt: 0, + isRiding: false, + manuallyPaused: true, + }); + store.actions.togglePause(true); + expect(store.get().isRiding).toBe(true); + }); + + test('does not resume an auto-paused ride until pedaling resumes', () => { + const store = createSessionStore(restoredSession({ elapsedSeconds: 10 }), 1000); + store.actions.prepareToEnd(2000); + store.actions.cancelEnd(); + store.actions.syncRiding(false); + expect(store.get()).toMatchObject({ + ended: false, + isRiding: false, + manuallyPaused: false, + }); + store.actions.syncRiding(true); + expect(store.get().isRiding).toBe(true); + }); + + test('never reopens a committed or restored ended ride when cancelling a prompt', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.prepareToEnd(2000); + store.actions.endSession(3000); + store.actions.cancelEnd(); + expect(store.get()).toMatchObject({ ended: true, endedAt: 2000, isRiding: false }); + + const restored = createSessionStore(storedSessionFromState(store.get()), 4000); + restored.actions.prepareToEnd(5000); + restored.actions.cancelEnd(); + expect(restored.get()).toMatchObject({ ended: true, endedAt: 2000, isRiding: false }); + }); + + test('starts the selected course over without carrying continuation history or saved identity', () => { + const [course] = WORKOUT_COURSES; + if (!course) { + throw new Error('Expected a built-in workout course'); + } + const store = createSessionStore( + restoredSession({ + calories: 100, + continuation: { + journeyId: 'journey', + previousSessionId: 'previous', + workoutStartDistance: 5, + }, + distance: 1, + elapsedSeconds: 60, + history: [{ ...liveMetrics, elapsedSeconds: 60, resistance: 45 }], + savedSessionId: 'saved', + workout: { course }, + }), + 1000 + ); + store.actions.endSession(2000); + store.actions.reset(CONTROL_MODE.RESISTANCE, 3000); + expect(sessionSnapshotFromState(store.get())).toEqual({ + aggregates: emptySession.aggregates, + calories: 0, + continuation: undefined, + controlMode: CONTROL_MODE.RESISTANCE, + distance: 0, + elapsedSeconds: 0, + elevationTotals: emptySession.elevationTotals, + endedAt: 0, + history: [], + maximums: emptyMetrics, + profileSnapshot: undefined, + startedAt: 3000, + workout: { course }, + }); + expect(store.get()).toMatchObject({ + discarded: false, + ended: false, + }); + expect(store.get().savedSessionId).toBeUndefined(); + }); + + test('keeps an unstarted selected course when resetting directly', () => { + const [course] = WORKOUT_COURSES; + if (!course) { + throw new Error('Expected a built-in workout course'); + } + const store = createSessionStore(restoredSession({ workout: { course } }), 1000); + store.actions.reset(CONTROL_MODE.RESISTANCE, 2000); + expect(store.get().workout?.course.id).toBe(course.id); + }); + test('plans a workout for the next session without changing the completed ride', () => { const [completedCourse, plannedCourse] = WORKOUT_COURSES; if (!(completedCourse && plannedCourse)) { diff --git a/tests/session-workflow.test.ts b/tests/session-workflow.test.ts index b1cd67a..6ef6ea8 100644 --- a/tests/session-workflow.test.ts +++ b/tests/session-workflow.test.ts @@ -1,26 +1,227 @@ import { describe, expect, test } from 'bun:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { emptySession } from '../src/constants'; +import { useSessionWorkflow } from '../src/hooks/use-session-workflow'; +import { sessionWorkoutDistance } from '../src/lib/session-continuation'; import { - finishRideSession, SESSION_WORKFLOW_INTENT, SESSION_WORKFLOW_PHASE, + type SessionWorkflow, sessionHistorySelectionAfterSave, } from '../src/lib/session-workflow'; +import { WORKOUT_COURSES } from '../src/lib/workouts'; +import { + createSessionStore, + type SessionStore, + sessionSnapshotFromState, +} from '../src/stores/session-store'; import { createSessionWorkflowStore, initialSessionWorkflowState, } from '../src/stores/session-workflow-store'; -import type { SavedSession } from '../src/types'; +import type { SavedSession, SessionSnapshot, StoredSession } from '../src/types'; +import { savedSessionFixture } from './fixtures/saved-session'; +import { requiredValue } from './test-values'; -describe('session workflow store', () => { - test('settles trainer resistance whenever a ride session finishes', () => { - const actions: string[] = []; - finishRideSession( - () => actions.push('end session'), - () => actions.push('settle resistance') +const course = requiredValue(WORKOUT_COURSES[0], 'built-in course'); +const recordedSession: StoredSession = { + ...emptySession, + ...savedSessionFixture, + continuation: { + journeyId: 'earlier-journey', + previousSessionId: 'earlier-session', + workoutStartDistance: 5, + }, + endedAt: 0, + workout: { course }, +}; + +function workflowForSession(store: SessionStore, settleResistance = () => undefined) { + const session = { + cancelEnd: store.actions.cancelEnd, + get discarded() { + return store.get().discarded; + }, + get elapsedSeconds() { + return store.get().elapsedSeconds; + }, + get ended() { + return store.get().ended; + }, + endSession: () => store.actions.endSession(3000), + extendFrom: (source: SessionSnapshot, previousSessionId?: string) => + store.actions.extendFrom( + source, + 4000, + source.continuation?.journeyId ?? previousSessionId ?? 'new-journey', + previousSessionId + ), + markDiscarded: store.actions.markDiscarded, + markSaved: store.actions.markSaved, + prepareToEnd: () => store.actions.prepareToEnd(2000), + get savedSessionId() { + return store.get().savedSessionId; + }, + get selectedWorkout() { + const current = store.get(); + return current.ended ? current.plannedWorkout : current.workout; + }, + get snapshot() { + return sessionSnapshotFromState(store.get()); + }, + startNew: () => store.actions.reset(store.get().controlMode, 4000), + }; + let workflow: SessionWorkflow | undefined; + function WorkflowHarness() { + workflow = useSessionWorkflow( + session, + () => undefined, + settleResistance, + () => undefined + ); + return null; + } + renderToStaticMarkup(createElement(WorkflowHarness)); + return requiredValue(workflow, 'rendered session workflow'); +} + +function expectFreshSession(store: SessionStore) { + expect(store.get()).toMatchObject({ + aggregates: emptySession.aggregates, + calories: 0, + discarded: false, + distance: 0, + elapsedSeconds: 0, + elevationTotals: emptySession.elevationTotals, + ended: false, + endedAt: 0, + history: [], + maximums: emptySession.maximums, + workout: { course }, + }); + expect(store.get().continuation).toBeUndefined(); + expect(store.get().savedSessionId).toBeUndefined(); + expect(sessionWorkoutDistance(store.get())).toBe(0); +} + +describe('session workflow lifecycle', () => { + test('cancels a finish without changing recorded data or permanently settling resistance', () => { + const store = createSessionStore(recordedSession); + store.actions.syncRiding(true); + const before = sessionSnapshotFromState(store.get()); + let resistance = 60; + const workflow = workflowForSession(store, () => { + resistance = 20; + }); + + workflow.endSession(); + expect(store.get()).toMatchObject({ ended: true, isRiding: false }); + expect(resistance).toBe(60); + workflow.closeSaveDialog(); + store.actions.syncRiding(true); + expect(store.get()).toMatchObject({ ended: false, isRiding: true }); + expect(sessionSnapshotFromState(store.get())).toEqual(before); + expect(resistance).toBe(60); + + workflow.endSession(); + workflow.proceedWithoutSaving(); + workflow.closeSaveDialog(); + expect(store.get()).toMatchObject({ discarded: true, ended: true, isRiding: false }); + expect(resistance).toBe(20); + }); + + test('closing a saved completed ride keeps its end time and saved identity', () => { + const store = createSessionStore({ + ...recordedSession, + ended: true, + endedAt: 1000, + savedSessionId: 'saved', + }); + const workflow = workflowForSession(store); + workflow.openSaveDialog(); + workflow.closeSaveDialog(); + store.actions.syncRiding(true); + expect(store.get()).toMatchObject({ + ended: true, + endedAt: 1000, + isRiding: false, + savedSessionId: 'saved', + }); + }); + + test('starts an ordinary new ride at course zero after a saved ride', () => { + const store = createSessionStore({ + ...recordedSession, + ended: true, + plannedWorkout: { course }, + savedSessionId: 'saved', + }); + workflowForSession(store).requestNewSession(); + expectFreshSession(store); + }); + + test('starts an ordinary new ride at course zero after discarding the current ride', () => { + const store = createSessionStore(recordedSession); + const workflow = workflowForSession(store); + workflow.requestNewSession(); + workflow.proceedWithoutSaving(); + expectFreshSession(store); + }); + + test('cancels a new-ride request back into the previous manual pause', () => { + const store = createSessionStore(recordedSession); + store.actions.togglePause(true); + const before = sessionSnapshotFromState(store.get()); + const workflow = workflowForSession(store); + workflow.requestNewSession(); + workflow.closeSaveDialog(); + store.actions.syncRiding(true); + expect(store.get()).toMatchObject({ + ended: false, + isRiding: false, + manuallyPaused: true, + }); + expect(sessionSnapshotFromState(store.get())).toEqual(before); + }); + + test('only an explicit saved-history extension continues its course position and lineage', () => { + const store = createSessionStore(recordedSession); + const source: SavedSession = { + ...savedSessionFixture, + continuation: { + journeyId: 'selected-history-journey', + previousSessionId: 'selected-history-parent', + workoutStartDistance: 10, + }, + workout: { course }, + }; + const workflow = workflowForSession(store); + workflow.requestExtension(source); + workflow.closeSaveDialog(); + expect(sessionSnapshotFromState(store.get())).toEqual( + sessionSnapshotFromState(createSessionStore(recordedSession).get()) ); - expect(actions).toEqual(['end session', 'settle resistance']); + + workflow.requestExtension(source); + workflow.proceedWithoutSaving(); + expect(store.get()).toMatchObject({ + continuation: { + journeyId: source.continuation?.journeyId, + previousSessionId: source.id, + workoutStartDistance: 11.5, + }, + distance: 0, + elapsedSeconds: 0, + ended: false, + history: [], + }); + expect(store.get().savedSessionId).toBeUndefined(); + expect(sessionWorkoutDistance(store.get())).toBe(11.5); }); +}); +describe('session workflow store', () => { test('opens with the ended-session intent when an unsaved session is restored', () => { expect(initialSessionWorkflowState(true)).toEqual({ intent: { kind: SESSION_WORKFLOW_INTENT.END }, From ffd3e03226aabc9cd7b442bf424ef38ea8585869 Mon Sep 17 00:00:00 2001 From: Ride Control Date: Sat, 5 Sep 2026 12:40:32 -0700 Subject: [PATCH 2/3] fix(bluetooth): recover silent heart-rate streams and cancel stale attempts --- src/hooks/use-heart-rate-monitor.ts | 68 +++-- src/lib/bluetooth-gatt-coordinator.ts | 57 ++++- src/lib/bluetooth-operation.ts | 6 +- src/lib/bluetooth.ts | 4 +- src/lib/heart-rate-device.ts | 177 +++++++++---- src/lib/promise-timeout.ts | 15 +- src/lib/reconnect-controller.ts | 5 +- tests/bluetooth-gatt-coordinator.test.ts | 95 +++++++ tests/device-connection.test.ts | 25 ++ tests/devices.test.ts | 301 +++++++++++++++++------ 10 files changed, 596 insertions(+), 157 deletions(-) diff --git a/src/hooks/use-heart-rate-monitor.ts b/src/hooks/use-heart-rate-monitor.ts index 0e6dac5..d1c452a 100644 --- a/src/hooks/use-heart-rate-monitor.ts +++ b/src/hooks/use-heart-rate-monitor.ts @@ -28,7 +28,7 @@ export function useHeartRateMonitor( const [heartRate, setHeartRate] = useState(0); const [battery, setBattery] = useState(); const autoReconnect = useRef(true); - const connecting = useRef(false); + const connectionAttempt = useRef(undefined); const connectionGeneration = useRef(0); const forgotten = useRef(false); const connectionCleanup = useRef<() => void>(() => undefined); @@ -45,6 +45,9 @@ export function useHeartRateMonitor( }) ); const handleDisconnect = useCallback((selected: BluetoothDevice) => { + connectionGeneration.current += 1; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; connectionCleanup.current(); setHeartRate(0); if (autoReconnect.current && !forgotten.current) { @@ -71,40 +74,45 @@ export function useHeartRateMonitor( const connectDevice = useCallback( async (selected: BluetoothDevice, reconnecting = false): Promise => { - if (forgotten.current || connecting.current) { + if (forgotten.current || connectionAttempt.current) { return false; } const generation = connectionGeneration.current + 1; connectionGeneration.current = generation; - connecting.current = true; + const attempt = new AbortController(); + connectionAttempt.current = attempt; setPhase(reconnecting ? 'reconnecting' : 'connecting'); connectionCleanup.current(); setBattery(undefined); try { - const connection = await connectHeartRateDevice(selected, reconnecting, { - onBattery: (nextBattery) => { - if (generation === connectionGeneration.current) { - setBattery(nextBattery); - } - }, - onDisconnect: () => { - if (generation === connectionGeneration.current) { - handleDisconnect(selected); - } - }, - onHeartRate: (nextHeartRate) => { - if (generation === connectionGeneration.current) { - setHeartRate(nextHeartRate); - } + const connection = await connectHeartRateDevice( + selected, + reconnecting, + { + onBattery: (nextBattery) => { + if (generation === connectionGeneration.current) { + setBattery(nextBattery); + } + }, + onDisconnect: () => { + if (generation === connectionGeneration.current) { + handleDisconnect(selected); + } + }, + onHeartRate: (nextHeartRate) => { + if (generation === connectionGeneration.current) { + setHeartRate(nextHeartRate); + } + }, }, - }); + { signal: attempt.signal } + ); if ( generation !== connectionGeneration.current || forgotten.current || !autoReconnect.current ) { connection.cleanup(); - selected.gatt?.disconnect(); return false; } connectionCleanup.current = connection.cleanup; @@ -119,7 +127,9 @@ export function useHeartRateMonitor( } return false; } finally { - connecting.current = false; + if (connectionAttempt.current === attempt) { + connectionAttempt.current = undefined; + } } }, [handleConnectionFailure, handleDisconnect] @@ -136,6 +146,8 @@ export function useHeartRateMonitor( } const generation = connectionGeneration.current + 1; connectionGeneration.current = generation; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; setPhase('pairing'); try { const selected = await navigator.bluetooth.requestDevice({ @@ -143,7 +155,7 @@ export function useHeartRateMonitor( optionalServices: [BATTERY], }); if (generation !== connectionGeneration.current) { - selected.gatt?.disconnect(); + // A stale chooser result does not own the selected device's current connection. return; } autoReconnect.current = true; @@ -174,6 +186,8 @@ export function useHeartRateMonitor( const disconnect = useCallback(() => { connectionGeneration.current += 1; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; autoReconnect.current = false; if (device) { reconnectController.current.cancel(device.id, true); @@ -186,6 +200,8 @@ export function useHeartRateMonitor( const cancelConnection = useCallback(() => { connectionGeneration.current += 1; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; autoReconnect.current = false; if (device) { reconnectController.current.cancel(device.id, true); @@ -200,6 +216,8 @@ export function useHeartRateMonitor( const forget = useCallback(async () => { const selected = device; connectionGeneration.current += 1; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; autoReconnect.current = false; forgotten.current = true; if (selected) { @@ -221,6 +239,9 @@ export function useHeartRateMonitor( }, [device]); usePageHide(() => { + connectionGeneration.current += 1; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; autoReconnect.current = false; reconnectController.current.cancelAll(); connectionCleanup.current(); @@ -253,6 +274,9 @@ export function useHeartRateMonitor( useEffect( () => () => { + connectionGeneration.current += 1; + connectionAttempt.current?.abort(); + connectionAttempt.current = undefined; autoReconnect.current = false; reconnectController.current.cancelAll(); connectionCleanup.current(); diff --git a/src/lib/bluetooth-gatt-coordinator.ts b/src/lib/bluetooth-gatt-coordinator.ts index f25c074..8969e38 100644 --- a/src/lib/bluetooth-gatt-coordinator.ts +++ b/src/lib/bluetooth-gatt-coordinator.ts @@ -4,19 +4,38 @@ export interface BluetoothGattCoordinator { connect: ( device: BluetoothDevice, timeoutMs: number, - timeoutMessage: string + timeoutMessage: string, + signal?: AbortSignal ) => Promise; } +interface GattAttempt { + abandoned: boolean; + pending?: Promise; +} + export function createBluetoothGattCoordinator(): BluetoothGattCoordinator { - const pending = new Map>(); + const attempts = new Map(); return { - connect: (device, timeoutMs, timeoutMessage) => { - const existing = pending.get(device.id); + connect: (device, timeoutMs, timeoutMessage, signal) => { + if (signal?.aborted) { + return Promise.reject(signal.reason); + } + const existing = attempts.get(device.id)?.pending; if (existing) { return existing; } + const attempt: GattAttempt = { abandoned: false }; + attempts.set(device.id, attempt); + const abort = () => { + attempt.abandoned = true; + if (attempts.get(device.id) === attempt) { + device.gatt?.disconnect(); + attempt.pending = undefined; + } + }; + signal?.addEventListener('abort', abort, { once: true }); const connect = async () => { const { gatt } = device; if (!gatt) { @@ -26,24 +45,40 @@ export function createBluetoothGattCoordinator(): BluetoothGattCoordinator { return gatt; } try { + const operation = gatt.connect(); + operation.then( + (server) => { + // A browser connect may settle after timeout or cancellation. Close it + // only while it still owns this device, never a newer connection. + if (attempt.abandoned && attempts.get(device.id) === attempt) { + server.disconnect(); + } + }, + () => undefined + ); return await withPromiseTimeout( - gatt.connect(), + operation, timeoutMs, - () => new Error(timeoutMessage) + () => new Error(timeoutMessage), + signal ); } catch (error) { - gatt.disconnect(); + attempt.abandoned = true; + if (attempts.get(device.id) === attempt) { + gatt.disconnect(); + } throw error; } }; // Chrome can establish independent devices concurrently. Only collapse duplicate // requests for the same physical device so one slow sensor never blocks another. const connection = connect(); - pending.set(device.id, connection); + if (!attempt.abandoned) { + attempt.pending = connection; + } const clearPending = () => { - if (pending.get(device.id) === connection) { - pending.delete(device.id); - } + attempt.pending = undefined; + signal?.removeEventListener('abort', abort); }; connection.then(clearPending, clearPending); return connection; diff --git a/src/lib/bluetooth-operation.ts b/src/lib/bluetooth-operation.ts index bcb4a3a..c7a5ec5 100644 --- a/src/lib/bluetooth-operation.ts +++ b/src/lib/bluetooth-operation.ts @@ -24,11 +24,13 @@ export function recoverableBluetoothOperationError(error: unknown): boolean { export function withBluetoothOperationTimeout( operation: Promise, description: string, - timeoutMs = BLUETOOTH_OPERATION_TIMEOUT_MS + timeoutMs = BLUETOOTH_OPERATION_TIMEOUT_MS, + signal?: AbortSignal ): Promise { return withPromiseTimeout( operation, timeoutMs, - () => new BluetoothOperationTimeoutError(description) + () => new BluetoothOperationTimeoutError(description), + signal ); } diff --git a/src/lib/bluetooth.ts b/src/lib/bluetooth.ts index 7d10933..11a5417 100644 --- a/src/lib/bluetooth.ts +++ b/src/lib/bluetooth.ts @@ -15,6 +15,7 @@ export interface CrankReading { interface GattConnectionTiming { directTimeoutMs: number; reconnectProbeTimeoutMs: number; + signal?: AbortSignal; } const ADVERTISEMENT_DISCOVERY_WARMUP_MS = 250; @@ -218,6 +219,7 @@ export function connectGatt( return bluetoothGattCoordinator.connect( device, rediscover ? timing.reconnectProbeTimeoutMs : timing.directTimeoutMs, - 'Bluetooth device connection timed out.' + 'Bluetooth device connection timed out.', + timing.signal ); } diff --git a/src/lib/heart-rate-device.ts b/src/lib/heart-rate-device.ts index efc53e8..4789b45 100644 --- a/src/lib/heart-rate-device.ts +++ b/src/lib/heart-rate-device.ts @@ -6,13 +6,14 @@ import { OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, } from '../constants'; import { connectGatt } from './bluetooth'; -import { startBluetoothNotifications } from './bluetooth-notifications'; +import { createBluetoothNotificationSubscription } from './bluetooth-notifications'; import { withBluetoothOperationTimeout } from './bluetooth-operation'; import { parseHeartRateMeasurement } from './heart-rate'; import { withPromiseTimeout } from './promise-timeout'; const HEART_RATE_MEASUREMENT = 0x2a_37; const BATTERY_LEVEL = 0x2a_19; +const HEART_RATE_MEASUREMENT_TIMEOUT_MS = 10_000; interface HeartRateDeviceCallbacks { onBattery: (battery: number) => void; @@ -25,14 +26,23 @@ export interface HeartRateDeviceConnection { } interface HeartRateDeviceConnectionTiming { + clearTimer?: typeof clearTimeout; + measurementTimeoutMs?: number; operationTimeoutMs?: number; reconnectProbeTimeoutMs?: number; + setTimer?: typeof setTimeout; + signal?: AbortSignal; } -async function readBatteryLevel(server: BluetoothRemoteGATTServer): Promise { - const batteryValue = await ( - await (await server.getPrimaryService(BATTERY)).getCharacteristic(BATTERY_LEVEL) - ).readValue(); +async function readBatteryLevel( + server: BluetoothRemoteGATTServer, + signal: AbortSignal +): Promise { + const service = await server.getPrimaryService(BATTERY); + signal.throwIfAborted(); + const characteristic = await service.getCharacteristic(BATTERY_LEVEL); + signal.throwIfAborted(); + const batteryValue = await characteristic.readValue(); return batteryValue.getUint8(0); } @@ -41,58 +51,127 @@ export async function connectHeartRateDevice( rediscover: boolean, { onBattery, onDisconnect, onHeartRate }: HeartRateDeviceCallbacks, { + clearTimer = clearTimeout, + measurementTimeoutMs = HEART_RATE_MEASUREMENT_TIMEOUT_MS, operationTimeoutMs, reconnectProbeTimeoutMs = HEART_RATE_RECONNECT_PROBE_TIMEOUT_MS, + setTimer = setTimeout, + signal, }: HeartRateDeviceConnectionTiming = {} ): Promise { - const server = await connectGatt(device, rediscover, { - directTimeoutMs: BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS, - reconnectProbeTimeoutMs, - }); - const service = await withBluetoothOperationTimeout( - server.getPrimaryService(HEART_RATE), - 'Heart rate service discovery', - operationTimeoutMs - ); - const measurement = await withBluetoothOperationTimeout( - service.getCharacteristic(HEART_RATE_MEASUREMENT), - 'Heart rate measurement discovery', - operationTimeoutMs - ); - const handleMeasurement = (event: Event) => { - const { value } = event.target as BluetoothRemoteGATTCharacteristic; - if (!value) { + signal?.throwIfAborted(); + const lifecycle = new AbortController(); + let active = true; + let ready = false; + let measurementTimer: ReturnType | undefined; + let removeMeasurementListener: (() => void) | undefined; + const cleanup = () => { + if (!active) { return; } - const heartRate = parseHeartRateMeasurement(value); - if (heartRate !== undefined) { - onHeartRate(heartRate); + active = false; + clearTimer(measurementTimer); + removeMeasurementListener?.(); + device.removeEventListener('gattserverdisconnected', handleDisconnect); + signal?.removeEventListener('abort', cleanup); + lifecycle.abort(); + device.gatt?.disconnect(); + }; + const handleDisconnect = () => { + cleanup(); + if (ready) { + onDisconnect(); } }; - const removeMeasurementListener = await startBluetoothNotifications( - measurement, - handleMeasurement, - operationTimeoutMs - ); - let active = true; - withPromiseTimeout( - readBatteryLevel(server), - OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, - () => new Error('Battery level unavailable.') - ).then( - (battery) => { - if (active) { - onBattery(battery); + const watchMeasurements = () => { + clearTimer(measurementTimer); + measurementTimer = setTimer(() => { + if (!active) { + return; } - }, - () => undefined - ); - device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); - return { - cleanup: () => { - active = false; - removeMeasurementListener(); - device.removeEventListener('gattserverdisconnected', onDisconnect); - }, + // GATT can remain connected after a sleeping monitor loses its stream. + // A fresh connection must rediscover and subscribe, not reuse that server. + cleanup(); + onDisconnect(); + }, measurementTimeoutMs); }; + device.addEventListener('gattserverdisconnected', handleDisconnect); + signal?.addEventListener('abort', cleanup, { once: true }); + try { + const server = await connectGatt(device, rediscover, { + directTimeoutMs: BLUETOOTH_GATT_CONNECTION_TIMEOUT_MS, + reconnectProbeTimeoutMs, + signal: lifecycle.signal, + }); + lifecycle.signal.throwIfAborted(); + const service = await withBluetoothOperationTimeout( + server.getPrimaryService(HEART_RATE), + 'Heart rate service discovery', + operationTimeoutMs, + lifecycle.signal + ); + lifecycle.signal.throwIfAborted(); + const measurement = await withBluetoothOperationTimeout( + service.getCharacteristic(HEART_RATE_MEASUREMENT), + 'Heart rate measurement discovery', + operationTimeoutMs, + lifecycle.signal + ); + lifecycle.signal.throwIfAborted(); + const { promise: firstMeasurement, resolve: resolveFirstMeasurement } = + Promise.withResolvers(); + const notifications = createBluetoothNotificationSubscription(measurement, (event) => { + if (!active) { + return; + } + const { value } = event.target as BluetoothRemoteGATTCharacteristic; + const heartRate = value ? parseHeartRateMeasurement(value) : undefined; + if (heartRate === undefined) { + return; + } + resolveFirstMeasurement(); + if (ready) { + watchMeasurements(); + } + onHeartRate(heartRate); + }); + removeMeasurementListener = notifications.cleanup; + await withBluetoothOperationTimeout( + notifications.start(), + 'Bluetooth notification setup', + operationTimeoutMs, + lifecycle.signal + ); + lifecycle.signal.throwIfAborted(); + await withBluetoothOperationTimeout( + firstMeasurement, + 'Heart rate measurement', + measurementTimeoutMs, + lifecycle.signal + ); + lifecycle.signal.throwIfAborted(); + if (!server.connected) { + throw new DOMException('Heart rate monitor disconnected during setup.', 'NetworkError'); + } + ready = true; + watchMeasurements(); + withPromiseTimeout( + readBatteryLevel(server, lifecycle.signal), + OPTIONAL_BLUETOOTH_OPERATION_TIMEOUT_MS, + () => new Error('Battery level unavailable.'), + lifecycle.signal + ).then( + (battery) => { + if (active) { + onBattery(battery); + } + }, + () => undefined + ); + lifecycle.signal.throwIfAborted(); + return { cleanup }; + } catch (error) { + cleanup(); + throw error; + } } diff --git a/src/lib/promise-timeout.ts b/src/lib/promise-timeout.ts index 7051462..9f58bdd 100644 --- a/src/lib/promise-timeout.ts +++ b/src/lib/promise-timeout.ts @@ -1,17 +1,30 @@ export async function withPromiseTimeout( promise: Promise, timeoutMs: number, - timeoutError: () => Error + timeoutError: () => Error, + signal?: AbortSignal ): Promise { let timeout: ReturnType | undefined; + let abort: (() => void) | undefined; try { return await Promise.race([ promise, new Promise((_, reject) => { timeout = setTimeout(() => reject(timeoutError()), timeoutMs); + if (signal) { + abort = () => reject(signal.reason); + if (signal.aborted) { + abort(); + } else { + signal.addEventListener('abort', abort, { once: true }); + } + } }), ]); } finally { clearTimeout(timeout); + if (abort) { + signal?.removeEventListener('abort', abort); + } } } diff --git a/src/lib/reconnect-controller.ts b/src/lib/reconnect-controller.ts index 1ae9177..159d007 100644 --- a/src/lib/reconnect-controller.ts +++ b/src/lib/reconnect-controller.ts @@ -53,7 +53,10 @@ export function createReconnectController({ onWaiting?.(entry.target); entry.timer = setTimer(async () => { entry.timer = undefined; - if (entries.get(key) !== entry || !canRetry(entry.target)) { + if (entries.get(key) !== entry) { + return; + } + if (!canRetry(entry.target)) { entries.delete(key); return; } diff --git a/tests/bluetooth-gatt-coordinator.test.ts b/tests/bluetooth-gatt-coordinator.test.ts index fc34eb8..78d9955 100644 --- a/tests/bluetooth-gatt-coordinator.test.ts +++ b/tests/bluetooth-gatt-coordinator.test.ts @@ -86,4 +86,99 @@ describe('Bluetooth GATT coordinator', () => { expect(operations).toContain('disconnect-click-minus'); expect(operations.slice(0, 2)).toEqual(['connect-click-minus', 'connect-trainer']); }); + + test('cancels an obsolete handshake without its late success disconnecting the replacement', async () => { + const coordinator = createBluetoothGattCoordinator(); + const oldHandshake = Promise.withResolvers(); + let attempts = 0; + let disconnects = 0; + const server = { + connected: false, + disconnect: () => { + disconnects += 1; + Object.assign(server, { connected: false }); + }, + } as BluetoothRemoteGATTServer; + const device = bluetoothDevice( + 'remembered-heart-rate', + () => { + attempts += 1; + if (attempts === 1) { + return oldHandshake.promise; + } + Object.assign(server, { connected: true }); + return Promise.resolve(server); + }, + server.disconnect + ); + const cancellation = new AbortController(); + const obsolete = coordinator.connect(device, 1000, 'timeout', cancellation.signal); + const rejected = obsolete.catch((error: unknown) => error); + cancellation.abort(); + const replacement = coordinator.connect(device, 1000, 'timeout'); + expect(await rejected).toMatchObject({ name: 'AbortError' }); + expect(await replacement).toBe(server); + const disconnectsAfterRecovery = disconnects; + oldHandshake.resolve(server); + await Promise.resolve(); + expect(server.connected).toBeTrue(); + expect(disconnects).toBe(disconnectsAfterRecovery); + expect(attempts).toBe(2); + }); + + test('closes a canceled handshake that settles late when no replacement owns the device', async () => { + const coordinator = createBluetoothGattCoordinator(); + const handshake = Promise.withResolvers(); + const server = { + connected: false, + disconnect: () => { + Object.assign(server, { connected: false }); + }, + } as BluetoothRemoteGATTServer; + const device = bluetoothDevice( + 'stopped-heart-rate', + () => handshake.promise, + server.disconnect + ); + const cancellation = new AbortController(); + const rejected = coordinator + .connect(device, 1000, 'timeout', cancellation.signal) + .catch((error: unknown) => error); + cancellation.abort(); + expect(await rejected).toMatchObject({ name: 'AbortError' }); + Object.assign(server, { connected: true }); + handshake.resolve(server); + await Promise.resolve(); + expect(server.connected).toBeFalse(); + }); + + test('ignores a timed-out handshake settling after another attempt has connected', async () => { + const coordinator = createBluetoothGattCoordinator(); + const handshake = Promise.withResolvers(); + let attempts = 0; + const server = { + connected: false, + disconnect: () => { + Object.assign(server, { connected: false }); + }, + } as BluetoothRemoteGATTServer; + const device = bluetoothDevice( + 'timed-out-heart-rate', + () => { + attempts += 1; + if (attempts === 1) { + return handshake.promise; + } + Object.assign(server, { connected: true }); + return Promise.resolve(server); + }, + server.disconnect + ); + await expect(coordinator.connect(device, 1, 'timeout')).rejects.toThrow('timeout'); + await coordinator.connect(device, 1000, 'timeout'); + handshake.reject(new Error('Old connection closed')); + await Promise.resolve(); + expect(server.connected).toBeTrue(); + expect(attempts).toBe(2); + }); }); diff --git a/tests/device-connection.test.ts b/tests/device-connection.test.ts index 115276d..6588a7d 100644 --- a/tests/device-connection.test.ts +++ b/tests/device-connection.test.ts @@ -166,6 +166,31 @@ describe('reconnect controller', () => { expect(controller.isPending('device')).toBeFalse(); }); + test('does not let an obsolete queued timer remove a newer recovery attempt', async () => { + const timers: Array<() => Promise> = []; + const attempted: string[] = []; + const controller = createReconnectController({ + attempt: (target) => { + attempted.push(target); + return Promise.resolve(true); + }, + canRetry: () => true, + clearTimer: () => undefined, + delayForAttempt: () => 100, + setTimer: ((callback: () => Promise) => { + timers.push(callback); + return timers.length; + }) as typeof setTimeout, + }); + controller.start('heart-rate', 'old'); + controller.cancel('heart-rate', true); + controller.start('heart-rate', 'recovered'); + await timers[0]?.(); + await timers[1]?.(); + expect(attempted).toEqual(['recovered']); + expect(controller.isPending('heart-rate')).toBeFalse(); + }); + test('cancels retries and ignores duplicate scheduling', () => { const callbacks: Array<() => void> = []; const cleared: number[] = []; diff --git a/tests/devices.test.ts b/tests/devices.test.ts index a53c837..9ecb4ba 100644 --- a/tests/devices.test.ts +++ b/tests/devices.test.ts @@ -35,6 +35,44 @@ function view(bytes: number[]) { return new DataView(new Uint8Array(bytes).buffer); } +function heartRateMonitor(id: string) { + const deviceEvents = new EventTarget(); + const measurement = Object.assign(new EventTarget(), { + startNotifications: (): Promise => { + emitMeasurement([0, 135]); + return Promise.resolve(measurement as unknown as BluetoothRemoteGATTCharacteristic); + }, + value: undefined as DataView | undefined, + }); + const emitMeasurement = (bytes: number[]) => { + measurement.value = view(bytes); + measurement.dispatchEvent(new Event('characteristicvaluechanged')); + }; + const service = { + getCharacteristic: (): Promise => + Promise.resolve(measurement as unknown as BluetoothRemoteGATTCharacteristic), + }; + const server = { + connect: (): Promise => { + server.connected = true; + return Promise.resolve(server as unknown as BluetoothRemoteGATTServer); + }, + connected: false, + disconnect: () => { + if (server.connected) { + server.connected = false; + deviceEvents.dispatchEvent(new Event('gattserverdisconnected')); + } + }, + getPrimaryService: (uuid: BluetoothServiceUUID): Promise => + uuid === HEART_RATE + ? Promise.resolve(service as unknown as BluetoothRemoteGATTService) + : Promise.reject(new Error('Battery unavailable')), + }; + const device = Object.assign(deviceEvents, { gatt: server, id }) as unknown as BluetoothDevice; + return { device, emitMeasurement, measurement, server, service }; +} + function bufferSourceBytes(value: BufferSource): number[] { return ArrayBuffer.isView(value) ? [...new Uint8Array(value.buffer, value.byteOffset, value.byteLength)] @@ -64,96 +102,219 @@ describe('paired device protocols', () => { }); test('retries a remembered heart rate monitor in separate bounded cycles', async () => { + const monitor = heartRateMonitor('remembered-heart-rate'); + const { connect } = monitor.server; let attempts = 0; - let notificationsStarted = 0; - const measurement = { - addEventListener: () => undefined, - removeEventListener: () => undefined, - startNotifications: () => { - notificationsStarted += 1; - return Promise.resolve(measurement); - }, - } as unknown as BluetoothRemoteGATTCharacteristic; - const server = { - getPrimaryService: (service: BluetoothServiceUUID) => { - if (service !== HEART_RATE) { - return Promise.reject(new Error('Battery unavailable')); - } - return Promise.resolve({ - getCharacteristic: () => Promise.resolve(measurement), - } as unknown as BluetoothRemoteGATTService); - }, - } as BluetoothRemoteGATTServer; - const device = { - addEventListener: () => undefined, - gatt: { - connect: () => { - attempts += 1; - return attempts === 1 ? new Promise(() => undefined) : Promise.resolve(server); - }, - disconnect: () => undefined, - }, - id: 'remembered-heart-rate', - removeEventListener: () => undefined, - } as unknown as BluetoothDevice; + monitor.server.connect = () => { + attempts += 1; + return attempts === 1 ? new Promise(() => undefined) : connect(); + }; + const readings: number[] = []; const callbacks = { onBattery: () => undefined, onDisconnect: () => undefined, - onHeartRate: () => undefined, + onHeartRate: (heartRate: number) => readings.push(heartRate), }; await expect( - connectHeartRateDevice(device, true, callbacks, { reconnectProbeTimeoutMs: 1 }) + connectHeartRateDevice(monitor.device, true, callbacks, { reconnectProbeTimeoutMs: 1 }) ).rejects.toThrow('Bluetooth device connection timed out.'); - const connection = await connectHeartRateDevice(device, true, callbacks); + const connection = await connectHeartRateDevice(monitor.device, true, callbacks); expect(attempts).toBe(2); - expect(notificationsStarted).toBe(1); + expect(readings).toEqual([135]); connection.cleanup(); }); - test('releases a stalled heart rate notification attempt so it can be retried', async () => { - const listeners = new Set(); - let notificationAttempts = 0; - const measurement = { - addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => - listeners.add(listener), - removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => - listeners.delete(listener), - startNotifications: () => { - notificationAttempts += 1; - return notificationAttempts === 1 - ? new Promise(() => undefined) - : Promise.resolve(measurement); - }, - } as unknown as BluetoothRemoteGATTCharacteristic; - const server = { - getPrimaryService: () => - Promise.resolve({ - getCharacteristic: () => Promise.resolve(measurement), - } as unknown as BluetoothRemoteGATTService), - } as unknown as BluetoothRemoteGATTServer; - const device = { - addEventListener: () => undefined, - gatt: { connect: () => Promise.resolve(server) }, - removeEventListener: () => undefined, - } as unknown as BluetoothDevice; + test('releases a stalled notification attempt without allowing late callbacks into its retry', async () => { + const monitor = heartRateMonitor('stalled-notifications'); + const { startNotifications } = monitor.measurement; + let finishNotifications: (() => void) | undefined; + monitor.measurement.startNotifications = () => + new Promise((resolve) => { + finishNotifications = () => + resolve(monitor.measurement as unknown as BluetoothRemoteGATTCharacteristic); + }); + const obsoleteReadings: number[] = []; + await expect( + connectHeartRateDevice( + monitor.device, + true, + { + onBattery: () => undefined, + onDisconnect: () => undefined, + onHeartRate: (heartRate) => obsoleteReadings.push(heartRate), + }, + { operationTimeoutMs: 1 } + ) + ).rejects.toThrow('Bluetooth notification setup timed out.'); + monitor.measurement.startNotifications = startNotifications; + const readings: number[] = []; + const connection = await connectHeartRateDevice(monitor.device, true, { + onBattery: () => undefined, + onDisconnect: () => undefined, + onHeartRate: (heartRate) => readings.push(heartRate), + }); + finishNotifications?.(); + await Promise.resolve(); + monitor.emitMeasurement([0, 142]); + expect(monitor.server.connected).toBeTrue(); + expect(obsoleteReadings).toEqual([]); + expect(readings).toEqual([135, 142]); + connection.cleanup(); + monitor.emitMeasurement([0, 150]); + expect(readings).toEqual([135, 142]); + }); + + test('requires a valid measurement before reporting a remembered monitor ready', async () => { + const monitor = heartRateMonitor('silent-notification-setup'); + monitor.measurement.startNotifications = () => + Promise.resolve(monitor.measurement as unknown as BluetoothRemoteGATTCharacteristic); + const readings: number[] = []; + const callbacks = { + onBattery: () => undefined, + onDisconnect: () => undefined, + onHeartRate: (heartRate: number) => readings.push(heartRate), + }; + await expect( + connectHeartRateDevice(monitor.device, true, callbacks, { measurementTimeoutMs: 1 }) + ).rejects.toThrow('Heart rate measurement timed out.'); + expect(monitor.server.connected).toBeFalse(); + monitor.measurement.startNotifications = () => { + monitor.emitMeasurement([1, 44]); + monitor.emitMeasurement([0, 148]); + return Promise.resolve( + monitor.measurement as unknown as BluetoothRemoteGATTCharacteristic + ); + }; + const connection = await connectHeartRateDevice(monitor.device, true, callbacks); + expect(readings).toEqual([148]); + connection.cleanup(); + }); + + test('rejects a disconnect during notification setup and reconnects without pairing again', async () => { + const monitor = heartRateMonitor('disconnect-during-setup'); + const { startNotifications } = monitor.measurement; + monitor.measurement.startNotifications = () => { + monitor.server.disconnect(); + return new Promise(() => undefined); + }; const callbacks = { onBattery: () => undefined, onDisconnect: () => undefined, onHeartRate: () => undefined, }; + await expect(connectHeartRateDevice(monitor.device, true, callbacks)).rejects.toThrow(); + monitor.measurement.startNotifications = startNotifications; + const connection = await connectHeartRateDevice(monitor.device, true, callbacks); + expect(monitor.server.connected).toBeTrue(); + connection.cleanup(); + }); - await expect( - connectHeartRateDevice(device, false, callbacks, { operationTimeoutMs: 1 }) - ).rejects.toThrow('Bluetooth notification setup timed out.'); - expect(listeners.size).toBe(0); + test('recovers a silent stream and cancels its watchdog on deliberate cleanup', async () => { + const monitor = heartRateMonitor('silent-active-stream'); + const timers = new Map void>(); + let nextTimer = 0; + const timing = { + clearTimer: ((timer: number) => timers.delete(timer)) as unknown as typeof clearTimeout, + setTimer: ((callback: () => void) => { + nextTimer += 1; + timers.set(nextTimer, callback); + return nextTimer; + }) as typeof setTimeout, + }; + let disconnected = 0; + const readings: number[] = []; + const callbacks = { + onBattery: () => undefined, + onDisconnect: () => { + disconnected += 1; + }, + onHeartRate: (heartRate: number) => readings.push(heartRate), + }; + await connectHeartRateDevice(monitor.device, true, callbacks, timing); + monitor.emitMeasurement([0, 145]); + const expireStream = timers.get(nextTimer); + expireStream?.(); + expect(disconnected).toBe(1); + expect(monitor.server.connected).toBeFalse(); + monitor.emitMeasurement([0, 150]); + expect(readings).toEqual([135, 145]); + const recovered = await connectHeartRateDevice(monitor.device, true, callbacks, timing); + expireStream?.(); + expect(monitor.server.connected).toBeTrue(); + expect(readings).toEqual([135, 145, 135]); + const stoppedWatchdog = timers.get(nextTimer); + recovered.cleanup(); + expect(timers.size).toBe(0); + stoppedWatchdog?.(); + expect(disconnected).toBe(1); + expect(monitor.server.connected).toBeFalse(); + }); - const connection = await connectHeartRateDevice(device, false, callbacks, { - operationTimeoutMs: 100, + test('cancels stalled discovery before a new attempt and ignores its late completion', async () => { + const monitor = heartRateMonitor('cancelled-discovery'); + const { getPrimaryService } = monitor.server; + let finishDiscovery: (() => void) | undefined; + let discoveryStarted: () => void = () => undefined; + const started = new Promise((resolve) => { + discoveryStarted = resolve; + }); + monitor.server.getPrimaryService = () => { + discoveryStarted(); + return new Promise((resolve) => { + finishDiscovery = () => + resolve(monitor.service as unknown as BluetoothRemoteGATTService); + }); + }; + const cancellation = new AbortController(); + const obsoleteReadings: number[] = []; + const obsolete = connectHeartRateDevice( + monitor.device, + true, + { + onBattery: () => undefined, + onDisconnect: () => undefined, + onHeartRate: (heartRate) => obsoleteReadings.push(heartRate), + }, + { signal: cancellation.signal } + ); + const rejected = obsolete.catch((error: unknown) => error); + await started; + cancellation.abort(); + monitor.server.getPrimaryService = getPrimaryService; + const readings: number[] = []; + const connection = await connectHeartRateDevice(monitor.device, true, { + onBattery: () => undefined, + onDisconnect: () => undefined, + onHeartRate: (heartRate) => readings.push(heartRate), }); - expect(notificationAttempts).toBe(2); - expect(listeners.size).toBe(1); + expect(await rejected).toMatchObject({ name: 'AbortError' }); + finishDiscovery?.(); + await Promise.resolve(); + monitor.emitMeasurement([0, 151]); + expect(monitor.server.connected).toBeTrue(); + expect(obsoleteReadings).toEqual([]); + expect(readings).toEqual([135, 151]); + connection.cleanup(); + }); + + test('does not let an already canceled request disconnect a live measurement stream', async () => { + const monitor = heartRateMonitor('already-canceled-request'); + const readings: number[] = []; + const callbacks = { + onBattery: () => undefined, + onDisconnect: () => undefined, + onHeartRate: (heartRate: number) => readings.push(heartRate), + }; + const connection = await connectHeartRateDevice(monitor.device, true, callbacks); + const cancellation = new AbortController(); + cancellation.abort(); + await expect( + connectHeartRateDevice(monitor.device, true, callbacks, { signal: cancellation.signal }) + ).rejects.toThrow(); + monitor.emitMeasurement([0, 146]); + expect(monitor.server.connected).toBeTrue(); + expect(readings).toEqual([135, 146]); connection.cleanup(); - expect(listeners.size).toBe(0); }); test('starts a Click V2 session with its RideOn command', () => { From a811033954e1bad8fe14e84c711fd7acf2dd7e96 Mon Sep 17 00:00:00 2001 From: Ride Control Date: Sat, 5 Sep 2026 12:40:32 -0700 Subject: [PATCH 3/3] docs: clarify session cancellation and heart-rate recovery --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 614d7a1..5000105 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - Detects browsers outside the currently tested Chrome environment and replaces the pairing controls with a compatibility notice, while showing Chrome's automatic-reconnect setup steps directly in the paired-devices panel only when its persistent permission capability is unavailable and confirming when it is configured correctly. - Shows each deployment's build time in the viewer's local timezone and links it to the GitHub pull request that produced the build, falling back to the closed pull-request list when no associated PR is available. A tiny static deployment marker is revalidated at most once per hour; when it differs from the running bundle, a persistent notice offers to reload into the latest version without automatically interrupting a ride. - Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and restores the trainer, heart-rate monitor, and `+` Click controller from one browser permission snapshot after a reload. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Offline remembered devices keep retrying while the page remains open, with bounded attempts so a stale browser request cannot stall the loop; background heart-rate probes use a shorter timeout so a monitor that wakes up gets a fresh connection attempt promptly. Starting a new session re-arms every remembered device that is not already connected, while **Disconnect**, **Stop connecting**, and closing the page cancel current retry work. Trainers 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. +- Reports a heart-rate monitor ready only after its first valid measurement. If readings stop for 10 seconds, the app closes the stale connection and retries the remembered monitor without requiring it to be forgotten or paired again. Disconnects during setup abort the attempt, and canceled or late connection work cannot tear down its replacement. - Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes. - Opens the linkable Profile view as a slide-out tray with shared, keyboard-accessible tabs that separate Personal details from Bikes while leaving room for future sections such as Premium and Teams. `/profile?tab=personal` and `/profile?tab=bikes` link directly to each section, browser history follows tab changes, and plain `/profile` safely defaults to Personal details. Switching tabs preserves every unsaved form edit. Profile data remains in IndexedDB on the current device and includes name, profile image, rider weight, an inclusive free-form sex or gender identity field that remembers saved custom entries in a separately labelled, removable suggestion group without relying on browser autofill, the app-wide Imperial or Metric display preference, and multiple named bikes. Every bike can store its own prepared image, manufacturer, model, color, purchase date, weight, front-chainring teeth, and rear-cassette teeth; rider and bike images share the same JPEG/PNG/WebP validation, browser-side resizing and compression, 32 MB source ceiling, 512-pixel edge, and 512 KB prepared-image ceiling. Removing a bike, profile image, or bike image requires explicit confirmation. 1×11, 1×12, 2×, and other valid drivetrains are supported up to 24 total combinations. Selecting the active bike immediately supplies that bike's mass and ordered virtual gear ratios to trainer physics. Existing single-bike and multi-bike profiles migrate automatically. Every actual rider-weight change is timestamped in the profile without adding duplicates for unchanged saves or unit conversions; the tray graphs the complete series with current weight and net change while retaining the complete local history for future encrypted sync. Weight follows the selected pounds or kilograms display while calculations use canonical kilograms, and the browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight plus the active bike's identity, weight, chainrings, and cassette before recording begins, preserves it through active-session recovery and continuation, and round-trips it through Ride Control TCX files so later bike edits do not rewrite historical settings. Those physics fields and the active-bike selection lock after recording begins and unlock when the session ends; names, images, identity, display units, and descriptive bike metadata remain editable. Identity, rider name, and images never affect workout calculations or enter session history. Future cloud storage and synchronization will be offered as a premium feature. - Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback: Up Arrow or Return increases resistance, while Down Arrow or Right Shift decreases it. The control shows smoothing progress inside the slider thumb and records resistance changes alongside the other ride metrics. @@ -22,10 +23,12 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - Downloads terrain workouts as standard GPX 1.1 files with ordinary geographic and elevation data plus Ride Control metadata for stable ids, difficulty, exact distance, starting location, and route type. Valid GPX tracks or routes can be imported through the file picker or by dropping a file anywhere in the workout tray, then saved into the current device's custom library. Before processing, Ride Control offers an ephemeral enhanced Worker path that removes invalid and duplicate points, rejects large coordinate gaps, smooths elevation noise and implausible grade spikes, calculates climbing and difficulty, and creates normalized map geometry without storing the uploaded file. Riders can decline and process the route entirely on the current device instead; a final dialog summarizes the imported route, processing path, and any errors or fallback warnings. Both paths enforce a 2 MiB file ceiling, a 25,000-point ceiling, safe XML restrictions, and bounded route validation. The map-first route browser can switch between providers and collections, including BikeGPX public routes and yearly Tour de France stages. BikeGPX is linked and thanked beside its collection description, with a note that Ride Control heavily processes and cleans the original route data. The browser searches by name, place, distance, group, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, displays available stage imagery, shows finalized elevation statistics, downloads GPX, and imports a course in one click. Every visible route already includes finalized distance, climbing, maximum grade, difficulty, and map data; routes with unusable coordinate or elevation data never appear, and the first matching route previews automatically. Imported route descriptions can open an in-app map with start and finish markers and an animated bicycle; routes with genuinely nearby endpoints become loops while other routes remain point-to-point. Stable fingerprints prevent duplicate imports. The workout library supports immediate filtering by name, difficulty, or an approximate distance in the selected Imperial or Metric units, plus renaming, confirmed removal, and vertical drag reordering with persistent order. The terrain tray and route browser remember their open state, collection-specific scroll positions, searches, filters, provider, collection, and selected route across reloads. - Replaces direct resistance controls with a focused virtual shifting interface whenever the `+` Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, Up Arrow or Return for a harder gear, and Down Arrow or Right Shift for an easier gear remain usable. The physical `+` button shifts up and its blue `Y` button shifts down. The configured chainrings and cassette determine the number of positions—11 for a 1×11, 12 for a 1×12, and up to 24 total—and define the drivetrain's easiest, neutral, and hardest ratios. Positions use equal percentage load steps on either side of the middle neutral gear, while the control identifies the selected physical chainring/cassette combination, its ratio, and its calibrated load multiplier, such as `53/15 · 3.53:1 · 2.21× load`. The progress meter retains visible fill in the easiest gear and increases at every higher position, so gear 1 remains represented while gear 2 is visibly farther along. The prepared route grade produces one stable terrain target, then the calibrated load curve progressively unloads gears below neutral and adds load above it: the middle gear preserves the terrain target at `1.00×`, gear 1 provides the easiest configured ratio, and the final gear provides the hardest configured ratio. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer resistance. - 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. Reloading while riding or explicitly paused retains the browser safety confirmation, while an inactivity-triggered auto-pause can reload immediately because its complete checkpoint is already stored locally. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects. +- Temporarily stops recording while the end-session save dialog is open. Escape, the close button, or the backdrop cancels that finish and restores the prior pause state; pedaling resumes the counters without adding dialog time or distance. Trainer resistance returns to 10% only after saving or explicitly ending without saving, and dismissal is disabled while saving. - Tracks every time-series sample plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with no duration-based truncation during recording or FIT/TCX import. Large, high-visibility numbers appear in space-efficient live metric and ride-summary cards, with oversized ride totals and subdued unit labels. Focused or combined charts use a responsive display-only sample of long histories without changing the complete data retained for summaries and exports. The resistance chart starts at a useful 50% scale and expands in ten-point steps as samples approach its ceiling. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. Workout elevation is recorded across the entire ride, so the course profile repeats for every completed loop. Saved sessions reference immutable, content-addressed workout snapshots in a separate IndexedDB store: identical course definitions share one snapshot, edited definitions retain their historical versions, and deleting a workout from the selectable library cannot break an older session's maps or terrain details. - Keeps the complete dashboard usable at phone widths: ride totals reflow when necessary, chart controls and plots shrink within the viewport, virtual shifting uses the available width, and the footer remains below the controls with device safe-area spacing. - Provides clear footer access to email contact, the privacy policy, terms of service, and the current deployment version in responsive in-app dialogs. The legal dialogs explain today's local-only storage and briefly disclose the planned optional paid premium cloud-storage features that will receive expanded terms and privacy details before launch. Production version details include links and merge dates for the ten most recent frontend pull requests. - 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. Saving an ended session immediately opens the Sessions drawer with that new ride selected, while save-and-start flows continue directly into the next ride. Saved and in-progress sessions use browser-managed IndexedDB storage, and active rides are checkpointed in small sample chunks so recovery does not repeatedly rewrite the complete history. Existing localStorage recovery data is migrated once and removed only after IndexedDB has accepted it. Saved sessions support an optional 500-character description with a live character count plus ride feeling, and persistent browser storage is requested when supported. +- Starts ordinary new sessions independently, even when the selected workout is unchanged: ride totals, samples, saved identity, journey linkage, and course position reset. Only starting from a saved ride in session history creates a linked course continuation. - Opens saved rides from the dashboard's Sessions button in a slide-out tray with Calendar, List, and Statistics views. The month calendar marks every day with rides and makes each event directly selectable, while the virtualized chronological list retains paginated loading for very large histories. Statistics are updated transactionally whenever a session is saved, replaced, imported, or deleted, then read from compact IndexedDB rollups instead of rescanning telemetry. All-time totals cover rides, distance, time, climbing, downhill, calories, speed, power, cadence, and heart rate; their responsive cards use at most three columns and always show complete numeric values instead of truncating them. The statistics view also graphs the same canonical profile-weight history shown in Profile, with values converted into the selected display unit. Personal-best cards open their source sessions, and dedicated weekly, monthly, yearly, and complete-history graphs show distance, time, elevation, calories, ride count, average speed, power, cadence, and heart rate. Trends remembers both the selected chart metric and timeframe. Detailed session metrics and charts, clear date ranges for rides that span midnight, keyboard navigation with grouped shortcut help, and permanent deletion remain available. The tray remembers its active view, selected session, list scroll position, and each session's independent detail-pane scroll position after a page reload. - Downloads saved rides as standards-compliant FIT activities for direct upload to Strava and other fitness services, including indoor-cycling and creator metadata, UTC and local timestamps, distance, speed, power, cadence, estimated crank revolutions and work, heart rate, resistance, elevation, calories, and ride totals. Each FIT filename includes a stable session token for reliable upload identity. TCX export remains available for the richer Ride Control round trip, including virtual gear, terrain workout metadata, ride feeling, session description, and the original session identifier. - Creates an on-demand 1200×630 workout card for sharing on X from a stable, stateless RideControl.xyz link containing selected summary stats, a compact route map and elevation preview, and accurate personal-best callouts. Public GPX workouts link back to their exact RideControl route. Cloudflare regenerates an evicted image from the link and serves it with immutable cache headers; no share data is stored in KV or R2. Sharing is explicit and does not publish raw ride samples, comments, or profile details. @@ -56,7 +59,7 @@ existing IndexedDB stores. A gated one-time migration moves the former localStor and deletes it only after a successful write. Each Bluetooth hook exposes one explicit connection phase instead of independently managed status flags. A single remembered-device catalog loads browser-authorized devices once, then the trainer, heart-rate, and Click adapters start their -advertisement-driven reconnections together. One GATT coordinator deduplicates overlapping requests +independent reconnections together. One GATT coordinator deduplicates overlapping requests to the same physical device while allowing the trainer, heart-rate monitor, and controllers to connect in parallel. Each device sequences its own service setup, so a sleeping device cannot block an awake one and concurrent ATT requests cannot fight over one connection.