From e32164fb1a24d2eeb9c0b375dd3cfe8248788acb Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 09:35:22 -0700 Subject: [PATCH 01/26] chore: clarify repository naming rules --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e603145..300326c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,8 @@ - After making any changes, always run `bun run ci` before handing work back. - `bun run ci` must run the Ultracite-configured Biome checks automatically, followed by the TypeScript check and production build. - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. -- Do not reference the AI name used (codex, openai, etc...) in any commits, pr's, issue titles or anywhere else. +- Never include AI assistant, product, vendor, or model names (such as `codex` or `openai`) in branch names, commit messages, pull request titles or descriptions, issue titles or descriptions, tags, release notes, or any other repository-visible metadata or content. +- Name branches only after the work being performed, such as `refactor/tanstack-store`; do not apply a tool's default branch prefix when that prefix would violate the naming rule above. - When the user says "add, commit", group all existing changes into logical sets, stage and commit each group, and repeat until every change is committed and the working tree is clean. - At the start of every new work request, check the current Git branch before changing files. If the current branch is `main`, create and switch to a descriptively named task branch first. - Never push work directly to `main`. If `main` already contains uncommitted changes or commits that have not been pushed, create the task branch from its current state so all of that work moves forward on the new branch, then continue the normal workflow there. From 15356d059d6bd39cdac4c6cdc9abac4e6d6bacce Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 09:35:26 -0700 Subject: [PATCH 02/26] refactor: centralize ride session state --- README.md | 9 ++ bun.lock | 7 + package.json | 5 +- src/hooks/use-session.ts | 247 +++++++++--------------------------- src/stores/session-store.ts | 190 +++++++++++++++++++++++++++ tests/session-store.test.ts | 187 +++++++++++++++++++++++++++ 6 files changed, 457 insertions(+), 188 deletions(-) create mode 100644 src/stores/session-store.ts create mode 100644 tests/session-store.test.ts diff --git a/README.md b/README.md index 0c5bfd2..2e0c7c1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ bun run dev Open in current Chrome. +## Architecture + +Ride session data is held in a per-app TanStack Store and changed through atomic domain actions. +The `useSession` adapter owns recording timers and durable browser persistence while exposing the +existing session controller API to the interface. Web Bluetooth devices, GATT characteristics, +retry queues, and listeners remain inside their dedicated device hooks; only serializable device +status should cross into shared application state. Temporary dialogs and form inputs remain local +React state. + ## Deployment Pull requests and pushes to `main` run the complete `bun run ci` suite in GitHub Actions. After diff --git a/bun.lock b/bun.lock index 14eb2cc..3c9bf75 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "ridecontrol", "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -152,6 +153,10 @@ "@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/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=="], + + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -434,6 +439,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/package.json b/package.json index 0987463..1079d1c 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,12 @@ "preview": "vite preview --host localhost --port 4200" }, "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", "react": "^19.2.7", "react-dom": "^19.2.7", - "tailwindcss": "^4.3.3", - "@tailwindcss/vite": "^4.3.3" + "tailwindcss": "^4.3.3" }, "devDependencies": { "@biomejs/biome": "^2.5.4", diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 7f772a9..f3eec50 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -1,13 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { emptyMetrics, emptySession, RECORDING_PAUSE_DELAY_MS } from '../constants'; -import { addMetricAggregates, loadStoredSession, sessionContinuation } from '../lib/session'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { RECORDING_PAUSE_DELAY_MS } from '../constants'; +import { loadStoredSession } from '../lib/session'; +import { + createSessionStore, + persistSessionState, + sessionSnapshotFromState, +} from '../stores/session-store'; import type { ControlMode, MetricSample, Metrics, SessionAggregates, SessionSnapshot, - StoredSession, } from '../types'; interface ActivityRef { @@ -51,92 +56,40 @@ export function useSession( lastPedalingAt: ActivityRef, trainerReportsDistance: FlagRef ): SessionController { - const restored = useMemo(loadStoredSession, []); - const initialStartedAt = useMemo(() => restored.startedAt || Date.now(), [restored.startedAt]); - const [isRiding, setIsRiding] = useState(false); - const [manuallyPaused, setManuallyPaused] = useState(false); - const [ended, setEnded] = useState(restored.ended); - const [endedAt, setEndedAt] = useState(restored.endedAt); - const [elapsedSeconds, setElapsedSeconds] = useState(restored.elapsedSeconds); - const [rideDistance, setRideDistance] = useState(restored.distance); - const [rideCalories, setRideCalories] = useState(restored.calories); - const [controlMode, setControlMode] = useState(restored.controlMode); - const [history, setHistory] = useState(restored.history); - const [maximums, setMaximums] = useState(restored.maximums); - const [aggregates, setAggregates] = useState(restored.aggregates); - const [savedSessionId, setSavedSessionId] = useState( - restored.savedSessionId - ); - const [startedAt, setStartedAt] = useState(initialStartedAt); + const store = useMemo(() => createSessionStore(loadStoredSession()), []); + const state = useSelector(store); const latestMetrics = useRef(metrics); const latestControl = useRef(control); - const elapsedRef = useRef(restored.elapsedSeconds); const lastTrainerDistance = useRef(undefined); useEffect(() => { latestMetrics.current = metrics; - if (ended) { - return; - } - setMaximums((current) => ({ - ...current, - cadence: Math.max(current.cadence, metrics.cadence), - heartRate: Math.max(current.heartRate, metrics.heartRate), - power: Math.max(current.power, metrics.power), - speed: Math.max(current.speed, metrics.speed), - })); - }, [ended, metrics]); + store.actions.observeMetrics(metrics); + }, [metrics, store]); useEffect(() => { latestControl.current = control; - if (elapsedRef.current === 0) { - setControlMode(control.mode); - } - }, [control]); + store.actions.observeControlMode(control.mode); + }, [control, store]); useEffect(() => { - localStorage.setItem( - 'trainer-session', - JSON.stringify({ - aggregates, - calories: rideCalories, - controlMode, - distance: rideDistance, - elapsedSeconds, - ended, - endedAt, - history: history.slice(-3600), - maximums, - savedSessionId, - startedAt, - } satisfies StoredSession) - ); - }, [ - aggregates, - controlMode, - elapsedSeconds, - ended, - endedAt, - history, - maximums, - rideCalories, - rideDistance, - savedSessionId, - startedAt, - ]); + persistSessionState(store.get()); + const subscription = store.subscribe((next) => persistSessionState(next)); + return () => subscription.unsubscribe(); + }, [store]); useEffect(() => { const interval = window.setInterval(() => { const recentlyPedaling = lastPedalingAt.current > 0 && performance.now() - lastPedalingAt.current <= RECORDING_PAUSE_DELAY_MS; - setIsRiding(!(ended || manuallyPaused) && recentlyPedaling); + store.actions.syncRiding(recentlyPedaling); }, 500); return () => window.clearInterval(interval); - }, [ended, lastPedalingAt, manuallyPaused]); + }, [lastPedalingAt, store]); useEffect(() => { - if (!isRiding) { + if (!state.isRiding) { return; } let lastTick = performance.now(); @@ -149,155 +102,77 @@ export function useSession( lastTick = now; const live = latestMetrics.current; const liveControl = latestControl.current; - const controlSample = - liveControl.mode === 'gear' - ? { gear: liveControl.gear } - : { resistance: liveControl.resistance }; - elapsedRef.current += seconds; - setElapsedSeconds(elapsedRef.current); - setControlMode(liveControl.mode); + let distanceDelta: number | undefined; if (trainerReportsDistance.current) { const previous = lastTrainerDistance.current; const delta = previous === undefined ? 0 : live.distance - previous; - if (delta >= 0 && delta < 0.25) { - setRideDistance((value) => value + delta); - } + distanceDelta = delta >= 0 && delta < 0.25 ? delta : 0; lastTrainerDistance.current = live.distance; - } else { - setRideDistance((value) => value + (live.speed * seconds) / 3600); - } - setHistory((samples) => [ - ...samples, - { - cadence: live.cadence, - elapsedSeconds: elapsedRef.current, - heartRate: live.heartRate, - power: live.power, - speed: live.speed, - ...controlSample, - }, - ]); - setAggregates((current) => addMetricAggregates(current, { ...live, ...controlSample })); - if (live.power > 0) { - setRideCalories((value) => value + (live.power * seconds) / (4184 * 0.24)); } + store.actions.recordTick({ + control: liveControl, + distanceDelta, + metrics: live, + seconds, + }); }, 1000); return () => window.clearInterval(interval); - }, [isRiding, trainerReportsDistance]); + }, [state.isRiding, store, trainerReportsDistance]); const togglePause = useCallback(() => { - if (ended) { - return; - } - if (manuallyPaused) { - setManuallyPaused(false); - setIsRiding( - lastPedalingAt.current > 0 && - performance.now() - lastPedalingAt.current <= RECORDING_PAUSE_DELAY_MS - ); - } else { - setManuallyPaused(true); - setIsRiding(false); - } - }, [ended, lastPedalingAt, manuallyPaused]); + const recentlyPedaling = + lastPedalingAt.current > 0 && + performance.now() - lastPedalingAt.current <= RECORDING_PAUSE_DELAY_MS; + store.actions.togglePause(recentlyPedaling); + }, [lastPedalingAt, store]); const endSession = useCallback(() => { - setEnded(true); - setEndedAt(Date.now()); - setIsRiding(false); - setManuallyPaused(false); - }, []); + store.actions.endSession(Date.now()); + }, [store]); - const markSaved = useCallback((id: string) => { - setSavedSessionId(id); - }, []); + const markSaved = useCallback( + (id: string) => { + store.actions.markSaved(id); + }, + [store] + ); const startNew = useCallback(() => { - elapsedRef.current = 0; lastTrainerDistance.current = latestMetrics.current.distance; lastPedalingAt.current = 0; - setEnded(false); - setEndedAt(0); - setElapsedSeconds(0); - setRideDistance(0); - setRideCalories(0); - setControlMode(latestControl.current.mode); - setHistory([]); - setIsRiding(false); - setManuallyPaused(false); - setMaximums(emptyMetrics); - setAggregates(emptySession.aggregates); - setSavedSessionId(undefined); - setStartedAt(Date.now()); localStorage.removeItem('trainer-session'); - }, [lastPedalingAt]); + store.actions.reset(latestControl.current.mode, Date.now()); + }, [lastPedalingAt, store]); const continueFrom = useCallback( (sourceSession: SessionSnapshot) => { - const continued = sessionContinuation(sourceSession); - elapsedRef.current = continued.elapsedSeconds; lastTrainerDistance.current = latestMetrics.current.distance; lastPedalingAt.current = 0; - setEnded(false); - setEndedAt(0); - setElapsedSeconds(continued.elapsedSeconds); - setRideDistance(continued.distance); - setRideCalories(continued.calories); - setControlMode(continued.controlMode); - setHistory(continued.history); - setIsRiding(false); - setManuallyPaused(false); - setMaximums(continued.maximums); - setAggregates(continued.aggregates); - setSavedSessionId(undefined); - setStartedAt(continued.startedAt); localStorage.removeItem('trainer-session'); + store.actions.continueFrom(sourceSession); }, - [lastPedalingAt] + [lastPedalingAt, store] ); - const snapshot = useMemo( - () => ({ - aggregates, - calories: rideCalories, - controlMode, - distance: rideDistance, - elapsedSeconds, - endedAt, - history, - maximums, - startedAt, - }), - [ - aggregates, - controlMode, - elapsedSeconds, - endedAt, - history, - maximums, - rideCalories, - rideDistance, - startedAt, - ] - ); + const snapshot = useMemo(() => sessionSnapshotFromState(state), [state]); return { - aggregates, + aggregates: state.aggregates, continueFrom, - controlMode, - elapsedSeconds, - ended, + controlMode: state.controlMode, + elapsedSeconds: state.elapsedSeconds, + ended: state.ended, endSession, - history, - isRiding, - manuallyPaused, + history: state.history, + isRiding: state.isRiding, + manuallyPaused: state.manuallyPaused, markSaved, - maximums, - rideCalories, - rideDistance, - savedSessionId, + maximums: state.maximums, + rideCalories: state.calories, + rideDistance: state.distance, + savedSessionId: state.savedSessionId, snapshot, - startedAt, + startedAt: state.startedAt, startNew, togglePause, }; diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts new file mode 100644 index 0000000..fdfb7ca --- /dev/null +++ b/src/stores/session-store.ts @@ -0,0 +1,190 @@ +import { createStore } from '@tanstack/react-store'; +import { emptyMetrics, emptySession } from '../constants'; +import { addMetricAggregates, sessionContinuation } from '../lib/session'; +import type { ControlMode, Metrics, SessionSnapshot, StoredSession } from '../types'; + +interface RecordSessionTick { + control: { + gear: number; + mode: ControlMode; + resistance: number; + }; + distanceDelta?: number; + metrics: Metrics; + seconds: number; +} + +export interface SessionStoreState extends StoredSession { + isRiding: boolean; + manuallyPaused: boolean; +} + +type SessionStorage = Pick; + +function initialSessionState(restored: StoredSession, now: number): SessionStoreState { + return { + ...restored, + isRiding: false, + manuallyPaused: false, + startedAt: restored.startedAt || now, + }; +} + +export function sessionSnapshotFromState(state: SessionStoreState): SessionSnapshot { + return { + aggregates: state.aggregates, + calories: state.calories, + controlMode: state.controlMode, + distance: state.distance, + elapsedSeconds: state.elapsedSeconds, + endedAt: state.endedAt, + history: state.history, + maximums: state.maximums, + startedAt: state.startedAt, + }; +} + +export function storedSessionFromState(state: SessionStoreState): StoredSession { + return { + aggregates: state.aggregates, + calories: state.calories, + controlMode: state.controlMode, + distance: state.distance, + elapsedSeconds: state.elapsedSeconds, + ended: state.ended, + endedAt: state.endedAt, + history: state.history.slice(-3600), + maximums: state.maximums, + savedSessionId: state.savedSessionId, + startedAt: state.startedAt, + }; +} + +export function persistSessionState( + state: SessionStoreState, + storage: SessionStorage = localStorage +) { + storage.setItem('trainer-session', JSON.stringify(storedSessionFromState(state))); +} + +export function createSessionStore(restored: StoredSession, now = Date.now()) { + return createStore(initialSessionState(restored, now), ({ setState }) => ({ + continueFrom: (sourceSession: SessionSnapshot) => { + const continued = sessionContinuation(sourceSession); + setState(() => ({ + ...continued, + isRiding: false, + manuallyPaused: false, + })); + }, + endSession: (endedAt: number) => { + setState((current) => ({ + ...current, + ended: true, + endedAt, + isRiding: false, + manuallyPaused: false, + })); + }, + markSaved: (savedSessionId: string) => { + setState((current) => ({ ...current, savedSessionId })); + }, + observeControlMode: (controlMode: ControlMode) => { + setState((current) => + current.elapsedSeconds === 0 && current.controlMode !== controlMode + ? { ...current, controlMode } + : current + ); + }, + observeMetrics: (metrics: Metrics) => { + setState((current) => { + if (current.ended) { + return current; + } + const maximums = { + ...current.maximums, + cadence: Math.max(current.maximums.cadence, metrics.cadence), + heartRate: Math.max(current.maximums.heartRate, metrics.heartRate), + power: Math.max(current.maximums.power, metrics.power), + speed: Math.max(current.maximums.speed, metrics.speed), + }; + if ( + maximums.cadence === current.maximums.cadence && + maximums.heartRate === current.maximums.heartRate && + maximums.power === current.maximums.power && + maximums.speed === current.maximums.speed + ) { + return current; + } + return { ...current, maximums }; + }); + }, + recordTick: ({ control, distanceDelta, metrics, seconds }: RecordSessionTick) => { + setState((current) => { + if (!current.isRiding) { + return current; + } + const elapsedSeconds = current.elapsedSeconds + seconds; + const controlSample = + control.mode === 'gear' + ? { gear: control.gear } + : { resistance: control.resistance }; + const sample = { + cadence: metrics.cadence, + elapsedSeconds, + heartRate: metrics.heartRate, + power: metrics.power, + speed: metrics.speed, + ...controlSample, + }; + return { + ...current, + aggregates: addMetricAggregates(current.aggregates, { + ...metrics, + ...controlSample, + }), + calories: + current.calories + + (metrics.power > 0 ? (metrics.power * seconds) / (4184 * 0.24) : 0), + controlMode: control.mode, + distance: + current.distance + (distanceDelta ?? (metrics.speed * seconds) / 3600), + elapsedSeconds, + history: [...current.history, sample], + }; + }); + }, + reset: (controlMode: ControlMode, startedAt: number) => { + setState(() => ({ + ...emptySession, + aggregates: emptySession.aggregates, + controlMode, + isRiding: false, + manuallyPaused: false, + maximums: emptyMetrics, + startedAt, + })); + }, + syncRiding: (recentlyPedaling: boolean) => { + setState((current) => { + const isRiding = !(current.ended || current.manuallyPaused) && recentlyPedaling; + return current.isRiding === isRiding ? current : { ...current, isRiding }; + }); + }, + togglePause: (recentlyPedaling: boolean) => { + setState((current) => { + if (current.ended) { + return current; + } + const manuallyPaused = !current.manuallyPaused; + return { + ...current, + isRiding: manuallyPaused ? false : recentlyPedaling, + manuallyPaused, + }; + }); + }, + })); +} + +export type SessionStore = ReturnType; diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts new file mode 100644 index 0000000..9ecb65c --- /dev/null +++ b/tests/session-store.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from 'bun:test'; +import { emptyMetrics, emptySession } from '../src/constants'; +import { + createSessionStore, + persistSessionState, + sessionSnapshotFromState, + storedSessionFromState, +} from '../src/stores/session-store'; +import type { Metrics, StoredSession } from '../src/types'; + +function restoredSession(overrides: Partial = {}): StoredSession { + return { + ...emptySession, + aggregates: { + cadence: { ...emptySession.aggregates.cadence }, + gear: { ...emptySession.aggregates.gear }, + heartRate: { ...emptySession.aggregates.heartRate }, + power: { ...emptySession.aggregates.power }, + resistance: { ...emptySession.aggregates.resistance }, + }, + maximums: { ...emptyMetrics }, + ...overrides, + }; +} + +const liveMetrics: Metrics = { + cadence: 82, + calories: 0, + distance: 0, + heartRate: 145, + power: 200, + speed: 36, +}; + +describe('session store', () => { + test('records a complete ride tick as one atomic update', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + let updates = 0; + const subscription = store.subscribe(() => { + updates += 1; + }); + + store.actions.recordTick({ + control: { gear: 12, mode: 'resistance', resistance: 42 }, + metrics: liveMetrics, + seconds: 2, + }); + + const state = store.get(); + expect(updates).toBe(1); + expect(state.elapsedSeconds).toBe(2); + expect(state.distance).toBeCloseTo(0.02); + expect(state.calories).toBeCloseTo(400 / (4184 * 0.24)); + expect(state.history).toEqual([ + { + cadence: 82, + elapsedSeconds: 2, + heartRate: 145, + power: 200, + resistance: 42, + speed: 36, + }, + ]); + expect(state.aggregates.power).toEqual({ count: 1, sum: 200 }); + expect(state.aggregates.resistance).toEqual({ count: 1, sum: 42 }); + subscription.unsubscribe(); + }); + + test('uses trainer distance deltas and records virtual gears independently', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + store.actions.recordTick({ + control: { gear: 16, mode: 'gear', resistance: 42 }, + distanceDelta: 0.01, + metrics: liveMetrics, + seconds: 1, + }); + + expect(store.get().distance).toBe(0.01); + expect(store.get().controlMode).toBe('gear'); + expect(store.get().history[0]?.gear).toBe(16); + expect(store.get().history[0]?.resistance).toBeUndefined(); + expect(store.get().aggregates.gear).toEqual({ count: 1, sum: 16 }); + expect(store.get().aggregates.resistance).toEqual({ count: 0, sum: 0 }); + }); + + test('coordinates pause, end, reset, and continuation transitions', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + expect(store.get().isRiding).toBeTrue(); + + store.actions.togglePause(true); + expect(store.get()).toMatchObject({ isRiding: false, manuallyPaused: true }); + store.actions.togglePause(true); + expect(store.get()).toMatchObject({ isRiding: true, manuallyPaused: false }); + + store.actions.endSession(5000); + expect(store.get()).toMatchObject({ + ended: true, + endedAt: 5000, + isRiding: false, + manuallyPaused: false, + }); + store.actions.reset('gear', 6000); + expect(store.get()).toMatchObject({ + controlMode: 'gear', + elapsedSeconds: 0, + ended: false, + startedAt: 6000, + }); + + store.actions.continueFrom({ + aggregates: emptySession.aggregates, + calories: 100, + controlMode: 'resistance', + distance: 12, + elapsedSeconds: 900, + endedAt: 4000, + history: [], + maximums: emptyMetrics, + startedAt: 2000, + }); + expect(store.get()).toMatchObject({ + calories: 100, + distance: 12, + elapsedSeconds: 900, + ended: false, + endedAt: 0, + startedAt: 2000, + }); + expect(store.get().savedSessionId).toBeUndefined(); + }); + + test('updates maxima without publishing unchanged snapshots or ended rides', () => { + const store = createSessionStore(restoredSession(), 1000); + let updates = 0; + const subscription = store.subscribe(() => { + updates += 1; + }); + + store.actions.observeMetrics(liveMetrics); + expect(updates).toBe(1); + expect(store.get().maximums).toMatchObject({ + cadence: 82, + heartRate: 145, + power: 200, + speed: 36, + }); + store.actions.observeMetrics(liveMetrics); + expect(updates).toBe(1); + store.actions.endSession(5000); + store.actions.observeMetrics({ ...liveMetrics, power: 300 }); + expect(store.get().maximums.power).toBe(200); + subscription.unsubscribe(); + }); + + test('persists only durable state and caps stored history', () => { + const history = Array.from({ length: 3601 }, (_, index) => ({ + cadence: 80, + elapsedSeconds: index, + heartRate: 140, + power: 180, + resistance: 40, + speed: 30, + })); + const store = createSessionStore(restoredSession({ history }), 1000); + store.actions.syncRiding(true); + const saved = storedSessionFromState(store.get()); + expect(saved.history).toHaveLength(3600); + expect(saved.history[0]?.elapsedSeconds).toBe(1); + expect(saved).not.toHaveProperty('isRiding'); + expect(saved).not.toHaveProperty('manuallyPaused'); + expect(sessionSnapshotFromState(store.get())).not.toHaveProperty('ended'); + + let storedKey = ''; + let storedValue = ''; + persistSessionState(store.get(), { + setItem: (key, value) => { + storedKey = key; + storedValue = value; + }, + }); + expect(storedKey).toBe('trainer-session'); + expect(JSON.parse(storedValue).history).toHaveLength(3600); + }); +}); From 6f6733b3d55374eaf81fe72af481da7e50f52167 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 10:02:43 -0700 Subject: [PATCH 03/26] refactor: unify device connection lifecycles --- src/components/device-pairing.tsx | 43 ++-- src/hooks/use-heart-rate-monitor.ts | 151 +++++++------ src/hooks/use-trainer.ts | 286 ++++++++---------------- src/hooks/use-zwift-click.ts | 333 ++++++++++------------------ src/lib/bluetooth-notifications.ts | 43 ++++ src/lib/bluetooth.ts | 6 +- src/lib/device-connection.ts | 77 +++++++ src/lib/heart-rate-device.ts | 59 +++++ src/lib/reconnect-controller.ts | 91 ++++++++ src/lib/trainer-device.ts | 141 ++++++++++++ src/lib/zwift-click-device.ts | 107 +++++++++ src/lib/zwift-click.ts | 7 +- tests/bluetooth.test.ts | 12 +- tests/components.test.tsx | 21 +- tests/device-connection.test.ts | 146 ++++++++++++ tests/devices.test.ts | 18 +- 16 files changed, 991 insertions(+), 550 deletions(-) create mode 100644 src/lib/bluetooth-notifications.ts create mode 100644 src/lib/device-connection.ts create mode 100644 src/lib/heart-rate-device.ts create mode 100644 src/lib/reconnect-controller.ts create mode 100644 src/lib/trainer-device.ts create mode 100644 src/lib/zwift-click-device.ts create mode 100644 tests/device-connection.test.ts diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 5239697..9dfaf19 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -1,25 +1,20 @@ import { useEffect } from 'react'; import { AUTOMATIC_RECONNECT_README_URL } from '../constants'; import { bluetoothBrowserNotice } from '../lib/browser'; +import type { DeviceConnectionView } from '../lib/device-connection'; import { Icon } from './icon'; -interface DeviceSlot { +interface DeviceSlot extends DeviceConnectionView { battery?: number; - busy: boolean; - connected: boolean; name?: string; onDisconnect: () => void; onForget: () => void | Promise; onPair: () => void | Promise; onReconnect: () => void | Promise; - paired: boolean; - status: string; } -interface ClickController { +interface ClickController extends DeviceConnectionView { active: boolean; - connected: boolean; - connecting: boolean; id: string; label: string; } @@ -30,7 +25,7 @@ interface ClickSlot extends DeviceSlot { onForgetController: (deviceId: string) => void | Promise; pairedCount: number; pairing: boolean; - reconnecting?: boolean; + reconnecting: boolean; } function clickControllerOrder(controller: ClickController) { @@ -65,31 +60,32 @@ function StatusDot({ ); } -function DeviceActions({ slot }: { slot: DeviceSlot }) { +function DeviceActions({ showBusy = true, slot }: { showBusy?: boolean; slot: DeviceSlot }) { + const actionBusy = showBusy && slot.busy; if (!slot.paired) { return ( ); } let connectionAction = 'Reconnect'; - if (slot.busy) { + if (actionBusy) { connectionAction = 'Connecting…'; } else if (slot.connected) { connectionAction = 'Disconnect'; } - const disconnecting = slot.connected && !slot.busy; + const disconnecting = slot.connected && !actionBusy; return (
- )} - - - ) : ( - <> - - - - )} -
-
- -
- - -
- - setDevicesOpen(true)} - pairedCount={pairedDeviceCount} - /> -
- - -
- - - - -
- -
-
-
- - - -
- -
-
-
-

- {click.paired ? 'Virtual shifting' : 'Resistance control'} -

-
- - {click.paired ? gearControl.gear : trainer.resistance} - - {click.paired ? 'of 24' : '%'} - - -
-
- {click.paired ? ( - - ) : ( - - )} -
-
- - + setDevicesOpen(true)} + onOpenHistory={() => { + setShortcutsOpen(false); + setHistoryOpen(true); + }} + onOpenShortcuts={() => { + setHistoryOpen(false); + setShortcutsOpen(true); + }} + onRequestNewSession={requestNewSession} + onSaveSession={() => { + setStartAfterSave(false); + setSaveDialogOpen(true); + }} + onSelectSpeedUnit={selectSpeedUnit} + onShiftGear={gearControl.shiftGear} + onTogglePause={session.togglePause} + onUpdateResistance={trainer.updateResistance} + pairedDeviceCount={pairedDeviceCount} + resistance={trainer.resistance} + resistanceKeyFlash={trainer.resistanceKeyFlash} + resistanceRamp={trainer.resistanceRamp} + session={{ + aggregates: session.aggregates, + controlMode: session.controlMode, + elapsedSeconds: session.elapsedSeconds, + ended: session.ended, + history: session.history, + isRiding, + manuallyPaused, + maximums: session.maximums, + rideCalories: session.rideCalories, + rideDistance: session.rideDistance, + }} + sessionIsSaved={sessionIsSaved} + shiftFlash={gearControl.shiftFlash} + speedUnit={speedUnit} + /> + setWelcomeOpen(true)} /> diff --git a/src/components/ride-dashboard.tsx b/src/components/ride-dashboard.tsx new file mode 100644 index 0000000..456d1c2 --- /dev/null +++ b/src/components/ride-dashboard.tsx @@ -0,0 +1,328 @@ +import { BUILD_PR_URL, BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from '../lib/build-info'; +import { formatAggregateAverage, formatDuration } from '../lib/format'; +import type { + ControlMode, + MetricSample, + Metrics, + ResistanceAdjustmentDirection, + ResistanceRamp, + SessionAggregates, + SpeedUnit, +} from '../types'; +import { DevicePairingButton } from './device-pairing'; +import { GearControl } from './gear-control'; +import { Icon } from './icon'; +import { Metric, SmallMetric } from './metrics'; +import { ResistanceControl } from './resistance-control'; +import { SessionChart } from './session-chart'; + +interface DashboardSession { + aggregates: SessionAggregates; + controlMode: ControlMode; + elapsedSeconds: number; + ended: boolean; + history: MetricSample[]; + isRiding: boolean; + manuallyPaused: boolean; + maximums: Metrics; + rideCalories: number; + rideDistance: number; +} + +interface RideDashboardProps { + clickPaired: boolean; + connected: boolean; + connectedDeviceCount: number; + dashboardKeyboardEnabled: boolean; + devicesConnecting: boolean; + gear: number; + liveMetrics: Metrics; + onEndSession: () => void; + onOpenDevices: () => void; + onOpenHistory: () => void; + onOpenShortcuts: () => void; + onRequestNewSession: () => void; + onSaveSession: () => void; + onSelectSpeedUnit: (unit: SpeedUnit) => void; + onShiftGear: (change: number) => void; + onTogglePause: () => void; + onUpdateResistance: (resistance: number) => void; + pairedDeviceCount: number; + resistance: number; + resistanceKeyFlash?: ResistanceAdjustmentDirection; + resistanceRamp: ResistanceRamp; + session: DashboardSession; + sessionIsSaved: boolean; + shiftFlash?: ResistanceAdjustmentDirection; + speedUnit: SpeedUnit; +} + +export function RideDashboard({ + clickPaired, + connected, + connectedDeviceCount, + dashboardKeyboardEnabled, + devicesConnecting, + gear, + liveMetrics, + onEndSession, + onOpenDevices, + onOpenHistory, + onOpenShortcuts, + onRequestNewSession, + onSaveSession, + onSelectSpeedUnit, + onShiftGear, + onTogglePause, + onUpdateResistance, + pairedDeviceCount, + resistance, + resistanceKeyFlash, + resistanceRamp, + session, + sessionIsSaved, + shiftFlash, + speedUnit, +}: RideDashboardProps) { + const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; + const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; + const displayedSpeed = liveMetrics.speed * unitFactor; + const displayedDistance = session.rideDistance * unitFactor; + const displayedMaximumSpeed = session.maximums.speed * unitFactor; + const averageSpeed = + session.elapsedSeconds > 0 ? session.rideDistance / (session.elapsedSeconds / 3600) : 0; + const displayedAverageSpeed = averageSpeed * unitFactor; + const controlValue = clickPaired ? gear : resistance; + let sessionControlLabel = 'Auto paused'; + let sessionControlIcon = 'stop'; + if (session.isRiding) { + sessionControlLabel = 'Pause'; + sessionControlIcon = 'pause'; + } + if (session.manuallyPaused) { + sessionControlLabel = 'Resume'; + sessionControlIcon = 'play'; + } + + return ( +
+
+
+ {session.ended ? ( + <> + {sessionIsSaved ? null : ( + + )} + + + ) : ( + <> + + + + )} +
+
+ +
+ {(['kmh', 'mph'] as const).map((unit) => ( + + ))} +
+ + +
+
+ +
+ + + + +
+ +
+
+
+ + + +
+ +
+
+
+

+ {clickPaired ? 'Virtual shifting' : 'Resistance control'} +

+
+ + {controlValue} + + {clickPaired ? 'of 24' : '%'} + + +
+
+ {clickPaired ? ( + + ) : ( + + )} +
+
+
+ ); +} + +export function AppFooter({ onOpenWelcome }: { onOpenWelcome: () => void }) { + return ( + + ); +} From 5eedf37c7c307f76dbab3377000f6e0bedb13ac0 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 10:52:22 -0700 Subject: [PATCH 05/26] refactor: centralize shared domain behavior Introduce common unit conversion, numeric bounds, metric presentation, formatting, error, DOM event, and dialog lifecycle utilities. Reuse storage keys, timing constants, distance and calorie calculations, chart metadata, session summaries, Bluetooth helpers, and export formatting across the application. Add focused unit coverage and document the repository's DRY expectations. --- AGENTS.md | 1 + src/components/keyboard-shortcuts-dialog.tsx | 17 +-- src/components/metrics.tsx | 33 +----- src/components/resistance-control.tsx | 17 ++- src/components/session-chart.tsx | 106 +++++++------------ src/components/session-history.tsx | 91 +++++++--------- src/components/session-save-dialog.tsx | 28 ++--- src/components/session-summary.tsx | 26 +++++ src/components/welcome-dialog.tsx | 15 +-- src/constants.ts | 5 + src/hooks/use-dialog-behavior.ts | 30 ++++++ src/hooks/use-heart-rate-monitor.ts | 14 +-- src/hooks/use-session.ts | 11 +- src/hooks/use-trainer.ts | 80 +++++++------- src/hooks/use-zwift-click.ts | 30 +++--- src/lib/bluetooth.ts | 22 ++-- src/lib/chart.ts | 17 +-- src/lib/dom.ts | 18 ++++ src/lib/errors.ts | 3 + src/lib/format.ts | 24 +++-- src/lib/gears.ts | 7 +- src/lib/gpx.ts | 3 +- src/lib/metric-presentation.ts | 72 +++++++++++++ src/lib/notification.ts | 6 +- src/lib/numbers.ts | 7 ++ src/lib/resistance.ts | 11 +- src/lib/saved-sessions.ts | 8 ++ src/lib/session.ts | 49 +++++---- src/lib/tcx.ts | 60 +++++------ src/lib/units.ts | 74 +++++++++++++ src/lib/zwift-click.ts | 11 +- src/stores/session-store.ts | 21 ++-- tests/components.test.tsx | 9 +- tests/session.test.ts | 2 +- tests/units.test.ts | 39 +++++++ 35 files changed, 609 insertions(+), 358 deletions(-) create mode 100644 src/components/session-summary.tsx create mode 100644 src/hooks/use-dialog-behavior.ts create mode 100644 src/lib/dom.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lib/metric-presentation.ts create mode 100644 src/lib/numbers.ts create mode 100644 src/lib/units.ts create mode 100644 tests/units.test.ts diff --git a/AGENTS.md b/AGENTS.md index 300326c..e2906a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ - After making any changes, always run `bun run ci` before handing work back. - `bun run ci` must run the Ultracite-configured Biome checks automatically, followed by the TypeScript check and production build. - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. +- Keep the codebase as DRY as practical: before adding constants, calculations, formatting, parsing, labels, or state-derivation logic, search for an existing domain utility and reuse or extend it. Consolidate meaningful duplication into clearly named domain modules, but do not introduce generic abstractions that hide simple behavior or couple unrelated concepts solely because their implementations look similar. - Never include AI assistant, product, vendor, or model names (such as `codex` or `openai`) in branch names, commit messages, pull request titles or descriptions, issue titles or descriptions, tags, release notes, or any other repository-visible metadata or content. - Name branches only after the work being performed, such as `refactor/tanstack-store`; do not apply a tool's default branch prefix when that prefix would violate the naming rule above. - When the user says "add, commit", group all existing changes into logical sets, stage and commit each group, and repeat until every change is committed and the working tree is clean. diff --git a/src/components/keyboard-shortcuts-dialog.tsx b/src/components/keyboard-shortcuts-dialog.tsx index a19e81d..51506c0 100644 --- a/src/components/keyboard-shortcuts-dialog.tsx +++ b/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,5 @@ -import { Fragment, useEffect } from 'react'; +import { Fragment } from 'react'; +import { useCloseOnEscape } from '../hooks/use-dialog-behavior'; import { dashboardKeyboardShortcuts, type KeyboardShortcutDescription } from '../lib/keyboard'; export function KeyboardShortcutsDialog({ @@ -14,19 +15,7 @@ export function KeyboardShortcutsDialog({ shortcuts?: KeyboardShortcutDescription[]; title?: string; }) { - useEffect(() => { - if (!(handleEscape && open)) { - return; - } - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); - } - }; - window.addEventListener('keydown', closeOnEscape); - return () => window.removeEventListener('keydown', closeOnEscape); - }, [handleEscape, onClose, open]); + useCloseOnEscape(handleEscape && open, onClose); if (!open) { return null; diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index cd1491b..4329713 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -1,37 +1,6 @@ +import { metricAccentClass, metricIconClass } from '../lib/metric-presentation'; import { Icon } from './icon'; -export function metricAccentClass(accent: string): string { - if (accent === 'sky') { - return 'bg-sky-400'; - } - if (accent === 'yellow') { - return 'bg-yellow-400'; - } - if (accent === 'violet') { - return 'bg-violet-400'; - } - if (accent === 'rose') { - return 'bg-rose-400'; - } - return 'bg-mint'; -} - -export function metricIconClass(accent: string): string { - if (accent === 'mint') { - return 'text-mint'; - } - if (accent === 'yellow') { - return 'text-yellow-400'; - } - if (accent === 'violet') { - return 'text-violet-400'; - } - if (accent === 'rose') { - return 'text-rose-400'; - } - return 'text-sky-400'; -} - export function Metric({ average, label, diff --git a/src/components/resistance-control.tsx b/src/components/resistance-control.tsx index 611f0cb..3a4d355 100644 --- a/src/components/resistance-control.tsx +++ b/src/components/resistance-control.tsx @@ -1,4 +1,6 @@ import { type CSSProperties, useEffect, useRef, useState } from 'react'; +import { CONTROL_FLASH_MS } from '../constants'; +import { clamp } from '../lib/numbers'; import { resistanceAdjustmentDirection } from '../lib/resistance'; import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; import { Icon } from './icon'; @@ -26,10 +28,9 @@ export function ResistanceControl({ const sliderDragging = useRef(false); const sliderFlashTimer = useRef(undefined); const sliderValue = useRef(value); - const rampProgress = ramp.phase === 'settled' ? 1 : Math.max(0, Math.min(1, ramp.progress)); + const rampProgress = ramp.phase === 'settled' ? 1 : clamp(ramp.progress, 0, 1); const rampProgressPercent = Math.round(rampProgress * 100); - const sliderPosition = - max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) * 100 : 0; + const sliderPosition = max > min ? clamp((value - min) / (max - min), 0, 1) * 100 : 0; const sliderStyle = { '--ramp-progress': `${rampProgress * 360}deg`, '--resistance-position': `${sliderPosition}%`, @@ -54,7 +55,10 @@ export function ResistanceControl({ const clearSliderFlash = () => { sliderDragging.current = false; window.clearTimeout(sliderFlashTimer.current); - sliderFlashTimer.current = window.setTimeout(() => setSliderFlash(undefined), 180); + sliderFlashTimer.current = window.setTimeout( + () => setSliderFlash(undefined), + CONTROL_FLASH_MS + ); }; const handleSliderChange = (next: number) => { @@ -64,7 +68,10 @@ export function ResistanceControl({ window.clearTimeout(sliderFlashTimer.current); setSliderFlash(direction); if (!sliderDragging.current) { - sliderFlashTimer.current = window.setTimeout(() => setSliderFlash(undefined), 180); + sliderFlashTimer.current = window.setTimeout( + () => setSliderFlash(undefined), + CONTROL_FLASH_MS + ); } } onChange(next); diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index be1374f..32ad7dc 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -1,11 +1,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { + CHART_MODE_STORAGE_KEY, chartModesForControl, chartPath, roundedChartMaximum, storedChartMode, } from '../lib/chart'; +import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { formatChartSeconds } from '../lib/format'; +import { MAX_GEAR, MIN_GEAR } from '../lib/gears'; +import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; +import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; +import { convertSpeed, minimumSpeedChartMaximum, speedUnitLabel } from '../lib/units'; import type { ChartMode, ControlMode, MetricSample, RoutePoint, SpeedUnit } from '../types'; interface PlotProps { @@ -91,7 +97,7 @@ export function SessionChart({ controlMode?: ControlMode; history: MetricSample[]; keyboardEnabled?: boolean; - route: RoutePoint[]; + route: readonly RoutePoint[]; speedUnit: SpeedUnit; }) { const [selectedMode, setSelectedMode] = useState(storedChartMode); @@ -107,92 +113,63 @@ export function SessionChart({ ? 'all' : modeForAvailableControl; const series = useMemo(() => { + const speedValues = history.map((sample) => convertSpeed(sample.speed, speedUnit)); + const standardSeries = STANDARD_METRIC_KEYS.map((key) => { + const presentation = METRIC_PRESENTATION[key]; + const values = history.map((sample) => sample[key]); + return { + chartMaximum: roundedChartMaximum( + Math.max(...values, 0), + presentation.chartMinimumMaximum, + presentation.chartStep + ), + color: presentation.chartColor, + decimals: 0, + key, + label: presentation.label, + minimum: 0, + unit: presentation.unit, + values, + }; + }); const controlSeries = resolvedControlMode === 'gear' ? { - chartMaximum: 24, + chartMaximum: MAX_GEAR, color: '#adf5bd', decimals: 0, key: 'gear' as const, label: 'Gear', - minimum: 1, + minimum: MIN_GEAR, unit: '', values: history.map((sample) => sample.gear), } : { - chartMaximum: 100, + chartMaximum: MAX_RESISTANCE, color: '#adf5bd', decimals: 0, key: 'resistance' as const, label: 'Resistance', - minimum: 0, + minimum: MIN_RESISTANCE, unit: '%', values: history.map((sample) => sample.resistance), }; return [ { chartMaximum: roundedChartMaximum( - Math.max( - ...history.map( - (sample) => sample.speed * (speedUnit === 'mph' ? 0.621_371 : 1) - ), - 0 - ), - speedUnit === 'mph' ? 20 : 30, + Math.max(...speedValues, 0), + minimumSpeedChartMaximum(speedUnit), 5 ), - color: '#38bdf8', + color: METRIC_PRESENTATION.speed.chartColor, decimals: 1, key: 'speed' as const, - label: 'Speed', - minimum: 0, - unit: speedUnit === 'mph' ? 'mph' : 'km/h', - values: history.map((sample) => - speedUnit === 'mph' ? sample.speed * 0.621_371 : sample.speed - ), - }, - { - chartMaximum: roundedChartMaximum( - Math.max(...history.map((sample) => sample.power), 0), - 100, - 50 - ), - color: '#facc15', - decimals: 0, - key: 'power' as const, - label: 'Power', - minimum: 0, - unit: 'W', - values: history.map((sample) => sample.power), - }, - { - chartMaximum: roundedChartMaximum( - Math.max(...history.map((sample) => sample.cadence), 0), - 80, - 10 - ), - color: '#a78bfa', - decimals: 0, - key: 'cadence' as const, - label: 'Cadence', - minimum: 0, - unit: 'rpm', - values: history.map((sample) => sample.cadence), - }, - { - chartMaximum: roundedChartMaximum( - Math.max(...history.map((sample) => sample.heartRate), 0), - 180, - 10 - ), - color: '#fb7185', - decimals: 0, - key: 'heartRate' as const, - label: 'Heart rate', + label: METRIC_PRESENTATION.speed.label, minimum: 0, - unit: 'bpm', - values: history.map((sample) => sample.heartRate), + unit: speedUnitLabel(speedUnit), + values: speedValues, }, + ...standardSeries, controlSeries, ]; }, [history, resolvedControlMode, speedUnit]); @@ -218,7 +195,7 @@ export function SessionChart({ const selectMode = useCallback((mode: ChartMode) => { setSelectedMode(mode); - localStorage.setItem('trainer-chart-mode', mode); + localStorage.setItem(CHART_MODE_STORAGE_KEY, mode); }, []); useEffect(() => { @@ -226,13 +203,10 @@ export function SessionChart({ return; } const handleKeys = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; if ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - target?.matches("input, textarea, select, [contenteditable='true']") || + keyboardEventHasModifiers(event) || + eventTargetsEditableControl(event) || !['ArrowLeft', 'ArrowRight'].includes(event.key) ) { return; diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index ea225a4..977cc2a 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,11 +1,19 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { formatAggregateAverage, formatDuration } from '../lib/format'; +import { EMPTY_ROUTE } from '../constants'; +import { + eventTargetsEditableControl, + eventTargetsInteractiveControl, + keyboardEventHasModifiers, +} from '../lib/dom'; +import { errorMessage } from '../lib/errors'; +import { formatAggregateAverage, formatDuration, formatWholeNumber } from '../lib/format'; import { type HistoryShortcut, historyKeyboardShortcuts, historyShortcutForKey, } from '../lib/keyboard'; +import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; import { adjacentSession, countSavedSessions, @@ -20,22 +28,20 @@ import { sessionListAfterDelete, } from '../lib/saved-sessions'; import { downloadSessionTcx } from '../lib/tcx'; +import { formatDistance } from '../lib/units'; import type { SavedSession, SavedSessionSummary, SpeedUnit } from '../types'; import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'; -import { SessionMetric, SmallMetric } from './metrics'; +import { SessionMetric } from './metrics'; import { SessionChart } from './session-chart'; +import { SessionSummary } from './session-summary'; const PAGE_SIZE = 30; -const EMPTY_ROUTE: [] = []; function shouldIgnoreHistoryAction(event: KeyboardEvent) { - const target = event.target as HTMLElement | null; return ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - target?.matches("input, textarea, select, [contenteditable='true']") + keyboardEventHasModifiers(event) || + eventTargetsEditableControl(event) ); } @@ -120,8 +126,6 @@ export function SessionDetail({ session: SavedSession; speedUnit: SpeedUnit; }) { - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; const usesGear = session.controlMode === 'gear'; const controlMetric = usesGear ? { @@ -138,6 +142,17 @@ export function SessionDetail({ label: 'RESISTANCE', unit: '%', }; + const standardMetrics = STANDARD_METRIC_KEYS.map((key) => { + const presentation = METRIC_PRESENTATION[key]; + return { + accent: presentation.accent, + average: formatAggregateAverage(session.aggregates[key], 0), + icon: presentation.icon, + label: presentation.label.toUpperCase(), + maximum: formatWholeNumber(session.maximums[key]), + unit: presentation.unit, + }; + }); return (
@@ -191,41 +206,16 @@ export function SessionDetail({ ) : null}
- - -
- {[ - { - accent: 'yellow', - average: formatAggregateAverage(session.aggregates.power, 0), - icon: 'bolt', - label: 'POWER', - maximum: String(Math.round(session.maximums.power)), - unit: 'W', - }, - { - accent: 'violet', - average: formatAggregateAverage(session.aggregates.cadence, 0), - icon: 'cadence', - label: 'CADENCE', - maximum: String(Math.round(session.maximums.cadence)), - unit: 'rpm', - }, - { - accent: 'rose', - average: formatAggregateAverage(session.aggregates.heartRate, 0), - icon: 'heart', - label: 'HEART RATE', - maximum: String(Math.round(session.maximums.heartRate)), - unit: 'bpm', - }, - controlMetric, - ].map((metric) => ( + {[...standardMetrics, controlMetric].map((metric) => ( ))}
@@ -280,8 +270,6 @@ export function SessionHistory({ const [error, setError] = useState(''); const deleteInProgress = useRef(false); const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; useEffect(() => { let frame: number | undefined; @@ -314,7 +302,7 @@ export function SessionHistory({ setSelected(await getSavedSession(id)); setError(''); } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : String(loadError)); + setError(errorMessage(loadError)); } finally { setLoading(false); } @@ -347,7 +335,7 @@ export function SessionHistory({ } loadHistory().catch((loadError: unknown) => { if (!cancelled) { - setError(loadError instanceof Error ? loadError.message : String(loadError)); + setError(errorMessage(loadError)); } }); return () => { @@ -375,7 +363,7 @@ export function SessionHistory({ setSelectedId(undefined); } } catch (deleteError) { - setError(deleteError instanceof Error ? deleteError.message : String(deleteError)); + setError(errorMessage(deleteError)); } finally { deleteInProgress.current = false; setDeleting(false); @@ -408,11 +396,7 @@ export function SessionHistory({ } }, confirmDelete: (event) => { - const target = event.target as HTMLElement | null; - if ( - !deleteConfirmationOpen || - target?.matches("button, a, input, textarea, select, [contenteditable='true']") - ) { + if (!deleteConfirmationOpen || eventTargetsInteractiveControl(event)) { return; } event.preventDefault(); @@ -579,8 +563,7 @@ export function SessionHistory({

- {(session.distance * unitFactor).toFixed(2)}{' '} - {distanceUnit} + {formatDistance(session.distance, speedUnit)} {session.feeling ? ` · ${feelingLabel(session.feeling)}` : null} diff --git a/src/components/session-save-dialog.tsx b/src/components/session-save-dialog.tsx index 1c601da..c097f86 100644 --- a/src/components/session-save-dialog.tsx +++ b/src/components/session-save-dialog.tsx @@ -1,16 +1,7 @@ import { useEffect, useState } from 'react'; -import { formatDuration } from '../lib/format'; -import { formatSessionTime } from '../lib/saved-sessions'; +import { formatSessionTime, SESSION_FEELING_OPTIONS } from '../lib/saved-sessions'; import type { SessionFeeling, SessionMetadata, SessionSnapshot, SpeedUnit } from '../types'; -import { SmallMetric } from './metrics'; - -const feelings: { label: string; value: SessionFeeling }[] = [ - { label: 'Great', value: 'great' }, - { label: 'Good', value: 'good' }, - { label: 'Okay', value: 'okay' }, - { label: 'Tough', value: 'tough' }, - { label: 'Exhausted', value: 'exhausted' }, -]; +import { SessionSummary } from './session-summary'; export function SessionSaveDialog({ continuing = false, @@ -45,9 +36,6 @@ export function SessionSaveDialog({ return null; } - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; - return (

- - -
How did it feel?
- {feelings.map((option) => ( + {SESSION_FEELING_OPTIONS.map((option) => ( + + + GitHub + + + + Sponsor + + + + + + + ); +} diff --git a/src/components/dashboard-layout.tsx b/src/components/dashboard-layout.tsx new file mode 100644 index 0000000..357614b --- /dev/null +++ b/src/components/dashboard-layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +export function Dashboard({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function DashboardToolbar({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function DashboardWorkspace({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/components/dashboard-tools.tsx b/src/components/dashboard-tools.tsx new file mode 100644 index 0000000..48a19f3 --- /dev/null +++ b/src/components/dashboard-tools.tsx @@ -0,0 +1,61 @@ +import { SPEED_UNIT_OPTIONS } from '../lib/units'; +import type { SpeedUnit } from '../types'; +import { DevicePairingButton } from './device-pairing'; + +export function DashboardTools({ + connectedDeviceCount, + devicesConnecting, + 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 ( +
+ +
+ {SPEED_UNIT_OPTIONS.map((option) => ( + + ))} +
+ + +
+ ); +} diff --git a/src/components/gear-training-control.tsx b/src/components/gear-training-control.tsx new file mode 100644 index 0000000..1c39f67 --- /dev/null +++ b/src/components/gear-training-control.tsx @@ -0,0 +1,27 @@ +import { MAX_GEAR } from '../lib/gears'; +import type { ResistanceAdjustmentDirection } from '../types'; +import { GearControl } from './gear-control'; +import { TrainingControlPanel } from './training-control-panel'; + +export function GearTrainingControl({ + connected, + gear, + onShift, + shiftFlash, +}: { + connected: boolean; + gear: number; + onShift: (change: number) => void; + shiftFlash?: ResistanceAdjustmentDirection; +}) { + return ( + + + + ); +} diff --git a/src/components/resistance-training-control.tsx b/src/components/resistance-training-control.tsx new file mode 100644 index 0000000..642f251 --- /dev/null +++ b/src/components/resistance-training-control.tsx @@ -0,0 +1,33 @@ +import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; +import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; +import { ResistanceControl } from './resistance-control'; +import { TrainingControlPanel } from './training-control-panel'; + +export function ResistanceTrainingControl({ + connected, + keyboardFlash, + onChange, + ramp, + resistance, +}: { + connected: boolean; + keyboardFlash?: ResistanceAdjustmentDirection; + onChange: (resistance: number) => void; + ramp: ResistanceRamp; + resistance: number; +}) { + return ( + + + + ); +} diff --git a/src/components/ride-dashboard.tsx b/src/components/ride-dashboard.tsx deleted file mode 100644 index 456d1c2..0000000 --- a/src/components/ride-dashboard.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { BUILD_PR_URL, BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from '../lib/build-info'; -import { formatAggregateAverage, formatDuration } from '../lib/format'; -import type { - ControlMode, - MetricSample, - Metrics, - ResistanceAdjustmentDirection, - ResistanceRamp, - SessionAggregates, - SpeedUnit, -} from '../types'; -import { DevicePairingButton } from './device-pairing'; -import { GearControl } from './gear-control'; -import { Icon } from './icon'; -import { Metric, SmallMetric } from './metrics'; -import { ResistanceControl } from './resistance-control'; -import { SessionChart } from './session-chart'; - -interface DashboardSession { - aggregates: SessionAggregates; - controlMode: ControlMode; - elapsedSeconds: number; - ended: boolean; - history: MetricSample[]; - isRiding: boolean; - manuallyPaused: boolean; - maximums: Metrics; - rideCalories: number; - rideDistance: number; -} - -interface RideDashboardProps { - clickPaired: boolean; - connected: boolean; - connectedDeviceCount: number; - dashboardKeyboardEnabled: boolean; - devicesConnecting: boolean; - gear: number; - liveMetrics: Metrics; - onEndSession: () => void; - onOpenDevices: () => void; - onOpenHistory: () => void; - onOpenShortcuts: () => void; - onRequestNewSession: () => void; - onSaveSession: () => void; - onSelectSpeedUnit: (unit: SpeedUnit) => void; - onShiftGear: (change: number) => void; - onTogglePause: () => void; - onUpdateResistance: (resistance: number) => void; - pairedDeviceCount: number; - resistance: number; - resistanceKeyFlash?: ResistanceAdjustmentDirection; - resistanceRamp: ResistanceRamp; - session: DashboardSession; - sessionIsSaved: boolean; - shiftFlash?: ResistanceAdjustmentDirection; - speedUnit: SpeedUnit; -} - -export function RideDashboard({ - clickPaired, - connected, - connectedDeviceCount, - dashboardKeyboardEnabled, - devicesConnecting, - gear, - liveMetrics, - onEndSession, - onOpenDevices, - onOpenHistory, - onOpenShortcuts, - onRequestNewSession, - onSaveSession, - onSelectSpeedUnit, - onShiftGear, - onTogglePause, - onUpdateResistance, - pairedDeviceCount, - resistance, - resistanceKeyFlash, - resistanceRamp, - session, - sessionIsSaved, - shiftFlash, - speedUnit, -}: RideDashboardProps) { - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; - const displayedSpeed = liveMetrics.speed * unitFactor; - const displayedDistance = session.rideDistance * unitFactor; - const displayedMaximumSpeed = session.maximums.speed * unitFactor; - const averageSpeed = - session.elapsedSeconds > 0 ? session.rideDistance / (session.elapsedSeconds / 3600) : 0; - const displayedAverageSpeed = averageSpeed * unitFactor; - const controlValue = clickPaired ? gear : resistance; - let sessionControlLabel = 'Auto paused'; - let sessionControlIcon = 'stop'; - if (session.isRiding) { - sessionControlLabel = 'Pause'; - sessionControlIcon = 'pause'; - } - if (session.manuallyPaused) { - sessionControlLabel = 'Resume'; - sessionControlIcon = 'play'; - } - - return ( -
-
-
- {session.ended ? ( - <> - {sessionIsSaved ? null : ( - - )} - - - ) : ( - <> - - - - )} -
-
- -
- {(['kmh', 'mph'] as const).map((unit) => ( - - ))} -
- - -
-
- -
- - - - -
- -
-
-
- - - -
- -
-
-
-

- {clickPaired ? 'Virtual shifting' : 'Resistance control'} -

-
- - {controlValue} - - {clickPaired ? 'of 24' : '%'} - - -
-
- {clickPaired ? ( - - ) : ( - - )} -
-
-
- ); -} - -export function AppFooter({ onOpenWelcome }: { onOpenWelcome: () => void }) { - return ( - - ); -} diff --git a/src/components/ride-metrics.tsx b/src/components/ride-metrics.tsx new file mode 100644 index 0000000..1c89051 --- /dev/null +++ b/src/components/ride-metrics.tsx @@ -0,0 +1,64 @@ +import { formatAggregateAverage, formatWholeNumber } from '../lib/format'; +import { METRIC_PRESENTATION } from '../lib/metric-presentation'; +import { averageSpeed, formatSpeed, speedUnitLabel } from '../lib/units'; +import type { Metrics, SessionAggregates, SpeedUnit } from '../types'; +import { Metric } from './metrics'; + +export function RideMetrics({ + aggregates, + elapsedSeconds, + liveMetrics, + maximums, + rideDistance, + speedUnit, +}: { + aggregates: SessionAggregates; + elapsedSeconds: number; + liveMetrics: Metrics; + maximums: Metrics; + rideDistance: number; + speedUnit: SpeedUnit; +}) { + const rideAverageSpeed = averageSpeed(rideDistance, elapsedSeconds); + const { cadence, heartRate, power, speed } = METRIC_PRESENTATION; + return ( +
+ + + + +
+ ); +} diff --git a/src/components/session-controls.tsx b/src/components/session-controls.tsx new file mode 100644 index 0000000..b0b9ae7 --- /dev/null +++ b/src/components/session-controls.tsx @@ -0,0 +1,74 @@ +import { Icon } from './icon'; + +export function SessionControls({ + ended, + isRiding, + manuallyPaused, + onEnd, + onRequestNew, + onSave, + onTogglePause, + saved, +}: { + ended: boolean; + isRiding: boolean; + manuallyPaused: boolean; + onEnd: () => void; + onRequestNew: () => void; + onSave: () => void; + onTogglePause: () => void; + saved: boolean; +}) { + if (ended) { + return ( +
+ {saved ? null : ( + + )} + +
+ ); + } + + let controlLabel = 'Auto paused'; + let controlIcon = 'stop'; + if (isRiding) { + controlLabel = 'Pause'; + controlIcon = 'pause'; + } + if (manuallyPaused) { + controlLabel = 'Resume'; + controlIcon = 'play'; + } + return ( +
+ + +
+ ); +} diff --git a/src/components/session-overview.tsx b/src/components/session-overview.tsx new file mode 100644 index 0000000..38b5836 --- /dev/null +++ b/src/components/session-overview.tsx @@ -0,0 +1,42 @@ +import { EMPTY_ROUTE } from '../constants'; +import type { ControlMode, MetricSample, SpeedUnit } from '../types'; +import { SessionChart } from './session-chart'; +import { SessionSummary } from './session-summary'; + +export function SessionOverview({ + controlMode, + elapsedSeconds, + history, + keyboardEnabled, + rideCalories, + rideDistance, + speedUnit, +}: { + controlMode: ControlMode; + elapsedSeconds: number; + history: MetricSample[]; + keyboardEnabled: boolean; + rideCalories: number; + rideDistance: number; + speedUnit: SpeedUnit; +}) { + return ( +
+
+ +
+ +
+ ); +} diff --git a/src/components/training-control-panel.tsx b/src/components/training-control-panel.tsx new file mode 100644 index 0000000..2c00487 --- /dev/null +++ b/src/components/training-control-panel.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; + +export function TrainingControlPanel({ + children, + title, + unit, + value, +}: { + children: ReactNode; + title: string; + unit: string; + value: number; +}) { + return ( +
+
+

{title}

+
+ + {value} + {unit} + +
+
+ {children} +
+ ); +} diff --git a/src/components/training-control.tsx b/src/components/training-control.tsx new file mode 100644 index 0000000..96db64c --- /dev/null +++ b/src/components/training-control.tsx @@ -0,0 +1,47 @@ +import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; +import { GearTrainingControl } from './gear-training-control'; +import { ResistanceTrainingControl } from './resistance-training-control'; + +type TrainingControlModel = + | { + gear: number; + mode: 'gear'; + onShift: (change: number) => void; + shiftFlash?: ResistanceAdjustmentDirection; + } + | { + keyboardFlash?: ResistanceAdjustmentDirection; + mode: 'resistance'; + onChange: (resistance: number) => void; + ramp: ResistanceRamp; + resistance: number; + }; + +export function TrainingControl({ + connected, + control, +}: { + connected: boolean; + control: TrainingControlModel; +}) { + if (control.mode === 'gear') { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/hooks/use-gear-control.ts b/src/hooks/use-gear-control.ts index 4041d11..d3df101 100644 --- a/src/hooks/use-gear-control.ts +++ b/src/hooks/use-gear-control.ts @@ -1,19 +1,25 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { gearForResistance, resistanceChangeForGears, shiftedGear, storedGear } from '../lib/gears'; +import { CONTROL_FLASH_MS } from '../constants'; +import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; +import { + GEAR_STORAGE_KEY, + gearForResistance, + resistanceChangeForGears, + shiftedGear, + storedGear, +} from '../lib/gears'; import { resistanceDirectionForKey } from '../lib/resistance'; import type { ResistanceAdjustmentDirection } from '../types'; export function useGearControl({ active, connected, - keyboardEnabled, onResistanceChange, resistance, setNotice, }: { active: boolean; connected: boolean; - keyboardEnabled: boolean; onResistanceChange: (change: number) => void; resistance: number; setNotice: (notice: string) => void; @@ -21,6 +27,7 @@ export function useGearControl({ const [gear, setGear] = useState(() => storedGear(localStorage, gearForResistance(resistance))); const [shiftFlash, setShiftFlash] = useState(); const gearRef = useRef(gear); + const keyboardControlsEnabled = useRef(true); const shiftFlashTimer = useRef(undefined); const shiftGear = useCallback( @@ -36,10 +43,13 @@ export function useGearControl({ } setShiftFlash(change > 0 ? 'increase' : 'decrease'); window.clearTimeout(shiftFlashTimer.current); - shiftFlashTimer.current = window.setTimeout(() => setShiftFlash(undefined), 180); + shiftFlashTimer.current = window.setTimeout( + () => setShiftFlash(undefined), + CONTROL_FLASH_MS + ); gearRef.current = next; setGear(next); - localStorage.setItem('trainer-virtual-gear', String(next)); + localStorage.setItem(GEAR_STORAGE_KEY, String(next)); onResistanceChange(resistanceChangeForGears(previous, next)); }, [connected, onResistanceChange, setNotice] @@ -50,16 +60,14 @@ export function useGearControl({ return; } const handleGearKey = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isGearControl = target?.matches('[data-gear-control="true"]'); + const isGearControl = + event.target instanceof HTMLElement && + event.target.matches('[data-gear-control="true"]'); if ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - !keyboardEnabled || - (!isGearControl && - target?.matches("input, textarea, select, [contenteditable='true']")) + keyboardEventHasModifiers(event) || + !keyboardControlsEnabled.current || + (!isGearControl && eventTargetsEditableControl(event)) ) { return; } @@ -74,7 +82,7 @@ export function useGearControl({ return () => { window.removeEventListener('keydown', handleGearKey); }; - }, [active, keyboardEnabled, shiftGear]); + }, [active, shiftGear]); useEffect( () => () => { @@ -83,5 +91,9 @@ export function useGearControl({ [] ); - return { gear, shiftFlash, shiftGear }; + const setKeyboardControlsEnabled = useCallback((enabled: boolean) => { + keyboardControlsEnabled.current = enabled; + }, []); + + return { gear, setKeyboardControlsEnabled, shiftFlash, shiftGear }; } diff --git a/src/hooks/use-session-workflow.ts b/src/hooks/use-session-workflow.ts new file mode 100644 index 0000000..7adaef8 --- /dev/null +++ b/src/hooks/use-session-workflow.ts @@ -0,0 +1,163 @@ +import { useCallback, useState } from 'react'; +import { errorMessage } from '../lib/errors'; +import { + createSavedSession, + requestPersistentSessionStorage, + saveSession, +} from '../lib/saved-sessions'; +import { + initialSessionWorkflowState, + type SessionWorkflowController, + type SessionWorkflowIntent, + sessionWorkflowReducer, +} from '../lib/session-workflow'; +import type { SavedSession, SessionMetadata } from '../types'; + +export function useSessionWorkflow( + session: SessionWorkflowController, + setNotice: (notice: string) => void +) { + const sessionIsSaved = Boolean(session.savedSessionId); + const [state, setState] = useState(() => + initialSessionWorkflowState(session.ended && !sessionIsSaved) + ); + const dispatch = useCallback((action: Parameters[1]) => { + setState((current) => sessionWorkflowReducer(current, action)); + }, []); + + const startNewSession = useCallback(() => { + session.startNew(); + dispatch({ type: 'close' }); + setNotice('New session ready.'); + }, [dispatch, session.startNew, setNotice]); + + const continueSession = useCallback( + (savedSession: SavedSession) => { + session.continueFrom(savedSession); + dispatch({ type: 'close' }); + setNotice('Session continued.'); + }, + [dispatch, session.continueFrom, setNotice] + ); + + const completeIntent = useCallback( + (intent: SessionWorkflowIntent, saved: boolean) => { + if (intent.kind === 'continue') { + session.continueFrom(intent.session); + setNotice( + saved ? 'Session saved. Selected session continued.' : 'Session continued.' + ); + } else if (intent.kind === 'new' || !saved) { + session.startNew(); + setNotice(saved ? 'Session saved. New session ready.' : 'New session ready.'); + } else { + setNotice('Session saved.'); + } + dispatch({ type: 'close' }); + }, + [dispatch, session.continueFrom, session.startNew, setNotice] + ); + + const endSession = useCallback(() => { + session.endSession(); + dispatch({ intent: { kind: 'end' }, type: 'open' }); + }, [dispatch, session.endSession]); + + const requestNewSession = useCallback(() => { + if (session.ended) { + if (sessionIsSaved) { + startNewSession(); + } else { + dispatch({ intent: { kind: 'new' }, type: 'open' }); + } + return; + } + if (session.elapsedSeconds > 0) { + session.endSession(); + dispatch({ intent: { kind: 'new' }, type: 'open' }); + return; + } + startNewSession(); + }, [ + session.elapsedSeconds, + session.endSession, + session.ended, + sessionIsSaved, + startNewSession, + dispatch, + ]); + + const requestContinuation = useCallback( + (savedSession: SavedSession) => { + const currentNeedsSave = + (session.ended && !sessionIsSaved) || + (!session.ended && session.elapsedSeconds > 0); + if (!currentNeedsSave) { + continueSession(savedSession); + return; + } + if (!session.ended) { + session.endSession(); + } + dispatch({ intent: { kind: 'continue', session: savedSession }, type: 'open' }); + }, + [ + continueSession, + dispatch, + session.elapsedSeconds, + session.endSession, + session.ended, + sessionIsSaved, + ] + ); + + const saveCurrentSession = useCallback( + async (metadata: SessionMetadata) => { + if (state.phase === 'closed') { + return; + } + const { intent } = state; + dispatch({ type: 'start-saving' }); + try { + const savedSession = createSavedSession(session.snapshot, metadata); + await saveSession(savedSession); + session.markSaved(savedSession.id); + completeIntent(intent, true); + } catch (error) { + dispatch({ type: 'save-failed' }); + setNotice(`Session could not be saved: ${errorMessage(error)}`); + } + }, + [completeIntent, dispatch, session.markSaved, session.snapshot, setNotice, state] + ); + + const proceedWithoutSaving = useCallback(() => { + if (state.phase !== 'closed') { + completeIntent(state.intent, false); + } + }, [completeIntent, state]); + const closeSaveDialog = useCallback(() => dispatch({ type: 'close' }), [dispatch]); + const openSaveDialog = useCallback( + () => dispatch({ intent: { kind: 'end' }, type: 'open' }), + [dispatch] + ); + const requestPersistentStorage = useCallback( + () => requestPersistentSessionStorage().catch(() => false), + [] + ); + + return { + closeSaveDialog, + continuing: state.phase !== 'closed' && state.intent.kind === 'continue', + endSession, + openSaveDialog, + proceedWithoutSaving, + requestContinuation, + requestNewSession, + requestPersistentStorage, + saveCurrentSession, + saveDialogOpen: state.phase !== 'closed', + saving: state.phase === 'saving', + sessionIsSaved, + }; +} diff --git a/src/lib/session-workflow.ts b/src/lib/session-workflow.ts new file mode 100644 index 0000000..103c0e0 --- /dev/null +++ b/src/lib/session-workflow.ts @@ -0,0 +1,49 @@ +import type { SavedSession, SessionSnapshot } from '../types'; + +export interface SessionWorkflowController { + continueFrom: (snapshot: SessionSnapshot) => void; + elapsedSeconds: number; + ended: boolean; + endSession: () => void; + markSaved: (id: string) => void; + savedSessionId?: string; + snapshot: SessionSnapshot; + startNew: () => void; +} + +export type SessionWorkflowIntent = + | { kind: 'end' } + | { kind: 'new' } + | { kind: 'continue'; session: SavedSession }; + +export type SessionWorkflowState = + | { phase: 'closed' } + | { intent: SessionWorkflowIntent; phase: 'prompt' | 'saving' }; + +export type SessionWorkflowAction = + | { type: 'close' } + | { intent: SessionWorkflowIntent; type: 'open' } + | { type: 'save-failed' } + | { type: 'start-saving' }; + +export function initialSessionWorkflowState(open: boolean): SessionWorkflowState { + return open ? { intent: { kind: 'end' }, phase: 'prompt' } : { phase: 'closed' }; +} + +export function sessionWorkflowReducer( + state: SessionWorkflowState, + action: SessionWorkflowAction +): SessionWorkflowState { + switch (action.type) { + case 'close': + return { phase: 'closed' }; + case 'open': + return { intent: action.intent, phase: 'prompt' }; + case 'save-failed': + return state.phase === 'saving' ? { ...state, phase: 'prompt' } : state; + case 'start-saving': + return state.phase === 'prompt' ? { ...state, phase: 'saving' } : state; + default: + return state; + } +} diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 605827c..152f30d 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -16,6 +16,7 @@ import { SessionHistory, } from '../src/components/session-history'; import { SessionSaveDialog } from '../src/components/session-save-dialog'; +import { TrainingControl } from '../src/components/training-control'; import { WelcomeDialog } from '../src/components/welcome-dialog'; import { CHROME_BLUETOOTH_PERMISSION_MESSAGE, emptyMetrics, emptySession } from '../src/constants'; import { historyKeyboardShortcuts } from '../src/lib/keyboard'; @@ -358,6 +359,32 @@ describe('view components', () => { expect(html).toContain('scale-105 border-mint bg-mint/15 text-mint'); }); + test('renders only the selected training control mode', () => { + const gear = render( + undefined }} + /> + ); + expect(gear).toContain('Virtual shifting'); + expect(gear).toContain('of 24'); + expect(gear).not.toContain('Resistance control'); + + const resistance = render( + undefined, + ramp: { current: 40, from: 40, phase: 'holding', progress: 0, to: 40 }, + resistance: 40, + }} + /> + ); + expect(resistance).toContain('Resistance control'); + expect(resistance).not.toContain('Virtual shifting'); + }); + test('hides empty notifications and expands setup guidance', () => { expect( render( undefined} />) diff --git a/tests/session-workflow.test.ts b/tests/session-workflow.test.ts new file mode 100644 index 0000000..9a7bcbc --- /dev/null +++ b/tests/session-workflow.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import { initialSessionWorkflowState, sessionWorkflowReducer } from '../src/lib/session-workflow'; +import type { SavedSession } from '../src/types'; + +describe('session workflow reducer', () => { + test('opens with the ended-session intent when an unsaved session is restored', () => { + expect(initialSessionWorkflowState(true)).toEqual({ + intent: { kind: 'end' }, + phase: 'prompt', + }); + expect(initialSessionWorkflowState(false)).toEqual({ phase: 'closed' }); + }); + + test('preserves the requested next session while saving', () => { + const session = { id: 'saved-session' } as SavedSession; + const prompt = sessionWorkflowReducer( + { phase: 'closed' }, + { intent: { kind: 'continue', session }, type: 'open' } + ); + expect(prompt).toEqual({ intent: { kind: 'continue', session }, phase: 'prompt' }); + const saving = sessionWorkflowReducer(prompt, { type: 'start-saving' }); + expect(saving).toEqual({ intent: { kind: 'continue', session }, phase: 'saving' }); + expect(sessionWorkflowReducer(saving, { type: 'save-failed' })).toEqual(prompt); + }); + + test('ignores invalid transitions and closes atomically', () => { + const closed = { phase: 'closed' } as const; + expect(sessionWorkflowReducer(closed, { type: 'start-saving' })).toBe(closed); + expect(sessionWorkflowReducer(closed, { type: 'save-failed' })).toBe(closed); + expect( + sessionWorkflowReducer({ intent: { kind: 'new' }, phase: 'prompt' }, { type: 'close' }) + ).toEqual(closed); + }); +}); From 0cc9cf1bbcc5747f4cfd158a13fdfc0b773bda5b Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 10:53:31 -0700 Subject: [PATCH 07/26] feat: guide Chrome reconnect setup in app Replace the external reconnect-help link with concise Chrome Bluetooth permission steps inside the paired-devices tray, while keeping the guidance hidden in unsupported browsers. Make the browser settings address copyable, confirm that it is ready to paste into a new tab, reuse the shared dialog lifecycle and controller-count helpers, remove the obsolete documentation URL, and cover the rendered guidance. --- README.md | 2 +- src/components/device-pairing.tsx | 77 ++++++++++++++++++------------- src/constants.ts | 2 - tests/components.test.tsx | 13 ++++-- 4 files changed, 55 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 2affae2..4c7f52f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; the welcome screen remains available from the Ride Control footer link. - Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices panel, with blue pulsing dots for controllers awaiting connection and a green indicator once every paired device is ready; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, continuously retries saved Click connections after a refresh or controller sleep, and keeps the sleeping-controller display stable between retry attempts; stalled attempts can be retried immediately, and Click presses made while this panel is open stay in setup and do not shift the ride. -- Detects browsers outside the currently tested Chrome environment and replaces the pairing controls with a compatibility notice, while showing reconnect-saving guidance alongside the pairing controls only in Chrome. +- 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. - 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 automatically reconnects when possible. - Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes. diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 9dfaf19..c382b49 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -1,7 +1,9 @@ -import { useEffect } from 'react'; -import { AUTOMATIC_RECONNECT_README_URL } from '../constants'; +import { useState } from 'react'; +import { CHROME_BLUETOOTH_FLAGS_URL } from '../constants'; +import { useBodyScrollLock, useCloseOnEscape } from '../hooks/use-dialog-behavior'; import { bluetoothBrowserNotice } from '../lib/browser'; import type { DeviceConnectionView } from '../lib/device-connection'; +import { MAX_CLICK_CONTROLLERS } from '../lib/zwift-click'; import { Icon } from './icon'; interface DeviceSlot extends DeviceConnectionView { @@ -199,24 +201,13 @@ export function DevicePairingPanel({ open: boolean; trainer: DeviceSlot; }) { - useEffect(() => { - if (!open) { - return; - } - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); - } - }; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - window.addEventListener('keydown', closeOnEscape); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', closeOnEscape); - }; - }, [onClose, open]); + const [flagsUrlCopied, setFlagsUrlCopied] = useState(false); + useCloseOnEscape(open, onClose); + useBodyScrollLock(open); + const copyChromeFlagsUrl = async () => { + await navigator.clipboard.writeText(CHROME_BLUETOOTH_FLAGS_URL); + setFlagsUrlCopied(true); + }; if (!open) { return null; @@ -233,6 +224,9 @@ export function DevicePairingPanel({ } else if (click.pairedCount) { pairControllerLabel = 'Pair other controller'; } + const chromeFlagsCopyLabel = flagsUrlCopied + ? 'copied, now paste it into a new tab.' + : CHROME_BLUETOOTH_FLAGS_URL; return (
@@ -358,7 +352,7 @@ export function DevicePairingPanel({ {click.pairedCount > 0 ? ( ) : null} - {click.pairedCount < 2 ? ( + {click.pairedCount < MAX_CLICK_CONTROLLERS ? ( + +
  • + Enable{' '} + + Use the new permissions backend for Web Bluetooth + + . +
  • +
  • Relaunch Chrome, then pair each device once more.
  • + +
    )}
    diff --git a/src/constants.ts b/src/constants.ts index 80cc1e4..b45e17f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,8 +13,6 @@ export const CYCLING_POWER_MEASUREMENT = 0x2a_63; export const CSC_MEASUREMENT = 0x2a_5b; export const CHROME_BLUETOOTH_FLAGS_URL = 'chrome://flags/#enable-web-bluetooth-new-permissions-backend'; -export const AUTOMATIC_RECONNECT_README_URL = - 'https://github.com/lookfirst/RideControl#automatic-reconnect'; export const CHROME_BLUETOOTH_PERMISSION_MESSAGE = 'Chrome returned no site-authorized.'; export const WEB_BLUETOOTH_UNAVAILABLE_MESSAGE = 'Web Bluetooth requires current Chrome or Edge on localhost or HTTPS.'; diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 152f30d..956edb9 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -305,8 +305,13 @@ describe('view components', () => { expect(panel).toContain('Reconnect'); expect(panel.match(/animate-pulse/g)).toHaveLength(2); expect(panel.match(/animate-pulse bg-sky-400/g)).toHaveLength(1); - expect(panel).toContain('Enable reconnect saving in Chrome'); - expect(panel).toContain('https://github.com/lookfirst/RideControl#automatic-reconnect'); + expect(panel).toContain('Automatic reconnect in Chrome'); + expect(panel).toContain('Chrome needs persistent Bluetooth permissions'); + expect(panel).toContain('chrome://flags/#enable-web-bluetooth-new-permissions-backend'); + expect(panel).toContain('Copy Chrome Bluetooth settings address'); + expect(panel).toContain('Use the new permissions backend for Web Bluetooth'); + expect(panel).toContain('Relaunch Chrome, then pair each device once more.'); + expect(panel).not.toContain('github.com/lookfirst/RideControl#automatic-reconnect'); expect(panel).toContain('+ Controller'); expect(panel.indexOf('+ Controller')).toBeLessThan(panel.indexOf('− Controller')); expect(panel).toContain('animate-pulse'); @@ -332,9 +337,9 @@ describe('view components', () => { /> ); expect(unsupportedPanel).toContain('Bluetooth does not work in Brave'); - expect(unsupportedPanel).not.toContain('Enable reconnect saving in Chrome'); + expect(unsupportedPanel).not.toContain('Automatic reconnect in Chrome'); expect(unsupportedPanel).not.toContain( - 'https://github.com/lookfirst/RideControl#automatic-reconnect' + 'chrome://flags/#enable-web-bluetooth-new-permissions-backend' ); expect(unsupportedPanel).not.toContain('Smart trainer'); expect(unsupportedPanel).not.toContain('Heart rate'); From 966bfad058b806b75038d4114671ed0f3105b043 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 10:59:59 -0700 Subject: [PATCH 08/26] refactor: centralize shared domain values Replace scattered control-mode strings with a named domain constant, derived union type, centralized boundary validation, and exhaustive rendering branches. Move cycling calorie assumptions and calculations out of session logic into a focused energy module with direct tests, and document the shared-domain constant convention. --- AGENTS.md | 1 + src/app.tsx | 9 +++--- src/components/session-chart.tsx | 18 ++++++----- src/components/session-history.tsx | 3 +- src/components/training-control.tsx | 47 ++++++++++++++++------------- src/constants.ts | 3 +- src/lib/chart.ts | 21 ++++++++----- src/lib/control-mode.ts | 12 ++++++++ src/lib/cycling-energy.ts | 8 +++++ src/lib/errors.ts | 4 +++ src/lib/session.ts | 17 +++-------- src/lib/tcx.ts | 3 +- src/stores/session-store.ts | 13 +++----- src/types.ts | 6 ++-- tests/cycling-energy.test.ts | 18 +++++++++++ 15 files changed, 118 insertions(+), 65 deletions(-) create mode 100644 src/lib/control-mode.ts create mode 100644 src/lib/cycling-energy.ts create mode 100644 tests/cycling-energy.test.ts diff --git a/AGENTS.md b/AGENTS.md index e2906a5..e97ffc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ - `bun run ci` must run the Ultracite-configured Biome checks automatically, followed by the TypeScript check and production build. - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. - Keep the codebase as DRY as practical: before adding constants, calculations, formatting, parsing, labels, or state-derivation logic, search for an existing domain utility and reuse or extend it. Consolidate meaningful duplication into clearly named domain modules, but do not introduce generic abstractions that hide simple behavior or couple unrelated concepts solely because their implementations look similar. +- Do not scatter raw string literals for shared domain modes through comparisons or state construction. Define or reuse a named `as const` domain object, derive its union type from those values, centralize validation of persisted or external strings, and reference the named members in application code. Prefer an exhaustive `switch` when every variant has distinct behavior. Raw strings remain appropriate for display text, object property names, browser or protocol values, and boundary-focused tests. - Never include AI assistant, product, vendor, or model names (such as `codex` or `openai`) in branch names, commit messages, pull request titles or descriptions, issue titles or descriptions, tags, release notes, or any other repository-visible metadata or content. - Name branches only after the work being performed, such as `refactor/tanstack-store`; do not apply a tool's default branch prefix when that prefix would violate the naming rule above. - When the user says "add, commit", group all existing changes into logical sets, stage and commit each group, and repeat until every change is committed and the working tree is clean. diff --git a/src/app.tsx b/src/app.tsx index 7120445..a597344 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -18,12 +18,13 @@ import { useSession } from './hooks/use-session'; import { useSessionWorkflow } from './hooks/use-session-workflow'; import { useTrainer } from './hooks/use-trainer'; import { useZwiftClick } from './hooks/use-zwift-click'; +import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; import { SPEED_UNIT_STORAGE_KEY, storedSpeedUnit } from './lib/units'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; -import type { ControlMode, Metrics, SavedSession, SpeedUnit } from './types'; +import type { Metrics, SavedSession, SpeedUnit } from './types'; type AppOverlay = 'devices' | 'history' | 'shortcuts' | 'welcome'; @@ -47,7 +48,7 @@ function shiftHandlerUnlessBlocked(handler: (change: number) => void, blocked: b } function controlModeForClick(paired: boolean): ControlMode { - return paired ? 'gear' : 'resistance'; + return paired ? CONTROL_MODE.GEAR : CONTROL_MODE.RESISTANCE; } export function App() { @@ -254,13 +255,13 @@ export function App() { click.paired ? { gear: gearControl.gear, - mode: 'gear', + mode: CONTROL_MODE.GEAR, onShift: gearControl.shiftGear, shiftFlash: gearControl.shiftFlash, } : { keyboardFlash: trainer.resistanceKeyFlash, - mode: 'resistance', + mode: CONTROL_MODE.RESISTANCE, onChange: trainer.updateResistance, ramp: trainer.resistanceRamp, resistance: trainer.resistance, diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index 32ad7dc..e0738c3 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -6,6 +6,7 @@ import { roundedChartMaximum, storedChartMode, } from '../lib/chart'; +import { CONTROL_MODE, isControlMode } from '../lib/control-mode'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { formatChartSeconds } from '../lib/format'; import { MAX_GEAR, MIN_GEAR } from '../lib/gears'; @@ -103,11 +104,12 @@ export function SessionChart({ const [selectedMode, setSelectedMode] = useState(storedChartMode); const resolvedControlMode = controlMode ?? - (history.some((sample) => sample.gear !== undefined) ? 'gear' : 'resistance'); - const modeForAvailableControl = - selectedMode === 'gear' || selectedMode === 'resistance' - ? resolvedControlMode - : selectedMode; + (history.some((sample) => sample.gear !== undefined) + ? CONTROL_MODE.GEAR + : CONTROL_MODE.RESISTANCE); + const modeForAvailableControl = isControlMode(selectedMode) + ? resolvedControlMode + : selectedMode; const effectiveMode = modeForAvailableControl === 'elevation' && route.length === 0 ? 'all' @@ -133,12 +135,12 @@ export function SessionChart({ }; }); const controlSeries = - resolvedControlMode === 'gear' + resolvedControlMode === CONTROL_MODE.GEAR ? { chartMaximum: MAX_GEAR, color: '#adf5bd', decimals: 0, - key: 'gear' as const, + key: CONTROL_MODE.GEAR, label: 'Gear', minimum: MIN_GEAR, unit: '', @@ -148,7 +150,7 @@ export function SessionChart({ chartMaximum: MAX_RESISTANCE, color: '#adf5bd', decimals: 0, - key: 'resistance' as const, + key: CONTROL_MODE.RESISTANCE, label: 'Resistance', minimum: MIN_RESISTANCE, unit: '%', diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index 977cc2a..0ae8412 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EMPTY_ROUTE } from '../constants'; +import { CONTROL_MODE } from '../lib/control-mode'; import { eventTargetsEditableControl, eventTargetsInteractiveControl, @@ -126,7 +127,7 @@ export function SessionDetail({ session: SavedSession; speedUnit: SpeedUnit; }) { - const usesGear = session.controlMode === 'gear'; + const usesGear = session.controlMode === CONTROL_MODE.GEAR; const controlMetric = usesGear ? { accent: 'mint', diff --git a/src/components/training-control.tsx b/src/components/training-control.tsx index 96db64c..24dd856 100644 --- a/src/components/training-control.tsx +++ b/src/components/training-control.tsx @@ -1,3 +1,5 @@ +import { CONTROL_MODE } from '../lib/control-mode'; +import { unreachable } from '../lib/errors'; import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; import { GearTrainingControl } from './gear-training-control'; import { ResistanceTrainingControl } from './resistance-training-control'; @@ -5,13 +7,13 @@ import { ResistanceTrainingControl } from './resistance-training-control'; type TrainingControlModel = | { gear: number; - mode: 'gear'; + mode: typeof CONTROL_MODE.GEAR; onShift: (change: number) => void; shiftFlash?: ResistanceAdjustmentDirection; } | { keyboardFlash?: ResistanceAdjustmentDirection; - mode: 'resistance'; + mode: typeof CONTROL_MODE.RESISTANCE; onChange: (resistance: number) => void; ramp: ResistanceRamp; resistance: number; @@ -24,24 +26,27 @@ export function TrainingControl({ connected: boolean; control: TrainingControlModel; }) { - if (control.mode === 'gear') { - return ( - - ); + switch (control.mode) { + case CONTROL_MODE.GEAR: + return ( + + ); + case CONTROL_MODE.RESISTANCE: + return ( + + ); + default: + return unreachable(control); } - - return ( - - ); } diff --git a/src/constants.ts b/src/constants.ts index b45e17f..f518398 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,4 @@ +import { CONTROL_MODE } from './lib/control-mode'; import type { Metrics, StoredSession } from './types'; export const FITNESS_MACHINE = 0x18_26; @@ -47,7 +48,7 @@ export const emptySession: StoredSession = { resistance: { count: 0, sum: 0 }, }, calories: 0, - controlMode: 'resistance', + controlMode: CONTROL_MODE.RESISTANCE, distance: 0, elapsedSeconds: 0, ended: false, diff --git a/src/lib/chart.ts b/src/lib/chart.ts index d2de830..8669457 100644 --- a/src/lib/chart.ts +++ b/src/lib/chart.ts @@ -1,4 +1,5 @@ -import type { ChartMode, ControlMode } from '../types'; +import type { ChartMode } from '../types'; +import { CONTROL_MODE, type ControlMode } from './control-mode'; import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from './metric-presentation'; import { clamp } from './numbers'; @@ -16,17 +17,23 @@ const baseChartModes: { label: string; value: ChartMode }[] = [ export function chartModesForControl(controlMode: ControlMode) { return [ ...baseChartModes, - controlMode === 'gear' - ? { label: 'Gear', value: 'gear' as const } - : { label: 'Resistance', value: 'resistance' as const }, + controlMode === CONTROL_MODE.GEAR + ? { label: 'Gear', value: CONTROL_MODE.GEAR } + : { label: 'Resistance', value: CONTROL_MODE.RESISTANCE }, ]; } export function storedChartMode(storage: Pick = localStorage): ChartMode { const saved = storage.getItem(CHART_MODE_STORAGE_KEY); - return ['all', 'speed', 'power', 'cadence', 'heartRate', 'gear', 'resistance'].includes( - saved ?? '' - ) + return [ + 'all', + 'speed', + 'power', + 'cadence', + 'heartRate', + CONTROL_MODE.GEAR, + CONTROL_MODE.RESISTANCE, + ].includes(saved ?? '') ? (saved as ChartMode) : 'all'; } diff --git a/src/lib/control-mode.ts b/src/lib/control-mode.ts new file mode 100644 index 0000000..bb87aca --- /dev/null +++ b/src/lib/control-mode.ts @@ -0,0 +1,12 @@ +export const CONTROL_MODE = { + GEAR: 'gear', + RESISTANCE: 'resistance', +} as const; + +export type ControlMode = (typeof CONTROL_MODE)[keyof typeof CONTROL_MODE]; + +const CONTROL_MODES = new Set(Object.values(CONTROL_MODE)); + +export function isControlMode(value: unknown): value is ControlMode { + return CONTROL_MODES.has(value); +} diff --git a/src/lib/cycling-energy.ts b/src/lib/cycling-energy.ts new file mode 100644 index 0000000..1242729 --- /dev/null +++ b/src/lib/cycling-energy.ts @@ -0,0 +1,8 @@ +export const JOULES_PER_KILOCALORIE = 4184; +export const ESTIMATED_CYCLING_EFFICIENCY = 0.24; + +export function estimatedCyclingCalories(powerWatts: number, seconds: number): number { + return powerWatts > 0 + ? (powerWatts * seconds) / (JOULES_PER_KILOCALORIE * ESTIMATED_CYCLING_EFFICIENCY) + : 0; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 5c48e9f..e8e38ba 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,3 +1,7 @@ export function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +export function unreachable(value: never): never { + throw new Error(`Unexpected value: ${String(value)}`); +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 659fcb8..0032e68 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,6 +1,5 @@ import { emptyMetrics, emptySession, MAX_SESSION_HISTORY_SAMPLES } from '../constants'; import type { - ControlMode, MetricAggregate, MetricSample, Metrics, @@ -8,6 +7,7 @@ import type { SessionSnapshot, StoredSession, } from '../types'; +import { CONTROL_MODE, type ControlMode, isControlMode } from './control-mode'; import { clampGear, MAX_GEAR, MIN_GEAR } from './gears'; import { clamp, nonNegativeNumber } from './numbers'; import { clampResistance, DEFAULT_RESISTANCE, MAX_RESISTANCE, MIN_RESISTANCE } from './resistance'; @@ -15,9 +15,6 @@ import { clampResistance, DEFAULT_RESISTANCE, MAX_RESISTANCE, MIN_RESISTANCE } f export const SESSION_STORAGE_KEY = 'trainer-session'; export const RESISTANCE_STORAGE_KEY = 'trainer-resistance-percent'; -const JOULES_PER_KILOCALORIE = 4184; -const ESTIMATED_CYCLING_EFFICIENCY = 0.24; - type ReadableStorage = Pick; export function sessionContinuation(snapshot: SessionSnapshot): StoredSession { @@ -39,12 +36,6 @@ export function sessionNeedsUnloadWarning(ended: boolean, elapsedSeconds: number return !ended && elapsedSeconds > 0; } -export function estimatedCyclingCalories(powerWatts: number, seconds: number): number { - return powerWatts > 0 - ? (powerWatts * seconds) / (JOULES_PER_KILOCALORIE * ESTIMATED_CYCLING_EFFICIENCY) - : 0; -} - export function requestUnloadConfirmation( event: Pick ): void { @@ -136,10 +127,12 @@ export function controlModeForHistory( history: Partial>[], savedMode?: ControlMode ): ControlMode { - if (savedMode === 'gear' || savedMode === 'resistance') { + if (isControlMode(savedMode)) { return savedMode; } - return history.some((sample) => typeof sample.gear === 'number') ? 'gear' : 'resistance'; + return history.some((sample) => typeof sample.gear === 'number') + ? CONTROL_MODE.GEAR + : CONTROL_MODE.RESISTANCE; } export function loadStoredSession(storage: ReadableStorage = localStorage): StoredSession { diff --git a/src/lib/tcx.ts b/src/lib/tcx.ts index b92cfd0..527b607 100644 --- a/src/lib/tcx.ts +++ b/src/lib/tcx.ts @@ -1,4 +1,5 @@ import type { MetricSample, SavedSession } from '../types'; +import { CONTROL_MODE } from './control-mode'; import { aggregateAverage } from './format'; import { nonNegativeNumber } from './numbers'; import { metersForKilometers, metersPerSecond, millisecondsForSeconds } from './units'; @@ -92,7 +93,7 @@ export function sessionToTcx(session: SavedSession): string { const averageCadence = aggregateAverage(session.aggregates.cadence); const averagePower = aggregateAverage(session.aggregates.power); const controlSummary = - session.controlMode === 'gear' + session.controlMode === CONTROL_MODE.GEAR ? `${aggregateAverage(session.aggregates.gear).toFixed(1)} ${Math.max(0, ...session.history.map((sample) => nonNegativeNumber(sample.gear))).toFixed(0)}` : `${aggregateAverage(session.aggregates.resistance).toFixed(1)} diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index 49b7778..0548902 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -1,13 +1,10 @@ import { createStore } from '@tanstack/react-store'; import { emptyMetrics, emptySession, MAX_SESSION_HISTORY_SAMPLES } from '../constants'; -import { - addMetricAggregates, - estimatedCyclingCalories, - SESSION_STORAGE_KEY, - sessionContinuation, -} from '../lib/session'; +import { CONTROL_MODE, type ControlMode } from '../lib/control-mode'; +import { estimatedCyclingCalories } from '../lib/cycling-energy'; +import { addMetricAggregates, SESSION_STORAGE_KEY, sessionContinuation } from '../lib/session'; import { kilometersTraveled } from '../lib/units'; -import type { ControlMode, Metrics, SessionSnapshot, StoredSession } from '../types'; +import type { Metrics, SessionSnapshot, StoredSession } from '../types'; interface RecordSessionTick { control: { @@ -132,7 +129,7 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { } const elapsedSeconds = current.elapsedSeconds + seconds; const controlSample = - control.mode === 'gear' + control.mode === CONTROL_MODE.GEAR ? { gear: control.gear } : { resistance: control.resistance }; const sample = { diff --git a/src/types.ts b/src/types.ts index 309f282..bc88b19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +import type { ControlMode } from './lib/control-mode'; + +export type { ControlMode } from './lib/control-mode'; + export interface Metrics { cadence: number; calories: number; @@ -51,8 +55,6 @@ export interface SessionAggregates { resistance: MetricAggregate; } -export type ControlMode = 'gear' | 'resistance'; - export interface StoredSession { aggregates: SessionAggregates; calories: number; diff --git a/tests/cycling-energy.test.ts b/tests/cycling-energy.test.ts new file mode 100644 index 0000000..5b2ff65 --- /dev/null +++ b/tests/cycling-energy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import { + ESTIMATED_CYCLING_EFFICIENCY, + estimatedCyclingCalories, + JOULES_PER_KILOCALORIE, +} from '../src/lib/cycling-energy'; + +describe('cycling energy estimates', () => { + test('converts mechanical work into estimated dietary calories', () => { + const seconds = 3600; + const powerWatts = 200; + const expected = + (powerWatts * seconds) / (JOULES_PER_KILOCALORIE * ESTIMATED_CYCLING_EFFICIENCY); + + expect(estimatedCyclingCalories(powerWatts, seconds)).toBe(expected); + expect(estimatedCyclingCalories(0, seconds)).toBe(0); + }); +}); From 01a5672682d1300c5c0665a6b9078d1644600d68 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:00:03 -0700 Subject: [PATCH 09/26] fix: wrap Chrome settings address cleanly Allow the copied Chrome Bluetooth settings address to wrap at natural URL boundaries and keep wrapped lines left aligned instead of orphaning centered characters. --- src/components/device-pairing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index c382b49..d026945 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -381,7 +381,7 @@ export function DevicePairingPanel({ {flagsUrlCopied ? null : 'Open '} + +
  • + Enable Use the new permissions backend for Web Bluetooth. +
  • +
  • Relaunch Chrome, then pair each device once more.
  • + + + ); +} + function DeviceCard({ description, icon, @@ -187,6 +239,7 @@ export function DevicePairingButton({ } export function DevicePairingPanel({ + automaticReconnectConfigured = automaticBluetoothReconnectConfigured(), browserNotice = bluetoothBrowserNotice(), click, heartRate, @@ -194,6 +247,7 @@ export function DevicePairingPanel({ open, trainer, }: { + automaticReconnectConfigured?: boolean; browserNotice?: string; click: ClickSlot; heartRate: DeviceSlot; @@ -368,36 +422,12 @@ export function DevicePairingPanel({ identified automatically and reconnect in the background.

    - +
    )} diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 75c9531..fbd79c6 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -1,3 +1,5 @@ +import { isFunction } from './type-guards'; + interface BrowserBrand { brand: string; } @@ -10,6 +12,10 @@ interface BrowserNavigator { }; } +interface BluetoothReconnectCapability { + getDevices?: unknown; +} + const CHROME_USER_AGENT = /\bChrome\//; const CHROMIUM_DERIVATIVE_USER_AGENT = /(?:Brave|Edg|OPR|Vivaldi|YaBrowser)\//; @@ -45,3 +51,9 @@ export function bluetoothBrowserNotice( } return 'Bluetooth is unavailable in this browser. Chrome is currently the only browser tested with Ride Control.'; } + +export function automaticBluetoothReconnectConfigured( + bluetooth: BluetoothReconnectCapability | undefined = globalThis.navigator?.bluetooth +): boolean { + return isFunction(bluetooth?.getDevices); +} diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 0f661c4..500f835 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { bluetoothBrowserNotice, isTestedChromeBrowser } from '../src/lib/browser'; +import { + automaticBluetoothReconnectConfigured, + bluetoothBrowserNotice, + isTestedChromeBrowser, +} from '../src/lib/browser'; function browser(userAgent: string, brands: string[] = [], extra: Record = {}) { return { @@ -39,4 +43,9 @@ describe('browser compatibility', () => { 'Chrome is currently the only browser tested' ); }); + + test('detects the persistent Bluetooth reconnect capability', () => { + expect(automaticBluetoothReconnectConfigured({ getDevices: async () => [] })).toBeTrue(); + expect(automaticBluetoothReconnectConfigured({})).toBeFalse(); + }); }); diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 956edb9..5ebd760 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -18,7 +18,12 @@ import { import { SessionSaveDialog } from '../src/components/session-save-dialog'; import { TrainingControl } from '../src/components/training-control'; import { WelcomeDialog } from '../src/components/welcome-dialog'; -import { CHROME_BLUETOOTH_PERMISSION_MESSAGE, emptyMetrics, emptySession } from '../src/constants'; +import { + CHROME_BLUETOOTH_FLAGS_URL, + CHROME_BLUETOOTH_PERMISSION_MESSAGE, + emptyMetrics, + emptySession, +} from '../src/constants'; import { historyKeyboardShortcuts } from '../src/lib/keyboard'; import { metricAccentClass, metricIconClass } from '../src/lib/metric-presentation'; @@ -318,6 +323,28 @@ describe('view components', () => { expect(panel).toContain('bg-mint/10'); expect(panel).not.toContain('shadow-[inset_0_0_18px'); expect(panel).not.toContain('divide-y'); + const configuredPanel = render( + undefined, + pairedCount: 0, + pairing: false, + reconnecting: false, + }} + heartRate={common} + onClose={() => undefined} + open + trainer={common} + /> + ); + expect(configuredPanel).toContain('Automatic reconnect is configured correctly'); + expect(configuredPanel).not.toContain(CHROME_BLUETOOTH_FLAGS_URL); + expect(configuredPanel).not.toContain('Use the new permissions backend for Web Bluetooth'); const unsupportedPanel = render( Date: Sun, 19 Jul 2026 11:14:25 -0700 Subject: [PATCH 12/26] feat: group history keyboard help Organize history shortcuts into Navigation, Session, and General sections so the history help dialog matches the grouped structure of the main keyboard reference. Add metadata and rendered-heading coverage and document the grouped history shortcut help. --- README.md | 2 +- src/lib/keyboard.ts | 12 ++++++------ tests/components.test.tsx | 3 +++ tests/keyboard.test.ts | 8 ++++++++ 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 857c42b..b718c33 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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. - Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with focused and combined chart views. - Saves completed sessions to browser-managed IndexedDB storage, including optional comments and how the ride felt, and requests persistent browser storage when supported. -- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation, and permanent deletion. +- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. - Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services. - Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. - Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session. diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index d4b18b1..89b878a 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -41,12 +41,12 @@ export const gearingKeyboardShortcuts: KeyboardShortcutDescription[] = ); export const historyKeyboardShortcuts: KeyboardShortcutDescription[] = [ - { keys: ['↑', '↓'], label: 'Select the previous or next session' }, - { keys: ['←', '→'], label: 'Change the session chart view' }, - { keys: ['d'], label: 'Delete the selected session' }, - { keys: ['Enter'], label: 'Confirm session deletion' }, - { keys: ['?'], label: 'Show history keyboard controls' }, - { keys: ['Esc'], label: 'Close help or session history' }, + { group: 'Navigation', keys: ['↑', '↓'], label: 'Select the previous or next session' }, + { group: 'Navigation', keys: ['←', '→'], label: 'Change the session chart view' }, + { group: 'Session', keys: ['d'], label: 'Delete the selected session' }, + { group: 'Session', keys: ['Enter'], label: 'Confirm session deletion' }, + { group: 'General', keys: ['?'], label: 'Show history keyboard controls' }, + { group: 'General', keys: ['Esc'], label: 'Close help or session history' }, ]; export function appShortcutForKey({ code, key }: Pick) { diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 5ebd760..6f54309 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -515,6 +515,9 @@ describe('view components', () => { expect(historyHtml).toContain('Change the session chart view'); expect(historyHtml).toContain('Delete the selected session'); expect(historyHtml).toContain('Confirm session deletion'); + expect(historyHtml).toContain('NAVIGATION'); + expect(historyHtml).toContain('SESSION'); + expect(historyHtml).toContain('GENERAL'); expect(historyHtml).not.toContain('Increase or decrease resistance'); expect(historyHtml).not.toContain('Pause or resume'); expect(historyHtml).not.toContain('Start a new session after ending'); diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts index 78c0083..7a86825 100644 --- a/tests/keyboard.test.ts +++ b/tests/keyboard.test.ts @@ -54,6 +54,14 @@ describe('keyboard shortcuts', () => { expect(historyShortcutForKey('D')).toBe('deleteSession'); expect(historyShortcutForKey('Enter')).toBe('confirmDelete'); expect(historyKeyboardShortcuts[2]?.keys).toEqual(['d']); + expect(historyKeyboardShortcuts.map(({ group }) => group)).toEqual([ + 'Navigation', + 'Navigation', + 'Session', + 'Session', + 'General', + 'General', + ]); expect(historyShortcutForKey('?')).toBe('help'); expect(historyShortcutForKey('Escape')).toBe('close'); expect(historyShortcutForKey('ArrowLeft')).toBeUndefined(); From 25286a2fac1cf072b37033874e2b8696b957eb59 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:25:50 -0700 Subject: [PATCH 13/26] refactor bluetooth state into device stores --- src/hooks/use-trainer.ts | 155 +++++++++++---------- src/hooks/use-zwift-click.ts | 236 +++++++++++++++----------------- src/stores/trainer-store.ts | 75 ++++++++++ src/stores/zwift-click-store.ts | 80 +++++++++++ 4 files changed, 344 insertions(+), 202 deletions(-) create mode 100644 src/stores/trainer-store.ts create mode 100644 src/stores/zwift-click-store.ts diff --git a/src/hooks/use-trainer.ts b/src/hooks/use-trainer.ts index bfcc36d..68ebd7f 100644 --- a/src/hooks/use-trainer.ts +++ b/src/hooks/use-trainer.ts @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { CHROME_BLUETOOTH_PERMISSION_MESSAGE, CONTROL_FLASH_MS, @@ -13,7 +14,7 @@ import { resistanceCommand, TRAINER_DEVICE_STORAGE_KEY, } from '../lib/bluetooth'; -import { type DeviceConnectionPhase, deviceConnectionView } from '../lib/device-connection'; +import { deviceConnectionView } from '../lib/device-connection'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { errorMessage } from '../lib/errors'; import { scheduleNoticeDismissal } from '../lib/notification'; @@ -21,39 +22,32 @@ import { clamp } from '../lib/numbers'; import { createReconnectController } from '../lib/reconnect-controller'; import { clampResistance, - MAX_RESISTANCE, - MIN_RESISTANCE, resistanceDirectionForKey, resistanceRampDuration, smoothedResistance, } from '../lib/resistance'; import { RESISTANCE_STORAGE_KEY, storedResistance } from '../lib/session'; import { connectTrainerDevice } from '../lib/trainer-device'; -import type { Metrics, Range, ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; +import { createTrainerStore } from '../stores/trainer-store'; function pairingWasCancelled(error: unknown, connectionCancelled: boolean) { return connectionCancelled || isBluetoothChooserCancellation(error); } export function useTrainer() { - const [device, setDevice] = useState(); - const [pairedDevice, setPairedDevice] = useState(); - const [controlPoint, setControlPoint] = useState(); - const [metrics, setMetrics] = useState(emptyMetrics); - const [resistance, setResistance] = useState(storedResistance); - const [resistanceKeyFlash, setResistanceKeyFlash] = useState< - ResistanceAdjustmentDirection | undefined - >(); - const [resistanceRamp, setResistanceRamp] = useState(() => { - const current = storedResistance(); - return { current, from: current, phase: 'holding', progress: 0, to: current }; - }); - const [connectionPhase, setConnectionPhase] = useState('unpaired'); - const [notice, setNotice] = useState(''); - const [resistanceRange, setResistanceRange] = useState({ - max: MAX_RESISTANCE, - min: MIN_RESISTANCE, - }); + const store = useMemo(() => createTrainerStore(), []); + const state = useSelector(store); + const { + setConnectionPhase, + setMetrics, + setNotice, + setResistance, + setResistanceKeyFlash, + setResistanceRamp, + setResistanceRange, + } = store.actions; + const device = useRef(undefined); + const pairedDevice = useRef(undefined); const commandQueue = useRef(Promise.resolve()); const resistanceTimer = useRef(undefined); const resistanceRampTimer = useRef(undefined); @@ -74,9 +68,9 @@ export function useTrainer() { const unloading = useRef(false); const lastPedalingAt = useRef(0); const trainerReportsDistance = useRef(false); - const controlPointRef = useRef(controlPoint); - const rangeRef = useRef(resistanceRange); - const connection = deviceConnectionView(connectionPhase); + const controlPointRef = useRef(undefined); + const rangeRef = useRef(state.resistanceRange); + const connection = deviceConnectionView(state.connectionPhase); const reconnectController = useRef( createReconnectController({ attempt: (selected) => @@ -88,15 +82,10 @@ export function useTrainer() { }) ); - useEffect(() => { - controlPointRef.current = controlPoint; - }, [controlPoint]); - - useEffect(() => { - rangeRef.current = resistanceRange; - }, [resistanceRange]); - - useEffect(() => scheduleNoticeDismissal(notice, () => setNotice('')), [notice]); + useEffect( + () => scheduleNoticeDismissal(state.notice, () => setNotice('')), + [setNotice, state.notice] + ); useEffect( () => () => { @@ -123,7 +112,7 @@ export function useTrainer() { commandQueue.current = commandQueue.current.then(action, action); await commandQueue.current; }, - [] + [setNotice] ); function connectionStopped(rediscover: boolean) { @@ -145,8 +134,9 @@ export function useTrainer() { const shouldReconnect = !(disconnectRequested.current || unloading.current) && autoReconnect.current; disconnectRequested.current = false; - setDevice(undefined); - setControlPoint(undefined); + device.current = undefined; + store.actions.setDeviceName(undefined); + controlPointRef.current = undefined; setMetrics(emptyMetrics); lastPedalingAt.current = 0; trainerReportsDistance.current = false; @@ -156,7 +146,7 @@ export function useTrainer() { setNotice('Trainer disconnected. Reconnecting automatically…'); reconnectController.current.start(selected.id, selected, 700); } else if (connectionCancelled.current) { - setConnectionPhase(pairedDevice ? 'offline' : 'unpaired'); + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); setNotice('Connection attempt stopped.'); } else { setConnectionPhase('offline'); @@ -172,11 +162,12 @@ export function useTrainer() { setConnectionPhase(rediscover ? 'reconnecting' : 'connecting'); connectionCleanup.current(); try { - setPairedDevice(selected); + pairedDevice.current = selected; + store.actions.setPairedDeviceName(selected.name); const nextConnection = await connectTrainerDevice( selected, rediscover, - resistanceRange, + rangeRef.current, { onControlRejected: () => setNotice('Trainer did not accept that command.'), onDisconnect: () => { @@ -188,7 +179,7 @@ export function useTrainer() { trainerReportsDistance.current = true; } recordMetricActivity(lastPedalingAt, nextMetrics); - setMetrics((current) => ({ ...current, ...nextMetrics })); + store.actions.mergeMetrics(nextMetrics); }, } ); @@ -199,8 +190,9 @@ export function useTrainer() { } connectionCleanup.current = nextConnection.cleanup; const point = nextConnection.controlPoint; - setControlPoint(point); + controlPointRef.current = point; const activeRange = nextConnection.resistanceRange; + rangeRef.current = activeRange; setResistanceRange(activeRange); const restored = storedResistance(); setResistance(restored); @@ -221,7 +213,8 @@ export function useTrainer() { return false; } localStorage.setItem(TRAINER_DEVICE_STORAGE_KEY, selected.id); - setDevice(selected); + device.current = selected; + store.actions.setDeviceName(selected.name); setConnectionPhase('connected'); reconnectController.current.reset(selected.id); setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`); @@ -255,13 +248,14 @@ export function useTrainer() { optionalServices, }); pendingDevice.current = selected; - setPairedDevice(selected); + pairedDevice.current = selected; + store.actions.setPairedDeviceName(selected.name); autoReconnect.current = true; if (!(await connectDevice(selected))) { reconnectController.current.start(selected.id, selected); } } catch (error) { - setConnectionPhase(pairedDevice ? 'offline' : 'unpaired'); + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); if (!pairingWasCancelled(error, connectionCancelled.current)) { setNotice(errorMessage(error)); } @@ -278,9 +272,9 @@ export function useTrainer() { connectionCleanup.current(); pendingDevice.current?.gatt?.disconnect(); pendingDevice.current = undefined; - setConnectionPhase(pairedDevice ? 'offline' : 'unpaired'); + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); setNotice('Connection attempt stopped.'); - }, [pairedDevice]); + }, [setConnectionPhase, setNotice]); const disconnect = useCallback(() => { connectionCancelled.current = false; @@ -288,23 +282,25 @@ export function useTrainer() { disconnectRequested.current = true; reconnectController.current.cancelAll(); connectionCleanup.current(); - device?.gatt?.disconnect(); - setDevice(undefined); - setControlPoint(undefined); + device.current?.gatt?.disconnect(); + device.current = undefined; + store.actions.setDeviceName(undefined); + controlPointRef.current = undefined; setMetrics(emptyMetrics); - setConnectionPhase(pairedDevice ? 'offline' : 'unpaired'); - }, [device, pairedDevice]); + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + }, [setConnectionPhase, setMetrics, store]); async function reconnect() { - if (!pairedDevice) { + if (!pairedDevice.current) { return; } + const selected = pairedDevice.current; connectionCancelled.current = false; disconnectRequested.current = false; autoReconnect.current = true; - reconnectController.current.reset(pairedDevice.id); - if (!(await connectDevice(pairedDevice, true))) { - reconnectController.current.start(pairedDevice.id, pairedDevice); + reconnectController.current.reset(selected.id); + if (!(await connectDevice(selected, true))) { + reconnectController.current.start(selected.id, selected); } } @@ -313,19 +309,21 @@ export function useTrainer() { disconnectRequested.current = true; reconnectController.current.cancelAll(); connectionCleanup.current(); - device?.gatt?.disconnect(); + device.current?.gatt?.disconnect(); try { - await pairedDevice?.forget(); + await pairedDevice.current?.forget(); } finally { localStorage.removeItem(TRAINER_DEVICE_STORAGE_KEY); - setDevice(undefined); - setPairedDevice(undefined); - setControlPoint(undefined); + device.current = undefined; + pairedDevice.current = undefined; + store.actions.setDeviceName(undefined); + store.actions.setPairedDeviceName(undefined); + controlPointRef.current = undefined; setMetrics(emptyMetrics); setConnectionPhase('unpaired'); setNotice('Trainer removed from paired devices.'); } - }, [device, pairedDevice]); + }, [setConnectionPhase, setMetrics, setNotice, store]); const sendResistance = useCallback( async (percent: number) => { @@ -378,7 +376,7 @@ export function useTrainer() { }; advance(); }, - [sendResistance] + [sendResistance, setNotice, setResistanceRamp] ); const updateResistance = useCallback( @@ -401,7 +399,7 @@ export function useTrainer() { rampResistance(next); }, 180); }, - [rampResistance] + [rampResistance, setResistance, setResistanceRamp] ); const shiftResistanceBy = useCallback( @@ -422,7 +420,7 @@ export function useTrainer() { localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); sendResistance(next).catch((error: unknown) => setNotice(errorMessage(error))); }, - [sendResistance] + [sendResistance, setNotice, setResistance, setResistanceRamp] ); useEffect(() => { @@ -432,11 +430,11 @@ export function useTrainer() { disconnectRequested.current = true; reconnectController.current.cancelAll(); connectionCleanup.current(); - device?.gatt?.disconnect(); + device.current?.gatt?.disconnect(); }; window.addEventListener('pagehide', handlePageHide); return () => window.removeEventListener('pagehide', handlePageHide); - }, [device]); + }, []); useEffect(() => { let cancelled = false; @@ -457,7 +455,8 @@ export function useTrainer() { setConnectionPhase('unpaired'); return; } - setPairedDevice(remembered); + pairedDevice.current = remembered; + store.actions.setPairedDeviceName(remembered.name); setConnectionPhase('reconnecting'); reconnectController.current.start(remembered.id, remembered, 1); } @@ -468,7 +467,7 @@ export function useTrainer() { reconnectController.current.cancelAll(); connectionCleanup.current(); }; - }, []); + }, [setConnectionPhase, setNotice, store.actions.setPairedDeviceName]); useEffect(() => { const handleKeys = (event: KeyboardEvent) => { @@ -523,7 +522,7 @@ export function useTrainer() { window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [updateResistance]); + }, [setResistanceKeyFlash, updateResistance]); const setKeyboardControlsEnabled = useCallback((enabled: boolean) => { keyboardControlsEnabled.current = enabled; @@ -538,17 +537,17 @@ export function useTrainer() { cancelConnection, connect, connectionBusy: connection.busy, - deviceName: device?.name, + deviceName: state.deviceName, disconnect, forget, lastPedalingAt, - metrics, - notice, - pairedDeviceName: pairedDevice?.name, + metrics: state.metrics, + notice: state.notice, + pairedDeviceName: state.pairedDeviceName, reconnect, - resistance, - resistanceKeyFlash, - resistanceRamp, + resistance: state.resistance, + resistanceKeyFlash: state.resistanceKeyFlash, + resistanceRamp: state.resistanceRamp, setGearControlsEnabled, setKeyboardControlsEnabled, setNotice, diff --git a/src/hooks/use-zwift-click.ts b/src/hooks/use-zwift-click.ts index 0b2ce56..79e18a5 100644 --- a/src/hooks/use-zwift-click.ts +++ b/src/hooks/use-zwift-click.ts @@ -1,13 +1,8 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { BATTERY, WEB_BLUETOOTH_UNAVAILABLE_MESSAGE } from '../constants'; import { isBluetoothChooserCancellation } from '../lib/bluetooth'; -import { - aggregateConnectionPhase, - type DeviceConnectionPhase, - deviceConnectionView, - removeConnectionPhase, - setConnectionPhase, -} from '../lib/device-connection'; +import { aggregateConnectionPhase, deviceConnectionView } from '../lib/device-connection'; import { errorMessage } from '../lib/errors'; import { createReconnectController } from '../lib/reconnect-controller'; import { @@ -28,6 +23,7 @@ import { ZWIFT_MANUFACTURER_ID, } from '../lib/zwift-click'; import { connectClickDevice, SupersededClickConnectionError } from '../lib/zwift-click-device'; +import { createZwiftClickStore } from '../stores/zwift-click-store'; interface ClickRepeatTimer { delay: number; @@ -81,13 +77,9 @@ export function useZwiftClick( setNotice: (notice: string) => void, identifyControllers = false ) { - const [devices, setDevices] = useState([]); - const [controllerPhases, setControllerPhases] = useState>( - {} - ); - const [activeControllerIds, setActiveControllerIds] = useState([]); - const [controllerRoles, setControllerRoles] = useState({}); - const [pairing, setPairing] = useState(false); + const store = useMemo(() => createZwiftClickStore(), []); + const state = useSelector(store); + const { clearActiveControllers, setControllerPhase } = store.actions; const autoReconnect = useRef(true); const connectingIds = useRef(new Set()); const connectionAttempts = useRef(new Map()); @@ -117,10 +109,7 @@ export function useZwiftClick( }) ?? Promise.resolve(false), canRetry: (selected) => autoReconnect.current && !forgottenIds.current.has(selected.id), delayForAttempt: (attempt) => Math.min(1000, 400 * 2 ** (attempt - 1)), - onWaiting: (selected) => - setControllerPhases((current) => - setConnectionPhase(current, selected.id, 'reconnecting') - ), + onWaiting: (selected) => store.actions.setControllerPhase(selected.id, 'reconnecting'), }) ); @@ -135,9 +124,9 @@ export function useZwiftClick( window.clearTimeout(timer); } controllerFlashTimers.current.clear(); - setActiveControllerIds((current) => (current.length ? [] : current)); + clearActiveControllers(); } - }, [identifyControllers]); + }, [clearActiveControllers, identifyControllers]); useEffect( () => () => { @@ -148,26 +137,32 @@ export function useZwiftClick( [] ); - const registerControllerRole = useCallback((deviceId: string, shifts: ClickShift[]) => { - const next = registerClickControllerRole(controllerRolesRef.current, deviceId, shifts); - if (next === controllerRolesRef.current) { - return; - } - controllerRolesRef.current = next; - setControllerRoles(next); - saveControllerRoles(next); - }, []); + const registerControllerRole = useCallback( + (deviceId: string, shifts: ClickShift[]) => { + const next = registerClickControllerRole(controllerRolesRef.current, deviceId, shifts); + if (next === controllerRolesRef.current) { + return; + } + controllerRolesRef.current = next; + store.actions.setControllerRoles(next); + saveControllerRoles(next); + }, + [store] + ); - const forgetControllerRole = useCallback((deviceId: string) => { - if (!controllerRolesRef.current[deviceId]) { - return; - } - const next = { ...controllerRolesRef.current }; - delete next[deviceId]; - controllerRolesRef.current = next; - setControllerRoles(next); - saveControllerRoles(next); - }, []); + const forgetControllerRole = useCallback( + (deviceId: string) => { + if (!controllerRolesRef.current[deviceId]) { + return; + } + const next = { ...controllerRolesRef.current }; + delete next[deviceId]; + controllerRolesRef.current = next; + store.actions.setControllerRoles(next); + saveControllerRoles(next); + }, + [store] + ); const stopRepeat = useCallback((shift: ClickShift) => { const timer = repeatTimers.current.get(shift); @@ -241,20 +236,21 @@ export function useZwiftClick( connectionCleanups.current.delete(deviceId); }, []); - const flashController = useCallback((deviceId: string) => { - if (!identifyControllersRef.current) { - return; - } - window.clearTimeout(controllerFlashTimers.current.get(deviceId)); - setActiveControllerIds((current) => - current.includes(deviceId) ? current : [...current, deviceId] - ); - const timer = window.setTimeout(() => { - controllerFlashTimers.current.delete(deviceId); - setActiveControllerIds((current) => current.filter((id) => id !== deviceId)); - }, CLICK_CONTROLLER_FLASH_MS); - controllerFlashTimers.current.set(deviceId, timer); - }, []); + const flashController = useCallback( + (deviceId: string) => { + if (!identifyControllersRef.current) { + return; + } + window.clearTimeout(controllerFlashTimers.current.get(deviceId)); + store.actions.activateController(deviceId); + const timer = window.setTimeout(() => { + controllerFlashTimers.current.delete(deviceId); + store.actions.deactivateController(deviceId); + }, CLICK_CONTROLLER_FLASH_MS); + controllerFlashTimers.current.set(deviceId, timer); + }, + [store] + ); const handleControllerMessage = useCallback( (deviceId: string, event: Event) => { @@ -269,7 +265,7 @@ export function useZwiftClick( operationalIds.current.add(deviceId); reconnectController.current.reset(deviceId); reportedConnectionFailures.current.delete(deviceId); - setControllerPhases((current) => setConnectionPhase(current, deviceId, 'connected')); + setControllerPhase(deviceId, 'connected'); previousButtonMaps.current.set(deviceId, parsed.buttonMap); const controllerRole = controllerRolesRef.current[deviceId]; const heldShifts = filterClickShiftsForController(parsed.heldShifts, controllerRole); @@ -290,7 +286,7 @@ export function useZwiftClick( onShiftRef.current(shift === 'down' ? -1 : 1); } }, - [flashController, registerControllerRole, setDeviceHeldShifts] + [flashController, registerControllerRole, setControllerPhase, setDeviceHeldShifts] ); const handleControllerDisconnect = useCallback( @@ -300,17 +296,13 @@ export function useZwiftClick( clearDeviceHeldShifts(selected.id); previousButtonMaps.current.delete(selected.id); if (shouldAutoReconnect(autoReconnect.current, forgottenIds.current, selected.id)) { - setControllerPhases((current) => - setConnectionPhase(current, selected.id, 'reconnecting') - ); + setControllerPhase(selected.id, 'reconnecting'); reconnectController.current.start(selected.id, selected); } else { - setControllerPhases((current) => - setConnectionPhase(current, selected.id, 'offline') - ); + setControllerPhase(selected.id, 'offline'); } }, - [cleanupConnection, clearDeviceHeldShifts] + [cleanupConnection, clearDeviceHeldShifts, setControllerPhase] ); const establishControllerConnection = useCallback( @@ -350,18 +342,12 @@ export function useZwiftClick( const attempt = (connectionAttempts.current.get(selected.id) ?? 0) + 1; connectionAttempts.current.set(selected.id, attempt); connectingIds.current.add(selected.id); - setControllerPhases((current) => - setConnectionPhase( - current, - selected.id, - force || rediscover ? 'reconnecting' : 'connecting' - ) - ); + setControllerPhase(selected.id, force || rediscover ? 'reconnecting' : 'connecting'); previousButtonMaps.current.delete(selected.id); clearDeviceHeldShifts(selected.id); return attempt; }, - [cleanupConnection, clearDeviceHeldShifts] + [cleanupConnection, clearDeviceHeldShifts, setControllerPhase] ); const handleConnectionFailure = useCallback( @@ -375,13 +361,7 @@ export function useZwiftClick( forgottenIds.current, selected.id ); - setControllerPhases((current) => - setConnectionPhase( - current, - selected.id, - shouldReconnect ? 'reconnecting' : 'offline' - ) - ); + setControllerPhase(selected.id, shouldReconnect ? 'reconnecting' : 'offline'); if (!(shouldReconnect || reportedConnectionFailures.current.has(selected.id))) { reportedConnectionFailures.current.add(selected.id); setNotice(`Zwift Click connection failed: ${errorMessage(error)}`); @@ -390,7 +370,7 @@ export function useZwiftClick( reconnectController.current.start(selected.id, selected); } }, - [cleanupConnection, clearDeviceHeldShifts, setNotice] + [cleanupConnection, clearDeviceHeldShifts, setControllerPhase, setNotice] ); useEffect( @@ -415,9 +395,7 @@ export function useZwiftClick( try { await establishControllerConnection(selected, isCurrentAttempt, rediscover); operationalIds.current.add(selected.id); - setControllerPhases((current) => - setConnectionPhase(current, selected.id, 'connected') - ); + setControllerPhase(selected.id, 'connected'); reconnectController.current.reset(selected.id); reportedConnectionFailures.current.delete(selected.id); return true; @@ -433,7 +411,12 @@ export function useZwiftClick( } } }, - [beginControllerConnectionAttempt, establishControllerConnection, handleConnectionFailure] + [ + beginControllerConnectionAttempt, + establishControllerConnection, + handleConnectionFailure, + setControllerPhase, + ] ); useEffect(() => { @@ -445,7 +428,7 @@ export function useZwiftClick( setNotice(WEB_BLUETOOTH_UNAVAILABLE_MESSAGE); return; } - setPairing(true); + store.actions.setPairing(true); try { const selected = await navigator.bluetooth.requestDevice({ filters: [{ name: ZWIFT_CLICK_NAME }], @@ -454,14 +437,13 @@ export function useZwiftClick( }); autoReconnect.current = true; forgottenIds.current.delete(selected.id); - setDevices((current) => { - const next = current.some(({ id }) => id === selected.id) - ? current - : [...current, selected].slice(0, MAX_CLICK_CONTROLLERS); - devicesRef.current = next; - saveDeviceIds(next); - return next; - }); + const { current } = devicesRef; + const next = current.some(({ id }) => id === selected.id) + ? current + : [...current, selected].slice(0, MAX_CLICK_CONTROLLERS); + devicesRef.current = next; + store.actions.setDeviceIds(next.map(({ id }) => id)); + saveDeviceIds(next); // Do not make selection of the second controller wait for this controller's // complete GATT setup. Its connection continues independently in the background. connectDevice(selected); @@ -470,23 +452,24 @@ export function useZwiftClick( setNotice(errorMessage(error)); } } finally { - setPairing(false); + store.actions.setPairing(false); } - }, [connectDevice, setNotice]); + }, [connectDevice, setNotice, store]); const reconnect = useCallback(() => { autoReconnect.current = true; - for (const selected of devices) { - if (controllerPhases[selected.id] === 'connected') { + for (const selected of devicesRef.current) { + if (store.get().controllerPhases[selected.id] === 'connected') { continue; } reconnectController.current.reset(selected.id); reconnectController.current.start(selected.id, selected, 1); } - }, [controllerPhases, devices]); + }, [store]); const disconnect = useCallback(() => { autoReconnect.current = false; + const devices = devicesRef.current; for (const selected of devices) { reconnectController.current.cancel(selected.id, true); operationalIds.current.delete(selected.id); @@ -494,10 +477,10 @@ export function useZwiftClick( cleanupConnection(selected.id); selected.gatt?.disconnect(); } - setControllerPhases( + store.actions.setControllerPhases( Object.fromEntries(devices.map((selected) => [selected.id, 'offline'])) ); - }, [cleanupConnection, clearDeviceHeldShifts, devices]); + }, [cleanupConnection, clearDeviceHeldShifts, store]); const forgetDevice = useCallback( async (deviceId: string) => { @@ -506,30 +489,28 @@ export function useZwiftClick( reconnectController.current.cancel(deviceId, true); clearDeviceHeldShifts(deviceId); forgetControllerRole(deviceId); - const selected = devices.find(({ id }) => id === deviceId); + const selected = devicesRef.current.find(({ id }) => id === deviceId); cleanupConnection(deviceId); selected?.gatt?.disconnect(); try { await selected?.forget(); } finally { - setDevices((current) => { - const next = current.filter(({ id }) => id !== deviceId); - devicesRef.current = next; - saveDeviceIds(next); - return next; - }); - setControllerPhases((current) => removeConnectionPhase(current, deviceId)); + const next = devicesRef.current.filter(({ id }) => id !== deviceId); + devicesRef.current = next; + store.actions.setDeviceIds(next.map(({ id }) => id)); + saveDeviceIds(next); + store.actions.removeControllerPhase(deviceId); } }, - [cleanupConnection, clearDeviceHeldShifts, devices, forgetControllerRole] + [cleanupConnection, clearDeviceHeldShifts, forgetControllerRole, store] ); const forget = useCallback(async () => { autoReconnect.current = false; - for (const selected of [...devices]) { + for (const selected of [...devicesRef.current]) { await forgetDevice(selected.id); } - }, [devices, forgetDevice]); + }, [forgetDevice]); useEffect(() => { let cancelled = false; @@ -557,9 +538,9 @@ export function useZwiftClick( ) as ClickControllerRoles; controllerRolesRef.current = rememberedRoles; devicesRef.current = remembered; - setControllerRoles(rememberedRoles); - setDevices(remembered); - setControllerPhases( + store.actions.setControllerRoles(rememberedRoles); + store.actions.setDeviceIds(remembered.map(({ id }) => id)); + store.actions.setControllerPhases( Object.fromEntries(remembered.map((selected) => [selected.id, 'reconnecting'])) ); for (const selected of remembered) { @@ -571,8 +552,13 @@ export function useZwiftClick( } } restore().catch(() => { - setControllerPhases((current) => - Object.fromEntries(Object.keys(current).map((deviceId) => [deviceId, 'offline'])) + store.actions.setControllerPhases( + Object.fromEntries( + Object.keys(store.get().controllerPhases).map((deviceId) => [ + deviceId, + 'offline', + ]) + ) ); }); return () => { @@ -580,26 +566,28 @@ export function useZwiftClick( autoReconnect.current = false; reconnectController.current.cancelAll(); }; - }, []); + }, [store]); - const connectionPhases = devices.map((selected) => controllerPhases[selected.id] ?? 'offline'); + const connectionPhases = state.deviceIds.map( + (deviceId) => state.controllerPhases[deviceId] ?? 'offline' + ); const connection = deviceConnectionView(aggregateConnectionPhase(connectionPhases)); const connectedCount = connectionPhases.filter((phase) => phase === 'connected').length; return { ...connection, connectedCount, - controllers: devices.map((selected) => ({ - active: activeControllerIds.includes(selected.id), - ...deviceConnectionView(controllerPhases[selected.id] ?? 'offline'), - id: selected.id, - label: controllerLabel(controllerRoles[selected.id]), + controllers: state.deviceIds.map((deviceId) => ({ + active: state.activeControllerIds.includes(deviceId), + ...deviceConnectionView(state.controllerPhases[deviceId] ?? 'offline'), + id: deviceId, + label: controllerLabel(state.controllerRoles[deviceId]), })), disconnect, forget, forgetDevice, pair, - pairedCount: devices.length, - pairing, + pairedCount: state.deviceIds.length, + pairing: state.pairing, reconnect, }; } diff --git a/src/stores/trainer-store.ts b/src/stores/trainer-store.ts new file mode 100644 index 0000000..a5464b8 --- /dev/null +++ b/src/stores/trainer-store.ts @@ -0,0 +1,75 @@ +import { createStore } from '@tanstack/react-store'; +import { emptyMetrics } from '../constants'; +import type { DeviceConnectionPhase } from '../lib/device-connection'; +import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; +import { storedResistance } from '../lib/session'; +import type { Metrics, Range, ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; + +export interface TrainerStoreState { + connectionPhase: DeviceConnectionPhase; + deviceName?: string; + metrics: Metrics; + notice: string; + pairedDeviceName?: string; + resistance: number; + resistanceKeyFlash?: ResistanceAdjustmentDirection; + resistanceRamp: ResistanceRamp; + resistanceRange: Range; +} + +export function createTrainerStore() { + const resistance = storedResistance(); + return createStore( + { + connectionPhase: 'unpaired', + metrics: emptyMetrics, + notice: '', + resistance, + resistanceRamp: { + current: resistance, + from: resistance, + phase: 'holding', + progress: 0, + to: resistance, + }, + resistanceRange: { max: MAX_RESISTANCE, min: MIN_RESISTANCE }, + } as TrainerStoreState, + ({ setState }) => ({ + mergeMetrics: (metrics: Partial) => { + setState((current) => ({ + ...current, + metrics: { ...current.metrics, ...metrics }, + })); + }, + setConnectionPhase: (connectionPhase: DeviceConnectionPhase) => { + setState((current) => ({ ...current, connectionPhase })); + }, + setDeviceName: (deviceName?: string) => { + setState((current) => ({ ...current, deviceName })); + }, + setMetrics: (metrics: Metrics) => { + setState((current) => ({ ...current, metrics })); + }, + setNotice: (notice: string) => { + setState((current) => ({ ...current, notice })); + }, + setPairedDeviceName: (pairedDeviceName?: string) => { + setState((current) => ({ ...current, pairedDeviceName })); + }, + setResistance: (nextResistance: number) => { + setState((current) => ({ ...current, resistance: nextResistance })); + }, + setResistanceKeyFlash: (resistanceKeyFlash?: ResistanceAdjustmentDirection) => { + setState((current) => ({ ...current, resistanceKeyFlash })); + }, + setResistanceRamp: (resistanceRamp: ResistanceRamp) => { + setState((current) => ({ ...current, resistanceRamp })); + }, + setResistanceRange: (resistanceRange: Range) => { + setState((current) => ({ ...current, resistanceRange })); + }, + }) + ); +} + +export type TrainerStore = ReturnType; diff --git a/src/stores/zwift-click-store.ts b/src/stores/zwift-click-store.ts new file mode 100644 index 0000000..8ede74c --- /dev/null +++ b/src/stores/zwift-click-store.ts @@ -0,0 +1,80 @@ +import { createStore } from '@tanstack/react-store'; +import { + type DeviceConnectionPhase, + removeConnectionPhase, + setConnectionPhase, +} from '../lib/device-connection'; +import type { ClickControllerRoles } from '../lib/zwift-click'; + +export interface ZwiftClickStoreState { + activeControllerIds: string[]; + controllerPhases: Record; + controllerRoles: ClickControllerRoles; + deviceIds: string[]; + pairing: boolean; +} + +export function createZwiftClickStore() { + return createStore( + { + activeControllerIds: [], + controllerPhases: {}, + controllerRoles: {}, + deviceIds: [], + pairing: false, + } as ZwiftClickStoreState, + ({ setState }) => ({ + activateController: (deviceId: string) => { + setState((current) => + current.activeControllerIds.includes(deviceId) + ? current + : { + ...current, + activeControllerIds: [...current.activeControllerIds, deviceId], + } + ); + }, + clearActiveControllers: () => { + setState((current) => + current.activeControllerIds.length + ? { ...current, activeControllerIds: [] } + : current + ); + }, + deactivateController: (deviceId: string) => { + setState((current) => ({ + ...current, + activeControllerIds: current.activeControllerIds.filter( + (id) => id !== deviceId + ), + })); + }, + removeControllerPhase: (deviceId: string) => { + setState((current) => ({ + ...current, + controllerPhases: removeConnectionPhase(current.controllerPhases, deviceId), + })); + }, + setControllerPhase: (deviceId: string, phase: DeviceConnectionPhase) => { + setState((current) => ({ + ...current, + controllerPhases: setConnectionPhase(current.controllerPhases, deviceId, phase), + })); + }, + setControllerPhases: (controllerPhases: Record) => { + setState((current) => ({ ...current, controllerPhases })); + }, + setControllerRoles: (controllerRoles: ClickControllerRoles) => { + setState((current) => ({ ...current, controllerRoles })); + }, + setDeviceIds: (deviceIds: string[]) => { + setState((current) => ({ ...current, deviceIds })); + }, + setPairing: (pairing: boolean) => { + setState((current) => ({ ...current, pairing })); + }, + }) + ); +} + +export type ZwiftClickStore = ReturnType; From ae068974e7db178eecdd363038ec162727204a2c Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:27:09 -0700 Subject: [PATCH 14/26] refactor display preferences into shared store --- src/app.tsx | 14 ++++-------- src/components/session-chart.tsx | 22 ++++++++---------- src/stores/preferences-store.ts | 39 ++++++++++++++++++++++++++++++++ tests/preferences-store.test.ts | 27 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 src/stores/preferences-store.ts create mode 100644 tests/preferences-store.test.ts diff --git a/src/app.tsx b/src/app.tsx index a597344..543ea79 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,3 +1,4 @@ +import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AppFooter } from './components/app-footer'; import { Dashboard, DashboardToolbar, DashboardWorkspace } from './components/dashboard-layout'; @@ -22,9 +23,9 @@ import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; -import { SPEED_UNIT_STORAGE_KEY, storedSpeedUnit } from './lib/units'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; -import type { Metrics, SavedSession, SpeedUnit } from './types'; +import { preferencesStore } from './stores/preferences-store'; +import type { Metrics, SavedSession } from './types'; type AppOverlay = 'devices' | 'history' | 'shortcuts' | 'welcome'; @@ -67,7 +68,7 @@ export function App() { heartRate.heartRate ); const { connected } = trainer; - const [speedUnit, setSpeedUnit] = useState(storedSpeedUnit); + const speedUnit = useSelector(preferencesStore, (preferences) => preferences.speedUnit); const gearControl = useGearControl({ active: click.paired, connected: trainer.connected, @@ -177,11 +178,6 @@ export function App() { workflow.saveDialogOpen, ]); - function selectSpeedUnit(unit: SpeedUnit) { - setSpeedUnit(unit); - localStorage.setItem(SPEED_UNIT_STORAGE_KEY, unit); - } - const closeWelcome = useCallback((dontShowAgain: boolean) => { if (dontShowAgain) { rememberWelcomeDismissal(); @@ -226,7 +222,7 @@ export function App() { onOpenDevices={() => setActiveOverlay('devices')} onOpenHistory={() => setActiveOverlay('history')} onOpenShortcuts={() => setActiveOverlay('shortcuts')} - onSelectSpeedUnit={selectSpeedUnit} + onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit} pairedDeviceCount={pairedDeviceCount} speedUnit={speedUnit} /> diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index e0738c3..bfac660 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -1,11 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - CHART_MODE_STORAGE_KEY, - chartModesForControl, - chartPath, - roundedChartMaximum, - storedChartMode, -} from '../lib/chart'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo } from 'react'; +import { chartModesForControl, chartPath, roundedChartMaximum } from '../lib/chart'; import { CONTROL_MODE, isControlMode } from '../lib/control-mode'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { formatChartSeconds } from '../lib/format'; @@ -13,6 +8,7 @@ import { MAX_GEAR, MIN_GEAR } from '../lib/gears'; import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; import { convertSpeed, minimumSpeedChartMaximum, speedUnitLabel } from '../lib/units'; +import { preferencesStore } from '../stores/preferences-store'; import type { ChartMode, ControlMode, MetricSample, RoutePoint, SpeedUnit } from '../types'; interface PlotProps { @@ -101,7 +97,7 @@ export function SessionChart({ route: readonly RoutePoint[]; speedUnit: SpeedUnit; }) { - const [selectedMode, setSelectedMode] = useState(storedChartMode); + const selectedMode = useSelector(preferencesStore, (preferences) => preferences.chartMode); const resolvedControlMode = controlMode ?? (history.some((sample) => sample.gear !== undefined) @@ -195,10 +191,10 @@ export function SessionChart({ const historySeconds = history.length > 1 ? (history.at(-1)?.elapsedSeconds ?? 0) - historyStart : 0; - const selectMode = useCallback((mode: ChartMode) => { - setSelectedMode(mode); - localStorage.setItem(CHART_MODE_STORAGE_KEY, mode); - }, []); + const selectMode = useCallback( + (mode: ChartMode) => preferencesStore.actions.selectChartMode(mode), + [] + ); useEffect(() => { if (!keyboardEnabled) { diff --git a/src/stores/preferences-store.ts b/src/stores/preferences-store.ts new file mode 100644 index 0000000..7f86940 --- /dev/null +++ b/src/stores/preferences-store.ts @@ -0,0 +1,39 @@ +import { createStore } from '@tanstack/react-store'; +import { CHART_MODE_STORAGE_KEY, storedChartMode } from '../lib/chart'; +import { SPEED_UNIT_STORAGE_KEY, storedSpeedUnit } from '../lib/units'; +import type { ChartMode, SpeedUnit } from '../types'; + +interface PreferencesStorage extends Pick {} + +const unavailableStorage: PreferencesStorage = { + getItem: () => null, + setItem: () => undefined, +}; + +const defaultPreferencesStorage = globalThis.localStorage ?? unavailableStorage; + +export interface PreferencesStoreState { + chartMode: ChartMode; + speedUnit: SpeedUnit; +} + +export function createPreferencesStore(storage: PreferencesStorage = defaultPreferencesStorage) { + return createStore( + { + chartMode: storedChartMode(storage), + speedUnit: storedSpeedUnit(storage), + }, + ({ setState }) => ({ + selectChartMode: (chartMode: ChartMode) => { + storage.setItem(CHART_MODE_STORAGE_KEY, chartMode); + setState((current) => ({ ...current, chartMode })); + }, + selectSpeedUnit: (speedUnit: SpeedUnit) => { + storage.setItem(SPEED_UNIT_STORAGE_KEY, speedUnit); + setState((current) => ({ ...current, speedUnit })); + }, + }) + ); +} + +export const preferencesStore = createPreferencesStore(); diff --git a/tests/preferences-store.test.ts b/tests/preferences-store.test.ts new file mode 100644 index 0000000..e41aa23 --- /dev/null +++ b/tests/preferences-store.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { createPreferencesStore } from '../src/stores/preferences-store'; + +function memoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + values, + }; +} + +describe('preferences store', () => { + test('restores and persists shared display preferences', () => { + const storage = memoryStorage({ 'speed-unit': 'kmh', 'trainer-chart-mode': 'power' }); + const store = createPreferencesStore(storage); + + expect(store.get()).toEqual({ chartMode: 'power', speedUnit: 'kmh' }); + + store.actions.selectChartMode('cadence'); + store.actions.selectSpeedUnit('mph'); + + expect(store.get()).toEqual({ chartMode: 'cadence', speedUnit: 'mph' }); + expect(storage.values.get('trainer-chart-mode')).toBe('cadence'); + expect(storage.values.get('speed-unit')).toBe('mph'); + }); +}); From bbc03eecd40bffdb1d78e0dfe9f59cff9e5a15f8 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:28:56 -0700 Subject: [PATCH 15/26] refactor session workflow into store actions --- src/hooks/use-session-workflow.ts | 72 ++++++++++++++-------------- src/lib/session-workflow.ts | 53 ++++++++------------ src/stores/session-workflow-store.ts | 43 +++++++++++++++++ tests/session-workflow.test.ts | 53 ++++++++++++-------- 4 files changed, 134 insertions(+), 87 deletions(-) create mode 100644 src/stores/session-workflow-store.ts diff --git a/src/hooks/use-session-workflow.ts b/src/hooks/use-session-workflow.ts index 7adaef8..80edb8c 100644 --- a/src/hooks/use-session-workflow.ts +++ b/src/hooks/use-session-workflow.ts @@ -1,4 +1,5 @@ -import { useCallback, useState } from 'react'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useRef } from 'react'; import { errorMessage } from '../lib/errors'; import { createSavedSession, @@ -6,11 +7,12 @@ import { saveSession, } from '../lib/saved-sessions'; import { - initialSessionWorkflowState, + SESSION_WORKFLOW_INTENT, + SESSION_WORKFLOW_PHASE, type SessionWorkflowController, type SessionWorkflowIntent, - sessionWorkflowReducer, } from '../lib/session-workflow'; +import { createSessionWorkflowStore } from '../stores/session-workflow-store'; import type { SavedSession, SessionMetadata } from '../types'; export function useSessionWorkflow( @@ -18,63 +20,61 @@ export function useSessionWorkflow( setNotice: (notice: string) => void ) { const sessionIsSaved = Boolean(session.savedSessionId); - const [state, setState] = useState(() => - initialSessionWorkflowState(session.ended && !sessionIsSaved) - ); - const dispatch = useCallback((action: Parameters[1]) => { - setState((current) => sessionWorkflowReducer(current, action)); - }, []); + const storeRef = useRef | undefined>(undefined); + storeRef.current ??= createSessionWorkflowStore(session.ended && !sessionIsSaved); + const store = storeRef.current; + const state = useSelector(store); const startNewSession = useCallback(() => { session.startNew(); - dispatch({ type: 'close' }); + store.actions.close(); setNotice('New session ready.'); - }, [dispatch, session.startNew, setNotice]); + }, [session.startNew, setNotice, store]); const continueSession = useCallback( (savedSession: SavedSession) => { session.continueFrom(savedSession); - dispatch({ type: 'close' }); + store.actions.close(); setNotice('Session continued.'); }, - [dispatch, session.continueFrom, setNotice] + [session.continueFrom, setNotice, store] ); const completeIntent = useCallback( (intent: SessionWorkflowIntent, saved: boolean) => { - if (intent.kind === 'continue') { + if (intent.kind === SESSION_WORKFLOW_INTENT.CONTINUE) { session.continueFrom(intent.session); setNotice( saved ? 'Session saved. Selected session continued.' : 'Session continued.' ); - } else if (intent.kind === 'new' || !saved) { + } else if (intent.kind === SESSION_WORKFLOW_INTENT.NEW || !saved) { session.startNew(); setNotice(saved ? 'Session saved. New session ready.' : 'New session ready.'); } else { setNotice('Session saved.'); } - dispatch({ type: 'close' }); + store.actions.close(); }, - [dispatch, session.continueFrom, session.startNew, setNotice] + [session.continueFrom, session.startNew, setNotice, store] ); const endSession = useCallback(() => { session.endSession(); - dispatch({ intent: { kind: 'end' }, type: 'open' }); - }, [dispatch, session.endSession]); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END }); + }, [session.endSession, store]); const requestNewSession = useCallback(() => { if (session.ended) { if (sessionIsSaved) { startNewSession(); } else { - dispatch({ intent: { kind: 'new' }, type: 'open' }); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); } return; } if (session.elapsedSeconds > 0) { session.endSession(); - dispatch({ intent: { kind: 'new' }, type: 'open' }); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); return; } startNewSession(); @@ -84,7 +84,7 @@ export function useSessionWorkflow( session.ended, sessionIsSaved, startNewSession, - dispatch, + store, ]); const requestContinuation = useCallback( @@ -99,47 +99,47 @@ export function useSessionWorkflow( if (!session.ended) { session.endSession(); } - dispatch({ intent: { kind: 'continue', session: savedSession }, type: 'open' }); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.CONTINUE, session: savedSession }); }, [ continueSession, - dispatch, session.elapsedSeconds, session.endSession, session.ended, sessionIsSaved, + store, ] ); const saveCurrentSession = useCallback( async (metadata: SessionMetadata) => { - if (state.phase === 'closed') { + if (state.phase === SESSION_WORKFLOW_PHASE.CLOSED) { return; } const { intent } = state; - dispatch({ type: 'start-saving' }); + store.actions.startSaving(); try { const savedSession = createSavedSession(session.snapshot, metadata); await saveSession(savedSession); session.markSaved(savedSession.id); completeIntent(intent, true); } catch (error) { - dispatch({ type: 'save-failed' }); + store.actions.saveFailed(); setNotice(`Session could not be saved: ${errorMessage(error)}`); } }, - [completeIntent, dispatch, session.markSaved, session.snapshot, setNotice, state] + [completeIntent, session.markSaved, session.snapshot, setNotice, state, store] ); const proceedWithoutSaving = useCallback(() => { - if (state.phase !== 'closed') { + if (state.phase !== SESSION_WORKFLOW_PHASE.CLOSED) { completeIntent(state.intent, false); } }, [completeIntent, state]); - const closeSaveDialog = useCallback(() => dispatch({ type: 'close' }), [dispatch]); + const closeSaveDialog = useCallback(() => store.actions.close(), [store]); const openSaveDialog = useCallback( - () => dispatch({ intent: { kind: 'end' }, type: 'open' }), - [dispatch] + () => store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END }), + [store] ); const requestPersistentStorage = useCallback( () => requestPersistentSessionStorage().catch(() => false), @@ -148,7 +148,9 @@ export function useSessionWorkflow( return { closeSaveDialog, - continuing: state.phase !== 'closed' && state.intent.kind === 'continue', + continuing: + state.phase !== SESSION_WORKFLOW_PHASE.CLOSED && + state.intent.kind === SESSION_WORKFLOW_INTENT.CONTINUE, endSession, openSaveDialog, proceedWithoutSaving, @@ -156,8 +158,8 @@ export function useSessionWorkflow( requestNewSession, requestPersistentStorage, saveCurrentSession, - saveDialogOpen: state.phase !== 'closed', - saving: state.phase === 'saving', + saveDialogOpen: state.phase !== SESSION_WORKFLOW_PHASE.CLOSED, + saving: state.phase === SESSION_WORKFLOW_PHASE.SAVING, sessionIsSaved, }; } diff --git a/src/lib/session-workflow.ts b/src/lib/session-workflow.ts index 103c0e0..711ab05 100644 --- a/src/lib/session-workflow.ts +++ b/src/lib/session-workflow.ts @@ -11,39 +11,26 @@ export interface SessionWorkflowController { startNew: () => void; } -export type SessionWorkflowIntent = - | { kind: 'end' } - | { kind: 'new' } - | { kind: 'continue'; session: SavedSession }; - -export type SessionWorkflowState = - | { phase: 'closed' } - | { intent: SessionWorkflowIntent; phase: 'prompt' | 'saving' }; +export const SESSION_WORKFLOW_INTENT = { + CONTINUE: 'continue', + END: 'end', + NEW: 'new', +} as const; -export type SessionWorkflowAction = - | { type: 'close' } - | { intent: SessionWorkflowIntent; type: 'open' } - | { type: 'save-failed' } - | { type: 'start-saving' }; +export const SESSION_WORKFLOW_PHASE = { + CLOSED: 'closed', + PROMPT: 'prompt', + SAVING: 'saving', +} as const; -export function initialSessionWorkflowState(open: boolean): SessionWorkflowState { - return open ? { intent: { kind: 'end' }, phase: 'prompt' } : { phase: 'closed' }; -} +export type SessionWorkflowIntent = + | { kind: typeof SESSION_WORKFLOW_INTENT.END } + | { kind: typeof SESSION_WORKFLOW_INTENT.NEW } + | { kind: typeof SESSION_WORKFLOW_INTENT.CONTINUE; session: SavedSession }; -export function sessionWorkflowReducer( - state: SessionWorkflowState, - action: SessionWorkflowAction -): SessionWorkflowState { - switch (action.type) { - case 'close': - return { phase: 'closed' }; - case 'open': - return { intent: action.intent, phase: 'prompt' }; - case 'save-failed': - return state.phase === 'saving' ? { ...state, phase: 'prompt' } : state; - case 'start-saving': - return state.phase === 'prompt' ? { ...state, phase: 'saving' } : state; - default: - return state; - } -} +export type SessionWorkflowState = + | { phase: typeof SESSION_WORKFLOW_PHASE.CLOSED } + | { + intent: SessionWorkflowIntent; + phase: typeof SESSION_WORKFLOW_PHASE.PROMPT | typeof SESSION_WORKFLOW_PHASE.SAVING; + }; diff --git a/src/stores/session-workflow-store.ts b/src/stores/session-workflow-store.ts new file mode 100644 index 0000000..4c008b5 --- /dev/null +++ b/src/stores/session-workflow-store.ts @@ -0,0 +1,43 @@ +import { createStore } from '@tanstack/react-store'; +import { + SESSION_WORKFLOW_INTENT, + SESSION_WORKFLOW_PHASE, + type SessionWorkflowIntent, + type SessionWorkflowState, +} from '../lib/session-workflow'; + +export function initialSessionWorkflowState(open: boolean): SessionWorkflowState { + return open + ? { + intent: { kind: SESSION_WORKFLOW_INTENT.END }, + phase: SESSION_WORKFLOW_PHASE.PROMPT, + } + : { phase: SESSION_WORKFLOW_PHASE.CLOSED }; +} + +export function createSessionWorkflowStore(open: boolean) { + return createStore(initialSessionWorkflowState(open), ({ setState }) => ({ + close: () => { + setState(() => ({ phase: SESSION_WORKFLOW_PHASE.CLOSED })); + }, + open: (intent: SessionWorkflowIntent) => { + setState(() => ({ intent, phase: SESSION_WORKFLOW_PHASE.PROMPT })); + }, + saveFailed: () => { + setState((current) => + current.phase === SESSION_WORKFLOW_PHASE.SAVING + ? { ...current, phase: SESSION_WORKFLOW_PHASE.PROMPT } + : current + ); + }, + startSaving: () => { + setState((current) => + current.phase === SESSION_WORKFLOW_PHASE.PROMPT + ? { ...current, phase: SESSION_WORKFLOW_PHASE.SAVING } + : current + ); + }, + })); +} + +export type SessionWorkflowStore = ReturnType; diff --git a/tests/session-workflow.test.ts b/tests/session-workflow.test.ts index 9a7bcbc..ef16046 100644 --- a/tests/session-workflow.test.ts +++ b/tests/session-workflow.test.ts @@ -1,34 +1,49 @@ import { describe, expect, test } from 'bun:test'; -import { initialSessionWorkflowState, sessionWorkflowReducer } from '../src/lib/session-workflow'; +import { SESSION_WORKFLOW_INTENT, SESSION_WORKFLOW_PHASE } from '../src/lib/session-workflow'; +import { + createSessionWorkflowStore, + initialSessionWorkflowState, +} from '../src/stores/session-workflow-store'; import type { SavedSession } from '../src/types'; -describe('session workflow reducer', () => { +describe('session workflow store', () => { test('opens with the ended-session intent when an unsaved session is restored', () => { expect(initialSessionWorkflowState(true)).toEqual({ - intent: { kind: 'end' }, - phase: 'prompt', + intent: { kind: SESSION_WORKFLOW_INTENT.END }, + phase: SESSION_WORKFLOW_PHASE.PROMPT, + }); + expect(initialSessionWorkflowState(false)).toEqual({ + phase: SESSION_WORKFLOW_PHASE.CLOSED, }); - expect(initialSessionWorkflowState(false)).toEqual({ phase: 'closed' }); }); test('preserves the requested next session while saving', () => { const session = { id: 'saved-session' } as SavedSession; - const prompt = sessionWorkflowReducer( - { phase: 'closed' }, - { intent: { kind: 'continue', session }, type: 'open' } - ); - expect(prompt).toEqual({ intent: { kind: 'continue', session }, phase: 'prompt' }); - const saving = sessionWorkflowReducer(prompt, { type: 'start-saving' }); - expect(saving).toEqual({ intent: { kind: 'continue', session }, phase: 'saving' }); - expect(sessionWorkflowReducer(saving, { type: 'save-failed' })).toEqual(prompt); + const store = createSessionWorkflowStore(false); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.CONTINUE, session }); + const prompt = store.get(); + expect(prompt).toEqual({ + intent: { kind: SESSION_WORKFLOW_INTENT.CONTINUE, session }, + phase: SESSION_WORKFLOW_PHASE.PROMPT, + }); + store.actions.startSaving(); + expect(store.get()).toEqual({ + intent: { kind: SESSION_WORKFLOW_INTENT.CONTINUE, session }, + phase: SESSION_WORKFLOW_PHASE.SAVING, + }); + store.actions.saveFailed(); + expect(store.get()).toEqual(prompt); }); test('ignores invalid transitions and closes atomically', () => { - const closed = { phase: 'closed' } as const; - expect(sessionWorkflowReducer(closed, { type: 'start-saving' })).toBe(closed); - expect(sessionWorkflowReducer(closed, { type: 'save-failed' })).toBe(closed); - expect( - sessionWorkflowReducer({ intent: { kind: 'new' }, phase: 'prompt' }, { type: 'close' }) - ).toEqual(closed); + const store = createSessionWorkflowStore(false); + const closed = store.get(); + store.actions.startSaving(); + expect(store.get()).toBe(closed); + store.actions.saveFailed(); + expect(store.get()).toBe(closed); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); + store.actions.close(); + expect(store.get()).toEqual({ phase: SESSION_WORKFLOW_PHASE.CLOSED }); }); }); From afcd3246908f241d3a45f1e230c9a0d8d0592c78 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:34:34 -0700 Subject: [PATCH 16/26] refactor session history into focused modules --- src/components/session-detail.tsx | 216 +++++++++++++ src/components/session-history-list.tsx | 76 +++++ src/components/session-history.tsx | 403 ++---------------------- src/hooks/use-session-history.ts | 124 ++++++++ tests/components.test.tsx | 7 +- 5 files changed, 453 insertions(+), 373 deletions(-) create mode 100644 src/components/session-detail.tsx create mode 100644 src/components/session-history-list.tsx create mode 100644 src/hooks/use-session-history.ts diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx new file mode 100644 index 0000000..46aafa3 --- /dev/null +++ b/src/components/session-detail.tsx @@ -0,0 +1,216 @@ +import { useEffect, useRef } from 'react'; +import { EMPTY_ROUTE } from '../constants'; +import { CONTROL_MODE } from '../lib/control-mode'; +import { formatAggregateAverage, formatWholeNumber } from '../lib/format'; +import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; +import { + feelingLabel, + formatSessionDateRange, + formatSessionTimeRange, +} from '../lib/saved-sessions'; +import { downloadSessionTcx } from '../lib/tcx'; +import type { SavedSession, SpeedUnit } from '../types'; +import { SessionMetric } from './metrics'; +import { SessionChart } from './session-chart'; +import { SessionSummary } from './session-summary'; + +export function DeleteSessionDialog({ + deleting, + onCancel, + onConfirm, + open, +}: { + deleting: boolean; + onCancel: () => void; + onConfirm: () => void; + open: boolean; +}) { + const confirmButton = useRef(null); + + useEffect(() => { + if (open) { + confirmButton.current?.focus(); + } + }, [open]); + + if (!open) { + return null; + } + + return ( +
    +

    + Delete this session? +

    +

    + This cannot be undone. +

    +
    + + +
    +
    + ); +} + +export function SessionDetail({ + chartKeyboardEnabled = true, + deleteConfirmationOpen = false, + deleting = false, + onCancelDelete, + onConfirmDelete, + onDelete, + onStartNew, + session, + speedUnit, +}: { + chartKeyboardEnabled?: boolean; + deleteConfirmationOpen?: boolean; + deleting?: boolean; + onCancelDelete?: () => void; + onConfirmDelete?: () => void; + onDelete?: () => void; + onStartNew?: () => void; + session: SavedSession; + speedUnit: SpeedUnit; +}) { + const usesGear = session.controlMode === CONTROL_MODE.GEAR; + const controlMetric = usesGear + ? { + accent: 'mint', + average: formatAggregateAverage(session.aggregates.gear, 0), + icon: 'controls', + label: 'GEAR', + unit: '', + } + : { + accent: 'mint', + average: formatAggregateAverage(session.aggregates.resistance, 0), + icon: 'resistance', + label: 'RESISTANCE', + unit: '%', + }; + const standardMetrics = STANDARD_METRIC_KEYS.map((key) => { + const presentation = METRIC_PRESENTATION[key]; + return { + accent: presentation.accent, + average: formatAggregateAverage(session.aggregates[key], 0), + icon: presentation.icon, + label: presentation.label.toUpperCase(), + maximum: formatWholeNumber(session.maximums[key]), + unit: presentation.unit, + }; + }); + + return ( +
    +
    +
    +

    + {formatSessionDateRange(session)} +

    +

    {formatSessionTimeRange(session)}

    +
    +
    + + {onStartNew ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
    + {onCancelDelete && onConfirmDelete ? ( + + ) : null} +
    +
    + +
    +
    + {[...standardMetrics, controlMetric].map((metric) => ( + + ))} +
    +
    +
    +

    FELT

    +

    + {feelingLabel(session.feeling)} +

    +
    +
    +

    + COMMENTS +

    +

    + {session.comments || 'No comments'} +

    +
    +
    + +
    + ); +} diff --git a/src/components/session-history-list.tsx b/src/components/session-history-list.tsx new file mode 100644 index 0000000..8399cd5 --- /dev/null +++ b/src/components/session-history-list.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; +import { formatDuration } from '../lib/format'; +import { feelingLabel, formatSessionListTime, groupSessionsByDate } from '../lib/saved-sessions'; +import { formatDistance } from '../lib/units'; +import type { SavedSessionSummary, SpeedUnit } from '../types'; + +export function SessionHistoryList({ + error, + onLoadMore, + onSelect, + selectedId, + speedUnit, + summaries, + total, +}: { + error: string; + onLoadMore: () => void; + onSelect: (id: string) => void; + selectedId?: string; + speedUnit: SpeedUnit; + summaries: SavedSessionSummary[]; + total: number; +}) { + const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); + + return ( + + ); +} diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index 0ae8412..71bb48a 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,42 +1,21 @@ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { EMPTY_ROUTE } from '../constants'; -import { CONTROL_MODE } from '../lib/control-mode'; +import { useCallback, useEffect, useState } from 'react'; +import { useSessionHistory } from '../hooks/use-session-history'; import { eventTargetsEditableControl, eventTargetsInteractiveControl, keyboardEventHasModifiers, } from '../lib/dom'; -import { errorMessage } from '../lib/errors'; -import { formatAggregateAverage, formatDuration, formatWholeNumber } from '../lib/format'; import { type HistoryShortcut, historyKeyboardShortcuts, historyShortcutForKey, } from '../lib/keyboard'; -import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; -import { - adjacentSession, - countSavedSessions, - deleteSavedSession, - feelingLabel, - formatSessionDateRange, - formatSessionListTime, - formatSessionTimeRange, - getSavedSession, - groupSessionsByDate, - listSavedSessions, - sessionListAfterDelete, -} from '../lib/saved-sessions'; -import { downloadSessionTcx } from '../lib/tcx'; -import { formatDistance } from '../lib/units'; -import type { SavedSession, SavedSessionSummary, SpeedUnit } from '../types'; +import { adjacentSession } from '../lib/saved-sessions'; +import type { SavedSession, SpeedUnit } from '../types'; import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'; -import { SessionMetric } from './metrics'; -import { SessionChart } from './session-chart'; -import { SessionSummary } from './session-summary'; - -const PAGE_SIZE = 30; +import { SessionDetail } from './session-detail'; +import { SessionHistoryList } from './session-history-list'; function shouldIgnoreHistoryAction(event: KeyboardEvent) { return ( @@ -46,207 +25,6 @@ function shouldIgnoreHistoryAction(event: KeyboardEvent) { ); } -export function DeleteSessionDialog({ - deleting, - onCancel, - onConfirm, - open, -}: { - deleting: boolean; - onCancel: () => void; - onConfirm: () => void; - open: boolean; -}) { - const confirmButton = useRef(null); - - useEffect(() => { - if (open) { - confirmButton.current?.focus(); - } - }, [open]); - - if (!open) { - return null; - } - - return ( -
    -

    - Delete this session? -

    -

    - This cannot be undone. -

    -
    - - -
    -
    - ); -} - -export function SessionDetail({ - chartKeyboardEnabled = true, - deleteConfirmationOpen = false, - deleting = false, - onCancelDelete, - onConfirmDelete, - onDelete, - onStartNew, - session, - speedUnit, -}: { - chartKeyboardEnabled?: boolean; - deleteConfirmationOpen?: boolean; - deleting?: boolean; - onCancelDelete?: () => void; - onConfirmDelete?: () => void; - onDelete?: () => void; - onStartNew?: () => void; - session: SavedSession; - speedUnit: SpeedUnit; -}) { - const usesGear = session.controlMode === CONTROL_MODE.GEAR; - const controlMetric = usesGear - ? { - accent: 'mint', - average: formatAggregateAverage(session.aggregates.gear, 0), - icon: 'controls', - label: 'GEAR', - unit: '', - } - : { - accent: 'mint', - average: formatAggregateAverage(session.aggregates.resistance, 0), - icon: 'resistance', - label: 'RESISTANCE', - unit: '%', - }; - const standardMetrics = STANDARD_METRIC_KEYS.map((key) => { - const presentation = METRIC_PRESENTATION[key]; - return { - accent: presentation.accent, - average: formatAggregateAverage(session.aggregates[key], 0), - icon: presentation.icon, - label: presentation.label.toUpperCase(), - maximum: formatWholeNumber(session.maximums[key]), - unit: presentation.unit, - }; - }); - - return ( -
    -
    -
    -

    - {formatSessionDateRange(session)} -

    -

    {formatSessionTimeRange(session)}

    -
    -
    - - {onStartNew ? ( - - ) : null} - {onDelete ? ( - - ) : null} -
    - {onCancelDelete && onConfirmDelete ? ( - - ) : null} -
    -
    - -
    -
    - {[...standardMetrics, controlMetric].map((metric) => ( - - ))} -
    -
    -
    -

    FELT

    -

    - {feelingLabel(session.feeling)} -

    -
    -
    -

    - COMMENTS -

    -

    - {session.comments || 'No comments'} -

    -
    -
    - -
    - ); -} - export function SessionHistory({ onClose, onStartNew, @@ -258,19 +36,22 @@ export function SessionHistory({ open: boolean; speedUnit: SpeedUnit; }) { - const [summaries, setSummaries] = useState([]); - const [total, setTotal] = useState(0); - const [selected, setSelected] = useState(); - const [selectedId, setSelectedId] = useState(); - const [loading, setLoading] = useState(false); - const [deleting, setDeleting] = useState(false); + const { + deleteSelectedSession: deleteHistorySession, + deleting, + error, + loading, + loadMore, + selected, + selectedId, + selectSession: selectHistorySession, + summaries, + total, + } = useSessionHistory(open); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [historyHelpOpen, setHistoryHelpOpen] = useState(false); const [rendered, setRendered] = useState(open); const [trayVisible, setTrayVisible] = useState(open); - const [error, setError] = useState(''); - const deleteInProgress = useRef(false); - const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); useEffect(() => { let frame: number | undefined; @@ -294,82 +75,20 @@ export function SessionHistory({ }; }, [open]); - const selectSession = useCallback(async (id: string) => { - setDeleteConfirmationOpen(false); - setHistoryHelpOpen(false); - setSelectedId(id); - setLoading(true); - try { - setSelected(await getSavedSession(id)); - setError(''); - } catch (loadError) { - setError(errorMessage(loadError)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (!open) { - return; - } - let cancelled = false; - async function loadHistory() { + const selectSession = useCallback( + (id: string) => { setDeleteConfirmationOpen(false); setHistoryHelpOpen(false); - const [sessions, count] = await Promise.all([ - listSavedSessions(PAGE_SIZE), - countSavedSessions(), - ]); - if (cancelled) { - return; - } - setSummaries(sessions); - setTotal(count); - setError(''); - if (sessions[0]) { - await selectSession(sessions[0].id); - } else { - setSelected(undefined); - setSelectedId(undefined); - } - } - loadHistory().catch((loadError: unknown) => { - if (!cancelled) { - setError(errorMessage(loadError)); - } - }); - return () => { - cancelled = true; - }; - }, [open, selectSession]); + selectHistorySession(id); + }, + [selectHistorySession] + ); const deleteSelectedSession = useCallback(async () => { - if (!selected || deleteInProgress.current) { - return; - } - deleteInProgress.current = true; - setDeleting(true); - try { - await deleteSavedSession(selected.id); + if (await deleteHistorySession()) { setDeleteConfirmationOpen(false); - const updated = sessionListAfterDelete(summaries, selected.id); - setSummaries(updated.sessions); - setTotal((current) => Math.max(0, current - 1)); - setError(''); - if (updated.next) { - await selectSession(updated.next.id); - } else { - setSelected(undefined); - setSelectedId(undefined); - } - } catch (deleteError) { - setError(errorMessage(deleteError)); - } finally { - deleteInProgress.current = false; - setDeleting(false); } - }, [selected, selectSession, summaries]); + }, [deleteHistorySession]); useEffect(() => { if (!open) { @@ -445,15 +164,6 @@ export function SessionHistory({ return null; } - async function loadMore() { - const last = summaries.at(-1); - if (!last) { - return; - } - const more = await listSavedSessions(PAGE_SIZE, last.endedAt); - setSummaries((current) => [...current, ...more]); - } - let detail: ReactNode = null; if (loading) { detail = ( @@ -532,58 +242,15 @@ export function SessionHistory({
    - + {detail}
    diff --git a/src/hooks/use-session-history.ts b/src/hooks/use-session-history.ts new file mode 100644 index 0000000..7d9bf0d --- /dev/null +++ b/src/hooks/use-session-history.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { errorMessage } from '../lib/errors'; +import { + countSavedSessions, + deleteSavedSession, + getSavedSession, + listSavedSessions, + sessionListAfterDelete, +} from '../lib/saved-sessions'; +import type { SavedSession, SavedSessionSummary } from '../types'; + +const PAGE_SIZE = 30; + +export function useSessionHistory(open: boolean) { + const [summaries, setSummaries] = useState([]); + const [total, setTotal] = useState(0); + const [selected, setSelected] = useState(); + const [selectedId, setSelectedId] = useState(); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(''); + const deleteInProgress = useRef(false); + + const selectSession = useCallback(async (id: string) => { + setSelectedId(id); + setLoading(true); + try { + setSelected(await getSavedSession(id)); + setError(''); + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!open) { + return; + } + let cancelled = false; + async function loadHistory() { + const [sessions, count] = await Promise.all([ + listSavedSessions(PAGE_SIZE), + countSavedSessions(), + ]); + if (cancelled) { + return; + } + setSummaries(sessions); + setTotal(count); + setError(''); + if (sessions[0]) { + await selectSession(sessions[0].id); + } else { + setSelected(undefined); + setSelectedId(undefined); + } + } + loadHistory().catch((loadError: unknown) => { + if (!cancelled) { + setError(errorMessage(loadError)); + } + }); + return () => { + cancelled = true; + }; + }, [open, selectSession]); + + const deleteSelectedSession = useCallback(async () => { + if (!selected || deleteInProgress.current) { + return false; + } + deleteInProgress.current = true; + setDeleting(true); + try { + await deleteSavedSession(selected.id); + const updated = sessionListAfterDelete(summaries, selected.id); + setSummaries(updated.sessions); + setTotal((current) => Math.max(0, current - 1)); + setError(''); + if (updated.next) { + await selectSession(updated.next.id); + } else { + setSelected(undefined); + setSelectedId(undefined); + } + return true; + } catch (deleteError) { + setError(errorMessage(deleteError)); + return false; + } finally { + deleteInProgress.current = false; + setDeleting(false); + } + }, [selected, selectSession, summaries]); + + const loadMore = useCallback(async () => { + const last = summaries.at(-1); + if (!last) { + return; + } + try { + const more = await listSavedSessions(PAGE_SIZE, last.endedAt); + setSummaries((current) => [...current, ...more]); + setError(''); + } catch (loadError) { + setError(errorMessage(loadError)); + } + }, [summaries]); + + return { + deleteSelectedSession, + deleting, + error, + loading, + loadMore, + selected, + selectedId, + selectSession, + summaries, + total, + }; +} diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 6f54309..550723a 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -10,11 +10,8 @@ import { Metric, SessionMetric, SmallMetric } from '../src/components/metrics'; import { Notification } from '../src/components/notification'; import { ResistanceControl } from '../src/components/resistance-control'; import { SessionChart } from '../src/components/session-chart'; -import { - DeleteSessionDialog, - SessionDetail, - SessionHistory, -} from '../src/components/session-history'; +import { DeleteSessionDialog, SessionDetail } from '../src/components/session-detail'; +import { SessionHistory } from '../src/components/session-history'; import { SessionSaveDialog } from '../src/components/session-save-dialog'; import { TrainingControl } from '../src/components/training-control'; import { WelcomeDialog } from '../src/components/welcome-dialog'; From 2c46953ae9c3bdf2b5fc61a87bd8639166febd4c Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:38:12 -0700 Subject: [PATCH 17/26] refactor trainer connection lifecycle --- src/hooks/use-trainer-connection.ts | 360 +++++++++++++++++++++++++ src/hooks/use-trainer.ts | 393 ++-------------------------- 2 files changed, 386 insertions(+), 367 deletions(-) create mode 100644 src/hooks/use-trainer-connection.ts diff --git a/src/hooks/use-trainer-connection.ts b/src/hooks/use-trainer-connection.ts new file mode 100644 index 0000000..b19f483 --- /dev/null +++ b/src/hooks/use-trainer-connection.ts @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + CHROME_BLUETOOTH_PERMISSION_MESSAGE, + emptyMetrics, + optionalServices, + WEB_BLUETOOTH_UNAVAILABLE_MESSAGE, +} from '../constants'; +import { + findRememberedKickr, + isBluetoothChooserCancellation, + recordMetricActivity, + resistanceCommand, + TRAINER_DEVICE_STORAGE_KEY, +} from '../lib/bluetooth'; +import { errorMessage } from '../lib/errors'; +import { createReconnectController } from '../lib/reconnect-controller'; +import { storedResistance } from '../lib/session'; +import { connectTrainerDevice } from '../lib/trainer-device'; +import type { TrainerStore } from '../stores/trainer-store'; + +interface NumberRef { + current: number; +} + +function pairingWasCancelled(error: unknown, connectionCancelled: boolean) { + return connectionCancelled || isBluetoothChooserCancellation(error); +} + +export function useTrainerConnection( + store: TrainerStore, + appliedResistance: NumberRef, + resistanceTarget: NumberRef +) { + const { setConnectionPhase, setMetrics, setNotice, setResistance, setResistanceRamp } = + store.actions; + const device = useRef(undefined); + const pairedDevice = useRef(undefined); + const commandQueue = useRef(Promise.resolve()); + const connecting = useRef(false); + const connectionCancelled = useRef(false); + const disconnectRequested = useRef(false); + const autoReconnect = useRef(true); + const pendingDevice = useRef(undefined); + const connectionCleanup = useRef<() => void>(() => undefined); + const connectDeviceRef = useRef< + ((selected: BluetoothDevice, rediscover?: boolean) => Promise) | undefined + >(undefined); + const unloading = useRef(false); + const lastPedalingAt = useRef(0); + const trainerReportsDistance = useRef(false); + const controlPoint = useRef(undefined); + const resistanceRange = useRef(store.get().resistanceRange); + const reconnectController = useRef( + createReconnectController({ + attempt: (selected) => + connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), + canRetry: () => + autoReconnect.current && !unloading.current && !connectionCancelled.current, + delayForAttempt: (attempt) => Math.min(5000, 700 * attempt), + onWaiting: () => setConnectionPhase('reconnecting'), + }) + ); + + const writeControl = useCallback( + async (characteristic: BluetoothRemoteGATTCharacteristic | undefined, bytes: number[]) => { + if (!characteristic) { + setNotice('Connect the trainer before changing its settings.'); + return; + } + const action = async () => { + try { + await characteristic.writeValueWithResponse(new Uint8Array(bytes)); + } catch (error) { + setNotice(`Trainer command failed: ${errorMessage(error)}`); + } + }; + commandQueue.current = commandQueue.current.then(action, action); + await commandQueue.current; + }, + [setNotice] + ); + + function connectionStopped(rediscover: boolean) { + return connectionCancelled.current || (rediscover && !autoReconnect.current); + } + + function handleConnectionError(error: unknown, rediscover: boolean) { + if (rediscover && autoReconnect.current && !connectionCancelled.current) { + setConnectionPhase('reconnecting'); + } else if (connectionCancelled.current) { + setConnectionPhase('offline'); + } else { + setConnectionPhase('offline'); + setNotice(errorMessage(error)); + } + } + + function handleTrainerDisconnected(selected: BluetoothDevice) { + const shouldReconnect = + !(disconnectRequested.current || unloading.current) && autoReconnect.current; + disconnectRequested.current = false; + device.current = undefined; + store.actions.setDeviceName(undefined); + controlPoint.current = undefined; + setMetrics(emptyMetrics); + lastPedalingAt.current = 0; + trainerReportsDistance.current = false; + if (shouldReconnect) { + pendingDevice.current = selected; + setConnectionPhase('reconnecting'); + setNotice('Trainer disconnected. Reconnecting automatically…'); + reconnectController.current.start(selected.id, selected, 700); + } else if (connectionCancelled.current) { + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + setNotice('Connection attempt stopped.'); + } else { + setConnectionPhase('offline'); + setNotice('Trainer disconnected.'); + } + } + + async function connectDevice(selected: BluetoothDevice, rediscover = false): Promise { + if (connecting.current) { + return false; + } + connecting.current = true; + setConnectionPhase(rediscover ? 'reconnecting' : 'connecting'); + connectionCleanup.current(); + try { + pairedDevice.current = selected; + store.actions.setPairedDeviceName(selected.name); + const nextConnection = await connectTrainerDevice( + selected, + rediscover, + resistanceRange.current, + { + onControlRejected: () => setNotice('Trainer did not accept that command.'), + onDisconnect: () => { + connectionCleanup.current(); + handleTrainerDisconnected(selected); + }, + onMetrics: (nextMetrics, reportsDistance) => { + if (reportsDistance) { + trainerReportsDistance.current = true; + } + recordMetricActivity(lastPedalingAt, nextMetrics); + store.actions.mergeMetrics(nextMetrics); + }, + } + ); + if (connectionStopped(rediscover)) { + nextConnection.cleanup(); + selected.gatt?.disconnect(); + return false; + } + connectionCleanup.current = nextConnection.cleanup; + controlPoint.current = nextConnection.controlPoint; + resistanceRange.current = nextConnection.resistanceRange; + store.actions.setResistanceRange(nextConnection.resistanceRange); + const restored = storedResistance(); + setResistance(restored); + appliedResistance.current = restored; + resistanceTarget.current = restored; + setResistanceRamp({ + current: restored, + from: restored, + phase: 'holding', + progress: 0, + to: restored, + }); + await writeControl(controlPoint.current, [0]); + await new Promise((resolve) => window.setTimeout(resolve, 150)); + await writeControl( + controlPoint.current, + resistanceCommand(restored, resistanceRange.current) + ); + if (connectionStopped(rediscover)) { + selected.gatt?.disconnect(); + return false; + } + localStorage.setItem(TRAINER_DEVICE_STORAGE_KEY, selected.id); + device.current = selected; + store.actions.setDeviceName(selected.name); + setConnectionPhase('connected'); + reconnectController.current.reset(selected.id); + setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`); + return true; + } catch (error) { + if (selected.gatt?.connected) { + selected.gatt.disconnect(); + } + handleConnectionError(error, rediscover); + return false; + } finally { + connecting.current = false; + } + } + + useEffect(() => { + connectDeviceRef.current = connectDevice; + }); + + async function connect() { + if (!navigator.bluetooth) { + setNotice(WEB_BLUETOOTH_UNAVAILABLE_MESSAGE); + return; + } + connectionCancelled.current = false; + disconnectRequested.current = false; + setConnectionPhase('pairing'); + try { + const selected = await navigator.bluetooth.requestDevice({ + filters: [{ namePrefix: 'KICKR' }], + optionalServices, + }); + pendingDevice.current = selected; + pairedDevice.current = selected; + store.actions.setPairedDeviceName(selected.name); + autoReconnect.current = true; + if (!(await connectDevice(selected))) { + reconnectController.current.start(selected.id, selected); + } + } catch (error) { + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + if (!pairingWasCancelled(error, connectionCancelled.current)) { + setNotice(errorMessage(error)); + } + } finally { + pendingDevice.current = undefined; + } + } + + const cancelConnection = useCallback(() => { + connectionCancelled.current = true; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + pendingDevice.current?.gatt?.disconnect(); + pendingDevice.current = undefined; + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + setNotice('Connection attempt stopped.'); + }, [setConnectionPhase, setNotice]); + + const disconnect = useCallback(() => { + connectionCancelled.current = false; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + device.current = undefined; + store.actions.setDeviceName(undefined); + controlPoint.current = undefined; + setMetrics(emptyMetrics); + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + }, [setConnectionPhase, setMetrics, store]); + + async function reconnect() { + if (!pairedDevice.current) { + return; + } + const selected = pairedDevice.current; + connectionCancelled.current = false; + disconnectRequested.current = false; + autoReconnect.current = true; + reconnectController.current.reset(selected.id); + if (!(await connectDevice(selected, true))) { + reconnectController.current.start(selected.id, selected); + } + } + + const forget = useCallback(async () => { + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + try { + await pairedDevice.current?.forget(); + } finally { + localStorage.removeItem(TRAINER_DEVICE_STORAGE_KEY); + device.current = undefined; + pairedDevice.current = undefined; + store.actions.setDeviceName(undefined); + store.actions.setPairedDeviceName(undefined); + controlPoint.current = undefined; + setMetrics(emptyMetrics); + setConnectionPhase('unpaired'); + setNotice('Trainer removed from paired devices.'); + } + }, [setConnectionPhase, setMetrics, setNotice, store]); + + const sendResistance = useCallback( + async (percent: number) => { + await writeControl( + controlPoint.current, + resistanceCommand(percent, resistanceRange.current) + ); + }, + [writeControl] + ); + + useEffect(() => { + const handlePageHide = () => { + unloading.current = true; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + }; + window.addEventListener('pagehide', handlePageHide); + return () => window.removeEventListener('pagehide', handlePageHide); + }, []); + + useEffect(() => { + let cancelled = false; + async function restore() { + autoReconnect.current = true; + connectionCancelled.current = false; + disconnectRequested.current = false; + if (!navigator.bluetooth?.getDevices) { + setConnectionPhase('unpaired'); + setNotice(CHROME_BLUETOOTH_PERMISSION_MESSAGE); + return; + } + const remembered = await findRememberedKickr(); + if (cancelled) { + return; + } + if (!remembered) { + setConnectionPhase('unpaired'); + return; + } + pairedDevice.current = remembered; + store.actions.setPairedDeviceName(remembered.name); + setConnectionPhase('reconnecting'); + reconnectController.current.start(remembered.id, remembered, 1); + } + restore().catch((error: unknown) => setNotice(errorMessage(error))); + return () => { + cancelled = true; + autoReconnect.current = false; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + }; + }, [setConnectionPhase, setNotice, store.actions.setPairedDeviceName]); + + return { + cancelConnection, + connect, + disconnect, + forget, + lastPedalingAt, + reconnect, + sendResistance, + trainerReportsDistance, + }; +} diff --git a/src/hooks/use-trainer.ts b/src/hooks/use-trainer.ts index 68ebd7f..6faade7 100644 --- a/src/hooks/use-trainer.ts +++ b/src/hooks/use-trainer.ts @@ -1,86 +1,34 @@ import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { - CHROME_BLUETOOTH_PERMISSION_MESSAGE, - CONTROL_FLASH_MS, - emptyMetrics, - optionalServices, - WEB_BLUETOOTH_UNAVAILABLE_MESSAGE, -} from '../constants'; -import { - findRememberedKickr, - isBluetoothChooserCancellation, - recordMetricActivity, - resistanceCommand, - TRAINER_DEVICE_STORAGE_KEY, -} from '../lib/bluetooth'; +import { CONTROL_FLASH_MS } from '../constants'; import { deviceConnectionView } from '../lib/device-connection'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { errorMessage } from '../lib/errors'; import { scheduleNoticeDismissal } from '../lib/notification'; import { clamp } from '../lib/numbers'; -import { createReconnectController } from '../lib/reconnect-controller'; import { clampResistance, resistanceDirectionForKey, resistanceRampDuration, smoothedResistance, } from '../lib/resistance'; -import { RESISTANCE_STORAGE_KEY, storedResistance } from '../lib/session'; -import { connectTrainerDevice } from '../lib/trainer-device'; +import { RESISTANCE_STORAGE_KEY } from '../lib/session'; import { createTrainerStore } from '../stores/trainer-store'; - -function pairingWasCancelled(error: unknown, connectionCancelled: boolean) { - return connectionCancelled || isBluetoothChooserCancellation(error); -} +import { useTrainerConnection } from './use-trainer-connection'; export function useTrainer() { const store = useMemo(() => createTrainerStore(), []); const state = useSelector(store); - const { - setConnectionPhase, - setMetrics, - setNotice, - setResistance, - setResistanceKeyFlash, - setResistanceRamp, - setResistanceRange, - } = store.actions; - const device = useRef(undefined); - const pairedDevice = useRef(undefined); - const commandQueue = useRef(Promise.resolve()); + const { setNotice, setResistance, setResistanceKeyFlash, setResistanceRamp } = store.actions; const resistanceTimer = useRef(undefined); const resistanceRampTimer = useRef(undefined); const resistanceKeyFlashTimer = useRef(undefined); - const appliedResistance = useRef(storedResistance()); - const resistanceTarget = useRef(storedResistance()); - const connecting = useRef(false); - const connectionCancelled = useRef(false); - const disconnectRequested = useRef(false); - const autoReconnect = useRef(true); - const pendingDevice = useRef(undefined); - const connectionCleanup = useRef<() => void>(() => undefined); - const connectDeviceRef = useRef< - ((selected: BluetoothDevice, rediscover?: boolean) => Promise) | undefined - >(undefined); + const appliedResistance = useRef(store.get().resistance); + const resistanceTarget = useRef(store.get().resistance); const keyboardControlsEnabled = useRef(true); const gearControlsEnabled = useRef(false); - const unloading = useRef(false); - const lastPedalingAt = useRef(0); - const trainerReportsDistance = useRef(false); - const controlPointRef = useRef(undefined); - const rangeRef = useRef(state.resistanceRange); + const trainerConnection = useTrainerConnection(store, appliedResistance, resistanceTarget); const connection = deviceConnectionView(state.connectionPhase); - const reconnectController = useRef( - createReconnectController({ - attempt: (selected) => - connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), - canRetry: () => - autoReconnect.current && !unloading.current && !connectionCancelled.current, - delayForAttempt: (attempt) => Math.min(5000, 700 * attempt), - onWaiting: () => setConnectionPhase('reconnecting'), - }) - ); useEffect( () => scheduleNoticeDismissal(state.notice, () => setNotice('')), @@ -96,245 +44,6 @@ export function useTrainer() { [] ); - const writeControl = useCallback( - async (characteristic: BluetoothRemoteGATTCharacteristic | undefined, bytes: number[]) => { - if (!characteristic) { - setNotice('Connect the trainer before changing its settings.'); - return; - } - const action = async () => { - try { - await characteristic.writeValueWithResponse(new Uint8Array(bytes)); - } catch (error) { - setNotice(`Trainer command failed: ${errorMessage(error)}`); - } - }; - commandQueue.current = commandQueue.current.then(action, action); - await commandQueue.current; - }, - [setNotice] - ); - - function connectionStopped(rediscover: boolean) { - return connectionCancelled.current || (rediscover && !autoReconnect.current); - } - - function handleConnectionError(error: unknown, rediscover: boolean) { - if (rediscover && autoReconnect.current && !connectionCancelled.current) { - setConnectionPhase('reconnecting'); - } else if (connectionCancelled.current) { - setConnectionPhase('offline'); - } else { - setConnectionPhase('offline'); - setNotice(errorMessage(error)); - } - } - - function handleTrainerDisconnected(selected: BluetoothDevice) { - const shouldReconnect = - !(disconnectRequested.current || unloading.current) && autoReconnect.current; - disconnectRequested.current = false; - device.current = undefined; - store.actions.setDeviceName(undefined); - controlPointRef.current = undefined; - setMetrics(emptyMetrics); - lastPedalingAt.current = 0; - trainerReportsDistance.current = false; - if (shouldReconnect) { - pendingDevice.current = selected; - setConnectionPhase('reconnecting'); - setNotice('Trainer disconnected. Reconnecting automatically…'); - reconnectController.current.start(selected.id, selected, 700); - } else if (connectionCancelled.current) { - setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); - setNotice('Connection attempt stopped.'); - } else { - setConnectionPhase('offline'); - setNotice('Trainer disconnected.'); - } - } - - async function connectDevice(selected: BluetoothDevice, rediscover = false): Promise { - if (connecting.current) { - return false; - } - connecting.current = true; - setConnectionPhase(rediscover ? 'reconnecting' : 'connecting'); - connectionCleanup.current(); - try { - pairedDevice.current = selected; - store.actions.setPairedDeviceName(selected.name); - const nextConnection = await connectTrainerDevice( - selected, - rediscover, - rangeRef.current, - { - onControlRejected: () => setNotice('Trainer did not accept that command.'), - onDisconnect: () => { - connectionCleanup.current(); - handleTrainerDisconnected(selected); - }, - onMetrics: (nextMetrics, reportsDistance) => { - if (reportsDistance) { - trainerReportsDistance.current = true; - } - recordMetricActivity(lastPedalingAt, nextMetrics); - store.actions.mergeMetrics(nextMetrics); - }, - } - ); - if (connectionStopped(rediscover)) { - nextConnection.cleanup(); - selected.gatt?.disconnect(); - return false; - } - connectionCleanup.current = nextConnection.cleanup; - const point = nextConnection.controlPoint; - controlPointRef.current = point; - const activeRange = nextConnection.resistanceRange; - rangeRef.current = activeRange; - setResistanceRange(activeRange); - const restored = storedResistance(); - setResistance(restored); - appliedResistance.current = restored; - resistanceTarget.current = restored; - setResistanceRamp({ - current: restored, - from: restored, - phase: 'holding', - progress: 0, - to: restored, - }); - await writeControl(point, [0]); - await new Promise((resolve) => window.setTimeout(resolve, 150)); - await writeControl(point, resistanceCommand(restored, activeRange)); - if (connectionStopped(rediscover)) { - selected.gatt?.disconnect(); - return false; - } - localStorage.setItem(TRAINER_DEVICE_STORAGE_KEY, selected.id); - device.current = selected; - store.actions.setDeviceName(selected.name); - setConnectionPhase('connected'); - reconnectController.current.reset(selected.id); - setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`); - return true; - } catch (error) { - if (selected.gatt?.connected) { - selected.gatt.disconnect(); - } - handleConnectionError(error, rediscover); - return false; - } finally { - connecting.current = false; - } - } - - useEffect(() => { - connectDeviceRef.current = connectDevice; - }); - - async function connect() { - if (!navigator.bluetooth) { - setNotice(WEB_BLUETOOTH_UNAVAILABLE_MESSAGE); - return; - } - connectionCancelled.current = false; - disconnectRequested.current = false; - setConnectionPhase('pairing'); - try { - const selected = await navigator.bluetooth.requestDevice({ - filters: [{ namePrefix: 'KICKR' }], - optionalServices, - }); - pendingDevice.current = selected; - pairedDevice.current = selected; - store.actions.setPairedDeviceName(selected.name); - autoReconnect.current = true; - if (!(await connectDevice(selected))) { - reconnectController.current.start(selected.id, selected); - } - } catch (error) { - setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); - if (!pairingWasCancelled(error, connectionCancelled.current)) { - setNotice(errorMessage(error)); - } - } finally { - pendingDevice.current = undefined; - } - } - - const cancelConnection = useCallback(() => { - connectionCancelled.current = true; - autoReconnect.current = false; - disconnectRequested.current = true; - reconnectController.current.cancelAll(); - connectionCleanup.current(); - pendingDevice.current?.gatt?.disconnect(); - pendingDevice.current = undefined; - setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); - setNotice('Connection attempt stopped.'); - }, [setConnectionPhase, setNotice]); - - const disconnect = useCallback(() => { - connectionCancelled.current = false; - autoReconnect.current = false; - disconnectRequested.current = true; - reconnectController.current.cancelAll(); - connectionCleanup.current(); - device.current?.gatt?.disconnect(); - device.current = undefined; - store.actions.setDeviceName(undefined); - controlPointRef.current = undefined; - setMetrics(emptyMetrics); - setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); - }, [setConnectionPhase, setMetrics, store]); - - async function reconnect() { - if (!pairedDevice.current) { - return; - } - const selected = pairedDevice.current; - connectionCancelled.current = false; - disconnectRequested.current = false; - autoReconnect.current = true; - reconnectController.current.reset(selected.id); - if (!(await connectDevice(selected, true))) { - reconnectController.current.start(selected.id, selected); - } - } - - const forget = useCallback(async () => { - autoReconnect.current = false; - disconnectRequested.current = true; - reconnectController.current.cancelAll(); - connectionCleanup.current(); - device.current?.gatt?.disconnect(); - try { - await pairedDevice.current?.forget(); - } finally { - localStorage.removeItem(TRAINER_DEVICE_STORAGE_KEY); - device.current = undefined; - pairedDevice.current = undefined; - store.actions.setDeviceName(undefined); - store.actions.setPairedDeviceName(undefined); - controlPointRef.current = undefined; - setMetrics(emptyMetrics); - setConnectionPhase('unpaired'); - setNotice('Trainer removed from paired devices.'); - } - }, [setConnectionPhase, setMetrics, setNotice, store]); - - const sendResistance = useCallback( - async (percent: number) => { - await writeControl( - controlPointRef.current, - resistanceCommand(percent, rangeRef.current) - ); - }, - [writeControl] - ); - const rampResistance = useCallback( (target: number) => { window.clearTimeout(resistanceRampTimer.current); @@ -369,14 +78,16 @@ export function useTrainer() { progress, to: target, }); - sendResistance(current).catch((error: unknown) => setNotice(errorMessage(error))); + trainerConnection + .sendResistance(current) + .catch((error: unknown) => setNotice(errorMessage(error))); if (progress < 1) { resistanceRampTimer.current = window.setTimeout(advance, 200); } }; advance(); }, - [sendResistance, setNotice, setResistanceRamp] + [setNotice, setResistanceRamp, trainerConnection.sendResistance] ); const updateResistance = useCallback( @@ -418,57 +129,13 @@ export function useTrainer() { to: next, }); localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); - sendResistance(next).catch((error: unknown) => setNotice(errorMessage(error))); + trainerConnection + .sendResistance(next) + .catch((error: unknown) => setNotice(errorMessage(error))); }, - [sendResistance, setNotice, setResistance, setResistanceRamp] + [setNotice, setResistance, setResistanceRamp, trainerConnection.sendResistance] ); - useEffect(() => { - const handlePageHide = () => { - unloading.current = true; - autoReconnect.current = false; - disconnectRequested.current = true; - reconnectController.current.cancelAll(); - connectionCleanup.current(); - device.current?.gatt?.disconnect(); - }; - window.addEventListener('pagehide', handlePageHide); - return () => window.removeEventListener('pagehide', handlePageHide); - }, []); - - useEffect(() => { - let cancelled = false; - async function restore() { - autoReconnect.current = true; - connectionCancelled.current = false; - disconnectRequested.current = false; - if (!navigator.bluetooth?.getDevices) { - setConnectionPhase('unpaired'); - setNotice(CHROME_BLUETOOTH_PERMISSION_MESSAGE); - return; - } - const remembered = await findRememberedKickr(); - if (cancelled) { - return; - } - if (!remembered) { - setConnectionPhase('unpaired'); - return; - } - pairedDevice.current = remembered; - store.actions.setPairedDeviceName(remembered.name); - setConnectionPhase('reconnecting'); - reconnectController.current.start(remembered.id, remembered, 1); - } - restore().catch((error: unknown) => setNotice(errorMessage(error))); - return () => { - cancelled = true; - autoReconnect.current = false; - reconnectController.current.cancelAll(); - connectionCleanup.current(); - }; - }, [setConnectionPhase, setNotice, store.actions.setPairedDeviceName]); - useEffect(() => { const handleKeys = (event: KeyboardEvent) => { const isResistanceControl = @@ -477,16 +144,12 @@ export function useTrainer() { if ( event.defaultPrevented || keyboardEventHasModifiers(event) || - (!isResistanceControl && eventTargetsEditableControl(event)) + (!isResistanceControl && eventTargetsEditableControl(event)) || + !keyboardControlsEnabled.current || + gearControlsEnabled.current ) { return; } - if (!keyboardControlsEnabled.current) { - return; - } - if (gearControlsEnabled.current) { - return; - } const direction = resistanceDirectionForKey(event.key); if (!direction) { return; @@ -494,11 +157,7 @@ export function useTrainer() { event.preventDefault(); setResistanceKeyFlash(direction); window.clearTimeout(resistanceKeyFlashTimer.current); - if (direction === 'increase') { - updateResistance(resistanceTarget.current + 1); - } else { - updateResistance(resistanceTarget.current - 1); - } + updateResistance(resistanceTarget.current + (direction === 'increase' ? 1 : -1)); }; const handleKeyUp = (event: KeyboardEvent) => { if (!resistanceDirectionForKey(event.key)) { @@ -534,17 +193,17 @@ export function useTrainer() { return { ...connection, - cancelConnection, - connect, + cancelConnection: trainerConnection.cancelConnection, + connect: trainerConnection.connect, connectionBusy: connection.busy, deviceName: state.deviceName, - disconnect, - forget, - lastPedalingAt, + disconnect: trainerConnection.disconnect, + forget: trainerConnection.forget, + lastPedalingAt: trainerConnection.lastPedalingAt, metrics: state.metrics, notice: state.notice, pairedDeviceName: state.pairedDeviceName, - reconnect, + reconnect: trainerConnection.reconnect, resistance: state.resistance, resistanceKeyFlash: state.resistanceKeyFlash, resistanceRamp: state.resistanceRamp, @@ -552,7 +211,7 @@ export function useTrainer() { setKeyboardControlsEnabled, setNotice, shiftResistanceBy, - trainerReportsDistance, + trainerReportsDistance: trainerConnection.trainerReportsDistance, updateResistance, }; } From 9b3be5d0c43b10647e1d4781bcda2773a69c956b Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:41:45 -0700 Subject: [PATCH 18/26] refactor click input orchestration --- src/hooks/use-zwift-click-input.ts | 233 ++++++++++++++++++++++++++ src/hooks/use-zwift-click.ts | 253 +++++------------------------ 2 files changed, 272 insertions(+), 214 deletions(-) create mode 100644 src/hooks/use-zwift-click-input.ts diff --git a/src/hooks/use-zwift-click-input.ts b/src/hooks/use-zwift-click-input.ts new file mode 100644 index 0000000..82bb6be --- /dev/null +++ b/src/hooks/use-zwift-click-input.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + CLICK_CONTROLLER_ROLES_STORAGE_KEY, + type ClickControllerRoles, + type ClickShift, + filterAcceptedClickShifts, + filterClickShiftsForController, + parseClickV2Shift, + registerClickControllerRole, +} from '../lib/zwift-click'; +import type { ZwiftClickStore } from '../stores/zwift-click-store'; + +interface ClickRepeatTimer { + delay: number; + interval?: number; +} + +const CLICK_HOLD_DELAY_MS = 600; +const CLICK_HOLD_REPEAT_MS = 220; +const CLICK_CONTROLLER_FLASH_MS = 350; +const CLICK_SHIFTS: ClickShift[] = ['down', 'up']; + +function saveControllerRoles(roles: ClickControllerRoles) { + localStorage.setItem(CLICK_CONTROLLER_ROLES_STORAGE_KEY, JSON.stringify(roles)); +} + +export function useZwiftClickInput({ + identifyControllers, + onOperational, + onShift, + store, +}: { + identifyControllers: boolean; + onOperational: (deviceId: string) => void; + onShift: (change: number) => void; + store: ZwiftClickStore; +}) { + const controllerFlashTimers = useRef(new Map()); + const controllerRoles = useRef({}); + const heldShiftsByDevice = useRef(new Map()); + const identifyControllersRef = useRef(identifyControllers); + const previousButtonMaps = useRef(new Map()); + const lastShiftTimes = useRef(new Map()); + const repeatTimers = useRef(new Map()); + const onShiftRef = useRef(onShift); + + useEffect(() => { + onShiftRef.current = onShift; + }, [onShift]); + + useEffect(() => { + identifyControllersRef.current = identifyControllers; + if (!identifyControllers) { + for (const timer of controllerFlashTimers.current.values()) { + window.clearTimeout(timer); + } + controllerFlashTimers.current.clear(); + store.actions.clearActiveControllers(); + } + }, [identifyControllers, store]); + + const registerControllerRole = useCallback( + (deviceId: string, shifts: ClickShift[]) => { + const next = registerClickControllerRole(controllerRoles.current, deviceId, shifts); + if (next === controllerRoles.current) { + return; + } + controllerRoles.current = next; + store.actions.setControllerRoles(next); + saveControllerRoles(next); + }, + [store] + ); + + const forgetControllerRole = useCallback( + (deviceId: string) => { + if (!controllerRoles.current[deviceId]) { + return; + } + const next = { ...controllerRoles.current }; + delete next[deviceId]; + controllerRoles.current = next; + store.actions.setControllerRoles(next); + saveControllerRoles(next); + }, + [store] + ); + + const stopRepeat = useCallback((shift: ClickShift) => { + const timer = repeatTimers.current.get(shift); + if (!timer) { + return; + } + window.clearTimeout(timer.delay); + window.clearInterval(timer.interval); + repeatTimers.current.delete(shift); + }, []); + + const syncRepeats = useCallback(() => { + const heldShifts = new Set(); + for (const deviceShifts of heldShiftsByDevice.current.values()) { + for (const shift of deviceShifts) { + heldShifts.add(shift); + } + } + for (const shift of CLICK_SHIFTS) { + if (!heldShifts.has(shift)) { + stopRepeat(shift); + continue; + } + if (repeatTimers.current.has(shift)) { + continue; + } + const change = shift === 'down' ? -1 : 1; + const timer: ClickRepeatTimer = { delay: 0 }; + timer.delay = window.setTimeout(() => { + onShiftRef.current(change); + timer.interval = window.setInterval( + () => onShiftRef.current(change), + CLICK_HOLD_REPEAT_MS + ); + }, CLICK_HOLD_DELAY_MS); + repeatTimers.current.set(shift, timer); + } + }, [stopRepeat]); + + const setDeviceHeldShifts = useCallback( + (deviceId: string, heldShifts: ClickShift[]) => { + if (heldShifts.length) { + heldShiftsByDevice.current.set(deviceId, heldShifts); + } else { + heldShiftsByDevice.current.delete(deviceId); + } + syncRepeats(); + }, + [syncRepeats] + ); + + const clearDeviceHeldShifts = useCallback( + (deviceId: string) => { + heldShiftsByDevice.current.delete(deviceId); + syncRepeats(); + }, + [syncRepeats] + ); + + const flashController = useCallback( + (deviceId: string) => { + if (!identifyControllersRef.current) { + return; + } + window.clearTimeout(controllerFlashTimers.current.get(deviceId)); + store.actions.activateController(deviceId); + const timer = window.setTimeout(() => { + controllerFlashTimers.current.delete(deviceId); + store.actions.deactivateController(deviceId); + }, CLICK_CONTROLLER_FLASH_MS); + controllerFlashTimers.current.set(deviceId, timer); + }, + [store] + ); + + const handleControllerMessage = useCallback( + (deviceId: string, event: Event) => { + const { value } = event.target as BluetoothRemoteGATTCharacteristic; + if (!value) { + return; + } + const parsed = parseClickV2Shift(value, previousButtonMaps.current.get(deviceId)); + if (!parsed) { + return; + } + onOperational(deviceId); + previousButtonMaps.current.set(deviceId, parsed.buttonMap); + const controllerRole = controllerRoles.current[deviceId]; + const heldShifts = filterClickShiftsForController(parsed.heldShifts, controllerRole); + const controllerShifts = filterClickShiftsForController(parsed.shifts, controllerRole); + setDeviceHeldShifts(deviceId, heldShifts); + const acceptedShifts = filterAcceptedClickShifts( + controllerShifts, + performance.now(), + lastShiftTimes.current + ); + if (acceptedShifts.length) { + flashController(deviceId); + } + if (identifyControllersRef.current) { + registerControllerRole(deviceId, acceptedShifts); + } + for (const shift of acceptedShifts) { + onShiftRef.current(shift === 'down' ? -1 : 1); + } + }, + [flashController, onOperational, registerControllerRole, setDeviceHeldShifts] + ); + + const resetControllerInput = useCallback( + (deviceId: string) => { + previousButtonMaps.current.delete(deviceId); + clearDeviceHeldShifts(deviceId); + }, + [clearDeviceHeldShifts] + ); + + const restoreControllerRoles = useCallback( + (roles: ClickControllerRoles) => { + controllerRoles.current = roles; + store.actions.setControllerRoles(roles); + }, + [store] + ); + + useEffect( + () => () => { + for (const timer of controllerFlashTimers.current.values()) { + window.clearTimeout(timer); + } + for (const shift of repeatTimers.current.keys()) { + stopRepeat(shift); + } + }, + [stopRepeat] + ); + + return { + clearDeviceHeldShifts, + forgetControllerRole, + handleControllerMessage, + registerControllerRole, + resetControllerInput, + restoreControllerRoles, + }; +} diff --git a/src/hooks/use-zwift-click.ts b/src/hooks/use-zwift-click.ts index 79e18a5..491c3af 100644 --- a/src/hooks/use-zwift-click.ts +++ b/src/hooks/use-zwift-click.ts @@ -6,15 +6,10 @@ import { aggregateConnectionPhase, deviceConnectionView } from '../lib/device-co import { errorMessage } from '../lib/errors'; import { createReconnectController } from '../lib/reconnect-controller'; import { - CLICK_CONTROLLER_ROLES_STORAGE_KEY, CLICK_DEVICE_IDS_STORAGE_KEY, type ClickControllerRoles, type ClickShift, - filterAcceptedClickShifts, - filterClickShiftsForController, MAX_CLICK_CONTROLLERS, - parseClickV2Shift, - registerClickControllerRole, storedClickControllerRoles, storedClickDeviceIds, ZWIFT_CLICK_NAME, @@ -24,11 +19,7 @@ import { } from '../lib/zwift-click'; import { connectClickDevice, SupersededClickConnectionError } from '../lib/zwift-click-device'; import { createZwiftClickStore } from '../stores/zwift-click-store'; - -interface ClickRepeatTimer { - delay: number; - interval?: number; -} +import { useZwiftClickInput } from './use-zwift-click-input'; interface ClickConnectionOptions { force?: boolean; @@ -36,11 +27,6 @@ interface ClickConnectionOptions { scheduleRetry?: boolean; } -const CLICK_HOLD_DELAY_MS = 600; -const CLICK_HOLD_REPEAT_MS = 220; -const CLICK_CONTROLLER_FLASH_MS = 350; -const CLICK_SHIFTS: ClickShift[] = ['down', 'up']; - type ClickConnectionCleanup = () => void; function saveDeviceIds(devices: BluetoothDevice[]) { @@ -50,10 +36,6 @@ function saveDeviceIds(devices: BluetoothDevice[]) { ); } -function saveControllerRoles(roles: ClickControllerRoles) { - localStorage.setItem(CLICK_CONTROLLER_ROLES_STORAGE_KEY, JSON.stringify(roles)); -} - function controllerLabel(role: ClickShift | undefined) { if (role === 'up') { return '+ Controller'; @@ -79,26 +61,18 @@ export function useZwiftClick( ) { const store = useMemo(() => createZwiftClickStore(), []); const state = useSelector(store); - const { clearActiveControllers, setControllerPhase } = store.actions; + const { setControllerPhase } = store.actions; const autoReconnect = useRef(true); const connectingIds = useRef(new Set()); const connectionAttempts = useRef(new Map()); const connectionCleanups = useRef(new Map()); - const controllerFlashTimers = useRef(new Map()); - const controllerRolesRef = useRef({}); const devicesRef = useRef([]); const forgottenIds = useRef(new Set()); - const heldShiftsByDevice = useRef(new Map()); - const identifyControllersRef = useRef(identifyControllers); - const previousButtonMaps = useRef(new Map()); const reportedConnectionFailures = useRef(new Set()); - const lastShiftTimes = useRef(new Map()); - const repeatTimers = useRef(new Map()); const connectDeviceRef = useRef< | ((selected: BluetoothDevice, options?: ClickConnectionOptions) => Promise) | undefined >(undefined); - const onShiftRef = useRef(onShift); const operationalIds = useRef(new Set()); const reconnectController = useRef( createReconnectController({ @@ -113,188 +87,32 @@ export function useZwiftClick( }) ); - useEffect(() => { - onShiftRef.current = onShift; - }, [onShift]); - - useEffect(() => { - identifyControllersRef.current = identifyControllers; - if (!identifyControllers) { - for (const timer of controllerFlashTimers.current.values()) { - window.clearTimeout(timer); - } - controllerFlashTimers.current.clear(); - clearActiveControllers(); - } - }, [clearActiveControllers, identifyControllers]); - - useEffect( - () => () => { - for (const timer of controllerFlashTimers.current.values()) { - window.clearTimeout(timer); - } - }, - [] - ); - - const registerControllerRole = useCallback( - (deviceId: string, shifts: ClickShift[]) => { - const next = registerClickControllerRole(controllerRolesRef.current, deviceId, shifts); - if (next === controllerRolesRef.current) { - return; - } - controllerRolesRef.current = next; - store.actions.setControllerRoles(next); - saveControllerRoles(next); - }, - [store] - ); - - const forgetControllerRole = useCallback( + const markControllerOperational = useCallback( (deviceId: string) => { - if (!controllerRolesRef.current[deviceId]) { - return; - } - const next = { ...controllerRolesRef.current }; - delete next[deviceId]; - controllerRolesRef.current = next; - store.actions.setControllerRoles(next); - saveControllerRoles(next); - }, - [store] - ); - - const stopRepeat = useCallback((shift: ClickShift) => { - const timer = repeatTimers.current.get(shift); - if (!timer) { - return; - } - window.clearTimeout(timer.delay); - window.clearInterval(timer.interval); - repeatTimers.current.delete(shift); - }, []); - - const syncRepeats = useCallback(() => { - const heldShifts = new Set(); - for (const deviceShifts of heldShiftsByDevice.current.values()) { - for (const shift of deviceShifts) { - heldShifts.add(shift); - } - } - for (const shift of CLICK_SHIFTS) { - if (!heldShifts.has(shift)) { - stopRepeat(shift); - continue; - } - if (repeatTimers.current.has(shift)) { - continue; - } - const change = shift === 'down' ? -1 : 1; - const timer: ClickRepeatTimer = { delay: 0 }; - timer.delay = window.setTimeout(() => { - onShiftRef.current(change); - timer.interval = window.setInterval( - () => onShiftRef.current(change), - CLICK_HOLD_REPEAT_MS - ); - }, CLICK_HOLD_DELAY_MS); - repeatTimers.current.set(shift, timer); - } - }, [stopRepeat]); - - const setDeviceHeldShifts = useCallback( - (deviceId: string, heldShifts: ClickShift[]) => { - if (heldShifts.length) { - heldShiftsByDevice.current.set(deviceId, heldShifts); - } else { - heldShiftsByDevice.current.delete(deviceId); - } - syncRepeats(); - }, - [syncRepeats] - ); - - const clearDeviceHeldShifts = useCallback( - (deviceId: string) => { - heldShiftsByDevice.current.delete(deviceId); - syncRepeats(); - }, - [syncRepeats] - ); - - useEffect( - () => () => { - for (const shift of repeatTimers.current.keys()) { - stopRepeat(shift); - } + operationalIds.current.add(deviceId); + reconnectController.current.reset(deviceId); + reportedConnectionFailures.current.delete(deviceId); + setControllerPhase(deviceId, 'connected'); }, - [stopRepeat] + [setControllerPhase] ); + const clickInput = useZwiftClickInput({ + identifyControllers, + onOperational: markControllerOperational, + onShift, + store, + }); const cleanupConnection = useCallback((deviceId: string) => { connectionCleanups.current.get(deviceId)?.(); connectionCleanups.current.delete(deviceId); }, []); - const flashController = useCallback( - (deviceId: string) => { - if (!identifyControllersRef.current) { - return; - } - window.clearTimeout(controllerFlashTimers.current.get(deviceId)); - store.actions.activateController(deviceId); - const timer = window.setTimeout(() => { - controllerFlashTimers.current.delete(deviceId); - store.actions.deactivateController(deviceId); - }, CLICK_CONTROLLER_FLASH_MS); - controllerFlashTimers.current.set(deviceId, timer); - }, - [store] - ); - - const handleControllerMessage = useCallback( - (deviceId: string, event: Event) => { - const { value } = event.target as BluetoothRemoteGATTCharacteristic; - if (!value) { - return; - } - const parsed = parseClickV2Shift(value, previousButtonMaps.current.get(deviceId)); - if (!parsed) { - return; - } - operationalIds.current.add(deviceId); - reconnectController.current.reset(deviceId); - reportedConnectionFailures.current.delete(deviceId); - setControllerPhase(deviceId, 'connected'); - previousButtonMaps.current.set(deviceId, parsed.buttonMap); - const controllerRole = controllerRolesRef.current[deviceId]; - const heldShifts = filterClickShiftsForController(parsed.heldShifts, controllerRole); - const controllerShifts = filterClickShiftsForController(parsed.shifts, controllerRole); - setDeviceHeldShifts(deviceId, heldShifts); - const acceptedShifts = filterAcceptedClickShifts( - controllerShifts, - performance.now(), - lastShiftTimes.current - ); - if (acceptedShifts.length) { - flashController(deviceId); - } - if (identifyControllersRef.current) { - registerControllerRole(deviceId, acceptedShifts); - } - for (const shift of acceptedShifts) { - onShiftRef.current(shift === 'down' ? -1 : 1); - } - }, - [flashController, registerControllerRole, setControllerPhase, setDeviceHeldShifts] - ); - const handleControllerDisconnect = useCallback( (selected: BluetoothDevice) => { cleanupConnection(selected.id); operationalIds.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); - previousButtonMaps.current.delete(selected.id); + clickInput.resetControllerInput(selected.id); if (shouldAutoReconnect(autoReconnect.current, forgottenIds.current, selected.id)) { setControllerPhase(selected.id, 'reconnecting'); reconnectController.current.start(selected.id, selected); @@ -302,7 +120,7 @@ export function useZwiftClick( setControllerPhase(selected.id, 'offline'); } }, - [cleanupConnection, clearDeviceHeldShifts, setControllerPhase] + [cleanupConnection, clickInput.resetControllerInput, setControllerPhase] ); const establishControllerConnection = useCallback( @@ -311,13 +129,17 @@ export function useZwiftClick( const cleanup = await connectClickDevice(selected, rediscover, { isCurrent: isCurrentAttempt, isOperational: () => operationalIds.current.has(selected.id), - onControllerRole: (role) => registerControllerRole(selected.id, [role]), + onControllerRole: (role) => clickInput.registerControllerRole(selected.id, [role]), onDisconnect: handleDisconnect, - onMessage: (event) => handleControllerMessage(selected.id, event), + onMessage: (event) => clickInput.handleControllerMessage(selected.id, event), }); connectionCleanups.current.set(selected.id, cleanup); }, - [handleControllerDisconnect, handleControllerMessage, registerControllerRole] + [ + clickInput.handleControllerMessage, + clickInput.registerControllerRole, + handleControllerDisconnect, + ] ); const beginControllerConnectionAttempt = useCallback( @@ -343,18 +165,17 @@ export function useZwiftClick( connectionAttempts.current.set(selected.id, attempt); connectingIds.current.add(selected.id); setControllerPhase(selected.id, force || rediscover ? 'reconnecting' : 'connecting'); - previousButtonMaps.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); + clickInput.resetControllerInput(selected.id); return attempt; }, - [cleanupConnection, clearDeviceHeldShifts, setControllerPhase] + [cleanupConnection, clickInput.resetControllerInput, setControllerPhase] ); const handleConnectionFailure = useCallback( (selected: BluetoothDevice, error: unknown, scheduleRetry: boolean) => { cleanupConnection(selected.id); operationalIds.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); + clickInput.clearDeviceHeldShifts(selected.id); selected.gatt?.disconnect(); const shouldReconnect = shouldAutoReconnect( autoReconnect.current, @@ -370,7 +191,7 @@ export function useZwiftClick( reconnectController.current.start(selected.id, selected); } }, - [cleanupConnection, clearDeviceHeldShifts, setControllerPhase, setNotice] + [cleanupConnection, clickInput.clearDeviceHeldShifts, setControllerPhase, setNotice] ); useEffect( @@ -473,22 +294,22 @@ export function useZwiftClick( for (const selected of devices) { reconnectController.current.cancel(selected.id, true); operationalIds.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); + clickInput.clearDeviceHeldShifts(selected.id); cleanupConnection(selected.id); selected.gatt?.disconnect(); } store.actions.setControllerPhases( Object.fromEntries(devices.map((selected) => [selected.id, 'offline'])) ); - }, [cleanupConnection, clearDeviceHeldShifts, store]); + }, [cleanupConnection, clickInput.clearDeviceHeldShifts, store]); const forgetDevice = useCallback( async (deviceId: string) => { forgottenIds.current.add(deviceId); operationalIds.current.delete(deviceId); reconnectController.current.cancel(deviceId, true); - clearDeviceHeldShifts(deviceId); - forgetControllerRole(deviceId); + clickInput.clearDeviceHeldShifts(deviceId); + clickInput.forgetControllerRole(deviceId); const selected = devicesRef.current.find(({ id }) => id === deviceId); cleanupConnection(deviceId); selected?.gatt?.disconnect(); @@ -502,7 +323,12 @@ export function useZwiftClick( store.actions.removeControllerPhase(deviceId); } }, - [cleanupConnection, clearDeviceHeldShifts, forgetControllerRole, store] + [ + cleanupConnection, + clickInput.clearDeviceHeldShifts, + clickInput.forgetControllerRole, + store, + ] ); const forget = useCallback(async () => { @@ -536,9 +362,8 @@ export function useZwiftClick( rememberedIds.has(deviceId) ) ) as ClickControllerRoles; - controllerRolesRef.current = rememberedRoles; + clickInput.restoreControllerRoles(rememberedRoles); devicesRef.current = remembered; - store.actions.setControllerRoles(rememberedRoles); store.actions.setDeviceIds(remembered.map(({ id }) => id)); store.actions.setControllerPhases( Object.fromEntries(remembered.map((selected) => [selected.id, 'reconnecting'])) @@ -566,7 +391,7 @@ export function useZwiftClick( autoReconnect.current = false; reconnectController.current.cancelAll(); }; - }, [store]); + }, [clickInput.restoreControllerRoles, store]); const connectionPhases = state.deviceIds.map( (deviceId) => state.controllerPhases[deviceId] ?? 'offline' From 3fb6d3b79b25bd428c3c10f9547b19384e162dc8 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:58:21 -0700 Subject: [PATCH 19/26] feat: allow sessions to end without saving --- README.md | 2 +- src/app.tsx | 4 +- src/components/session-controls.tsx | 6 +-- src/components/session-save-dialog.tsx | 38 ++++++++++++++--- src/constants.ts | 1 + src/hooks/use-session-workflow.ts | 56 ++++++++++++++++---------- src/hooks/use-session.ts | 7 ++++ src/lib/session-workflow.ts | 2 + src/lib/session.ts | 2 + src/stores/session-store.ts | 6 ++- src/types.ts | 1 + tests/components.test.tsx | 33 ++++++++++++++- tests/session-store.test.ts | 16 ++++++++ tests/session.test.ts | 1 + 14 files changed, 140 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b718c33..e7143e9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. - 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. - Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with focused and combined chart views. -- Saves completed sessions to browser-managed IndexedDB storage, including optional comments and how the ride felt, and requests persistent browser storage when supported. +- Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. - Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. - Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services. - Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. diff --git a/src/app.tsx b/src/app.tsx index 543ea79..7080172 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -214,7 +214,7 @@ export function App() { onRequestNew={workflow.requestNewSession} onSave={workflow.openSaveDialog} onTogglePause={session.togglePause} - saved={workflow.sessionIsSaved} + saveResolved={workflow.sessionIsResolved} /> trainer.setNotice('')} /> void; onSave: () => void; onTogglePause: () => void; - saved: boolean; + saveResolved: boolean; }) { if (ended) { return (
    - {saved ? null : ( + {saveResolved ? null : (
    diff --git a/src/constants.ts b/src/constants.ts index f518398..5c02c7a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -49,6 +49,7 @@ export const emptySession: StoredSession = { }, calories: 0, controlMode: CONTROL_MODE.RESISTANCE, + discarded: false, distance: 0, elapsedSeconds: 0, ended: false, diff --git a/src/hooks/use-session-workflow.ts b/src/hooks/use-session-workflow.ts index 80edb8c..50b4d80 100644 --- a/src/hooks/use-session-workflow.ts +++ b/src/hooks/use-session-workflow.ts @@ -1,6 +1,6 @@ import { useSelector } from '@tanstack/react-store'; import { useCallback, useRef } from 'react'; -import { errorMessage } from '../lib/errors'; +import { errorMessage, unreachable } from '../lib/errors'; import { createSavedSession, requestPersistentSessionStorage, @@ -19,9 +19,9 @@ export function useSessionWorkflow( session: SessionWorkflowController, setNotice: (notice: string) => void ) { - const sessionIsSaved = Boolean(session.savedSessionId); + const sessionIsResolved = Boolean(session.savedSessionId) || session.discarded; const storeRef = useRef | undefined>(undefined); - storeRef.current ??= createSessionWorkflowStore(session.ended && !sessionIsSaved); + storeRef.current ??= createSessionWorkflowStore(session.ended && !sessionIsResolved); const store = storeRef.current; const state = useSelector(store); @@ -42,20 +42,31 @@ export function useSessionWorkflow( const completeIntent = useCallback( (intent: SessionWorkflowIntent, saved: boolean) => { - if (intent.kind === SESSION_WORKFLOW_INTENT.CONTINUE) { - session.continueFrom(intent.session); - setNotice( - saved ? 'Session saved. Selected session continued.' : 'Session continued.' - ); - } else if (intent.kind === SESSION_WORKFLOW_INTENT.NEW || !saved) { - session.startNew(); - setNotice(saved ? 'Session saved. New session ready.' : 'New session ready.'); - } else { - setNotice('Session saved.'); + switch (intent.kind) { + case SESSION_WORKFLOW_INTENT.CONTINUE: + session.continueFrom(intent.session); + setNotice( + saved ? 'Session saved. Selected session continued.' : 'Session continued.' + ); + break; + case SESSION_WORKFLOW_INTENT.NEW: + session.startNew(); + setNotice(saved ? 'Session saved. New session ready.' : 'New session ready.'); + break; + case SESSION_WORKFLOW_INTENT.END: + if (saved) { + setNotice('Session saved.'); + } else { + session.markDiscarded(); + setNotice('Session ended without saving.'); + } + break; + default: + unreachable(intent); } store.actions.close(); }, - [session.continueFrom, session.startNew, setNotice, store] + [session.continueFrom, session.markDiscarded, session.startNew, setNotice, store] ); const endSession = useCallback(() => { @@ -65,7 +76,7 @@ export function useSessionWorkflow( const requestNewSession = useCallback(() => { if (session.ended) { - if (sessionIsSaved) { + if (sessionIsResolved) { startNewSession(); } else { store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); @@ -82,7 +93,7 @@ export function useSessionWorkflow( session.elapsedSeconds, session.endSession, session.ended, - sessionIsSaved, + sessionIsResolved, startNewSession, store, ]); @@ -90,7 +101,7 @@ export function useSessionWorkflow( const requestContinuation = useCallback( (savedSession: SavedSession) => { const currentNeedsSave = - (session.ended && !sessionIsSaved) || + (session.ended && !sessionIsResolved) || (!session.ended && session.elapsedSeconds > 0); if (!currentNeedsSave) { continueSession(savedSession); @@ -106,7 +117,7 @@ export function useSessionWorkflow( session.elapsedSeconds, session.endSession, session.ended, - sessionIsSaved, + sessionIsResolved, store, ] ); @@ -148,9 +159,6 @@ export function useSessionWorkflow( return { closeSaveDialog, - continuing: - state.phase !== SESSION_WORKFLOW_PHASE.CLOSED && - state.intent.kind === SESSION_WORKFLOW_INTENT.CONTINUE, endSession, openSaveDialog, proceedWithoutSaving, @@ -158,8 +166,12 @@ export function useSessionWorkflow( requestNewSession, requestPersistentStorage, saveCurrentSession, + saveDialogIntent: + state.phase === SESSION_WORKFLOW_PHASE.CLOSED + ? SESSION_WORKFLOW_INTENT.END + : state.intent.kind, saveDialogOpen: state.phase !== SESSION_WORKFLOW_PHASE.CLOSED, saving: state.phase === SESSION_WORKFLOW_PHASE.SAVING, - sessionIsSaved, + sessionIsResolved, }; } diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 55082b5..28eb900 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -34,12 +34,14 @@ interface SessionController { aggregates: SessionAggregates; continueFrom: (sourceSession: SessionSnapshot) => void; controlMode: ControlMode; + discarded: boolean; elapsedSeconds: number; ended: boolean; endSession: () => void; history: MetricSample[]; isRiding: boolean; manuallyPaused: boolean; + markDiscarded: () => void; markSaved: (id: string) => void; maximums: Metrics; rideCalories: number; @@ -137,6 +139,9 @@ export function useSession( }, [store] ); + const markDiscarded = useCallback(() => { + store.actions.markDiscarded(); + }, [store]); const startNew = useCallback(() => { lastTrainerDistance.current = latestMetrics.current.distance; @@ -161,12 +166,14 @@ export function useSession( aggregates: state.aggregates, continueFrom, controlMode: state.controlMode, + discarded: state.discarded, elapsedSeconds: state.elapsedSeconds, ended: state.ended, endSession, history: state.history, isRiding: state.isRiding, manuallyPaused: state.manuallyPaused, + markDiscarded, markSaved, maximums: state.maximums, rideCalories: state.calories, diff --git a/src/lib/session-workflow.ts b/src/lib/session-workflow.ts index 711ab05..fd4e46b 100644 --- a/src/lib/session-workflow.ts +++ b/src/lib/session-workflow.ts @@ -2,9 +2,11 @@ import type { SavedSession, SessionSnapshot } from '../types'; export interface SessionWorkflowController { continueFrom: (snapshot: SessionSnapshot) => void; + discarded: boolean; elapsedSeconds: number; ended: boolean; endSession: () => void; + markDiscarded: () => void; markSaved: (id: string) => void; savedSessionId?: string; snapshot: SessionSnapshot; diff --git a/src/lib/session.ts b/src/lib/session.ts index c5d6b75..403daf2 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -23,6 +23,7 @@ export function sessionContinuation(snapshot: SessionSnapshot): StoredSession { aggregates: snapshot.aggregates, calories: snapshot.calories, controlMode: snapshot.controlMode, + discarded: false, distance: snapshot.distance, elapsedSeconds: snapshot.elapsedSeconds, ended: false, @@ -176,6 +177,7 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor }, calories: nonNegativeNumber(parsed.calories), controlMode: controlModeForHistory(history, parsed.controlMode), + discarded: parsed.discarded === true, distance: nonNegativeNumber(parsed.distance), elapsedSeconds: nonNegativeNumber(parsed.elapsedSeconds), ended: parsed.ended === true, diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts index 0548902..e207d81 100644 --- a/src/stores/session-store.ts +++ b/src/stores/session-store.ts @@ -52,6 +52,7 @@ export function storedSessionFromState(state: SessionStoreState): StoredSession aggregates: state.aggregates, calories: state.calories, controlMode: state.controlMode, + discarded: state.discarded, distance: state.distance, elapsedSeconds: state.elapsedSeconds, ended: state.ended, @@ -89,8 +90,11 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) { manuallyPaused: false, })); }, + markDiscarded: () => { + setState((current) => ({ ...current, discarded: true, savedSessionId: undefined })); + }, markSaved: (savedSessionId: string) => { - setState((current) => ({ ...current, savedSessionId })); + setState((current) => ({ ...current, discarded: false, savedSessionId })); }, observeControlMode: (controlMode: ControlMode) => { setState((current) => diff --git a/src/types.ts b/src/types.ts index bc88b19..b4f1488 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,6 +59,7 @@ export interface StoredSession { aggregates: SessionAggregates; calories: number; controlMode: ControlMode; + discarded: boolean; distance: number; elapsedSeconds: number; ended: boolean; diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 550723a..eacc02b 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -23,6 +23,7 @@ import { } from '../src/constants'; import { historyKeyboardShortcuts } from '../src/lib/keyboard'; import { metricAccentClass, metricIconClass } from '../src/lib/metric-presentation'; +import { SESSION_WORKFLOW_INTENT } from '../src/lib/session-workflow'; const render = (element: React.ReactNode) => renderToStaticMarkup(element); const enabledEndSessionButton = /]*disabled)[^>]*>End session<\/button>/; @@ -583,6 +584,7 @@ describe('view components', () => { expect( render( undefined} onSave={async () => undefined} onStartWithoutSaving={() => undefined} @@ -595,7 +597,7 @@ describe('view components', () => { ).toBe(''); const html = render( undefined} onSave={async () => undefined} onStartWithoutSaving={() => undefined} @@ -618,6 +620,35 @@ describe('view components', () => { expect(html).toContain('Save this session?'); expect(html).toContain('How did it feel?'); expect(html).toContain('Continue without saving'); + expect(html).toContain('Save & continue'); + const endSession = render( + undefined} + onSave={async () => undefined} + onStartWithoutSaving={() => undefined} + open + saving={false} + session={{ ...emptySession, maximums: emptyMetrics }} + speedUnit="kmh" + /> + ); + expect(endSession).toContain('End without saving'); + expect(endSession).toContain('Save session'); + const newSession = render( + undefined} + onSave={async () => undefined} + onStartWithoutSaving={() => undefined} + open + saving={false} + session={{ ...emptySession, maximums: emptyMetrics }} + speedUnit="kmh" + /> + ); + expect(newSession).toContain('Start new without saving'); + expect(newSession).toContain('Save & start new'); }); test('renders an empty session history', () => { diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts index 9ecb65c..60b6c3b 100644 --- a/tests/session-store.test.ts +++ b/tests/session-store.test.ts @@ -132,6 +132,22 @@ describe('session store', () => { expect(store.get().savedSessionId).toBeUndefined(); }); + test('records an intentional discard until the session is reset or saved', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.endSession(5000); + store.actions.markDiscarded(); + expect(store.get()).toMatchObject({ discarded: true, savedSessionId: undefined }); + expect(storedSessionFromState(store.get()).discarded).toBe(true); + + store.actions.markSaved('saved-session'); + expect(store.get()).toMatchObject({ discarded: false, savedSessionId: 'saved-session' }); + + store.actions.markDiscarded(); + store.actions.reset('resistance', 6000); + expect(store.get().discarded).toBe(false); + expect(store.get().savedSessionId).toBeUndefined(); + }); + test('updates maxima without publishing unchanged snapshots or ended rides', () => { const store = createSessionStore(restoredSession(), 1000); let updates = 0; diff --git a/tests/session.test.ts b/tests/session.test.ts index c566546..4eb8f49 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -156,6 +156,7 @@ describe('session utilities', () => { const session = loadStoredSession(storageWith(stored)); expect(session.calories).toBe(0); expect(session.distance).toBe(12); + expect(session.discarded).toBe(false); expect(session.ended).toBe(true); expect(session.endedAt).toBe(5000); expect(session.history[0]?.speed).toBe(0); From 54efab256d195fe6b3f77f876d89837926c3ee38 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:59:00 -0700 Subject: [PATCH 20/26] refactor: unify device and history trays --- README.md | 7 +- src/components/device-pairing.tsx | 273 +++++++++++++---------------- src/components/session-history.tsx | 50 ++---- src/components/side-tray.tsx | 50 ++++++ src/hooks/use-dialog-behavior.ts | 34 +++- tests/components.test.tsx | 11 +- 6 files changed, 234 insertions(+), 191 deletions(-) create mode 100644 src/components/side-tray.tsx diff --git a/README.md b/README.md index e7143e9..3a735a8 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 ## Features - Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; the welcome screen remains available from the Ride Control footer link. -- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices panel, with blue pulsing dots for controllers awaiting connection and a green indicator once every paired device is ready; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, continuously retries saved Click connections after a refresh or controller sleep, and keeps the sleeping-controller display stable between retry attempts; stalled attempts can be retried immediately, and Click presses made while this panel is open stay in setup and do not shift the ride. +- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices tray that slides smoothly into and out of view, with blue pulsing dots and a consistent reconnecting status for controllers awaiting connection and a green indicator once every paired device is ready; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, continuously retries saved Click connections after a refresh or controller sleep, and keeps the sleeping-controller display stable between retry attempts; stalled attempts can be retried immediately, and Click presses made while this panel is open stay in setup and do not shift the ride. - 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. - Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and automatically reconnects when possible. @@ -43,8 +43,9 @@ connection phase instead of independently managed status flags. Shared reconnect notification subscriptions live in plain controllers, while device-specific adapters own GATT setup for heart-rate monitors, trainers, and Click controllers. Bluetooth objects and timers stay outside shared application state. The application component coordinates focused dashboard regions, -uses an explicit overlay state for mutually exclusive trays, and delegates save/start/continue -transitions to a reducer-backed session workflow. Temporary form inputs remain local React state. +uses an explicit overlay state for mutually exclusive trays, renders every side tray through one +animated and accessible shell, and delegates save/discard/start/continue transitions to a +store-backed session workflow. Temporary form inputs remain local React state. Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation, and repeated dialog and keyboard behavior so those rules stay consistent across views and exports. diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 11ce0d8..6c36bb1 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; import { CHROME_BLUETOOTH_FLAGS_URL } from '../constants'; -import { useBodyScrollLock, useCloseOnEscape } from '../hooks/use-dialog-behavior'; import { automaticBluetoothReconnectConfigured, bluetoothBrowserNotice } from '../lib/browser'; import type { DeviceConnectionView } from '../lib/device-connection'; import { MAX_CLICK_CONTROLLERS } from '../lib/zwift-click'; import { Icon } from './icon'; +import { SideTray } from './side-tray'; interface DeviceSlot extends DeviceConnectionView { battery?: number; @@ -62,8 +62,8 @@ function StatusDot({ ); } -function DeviceActions({ showBusy = true, slot }: { showBusy?: boolean; slot: DeviceSlot }) { - const actionBusy = showBusy && slot.busy; +function DeviceActions({ slot }: { slot: DeviceSlot }) { + const actionBusy = slot.busy; if (!slot.paired) { return ( + +
    +
    +

    + Paired devices +

    +

    + Pair each sensor once. Ride Control reconnects remembered devices when they + wake up. +

    + +
    - {browserNotice ? ( -
    -

    {browserNotice}

    -
    - ) : null} + {browserNotice ? ( +
    +

    {browserNotice}

    +
    + ) : null} - {browserNotice ? null : ( -
    - - + {browserNotice ? null : ( +
    + + -
    -
    -
    - +
    +
    +
    + +
    +
    +
    + 0} + /> +

    Zwift Click V2

    -
    -
    +

    + {click.pairedCount + ? `${click.connectedCount} of ${click.pairedCount} controllers connected` + : 'Pair each controller separately'} +

    +

    + {waitingForControllers ? 'Reconnecting…' : click.status} +

    +
    +
    + + {orderedClickControllers.length ? ( +
    + {orderedClickControllers.map((controller) => ( +
    0} + bluePulse + busy={controller.busy} + connected={controller.connected} /> -

    - Zwift Click V2 -

    +
    +

    + {controller.label} +

    +
    +
    -

    - {click.pairedCount - ? `${click.connectedCount} of ${click.pairedCount} controllers connected` - : 'Pair each controller separately'} -

    -

    - {waitingForControllers - ? 'Waiting for controllers…' - : click.status} -

    -
    + ))}
    + ) : null} - {orderedClickControllers.length ? ( -
    - {orderedClickControllers.map((controller) => ( -
    - -
    -

    - {controller.label} -

    -
    - -
    - ))} -
    +
    + {click.pairedCount > 0 ? : null} + {click.pairedCount < MAX_CLICK_CONTROLLERS ? ( + ) : null} - -
    - {click.pairedCount > 0 ? ( - - ) : null} - {click.pairedCount < MAX_CLICK_CONTROLLERS ? ( - - ) : null} -
    -

    - Wake each controller before pairing. The + and − sides are - identified automatically and reconnect in the background. -

    -
    - -
    - )} - -
    +
    +

    + Wake each controller before pairing. The + and − sides are identified + automatically and reconnect in the background. +

    + + +
    + )} +
    ); } diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index 71bb48a..a633cb2 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -16,6 +16,7 @@ import type { SavedSession, SpeedUnit } from '../types'; import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'; import { SessionDetail } from './session-detail'; import { SessionHistoryList } from './session-history-list'; +import { SideTray } from './side-tray'; function shouldIgnoreHistoryAction(event: KeyboardEvent) { return ( @@ -50,29 +51,12 @@ export function SessionHistory({ } = useSessionHistory(open); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [historyHelpOpen, setHistoryHelpOpen] = useState(false); - const [rendered, setRendered] = useState(open); - const [trayVisible, setTrayVisible] = useState(open); useEffect(() => { - let frame: number | undefined; - let timeout: number | undefined; - if (open) { - setRendered(true); - frame = window.requestAnimationFrame(() => setTrayVisible(true)); - } else { - setTrayVisible(false); + if (!open) { setDeleteConfirmationOpen(false); setHistoryHelpOpen(false); - timeout = window.setTimeout(() => setRendered(false), 200); } - return () => { - if (frame !== undefined) { - window.cancelAnimationFrame(frame); - } - if (timeout !== undefined) { - window.clearTimeout(timeout); - } - }; }, [open]); const selectSession = useCallback( @@ -160,10 +144,6 @@ export function SessionHistory({ summaries, ]); - if (!rendered) { - return null; - } - let detail: ReactNode = null; if (loading) { detail = ( @@ -195,20 +175,14 @@ export function SessionHistory({ } return ( -
    -
    + ); } diff --git a/src/components/side-tray.tsx b/src/components/side-tray.tsx new file mode 100644 index 0000000..19a12c9 --- /dev/null +++ b/src/components/side-tray.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { useAnimatedTray, useBodyScrollLock, useCloseOnEscape } from '../hooks/use-dialog-behavior'; + +export function SideTray({ + children, + closeLabel, + closeOnEscape = true, + labelledBy, + onClose, + open, + panelClassName = '', +}: { + children: ReactNode; + closeLabel: string; + closeOnEscape?: boolean; + labelledBy: string; + onClose: () => void; + open: boolean; + panelClassName?: string; +}) { + const { rendered, visible } = useAnimatedTray(open); + useCloseOnEscape(closeOnEscape && open, onClose); + useBodyScrollLock(rendered); + + if (!rendered) { + return null; + } + + return ( +
    +
    + ); +} diff --git a/src/hooks/use-dialog-behavior.ts b/src/hooks/use-dialog-behavior.ts index 5a5df53..52c5289 100644 --- a/src/hooks/use-dialog-behavior.ts +++ b/src/hooks/use-dialog-behavior.ts @@ -1,4 +1,36 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; + +const TRAY_TRANSITION_MS = 200; + +export function useAnimatedTray(open: boolean): { + rendered: boolean; + visible: boolean; +} { + const [rendered, setRendered] = useState(open); + const [visible, setVisible] = useState(open); + + useEffect(() => { + let frame: number | undefined; + let timeout: number | undefined; + if (open) { + setRendered(true); + frame = window.requestAnimationFrame(() => setVisible(true)); + } else { + setVisible(false); + timeout = window.setTimeout(() => setRendered(false), TRAY_TRANSITION_MS); + } + return () => { + if (frame !== undefined) { + window.cancelAnimationFrame(frame); + } + if (timeout !== undefined) { + window.clearTimeout(timeout); + } + }; + }, [open]); + + return { rendered, visible }; +} export function useCloseOnEscape(enabled: boolean, onClose: () => void): void { useEffect(() => { diff --git a/tests/components.test.tsx b/tests/components.test.tsx index eacc02b..2cdbe28 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -300,12 +300,18 @@ describe('view components', () => { /> ); expect(panel).toContain('Paired devices'); + expect(panel).toContain('data-side-tray="true"'); + expect(panel).toContain('transition-opacity duration-200'); + expect(panel).toContain('transition-transform duration-200'); + expect(panel).toContain('translate-x-0'); expect(panel).toContain('Smart trainer'); expect(panel).toContain('Heart rate'); expect(panel).toContain('Zwift Click V2'); - expect(panel).toContain('Waiting for controllers…'); + expect(panel).toContain('Reconnecting…'); + expect(panel).not.toContain('Waiting for controllers…'); expect(panel).not.toContain('Retry'); - expect(panel).toContain('Reconnect'); + expect(panel).toContain('Connecting…'); + expect(panel).not.toContain('>Reconnect'); expect(panel.match(/animate-pulse/g)).toHaveLength(2); expect(panel.match(/animate-pulse bg-sky-400/g)).toHaveLength(1); expect(panel).toContain('Automatic reconnect in Chrome'); @@ -661,6 +667,7 @@ describe('view components', () => { /> ); expect(html).toContain('Session history'); + expect(html).toContain('data-side-tray="true"'); expect(html).toContain('No saved sessions yet'); expect(html).toContain('ml-auto'); expect(html).toContain('translate-x-0'); From a078f42d44198c6ccd4ddb7e62f6d241f97438da Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 11:59:17 -0700 Subject: [PATCH 21/26] refactor: simplify live metric summaries --- README.md | 2 +- src/components/metrics.tsx | 35 +++++++++++++++++++++-------------- tests/components.test.tsx | 5 +++-- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3a735a8..02de159 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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. - Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. - 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. -- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with focused and combined chart views. +- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with uncluttered live metric cards that establish each unit once and focused or combined chart views. - Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. - Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. - Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services. diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index 4329713..f9fce1d 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -1,6 +1,25 @@ import { metricAccentClass, metricIconClass } from '../lib/metric-presentation'; import { Icon } from './icon'; +function MetricSummary({ + label, + rightAligned = false, + value, +}: { + label: string; + rightAligned?: boolean; + value: string; +}) { + return ( +
    +

    {label}

    +

    + {value} +

    +
    + ); +} + export function Metric({ average, label, @@ -29,20 +48,8 @@ export function Metric({ {unit}
    -
    -

    AVG

    -

    - {average} - {unit} -

    -
    -
    -

    MAX

    -

    - {maximum} - {unit} -

    -
    + +
    diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 2cdbe28..ca5f85d 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -53,8 +53,9 @@ describe('view components', () => { expect(html).toContain('200'); expect(html).toContain('grid grid-cols-2 gap-3 border-line border-t pt-3'); expect(html).toContain('font-semibold text-2xl text-white tabular-nums tracking-tight'); - expect(html).toContain('180'); - expect(html).toContain('300'); + expect(html).toContain('>180

    '); + expect(html).toContain('>300

    '); + expect(html.match(/watts/g)).toHaveLength(1); expect(metricAccentClass('rose')).toBe('bg-rose-400'); expect(metricAccentClass('other')).toBe('bg-mint'); expect(metricIconClass('violet')).toBe('text-violet-400'); From f413bcce5683a0be6fc7f3b7eaf4fd5d864b4739 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 12:22:55 -0700 Subject: [PATCH 22/26] chore: validate Tailwind CSS in CI --- AGENTS.md | 2 +- bun.lock | 3 + package.json | 4 +- scripts/check-tailwind.ts | 354 +++++++++++++++++++ src/components/device-pairing.tsx | 2 +- src/components/keyboard-shortcuts-dialog.tsx | 4 +- src/components/session-history-list.tsx | 2 +- 7 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 scripts/check-tailwind.ts diff --git a/AGENTS.md b/AGENTS.md index b10b481..1786071 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Project instructions - After making any changes, always run `bun run ci` before handing work back. -- `bun run ci` must run the Ultracite-configured Biome checks automatically, followed by the TypeScript check and production build. +- `bun run ci` must run the Ultracite-configured Biome checks and Tailwind CSS language-server diagnostics automatically, followed by the unit tests, TypeScript check, and production build. - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. - Keep the codebase as DRY as practical: before adding constants, calculations, formatting, parsing, labels, or state-derivation logic, search for an existing domain utility and reuse or extend it. Consolidate meaningful duplication into clearly named domain modules, but do not introduce generic abstractions that hide simple behavior or couple unrelated concepts solely because their implementations look similar. - Do not scatter raw string literals for shared domain modes through comparisons or state construction. Define or reuse a named `as const` domain object, derive its union type from those values, centralize validation of persisted or external strings, and reference the named members in application code. Prefer an exhaustive `switch` when every variant has distinct behavior. Raw strings remain appropriate for display text, object property names, browser or protocol values, and boundary-focused tests. diff --git a/bun.lock b/bun.lock index 3c9bf75..4e98339 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.4", + "@tailwindcss/language-server": "^0.16.0", "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -123,6 +124,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@tailwindcss/language-server": ["@tailwindcss/language-server@0.16.0", "", { "bin": { "css-language-server": "bin/css-language-server", "tailwindcss-language-server": "bin/tailwindcss-language-server" } }, "sha512-ko2xr4nlFIkqwJFnnw7hfdpjbsnK0DOk+QEEI51tvKOCF97OG0RU59iGfadoaOoY6I9xkSMbTW+AAwgnGnLH3w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], diff --git a/package.json b/package.json index 1079d1c..7334016 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "check": "biome check .", "fix": "biome check --write .", "test": "bun test", - "ci": "biome check . && bun test && tsc --noEmit && vite build", + "tailwind:check": "bun scripts/check-tailwind.ts", + "ci": "biome check . && bun run tailwind:check && bun test && tsc --noEmit && vite build", "preview": "vite preview --host localhost --port 4200" }, "dependencies": { @@ -22,6 +23,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.5.4", + "@tailwindcss/language-server": "^0.16.0", "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/scripts/check-tailwind.ts b/scripts/check-tailwind.ts new file mode 100644 index 0000000..3962946 --- /dev/null +++ b/scripts/check-tailwind.ts @@ -0,0 +1,354 @@ +import { spawn } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import { extname, join, relative, resolve as resolvePath } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const DIAGNOSTIC_TIMEOUT_MS = 30_000; +const CONTENT_LENGTH_PATTERN = /Content-Length: (\d+)/i; +const LANGUAGE_IDS = new Map([ + ['.css', 'css'], + ['.html', 'html'], + ['.js', 'javascript'], + ['.jsx', 'javascriptreact'], + ['.ts', 'typescript'], + ['.tsx', 'typescriptreact'], +]); + +const defaultSettings = { + editor: { tabSize: 4 }, + tailwindCSS: { + classAttributes: ['class', 'className', 'ngClass', 'class:list'], + classFunctions: [], + codeActions: true, + codeLens: true, + colorDecorators: true, + emmetCompletions: false, + experimental: { classRegex: [], configFile: null }, + files: { + exclude: ['**/.git/**', '**/dist/**', '**/node_modules/**'], + }, + hovers: true, + includeLanguages: {}, + inspectPort: null, + lint: { + cssConflict: 'warning', + deprecatedAtRule: 'warning', + invalidApply: 'error', + invalidConfigPath: 'error', + invalidScreen: 'error', + invalidSourceDirective: 'error', + invalidTailwindDirective: 'error', + invalidVariant: 'error', + recommendedVariantOrder: 'warning', + suggestCanonicalClasses: 'warning', + usedBlocklistedClass: 'warning', + }, + rootFontSize: 16, + showPixelEquivalents: true, + suggestions: true, + validate: true, + }, +}; + +interface Diagnostic { + code?: number | string; + message: string; + range: { + start: { character: number; line: number }; + }; + severity?: number; +} + +interface JsonRpcMessage { + error?: { code: number; message: string }; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; +} + +interface PublishedDiagnostics { + diagnostics: Diagnostic[]; + uri: string; +} + +interface Deferred { + promise: Promise; + reject: (reason: Error) => void; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let reject = (_reason: Error) => undefined; + let resolve = (_value: T) => undefined; + const promise = new Promise((resolvePromise, rejectPromise) => { + reject = rejectPromise; + resolve = resolvePromise; + }); + return { promise, reject, resolve }; +} + +function withTimeout(promise: Promise, description: string): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error(`Timed out waiting for ${description}.`)), + DIAGNOSTIC_TIMEOUT_MS + ); + promise.then( + (value) => { + clearTimeout(timer); + resolvePromise(value); + }, + (error: unknown) => { + clearTimeout(timer); + rejectPromise(error); + } + ); + }); +} + +async function sourceFiles(directory: string): Promise { + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await sourceFiles(path))); + } else if (LANGUAGE_IDS.has(extname(entry.name))) { + files.push(path); + } + } + return files; +} + +function settingForSection(section: string | undefined) { + if (!section) { + return defaultSettings; + } + return defaultSettings[section as keyof typeof defaultSettings] ?? {}; +} + +function diagnosticLevel(severity: number | undefined): string { + if (severity === 1) { + return 'error'; + } + if (severity === 2) { + return 'warning'; + } + return 'notice'; +} + +async function main() { + const workspace = process.cwd(); + const workspaceUri = pathToFileURL(workspace).href; + const server = spawn( + resolvePath('node_modules/.bin/tailwindcss-language-server'), + ['--stdio'], + { + cwd: workspace, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + const pendingRequests = new Map>(); + const diagnostics = new Map(); + const diagnosticWaiters = new Map>(); + const serverReady = deferred(); + let nextRequestId = 1; + let output = Buffer.alloc(0); + let serverErrors = ''; + + server.stderr.setEncoding('utf8'); + server.stderr.on('data', (chunk: string) => { + serverErrors += chunk; + }); + + const send = (message: JsonRpcMessage) => { + const body = JSON.stringify({ jsonrpc: '2.0', ...message }); + server.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); + }; + + const respond = (id: number | string, result: unknown) => send({ id, result }); + + const handleMessage = (message: JsonRpcMessage) => { + if (message.id !== undefined && !message.method) { + const request = pendingRequests.get(Number(message.id)); + if (!request) { + return; + } + pendingRequests.delete(Number(message.id)); + if (message.error) { + request.reject(new Error(message.error.message)); + } else { + request.resolve(message.result); + } + return; + } + + if (message.method === 'textDocument/publishDiagnostics') { + const published = message.params as PublishedDiagnostics; + diagnostics.set(published.uri, published.diagnostics); + diagnosticWaiters.get(published.uri)?.resolve(published.diagnostics); + return; + } + + if (message.method === '@/tailwindCSS/serverReady') { + serverReady.resolve(); + return; + } + + if (message.id === undefined) { + return; + } + + if (message.method === 'workspace/configuration') { + const params = message.params as { items: Array<{ section?: string }> }; + respond( + message.id, + params.items.map((item) => settingForSection(item.section)) + ); + return; + } + + if (message.method === 'workspace/workspaceFolders') { + respond(message.id, [{ name: 'RideControl', uri: workspaceUri }]); + return; + } + + if (message.method === 'workspace/applyEdit') { + respond(message.id, { applied: false }); + return; + } + + respond(message.id, null); + }; + + server.stdout.on('data', (chunk: Buffer) => { + output = Buffer.concat([output, chunk]); + while (output.length > 0) { + const headerEnd = output.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + break; + } + const header = output.subarray(0, headerEnd).toString('ascii'); + const contentLength = CONTENT_LENGTH_PATTERN.exec(header)?.[1]; + if (!contentLength) { + throw new Error('Tailwind language server sent an invalid response header.'); + } + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + Number(contentLength); + if (output.length < bodyEnd) { + break; + } + const body = output.subarray(bodyStart, bodyEnd).toString('utf8'); + output = output.subarray(bodyEnd); + handleMessage(JSON.parse(body) as JsonRpcMessage); + } + }); + + const request = (method: string, params?: unknown) => { + const id = nextRequestId; + nextRequestId += 1; + const response = deferred(); + pendingRequests.set(id, response); + send({ id, method, params }); + return response.promise; + }; + + try { + await withTimeout( + request('initialize', { + capabilities: { + experimental: { tailwind: { projectDetails: true } }, + textDocument: { + codeAction: { dynamicRegistration: true }, + completion: { dynamicRegistration: true }, + publishDiagnostics: { relatedInformation: true }, + synchronization: { didSave: true, dynamicRegistration: true }, + }, + workspace: { + configuration: true, + didChangeConfiguration: { dynamicRegistration: true }, + didChangeWatchedFiles: { dynamicRegistration: true }, + workspaceFolders: true, + }, + }, + initializationOptions: { testMode: true }, + processId: process.pid, + rootUri: workspaceUri, + trace: 'off', + workspaceFolders: [{ name: 'RideControl', uri: workspaceUri }], + }), + 'Tailwind language server initialization' + ); + send({ method: 'initialized', params: {} }); + await withTimeout(serverReady.promise, 'Tailwind language server readiness'); + + const files = [ + ...(await sourceFiles(resolvePath('src'))), + resolvePath('index.html'), + ].sort(); + await Promise.all( + files.map(async (file) => { + const uri = pathToFileURL(file).href; + const waiter = deferred(); + diagnosticWaiters.set(uri, waiter); + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { + languageId: LANGUAGE_IDS.get(extname(file)) ?? 'plaintext', + text: await readFile(file, 'utf8'), + uri, + version: 1, + }, + }, + }); + await withTimeout( + waiter.promise, + `Tailwind diagnostics for ${relative(workspace, file)}` + ); + }) + ); + + const failures = [...diagnostics.entries()] + .flatMap(([uri, fileDiagnostics]) => + fileDiagnostics + .filter( + (diagnostic) => + diagnostic.severity === undefined || diagnostic.severity <= 2 + ) + .map((diagnostic) => ({ diagnostic, file: fileURLToPath(uri) })) + ) + .sort( + (left, right) => + left.file.localeCompare(right.file) || + left.diagnostic.range.start.line - right.diagnostic.range.start.line || + left.diagnostic.range.start.character - right.diagnostic.range.start.character + ); + + if (failures.length > 0) { + for (const { diagnostic, file } of failures) { + const location = `${relative(workspace, file)}:${diagnostic.range.start.line + 1}:${diagnostic.range.start.character + 1}`; + const code = diagnostic.code === undefined ? '' : ` [${diagnostic.code}]`; + console.error( + `${location} ${diagnosticLevel(diagnostic.severity)}${code} ${diagnostic.message}` + ); + } + throw new Error(`Tailwind CSS reported ${failures.length} diagnostic(s).`); + } + + console.log(`Tailwind CSS diagnostics passed for ${files.length} source files.`); + } finally { + try { + await request('shutdown'); + send({ method: 'exit' }); + } catch { + // Preserve the original failure when shutdown cannot complete. + } + server.kill(); + if (serverErrors.trim()) { + console.error(serverErrors.trim()); + } + } +} + +await main(); diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 6c36bb1..6626bff 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -140,7 +140,7 @@ function AutomaticReconnectStatus({ {copied ? null : 'Open '} -

    - Use Zwift Click or {' '} - to shift -

    + {disabled ? null : ( +

    + Use Zwift Click or {' '} + to shift +

    + )} ); } diff --git a/src/hooks/use-gear-control.ts b/src/hooks/use-gear-control.ts index d3df101..9151690 100644 --- a/src/hooks/use-gear-control.ts +++ b/src/hooks/use-gear-control.ts @@ -5,6 +5,7 @@ import { GEAR_STORAGE_KEY, gearForResistance, resistanceChangeForGears, + SHIFTING_CONNECTION_MESSAGE, shiftedGear, storedGear, } from '../lib/gears'; @@ -13,14 +14,14 @@ import type { ResistanceAdjustmentDirection } from '../types'; export function useGearControl({ active, - connected, onResistanceChange, + ready, resistance, setNotice, }: { active: boolean; - connected: boolean; onResistanceChange: (change: number) => void; + ready: boolean; resistance: number; setNotice: (notice: string) => void; }) { @@ -32,8 +33,8 @@ export function useGearControl({ const shiftGear = useCallback( (change: number) => { - if (!connected) { - setNotice('Connect the trainer before shifting gears.'); + if (!ready) { + setNotice(SHIFTING_CONNECTION_MESSAGE); return; } const previous = gearRef.current; @@ -52,7 +53,7 @@ export function useGearControl({ localStorage.setItem(GEAR_STORAGE_KEY, String(next)); onResistanceChange(resistanceChangeForGears(previous, next)); }, - [connected, onResistanceChange, setNotice] + [onResistanceChange, ready, setNotice] ); useEffect(() => { diff --git a/src/lib/gears.ts b/src/lib/gears.ts index f587f44..590c6bf 100644 --- a/src/lib/gears.ts +++ b/src/lib/gears.ts @@ -5,6 +5,8 @@ export const MAX_GEAR = 24; export const DEFAULT_GEAR = 12; export const RESISTANCE_POINTS_PER_GEAR = 3; export const GEAR_STORAGE_KEY = 'trainer-virtual-gear'; +export const SHIFTING_CONNECTION_MESSAGE = + 'Connect the trainer and controllers before shifting gears.'; export function clampGear(gear: number): number { return clamp(Math.round(gear), MIN_GEAR, MAX_GEAR); diff --git a/tests/components.test.tsx b/tests/components.test.tsx index ca5f85d..000df5b 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -394,6 +394,13 @@ describe('view components', () => { expect(html).toContain('HARDER'); expect(html).toContain('grid h-9 w-9 shrink-0 place-items-center rounded-lg'); expect(html).toContain('scale-105 border-mint bg-mint/15 text-mint'); + expect(html).not.toContain('Connect the trainer and controllers before shifting gears.'); + const disabled = render( undefined} />); + expect(disabled).not.toContain( + 'Connect the trainer and controllers before shifting gears.' + ); + expect(disabled).not.toContain('Use Zwift Click'); + expect(disabled.match(/disabled=""/g)).toHaveLength(2); }); test('renders only the selected training control mode', () => { From fd44afa8359c6e18e1f21262e1b49611b7b46210 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 12:23:44 -0700 Subject: [PATCH 24/26] refactor: emphasize dashboard ride metrics --- README.md | 2 +- src/components/metrics.tsx | 20 ++++++++++++++++---- src/components/session-overview.tsx | 1 + src/components/session-summary.tsx | 16 +++++++++++++--- tests/components.test.tsx | 6 +++++- 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index dd3e28c..c917d30 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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. - Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. - 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. -- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with uncluttered live metric cards that establish each unit once and focused or combined chart views. +- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized ride totals for time, distance, and calories, and focused or combined chart views. - Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. - Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. - Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services. diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index f9fce1d..7226f59 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -13,7 +13,7 @@ function MetricSummary({ return (

    {label}

    -

    +

    {value}

    @@ -44,7 +44,7 @@ export function Metric({ {icon ? : null}
    - {value} + {value} {unit}
    @@ -56,11 +56,23 @@ export function Metric({ ); } -export function SmallMetric({ label, value }: { label: string; value: string }) { +export function SmallMetric({ + large = false, + label, + value, +}: { + large?: boolean; + label: string; + value: string; +}) { return (

    {label}

    -

    {value}

    +

    + {value} +

    ); } diff --git a/src/components/session-overview.tsx b/src/components/session-overview.tsx index 38b5836..152ffa4 100644 --- a/src/components/session-overview.tsx +++ b/src/components/session-overview.tsx @@ -27,6 +27,7 @@ export function SessionOverview({ calories={rideCalories} distance={rideDistance} elapsedSeconds={elapsedSeconds} + large speedUnit={speedUnit} />
    diff --git a/src/components/session-summary.tsx b/src/components/session-summary.tsx index 36c6574..930b036 100644 --- a/src/components/session-summary.tsx +++ b/src/components/session-summary.tsx @@ -7,20 +7,30 @@ export function SessionSummary({ calories, distance, elapsedSeconds, + large = false, speedUnit, timeLabel = 'TIME', }: { calories: number; distance: number; elapsedSeconds: number; + large?: boolean; speedUnit: SpeedUnit; timeLabel?: string; }) { return ( <> - - - + + + ); } diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 000df5b..0d220d8 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -52,7 +52,8 @@ describe('view components', () => { expect(html).toContain('POWER'); expect(html).toContain('200'); expect(html).toContain('grid grid-cols-2 gap-3 border-line border-t pt-3'); - expect(html).toContain('font-semibold text-2xl text-white tabular-nums tracking-tight'); + expect(html).toContain('font-semibold text-6xl tracking-tight'); + expect(html).toContain('font-semibold text-4xl text-white tabular-nums tracking-tight'); expect(html).toContain('>180

    '); expect(html).toContain('>300

    '); expect(html.match(/watts/g)).toHaveLength(1); @@ -64,6 +65,9 @@ describe('view components', () => { test('renders a compact session metric', () => { expect(render()).toContain('01:02:03'); + expect(render()).toContain( + 'text-3xl sm:text-5xl' + ); const html = render( Date: Sun, 19 Jul 2026 13:11:28 -0700 Subject: [PATCH 25/26] feat: enrich saved session metrics Track maximum resistance and virtual gear values in session aggregates and preserve them across restore flows. Separate numeric totals from subdued unit labels and remove repeated units from saved-session metric summaries. --- src/components/metrics.tsx | 19 +++++++++++++++---- src/components/session-summary.tsx | 8 +++++--- src/lib/format.ts | 5 +++++ src/lib/session.ts | 4 ++++ src/lib/units.ts | 6 +++++- src/types.ts | 3 +++ tests/format-chart.test.ts | 9 ++++++++- tests/session-store.test.ts | 30 +++++++++++++++++++++++++++--- tests/session.test.ts | 23 +++++++++++++---------- tests/units.test.ts | 2 ++ 10 files changed, 87 insertions(+), 22 deletions(-) diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index 7226f59..e173eb8 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -41,7 +41,9 @@ export function Metric({
    {label} - {icon ? : null} + {icon ? ( + + ) : null}
    {value} @@ -59,19 +61,28 @@ export function Metric({ export function SmallMetric({ large = false, label, + unit, value, }: { large?: boolean; label: string; + unit?: string; value: string; }) { return (

    {label}

    - {value} + {value} + {unit ? ( + + {unit} + + ) : null}

    ); @@ -109,7 +120,7 @@ export function SessionMetric({ MAX - {maximum} {unit} + {maximum} )}
    diff --git a/src/components/session-summary.tsx b/src/components/session-summary.tsx index 930b036..ba5b38c 100644 --- a/src/components/session-summary.tsx +++ b/src/components/session-summary.tsx @@ -1,5 +1,5 @@ import { formatDuration, formatWholeNumber } from '../lib/format'; -import { formatDistance } from '../lib/units'; +import { distanceUnitLabel, formatDistanceValue } from '../lib/units'; import type { SpeedUnit } from '../types'; import { SmallMetric } from './metrics'; @@ -24,12 +24,14 @@ export function SessionSummary({ ); diff --git a/src/lib/format.ts b/src/lib/format.ts index a5128db..7008960 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,4 +1,5 @@ import type { MetricAggregate } from '../types'; +import { nonNegativeNumber } from './numbers'; import { SECONDS_PER_HOUR, SECONDS_PER_MINUTE } from './units'; export function formatDuration(totalSeconds: number) { @@ -24,6 +25,10 @@ export function aggregateAverage(aggregate: MetricAggregate): number { return aggregate.count > 0 ? aggregate.sum / aggregate.count : 0; } +export function aggregateMaximum(aggregate: MetricAggregate): number { + return nonNegativeNumber(aggregate.maximum); +} + export function formatWholeNumber(value: number): string { return String(Math.round(value)); } diff --git a/src/lib/session.ts b/src/lib/session.ts index 403daf2..d1e40bb 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -55,6 +55,7 @@ export function addAggregate( } return { count: aggregate.count + 1, + maximum: Math.max(nonNegativeNumber(aggregate.maximum), Math.max(0, value)), sum: aggregate.sum + Math.max(0, value), }; } @@ -114,6 +115,9 @@ export function restoreAggregate( } return { count: nonNegativeNumber(saved.count), + maximum: isFiniteNumber(saved.maximum) + ? nonNegativeNumber(saved.maximum) + : nonNegativeNumber(fallback.maximum), sum: nonNegativeNumber(saved.sum), }; } diff --git a/src/lib/units.ts b/src/lib/units.ts index 4b06b4c..0a5056d 100644 --- a/src/lib/units.ts +++ b/src/lib/units.ts @@ -37,8 +37,12 @@ export function convertSpeed(kilometersPerHour: number, unit: SpeedUnit): number return unit === 'mph' ? kilometersPerHour / KILOMETERS_PER_MILE : kilometersPerHour; } +export function formatDistanceValue(kilometers: number, unit: SpeedUnit, decimals = 2): string { + return convertDistance(kilometers, unit).toFixed(decimals); +} + export function formatDistance(kilometers: number, unit: SpeedUnit, decimals = 2): string { - return `${convertDistance(kilometers, unit).toFixed(decimals)} ${distanceUnitLabel(unit)}`; + return `${formatDistanceValue(kilometers, unit, decimals)} ${distanceUnitLabel(unit)}`; } export function formatSpeed(kilometersPerHour: number, unit: SpeedUnit, decimals = 1): string { diff --git a/src/types.ts b/src/types.ts index b4f1488..cd970c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,7 @@ export interface MetricSample { export interface MetricAggregate { count: number; + maximum?: number; sum: number; } @@ -91,6 +92,7 @@ export interface SessionSnapshot { export interface SavedSession extends SessionSnapshot, SessionMetadata { id: string; + importedAt?: number; } export interface SavedSessionSummary { @@ -100,6 +102,7 @@ export interface SavedSessionSummary { endedAt: number; feeling?: SessionFeeling; id: string; + importedAt?: number; startedAt: number; } diff --git a/tests/format-chart.test.ts b/tests/format-chart.test.ts index 50b30b5..f5a0c78 100644 --- a/tests/format-chart.test.ts +++ b/tests/format-chart.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { chartPath, roundedChartMaximum, storedChartMode } from '../src/lib/chart'; -import { formatAggregateAverage, formatChartSeconds, formatDuration } from '../src/lib/format'; +import { + aggregateMaximum, + formatAggregateAverage, + formatChartSeconds, + formatDuration, +} from '../src/lib/format'; describe('format utilities', () => { test('formats ride durations', () => { @@ -15,6 +20,8 @@ describe('format utilities', () => { test('formats aggregate averages', () => { expect(formatAggregateAverage({ count: 2, sum: 11 }, 1)).toBe('5.5'); expect(formatAggregateAverage({ count: 0, sum: 0 }, 0)).toBe('0'); + expect(aggregateMaximum({ count: 2, maximum: 8, sum: 11 })).toBe(8); + expect(aggregateMaximum({ count: 2, sum: 11 })).toBe(0); }); }); diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts index 60b6c3b..5669c86 100644 --- a/tests/session-store.test.ts +++ b/tests/session-store.test.ts @@ -62,8 +62,8 @@ describe('session store', () => { speed: 36, }, ]); - expect(state.aggregates.power).toEqual({ count: 1, sum: 200 }); - expect(state.aggregates.resistance).toEqual({ count: 1, sum: 42 }); + expect(state.aggregates.power).toEqual({ count: 1, maximum: 200, sum: 200 }); + expect(state.aggregates.resistance).toEqual({ count: 1, maximum: 42, sum: 42 }); subscription.unsubscribe(); }); @@ -81,10 +81,34 @@ describe('session store', () => { expect(store.get().controlMode).toBe('gear'); expect(store.get().history[0]?.gear).toBe(16); expect(store.get().history[0]?.resistance).toBeUndefined(); - expect(store.get().aggregates.gear).toEqual({ count: 1, sum: 16 }); + expect(store.get().aggregates.gear).toEqual({ count: 1, maximum: 16, sum: 16 }); expect(store.get().aggregates.resistance).toEqual({ count: 0, sum: 0 }); }); + test('retains resistance and gear maxima after lower control samples', () => { + const resistanceStore = createSessionStore(restoredSession(), 1000); + resistanceStore.actions.syncRiding(true); + for (const resistance of [70, 35]) { + resistanceStore.actions.recordTick({ + control: { gear: 12, mode: 'resistance', resistance }, + metrics: liveMetrics, + seconds: 1, + }); + } + expect(resistanceStore.get().aggregates.resistance.maximum).toBe(70); + + const gearStore = createSessionStore(restoredSession(), 1000); + gearStore.actions.syncRiding(true); + for (const gear of [20, 12]) { + gearStore.actions.recordTick({ + control: { gear, mode: 'gear', resistance: 42 }, + metrics: liveMetrics, + seconds: 1, + }); + } + expect(gearStore.get().aggregates.gear.maximum).toBe(20); + }); + test('coordinates pause, end, reset, and continuation transitions', () => { const store = createSessionStore(restoredSession(), 1000); store.actions.syncRiding(true); diff --git a/tests/session.test.ts b/tests/session.test.ts index 4eb8f49..3adbfbc 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -70,9 +70,9 @@ describe('session utilities', () => { test('adds aggregate samples according to zero policy', () => { const initial = { count: 2, sum: 10 }; - expect(addAggregate(initial, 5, false)).toEqual({ count: 3, sum: 15 }); + expect(addAggregate(initial, 5, false)).toEqual({ count: 3, maximum: 5, sum: 15 }); expect(addAggregate(initial, 0, false)).toBe(initial); - expect(addAggregate(initial, 0, true)).toEqual({ count: 3, sum: 10 }); + expect(addAggregate(initial, 0, true)).toEqual({ count: 3, maximum: 0, sum: 10 }); }); test('aggregates cadence, heart rate, power, and resistance', () => { @@ -86,9 +86,9 @@ describe('session utilities', () => { ).toEqual({ cadence: { count: 0, sum: 0 }, gear: { count: 0, sum: 0 }, - heartRate: { count: 1, sum: 145 }, - power: { count: 1, sum: 0 }, - resistance: { count: 1, sum: 42 }, + heartRate: { count: 1, maximum: 145, sum: 145 }, + power: { count: 1, maximum: 0, sum: 0 }, + resistance: { count: 1, maximum: 42, sum: 42 }, }); }); @@ -101,7 +101,7 @@ describe('session utilities', () => { power: 200, }) ).toMatchObject({ - gear: { count: 1, sum: 14 }, + gear: { count: 1, maximum: 14, sum: 14 }, resistance: { count: 0, sum: 0 }, }); }); @@ -117,17 +117,20 @@ describe('session utilities', () => { ]) ).toEqual({ count: 3, + maximum: 100, sum: 125, }); }); test('restores valid aggregate values and falls back when absent', () => { - expect(restoreAggregate({ count: -2, sum: 20 }, { count: 1, sum: 2 })).toEqual({ + expect(restoreAggregate({ count: -2, sum: 20 }, { count: 1, maximum: 8, sum: 2 })).toEqual({ count: 0, + maximum: 8, sum: 20, }); - expect(restoreAggregate(undefined, { count: 1, sum: 2 })).toEqual({ + expect(restoreAggregate(undefined, { count: 1, maximum: 2, sum: 2 })).toEqual({ count: 1, + maximum: 2, sum: 2, }); }); @@ -163,8 +166,8 @@ describe('session utilities', () => { expect(session.history[0]?.resistance).toBe(42); expect(session.history[0]?.gear).toBeUndefined(); expect(session.controlMode).toBe('resistance'); - expect(session.aggregates.power).toEqual({ count: 1, sum: 200 }); - expect(session.aggregates.resistance).toEqual({ count: 1, sum: 42 }); + 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.savedSessionId).toBe('saved-session'); expect(session.startedAt).toBe(1000); diff --git a/tests/units.test.ts b/tests/units.test.ts index 048c0f2..2edd624 100644 --- a/tests/units.test.ts +++ b/tests/units.test.ts @@ -4,6 +4,7 @@ import { convertDistance, convertSpeed, formatDistance, + formatDistanceValue, kilometersForMeters, kilometersTraveled, metersForKilometers, @@ -19,6 +20,7 @@ describe('unit conversions', () => { expect(convertSpeed(32.186_88, 'mph')).toBeCloseTo(20); expect(formatDistance(16.093_44, 'mph')).toBe('10.00 mi'); expect(formatDistance(16.093_44, 'kmh')).toBe('16.09 km'); + expect(formatDistanceValue(16.093_44, 'mph')).toBe('10.00'); }); test('converts stored SI and ride timing values consistently', () => { From 31648854fbf342585a6a911aa3171877ede8b570 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 13:11:38 -0700 Subject: [PATCH 26/26] feat: add portable TCX session history Import individual TCX files or nested ZIP archives, preserve Ride Control identifiers, and reject duplicate sessions using identifiers or stable legacy fingerprints. Export all saved rides as a collision-safe ZIP, retain imported-session metadata, highlight fresh imports, and label imported rides with a compact file-check marker. Tighten history controls, show control metric maximums, and add subtle separator bands between stacked session graphs. --- README.md | 6 +- bun.lock | 6 + package.json | 2 + src/components/icon.tsx | 16 +- src/components/session-chart.tsx | 36 +- src/components/session-detail.tsx | 23 +- src/components/session-history-list.tsx | 78 ++-- src/components/session-history.tsx | 53 ++- src/hooks/use-session-history.ts | 97 ++++- src/lib/download.ts | 10 + src/lib/saved-sessions.ts | 66 +++- src/lib/tcx-archive.ts | 56 +++ src/lib/tcx-import.ts | 462 ++++++++++++++++++++++++ src/lib/tcx-schema.ts | 6 + src/lib/tcx.ts | 33 +- tests/components.test.tsx | 65 +++- tests/fixtures/saved-session.ts | 49 +++ tests/saved-sessions.test.ts | 18 +- tests/tcx-archive.test.ts | 50 +++ tests/tcx-import.test.ts | 121 +++++++ tests/tcx.test.ts | 54 +-- 21 files changed, 1160 insertions(+), 147 deletions(-) create mode 100644 src/lib/download.ts create mode 100644 src/lib/tcx-archive.ts create mode 100644 src/lib/tcx-import.ts create mode 100644 src/lib/tcx-schema.ts create mode 100644 tests/fixtures/saved-session.ts create mode 100644 tests/tcx-archive.test.ts create mode 100644 tests/tcx-import.test.ts diff --git a/README.md b/README.md index c917d30..23462d7 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,12 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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. - Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. - 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. -- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized ride totals for time, distance, and calories, and focused or combined chart views. +- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. - Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. - Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. -- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services. +- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, comments, and a unique Ride Control session identifier for reliable duplicate detection. +- Imports an individual TCX file or every TCX file inside nested folders in a ZIP directly into local session history, preserves compatible ride data and Ride Control session identifiers, detects duplicates by identifier or stable ride data for legacy exports, and continues past individual invalid files in a batch; imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes. +- Downloads every locally saved ride at once as a compressed ZIP containing a folder of individual TCX files, with collision-safe filenames when sessions share the same start time. - Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. - Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session. - Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions. diff --git a/bun.lock b/bun.lock index 4e98339..06f20f9 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", + "fflate": "^0.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -19,6 +20,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/web-bluetooth": "^0.0.21", + "@xmldom/xmldom": "^0.9.10", "typescript": "^7.0.2", "ultracite": "^7.9.4", "vite": "^8.1.5", @@ -234,6 +236,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -296,6 +300,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], diff --git a/package.json b/package.json index 7334016..c881015 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", + "fflate": "^0.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3" @@ -28,6 +29,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/web-bluetooth": "^0.0.21", + "@xmldom/xmldom": "^0.9.10", "typescript": "^7.0.2", "ultracite": "^7.9.4", "vite": "^8.1.5" diff --git a/src/components/icon.tsx b/src/components/icon.tsx index 5c8a2f6..eaa07bf 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -5,6 +5,8 @@ const paths: Record = { cadence: 'M20 11a8 8 0 0 0-15.5-2M4 4v5h5M4 13a8 8 0 0 0 15.5 2M20 20v-5h-5', controls: 'M7 4v6m0 4v6m10-16v2m0 4v10M4 10h6m4-4h6M4 14h6m4-4h6', heart: 'M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6a5.5 5.5 0 0 0 1-8.8Z', + imported: + 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8m-6-6 6 6m-6-6v6h6M8 15l2 2 5-5', minus: 'M5 12h14', pause: 'M9 5v14m6-14v14', play: 'm9 5 10 7-10 7V5Z', @@ -16,11 +18,19 @@ const paths: Record = { upload: 'M12 16V4m0 0 4 4m-4-4L8 8M5 20h14', }; -export function Icon({ name, className = '' }: { name: string; className?: string }) { +export function Icon({ + name, + className = 'h-5 w-5', + title = name, +}: { + name: string; + className?: string; + title?: string; +}) { return ( ); diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index bfac660..f3f20a6 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -1,5 +1,5 @@ import { useSelector } from '@tanstack/react-store'; -import { useCallback, useEffect, useMemo } from 'react'; +import { Fragment, useCallback, useEffect, useMemo } from 'react'; import { chartModesForControl, chartPath, roundedChartMaximum } from '../lib/chart'; import { CONTROL_MODE, isControlMode } from '../lib/control-mode'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; @@ -271,19 +271,27 @@ export function SessionChart({ values={elevationValues} /> ) : ( - visibleSeries.map((item) => ( - + visibleSeries.map((item, index) => ( + + + {effectiveMode === 'all' && index < visibleSeries.length - 1 ? ( + diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx index 46aafa3..51dd684 100644 --- a/src/components/session-detail.tsx +++ b/src/components/session-detail.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef } from 'react'; import { EMPTY_ROUTE } from '../constants'; import { CONTROL_MODE } from '../lib/control-mode'; -import { formatAggregateAverage, formatWholeNumber } from '../lib/format'; +import { aggregateMaximum, formatAggregateAverage, formatWholeNumber } from '../lib/format'; import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; import { feelingLabel, formatSessionDateRange, + formatSessionImportLabel, formatSessionTimeRange, + isImportedSession, } from '../lib/saved-sessions'; import { downloadSessionTcx } from '../lib/tcx'; import type { SavedSession, SpeedUnit } from '../types'; @@ -96,12 +98,14 @@ export function SessionDetail({ speedUnit: SpeedUnit; }) { const usesGear = session.controlMode === CONTROL_MODE.GEAR; + const imported = isImportedSession(session); const controlMetric = usesGear ? { accent: 'mint', average: formatAggregateAverage(session.aggregates.gear, 0), icon: 'controls', label: 'GEAR', + maximum: formatWholeNumber(aggregateMaximum(session.aggregates.gear)), unit: '', } : { @@ -109,6 +113,7 @@ export function SessionDetail({ average: formatAggregateAverage(session.aggregates.resistance, 0), icon: 'resistance', label: 'RESISTANCE', + maximum: formatWholeNumber(aggregateMaximum(session.aggregates.resistance)), unit: '%', }; const standardMetrics = STANDARD_METRIC_KEYS.map((key) => { @@ -127,9 +132,19 @@ export function SessionDetail({
    -

    - {formatSessionDateRange(session)} -

    +
    +

    + {formatSessionDateRange(session)} +

    + {imported ? ( + + Imported + + ) : null} +

    {formatSessionTimeRange(session)}

    diff --git a/src/components/session-history-list.tsx b/src/components/session-history-list.tsx index e5b9e8e..6b9a457 100644 --- a/src/components/session-history-list.tsx +++ b/src/components/session-history-list.tsx @@ -1,11 +1,18 @@ import { useMemo } from 'react'; import { formatDuration } from '../lib/format'; -import { feelingLabel, formatSessionListTime, groupSessionsByDate } from '../lib/saved-sessions'; +import { + feelingLabel, + formatSessionListTime, + groupSessionsByDate, + isImportedSession, +} from '../lib/saved-sessions'; import { formatDistance } from '../lib/units'; import type { SavedSessionSummary, SpeedUnit } from '../types'; +import { Icon } from './icon'; export function SessionHistoryList({ error, + highlightedSessionIds, onLoadMore, onSelect, selectedId, @@ -14,6 +21,7 @@ export function SessionHistoryList({ total, }: { error: string; + highlightedSessionIds: string[]; onLoadMore: () => void; onSelect: (id: string) => void; selectedId?: string; @@ -22,6 +30,7 @@ export function SessionHistoryList({ total: number; }) { const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); + const highlightedIds = useMemo(() => new Set(highlightedSessionIds), [highlightedSessionIds]); return (
    ))} diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index a633cb2..a81b65f 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSessionHistory } from '../hooks/use-session-history'; import { eventTargetsEditableControl, @@ -40,7 +40,13 @@ export function SessionHistory({ const { deleteSelectedSession: deleteHistorySession, deleting, + downloadAllTcx, error, + exporting, + historyStatus, + highlightedSessionIds, + importTcxFile, + importing, loading, loadMore, selected, @@ -51,6 +57,8 @@ export function SessionHistory({ } = useSessionHistory(open); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [historyHelpOpen, setHistoryHelpOpen] = useState(false); + const importInput = useRef(null); + const transferring = exporting || importing; useEffect(() => { if (!open) { @@ -184,16 +192,52 @@ export function SessionHistory({ open={open} panelClassName="flex max-w-6xl flex-col overflow-hidden sm:w-[min(72rem,calc(100vw-2rem))]" > -
    -
    +
    +

    Session history

    -

    +

    Saved on this device · {total} {total === 1 ? 'session' : 'sessions'} + {historyStatus ? ( + · {historyStatus} + ) : null}

    + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (file) { + importTcxFile(file); + } + }} + ref={importInput} + type="file" + /> + +