From 61c35ce322fb53565fe468be0dc59076b3525dfb Mon Sep 17 00:00:00 2001 From: Ride Control Date: Wed, 22 Jul 2026 22:42:15 -0700 Subject: [PATCH 1/2] Adopt TanStack Router for deep links --- README.md | 2 +- bun.lock | 19 ++++++ package.json | 1 + src/app.tsx | 133 ++++++++++++++++++++++++------------- src/lib/app-route.ts | 98 +++++++++++---------------- src/lib/control-mode.ts | 2 - src/lib/gears.ts | 3 +- src/main.tsx | 6 +- src/router.ts | 53 +++++++++++++++ tests/app-route.test.ts | 119 +++++++++++++++++++++++---------- tests/components.test.tsx | 36 +++++----- tests/control-mode.test.ts | 22 ++---- vite.config.ts | 14 ++++ 13 files changed, 330 insertions(+), 178 deletions(-) create mode 100644 src/router.ts diff --git a/README.md b/README.md index c20a535..0d85b5d 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. - Restores the paired-devices, terrain-workouts, or session-history side tray when the page reloads while that tray is open, and clears the remembered tray as soon as it closes. Pulsing indicators that communicate live connection or route state remain active even when Chromium reports a reduced-motion preference. -- Keeps shareable deep links synchronized with the visible selection: `/bikegpx/:routeId` opens the terrain-workout tray, BikeGPX browser, map, and requested prepared route; `/workouts/:workoutId` opens and centers the requested workout; `/sessions/:sessionId` opens the complete saved-session detail; and `/devices` opens the paired-devices tray. Collection links open the corresponding tray, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface. +- Uses TanStack Router to keep shareable deep links synchronized with the visible selection: `/bikegpx/:routeId` opens the terrain-workout tray, BikeGPX browser, map, and requested prepared route; `/workouts/:workoutId` opens and centers the requested workout; `/sessions/:sessionId` opens the complete saved-session detail; and `/devices` opens the paired-devices tray. Collection links open the corresponding tray, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface. - Manages the smart trainer, heart rate monitor, and the physical `+` Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, one animated `Connecting...` label in device details and reconnect buttons, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Ride Control currently exposes only the reliable `+` controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical `+` button shifts up, and the blue `Y` button shifts down; the controller row briefly identifies those inputs as `+` and `−` while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than `1.2.0` with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices 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. diff --git a/bun.lock b/bun.lock index 425a5e7..efeaebc 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@dnd-kit/sortable": "10.0.0", "@garmin/fitsdk": "^21.208.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-router": "^1.170.18", "@tanstack/react-store": "^0.11.0", "@tanstack/react-virtual": "^3.14.7", "@vitejs/plugin-react": "^6.0.3", @@ -309,10 +310,16 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.170.18", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.15", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.7", "", { "dependencies": { "@tanstack/virtual-core": "3.17.5" }, "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-11uSrj77IDijNBqizD4lY4y1laMyRrqMLSxjnWy5CvWkCjyRDW+gGmxYq0lwQKVas/sq7zyzYWXbL/BvBzR32g=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA=="], + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.5", "", {}, "sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg=="], @@ -417,6 +424,8 @@ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -493,6 +502,8 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -593,6 +604,10 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], + + "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -671,10 +686,14 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/react-router/@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "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-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "espree/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], } } diff --git a/package.json b/package.json index d360eb1..0b6ae8b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@dnd-kit/sortable": "10.0.0", "@garmin/fitsdk": "^21.208.0", "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-router": "^1.170.18", "@tanstack/react-store": "^0.11.0", "@tanstack/react-virtual": "^3.14.7", "@vitejs/plugin-react": "^6.0.3", diff --git a/src/app.tsx b/src/app.tsx index 9f12a24..8d71eba 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,3 +1,4 @@ +import { useNavigate, useRouterState } from '@tanstack/react-router'; import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AppFooter } from './components/app-footer'; @@ -28,14 +29,15 @@ import { useZwiftClick } from './hooks/use-zwift-click'; import { APP_OVERLAY, type AppOverlay, + isSideTrayOverlay, loadOpenSideTray, persistOpenSideTray, } from './lib/app-overlay'; import { APP_ROUTE_KIND, + APP_ROUTE_PATH, type AppRoute, - appRouteFromPathname, - appRoutePath, + appRouteFromRouterMatch, appRouteSideTray, HOME_APP_ROUTE, } from './lib/app-route'; @@ -50,6 +52,7 @@ import { virtualShiftingConnectionReady, } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; +import { unreachable } from './lib/errors'; import { resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; @@ -106,13 +109,14 @@ function restoredRoute(overlay: AppOverlay | undefined): AppRoute { return HOME_APP_ROUTE; } -function initialNavigation(): InitialNavigation { - const pathname = globalThis.location?.pathname ?? '/'; - const linkedRoute = appRouteFromPathname(pathname); +function initialNavigation(linkedRoute: AppRoute, pathname: string): InitialNavigation { const linkedOverlay = appRouteSideTray(linkedRoute); if (linkedOverlay) { return { overlay: linkedOverlay, route: linkedRoute }; } + if (pathname !== APP_ROUTE_PATH.HOME) { + return { route: HOME_APP_ROUTE }; + } const restoredOverlay = loadOpenSideTray(); return { overlay: restoredOverlay ?? (shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined), @@ -120,42 +124,77 @@ function initialNavigation(): InitialNavigation { }; } -function updateBrowserRoute(route: AppRoute, replace: boolean) { - if (!globalThis.history) { - return; - } - const path = appRoutePath(route); - if (globalThis.location.pathname === path) { - return; - } - if (replace) { - globalThis.history.replaceState(null, '', path); - } else { - globalThis.history.pushState(null, '', path); - } -} - export function App({ initialSession = emptySession }: { initialSession?: StoredSession }) { - const [initialAppNavigation] = useState(initialNavigation); + const routerNavigation = useRouterState({ + select: (state) => ({ + pathname: state.location.pathname, + route: appRouteFromRouterMatch(state.matches.at(-1)), + }), + }); + const { pathname, route: matchedAppRoute } = routerNavigation; + const navigate = useNavigate(); + const [initialAppNavigation] = useState(() => initialNavigation(matchedAppRoute, pathname)); + const restoringRoute = useRef( + pathname === APP_ROUTE_PATH.HOME && + initialAppNavigation.route.kind !== APP_ROUTE_KIND.HOME && + matchedAppRoute.kind === APP_ROUTE_KIND.HOME + ); + const appRoute = restoringRoute.current ? initialAppNavigation.route : matchedAppRoute; const rememberedDevices = useRememberedBluetoothDevices(); const trainer = useTrainer(rememberedDevices); - const [appRoute, setAppRoute] = useState(initialAppNavigation.route); const [activeOverlay, setActiveOverlayState] = useState( initialAppNavigation.overlay ); - const showAppRoute = useCallback((route: AppRoute) => { - const overlay = appRouteSideTray(route); - persistBikeGpxBrowserOpen(route.kind === APP_ROUTE_KIND.BIKEGPX); - persistOpenSideTray(overlay); - setAppRoute(route); - setActiveOverlayState(overlay); - }, []); const navigateToAppRoute = useCallback( (route: AppRoute, replace = false) => { - showAppRoute(route); - updateBrowserRoute(route, replace); + const overlay = appRouteSideTray(route); + persistBikeGpxBrowserOpen(route.kind === APP_ROUTE_KIND.BIKEGPX); + persistOpenSideTray(overlay); + switch (route.kind) { + case APP_ROUTE_KIND.BIKEGPX: + if (route.routeId) { + navigate({ + params: { routeId: route.routeId }, + replace, + to: APP_ROUTE_PATH.BIKEGPX_ROUTE, + }).catch(() => undefined); + } else { + navigate({ replace, to: APP_ROUTE_PATH.BIKEGPX }).catch(() => undefined); + } + return; + case APP_ROUTE_KIND.DEVICES: + navigate({ replace, to: APP_ROUTE_PATH.DEVICES }).catch(() => undefined); + return; + case APP_ROUTE_KIND.HOME: + navigate({ replace, to: APP_ROUTE_PATH.HOME }).catch(() => undefined); + return; + case APP_ROUTE_KIND.SESSION: + if (route.sessionId) { + navigate({ + params: { sessionId: route.sessionId }, + replace, + to: APP_ROUTE_PATH.SESSION, + }).catch(() => undefined); + } else { + navigate({ replace, to: APP_ROUTE_PATH.SESSIONS }).catch(() => undefined); + } + return; + case APP_ROUTE_KIND.WORKOUT: + if (route.workoutId) { + navigate({ + params: { workoutId: route.workoutId }, + replace, + to: APP_ROUTE_PATH.WORKOUT, + }).catch(() => undefined); + } else { + navigate({ replace, to: APP_ROUTE_PATH.WORKOUTS }).catch(() => undefined); + } + return; + default: + return unreachable(route); + } }, - [showAppRoute] + [navigate] ); const setActiveOverlay = useCallback( (overlay: AppOverlay | undefined) => { @@ -173,21 +212,27 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored } persistBikeGpxBrowserOpen(false); persistOpenSideTray(overlay); - setAppRoute(HOME_APP_ROUTE); setActiveOverlayState(overlay); - updateBrowserRoute(HOME_APP_ROUTE, true); + navigate({ replace: true, to: APP_ROUTE_PATH.HOME }).catch(() => undefined); }, - [navigateToAppRoute] + [navigate, navigateToAppRoute] ); useEffect(() => { - updateBrowserRoute(initialAppNavigation.route, true); - persistBikeGpxBrowserOpen(initialAppNavigation.route.kind === APP_ROUTE_KIND.BIKEGPX); - const handlePopState = () => { - showAppRoute(appRouteFromPathname(globalThis.location.pathname)); - }; - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, [initialAppNavigation.route, showAppRoute]); + if (restoringRoute.current) { + restoringRoute.current = false; + navigateToAppRoute(initialAppNavigation.route, true); + return; + } + const routeOverlay = appRouteSideTray(matchedAppRoute); + persistBikeGpxBrowserOpen(matchedAppRoute.kind === APP_ROUTE_KIND.BIKEGPX); + persistOpenSideTray(routeOverlay); + setActiveOverlayState((currentOverlay) => { + if (routeOverlay) { + return routeOverlay; + } + return isSideTrayOverlay(currentOverlay) ? undefined : currentOverlay; + }); + }, [initialAppNavigation.route, matchedAppRoute, navigateToAppRoute]); const devicesOpen = activeOverlay === APP_OVERLAY.DEVICES; const clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); @@ -207,8 +252,6 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const speedUnit = useSelector(preferencesStore, (preferences) => preferences.speedUnit); const workoutLibrary = useWorkoutLibrary(); const virtualShiftingReady = virtualShiftingConnectionReady({ - clickConnectedCount: click.connectedCount, - clickPairedCount: click.pairedCount, trainerConnected: trainer.connected, }); const gearResistanceRef = useRef<(fromGear: number, toGear: number) => void>( diff --git a/src/lib/app-route.ts b/src/lib/app-route.ts index 7c26d2e..bbf9efd 100644 --- a/src/lib/app-route.ts +++ b/src/lib/app-route.ts @@ -1,6 +1,17 @@ import { APP_OVERLAY, type SideTrayOverlay } from './app-overlay'; import { unreachable } from './errors'; +export const APP_ROUTE_PATH = { + BIKEGPX: '/bikegpx', + BIKEGPX_ROUTE: '/bikegpx/$routeId', + DEVICES: '/devices', + HOME: '/', + SESSION: '/sessions/$sessionId', + SESSIONS: '/sessions', + WORKOUT: '/workouts/$workoutId', + WORKOUTS: '/workouts', +} as const; + export const APP_ROUTE_KIND = { BIKEGPX: 'bikegpx', DEVICES: 'devices', @@ -18,69 +29,40 @@ export type AppRoute = export const HOME_APP_ROUTE: AppRoute = { kind: APP_ROUTE_KIND.HOME }; -const APP_ROUTE_SEGMENT = { - BIKEGPX: 'bikegpx', - DEVICES: 'devices', - SESSIONS: 'sessions', - WORKOUTS: 'workouts', -} as const; - -function decodedRouteId(segment: string | undefined): string | undefined { - if (!segment) { - return; - } - try { - return decodeURIComponent(segment) || undefined; - } catch { - // Ignore malformed percent-encoding at the URL boundary. - } -} - -export function appRouteFromPathname(pathname: string): AppRoute { - const segments = pathname.split('/').filter(Boolean); - if (segments.length === 0) { - return HOME_APP_ROUTE; - } - const [section, encodedId, ...extra] = segments; - if (extra.length > 0) { - return HOME_APP_ROUTE; - } - const id = decodedRouteId(encodedId); - switch (section) { - case APP_ROUTE_SEGMENT.BIKEGPX: - return { kind: APP_ROUTE_KIND.BIKEGPX, routeId: id }; - case APP_ROUTE_SEGMENT.DEVICES: - return id ? HOME_APP_ROUTE : { kind: APP_ROUTE_KIND.DEVICES }; - case APP_ROUTE_SEGMENT.SESSIONS: - return { kind: APP_ROUTE_KIND.SESSION, sessionId: id }; - case APP_ROUTE_SEGMENT.WORKOUTS: - return { kind: APP_ROUTE_KIND.WORKOUT, workoutId: id }; +export function appRouteFromRouterMatch( + match: + | { + params: Readonly>; + routeId: string; + } + | undefined +): AppRoute { + switch (match?.routeId) { + case APP_ROUTE_PATH.BIKEGPX_ROUTE: + return match.params.routeId + ? { kind: APP_ROUTE_KIND.BIKEGPX, routeId: match.params.routeId } + : { kind: APP_ROUTE_KIND.BIKEGPX }; + case APP_ROUTE_PATH.BIKEGPX: + return { kind: APP_ROUTE_KIND.BIKEGPX }; + case APP_ROUTE_PATH.DEVICES: + return { kind: APP_ROUTE_KIND.DEVICES }; + case APP_ROUTE_PATH.SESSION: + return match.params.sessionId + ? { kind: APP_ROUTE_KIND.SESSION, sessionId: match.params.sessionId } + : { kind: APP_ROUTE_KIND.SESSION }; + case APP_ROUTE_PATH.SESSIONS: + return { kind: APP_ROUTE_KIND.SESSION }; + case APP_ROUTE_PATH.WORKOUT: + return match.params.workoutId + ? { kind: APP_ROUTE_KIND.WORKOUT, workoutId: match.params.workoutId } + : { kind: APP_ROUTE_KIND.WORKOUT }; + case APP_ROUTE_PATH.WORKOUTS: + return { kind: APP_ROUTE_KIND.WORKOUT }; default: return HOME_APP_ROUTE; } } -function routePath(section: string, id?: string): string { - return id ? `/${section}/${encodeURIComponent(id)}` : `/${section}`; -} - -export function appRoutePath(route: AppRoute): string { - switch (route.kind) { - case APP_ROUTE_KIND.BIKEGPX: - return routePath(APP_ROUTE_SEGMENT.BIKEGPX, route.routeId); - case APP_ROUTE_KIND.DEVICES: - return routePath(APP_ROUTE_SEGMENT.DEVICES); - case APP_ROUTE_KIND.HOME: - return '/'; - case APP_ROUTE_KIND.SESSION: - return routePath(APP_ROUTE_SEGMENT.SESSIONS, route.sessionId); - case APP_ROUTE_KIND.WORKOUT: - return routePath(APP_ROUTE_SEGMENT.WORKOUTS, route.workoutId); - default: - return unreachable(route); - } -} - export function appRouteSideTray(route: AppRoute): SideTrayOverlay | undefined { switch (route.kind) { case APP_ROUTE_KIND.BIKEGPX: diff --git a/src/lib/control-mode.ts b/src/lib/control-mode.ts index 7e36812..fc32bca 100644 --- a/src/lib/control-mode.ts +++ b/src/lib/control-mode.ts @@ -18,8 +18,6 @@ export function trainingControlMode(clickPaired: boolean, workoutSelected: boole export function virtualShiftingConnectionReady({ trainerConnected, }: { - clickConnectedCount: number; - clickPairedCount: number; trainerConnected: boolean; }): boolean { return trainerConnected; diff --git a/src/lib/gears.ts b/src/lib/gears.ts index 30873d4..b174b54 100644 --- a/src/lib/gears.ts +++ b/src/lib/gears.ts @@ -19,8 +19,7 @@ export const MIN_GEAR = 1; export const MAX_GEAR = VIRTUAL_GEAR_COMBINATIONS.length; export const DEFAULT_GEAR = 12; export const GEAR_STORAGE_KEY = 'trainer-virtual-gear'; -export const SHIFTING_CONNECTION_MESSAGE = - 'Connect the trainer and controllers before shifting gears.'; +export const SHIFTING_CONNECTION_MESSAGE = 'Connect the trainer before shifting gears.'; export const MINIMUM_VIRTUAL_DRIVE_RATIO = Math.min( ...VIRTUAL_GEAR_COMBINATIONS.map(({ ratio }) => ratio) ); diff --git a/src/main.tsx b/src/main.tsx index 78b64e5..10cfbd3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ +import { RouterProvider } from '@tanstack/react-router'; import { createRoot } from 'react-dom/client'; -import { App } from './app'; import { loadInitialSession } from './lib/active-session'; +import { createAppRouter } from './router'; import './style.css'; const root = document.getElementById('root'); @@ -8,4 +9,5 @@ if (!root) { throw new Error('Missing #root element.'); } -createRoot(root).render(); +const router = createAppRouter({ initialSession: await loadInitialSession() }); +createRoot(root).render(); diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..c4809bc --- /dev/null +++ b/src/router.ts @@ -0,0 +1,53 @@ +import { + createRootRoute, + createRoute, + createRouter, + type RouterHistory, + redirect, +} from '@tanstack/react-router'; +import { createElement } from 'react'; +import { App } from './app'; +import { emptySession } from './constants'; +import { APP_ROUTE_PATH } from './lib/app-route'; +import type { StoredSession } from './types'; + +export interface AppRouterOptions { + history?: RouterHistory; + initialSession?: StoredSession; +} + +export function createAppRouter({ history, initialSession = emptySession }: AppRouterOptions = {}) { + const rootRoute = createRootRoute({ + component: () => createElement(App, { initialSession }), + }); + const childRoutes = [ + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.HOME }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.DEVICES }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.WORKOUTS }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.WORKOUT }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.BIKEGPX }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.BIKEGPX_ROUTE }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.SESSIONS }), + createRoute({ getParentRoute: () => rootRoute, path: APP_ROUTE_PATH.SESSION }), + createRoute({ + beforeLoad: () => { + throw redirect({ replace: true, to: APP_ROUTE_PATH.HOME }); + }, + getParentRoute: () => rootRoute, + path: '$', + }), + ]; + return createRouter({ + history, + routeTree: rootRoute.addChildren(childRoutes), + trailingSlash: 'never', + }); +} + +export type AppRouter = ReturnType; + +declare module '@tanstack/react-router' { + interface Register { + router: AppRouter; + } +} diff --git a/tests/app-route.test.ts b/tests/app-route.test.ts index 4842d90..5ee4a03 100644 --- a/tests/app-route.test.ts +++ b/tests/app-route.test.ts @@ -1,58 +1,109 @@ import { describe, expect, test } from 'bun:test'; +import { createMemoryHistory } from '@tanstack/react-router'; import { APP_OVERLAY } from '../src/lib/app-overlay'; import { APP_ROUTE_KIND, - appRouteFromPathname, - appRoutePath, + APP_ROUTE_PATH, + appRouteFromRouterMatch, appRouteSideTray, } from '../src/lib/app-route'; +import { createAppRouter } from '../src/router'; + +async function loadedRoute(pathname: string) { + const router = createAppRouter({ + history: createMemoryHistory({ initialEntries: [pathname] }), + }); + await router.load(); + return { + match: router.state.matches.at(-1), + redirectHref: router.state.redirect?.options.href, + }; +} describe('application deep links', () => { - test('parses direct BikeGPX, workout, session, and devices links', () => { - expect(appRouteFromPathname('/bikegpx/2635')).toEqual({ + test('matches direct BikeGPX, workout, session, and devices links', async () => { + const bikeGpx = await loadedRoute('/bikegpx/2635'); + expect(bikeGpx.match?.routeId).toBe(APP_ROUTE_PATH.BIKEGPX_ROUTE); + expect(bikeGpx.match?.params).toEqual({ routeId: '2635' }); + expect(appRouteFromRouterMatch(bikeGpx.match)).toEqual({ kind: APP_ROUTE_KIND.BIKEGPX, routeId: '2635', }); - expect(appRouteFromPathname('/workouts/prairie%20roll')).toEqual({ + + const workout = await loadedRoute('/workouts/prairie%20roll'); + expect(workout.match?.routeId).toBe(APP_ROUTE_PATH.WORKOUT); + expect(workout.match?.params).toEqual({ workoutId: 'prairie roll' }); + expect(appRouteFromRouterMatch(workout.match)).toEqual({ kind: APP_ROUTE_KIND.WORKOUT, workoutId: 'prairie roll', }); - expect(appRouteFromPathname('/sessions/ride%2Fmorning')).toEqual({ + + const session = await loadedRoute('/sessions/ride%2Fmorning'); + expect(session.match?.routeId).toBe(APP_ROUTE_PATH.SESSION); + expect(session.match?.params).toEqual({ sessionId: 'ride/morning' }); + expect(appRouteFromRouterMatch(session.match)).toEqual({ kind: APP_ROUTE_KIND.SESSION, sessionId: 'ride/morning', }); - expect(appRouteFromPathname('/devices')).toEqual({ kind: APP_ROUTE_KIND.DEVICES }); - }); - test('supports collection links and rejects malformed paths', () => { - expect(appRouteFromPathname('/bikegpx/')).toEqual({ - kind: APP_ROUTE_KIND.BIKEGPX, - }); - expect(appRouteFromPathname('/workouts')).toEqual({ - kind: APP_ROUTE_KIND.WORKOUT, - }); - expect(appRouteFromPathname('/sessions')).toEqual({ - kind: APP_ROUTE_KIND.SESSION, + const devices = await loadedRoute('/devices'); + expect(devices.match?.routeId).toBe(APP_ROUTE_PATH.DEVICES); + expect(appRouteFromRouterMatch(devices.match)).toEqual({ + kind: APP_ROUTE_KIND.DEVICES, }); - expect(appRouteFromPathname('/unknown/path')).toEqual({ kind: APP_ROUTE_KIND.HOME }); - expect(appRouteFromPathname('/devices/trainer')).toEqual({ kind: APP_ROUTE_KIND.HOME }); - expect(appRouteFromPathname('/sessions/%E0%A4%A')).toEqual({ - kind: APP_ROUTE_KIND.SESSION, + }); + + test('matches collection links and redirects unknown paths home', async () => { + expect((await loadedRoute('/bikegpx')).match?.routeId).toBe(APP_ROUTE_PATH.BIKEGPX); + expect((await loadedRoute('/workouts')).match?.routeId).toBe(APP_ROUTE_PATH.WORKOUTS); + expect((await loadedRoute('/sessions')).match?.routeId).toBe(APP_ROUTE_PATH.SESSIONS); + expect((await loadedRoute('/unknown/path')).redirectHref).toBe(APP_ROUTE_PATH.HOME); + expect((await loadedRoute('/devices/trainer')).redirectHref).toBe(APP_ROUTE_PATH.HOME); + }); + + test('builds encoded direct links and selects their parent trays', async () => { + const router = createAppRouter({ + history: createMemoryHistory({ initialEntries: [APP_ROUTE_PATH.HOME] }), }); + await router.load(); + expect( + router.buildLocation({ + params: { routeId: '26/35' }, + to: APP_ROUTE_PATH.BIKEGPX_ROUTE, + }).href + ).toBe('/bikegpx/26%2F35'); + expect( + router.buildLocation({ + params: { workoutId: 'hill climb' }, + to: APP_ROUTE_PATH.WORKOUT, + }).href + ).toBe('/workouts/hill%20climb'); + expect( + router.buildLocation({ + params: { sessionId: 'ride#1' }, + to: APP_ROUTE_PATH.SESSION, + }).href + ).toBe('/sessions/ride%231'); + + expect(appRouteSideTray({ kind: APP_ROUTE_KIND.BIKEGPX })).toBe(APP_OVERLAY.WORKOUTS); + expect(appRouteSideTray({ kind: APP_ROUTE_KIND.WORKOUT })).toBe(APP_OVERLAY.WORKOUTS); + expect(appRouteSideTray({ kind: APP_ROUTE_KIND.SESSION })).toBe(APP_OVERLAY.HISTORY); + expect(appRouteSideTray({ kind: APP_ROUTE_KIND.DEVICES })).toBe(APP_OVERLAY.DEVICES); }); - test('serializes encoded direct links and selects their parent tray', () => { - const bikeGpx = { kind: APP_ROUTE_KIND.BIKEGPX, routeId: '26/35' } as const; - const workout = { kind: APP_ROUTE_KIND.WORKOUT, workoutId: 'hill climb' } as const; - const session = { kind: APP_ROUTE_KIND.SESSION, sessionId: 'ride#1' } as const; - const devices = { kind: APP_ROUTE_KIND.DEVICES } as const; - expect(appRoutePath(bikeGpx)).toBe('/bikegpx/26%2F35'); - expect(appRoutePath(workout)).toBe('/workouts/hill%20climb'); - expect(appRoutePath(session)).toBe('/sessions/ride%231'); - expect(appRoutePath(devices)).toBe('/devices'); - expect(appRouteSideTray(bikeGpx)).toBe(APP_OVERLAY.WORKOUTS); - expect(appRouteSideTray(workout)).toBe(APP_OVERLAY.WORKOUTS); - expect(appRouteSideTray(session)).toBe(APP_OVERLAY.HISTORY); - expect(appRouteSideTray(devices)).toBe(APP_OVERLAY.DEVICES); + test('moves through application history without reloading the dashboard', async () => { + const history = createMemoryHistory({ initialEntries: [APP_ROUTE_PATH.HOME] }); + const router = createAppRouter({ history }); + await router.load(); + await router.navigate({ to: APP_ROUTE_PATH.DEVICES }); + expect(router.state.location.pathname).toBe(APP_ROUTE_PATH.DEVICES); + + router.history.back(); + await router.load(); + expect(router.state.location.pathname).toBe(APP_ROUTE_PATH.HOME); + + router.history.forward(); + await router.load(); + expect(router.state.location.pathname).toBe(APP_ROUTE_PATH.DEVICES); }); }); diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 7870a33..fa4f6f5 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; +import { createMemoryHistory, RouterProvider } from '@tanstack/react-router'; import { renderToStaticMarkup } from 'react-dom/server'; -import { App } from '../src/app'; import { ConnectionControl } from '../src/components/connection-control'; import { DevicePairingButton, DevicePairingPanel } from '../src/components/device-pairing'; import { GearControl } from '../src/components/gear-control'; @@ -34,9 +34,19 @@ import { SESSION_WORKFLOW_INTENT } from '../src/lib/session-workflow'; import { WORKOUT_DESCRIPTION_ATTRIBUTION } from '../src/lib/workout-description'; import { WORKOUT_ROUTE_TYPE } from '../src/lib/workout-schema'; import { WORKOUT_COURSES, workoutTerrainAtDistance } from '../src/lib/workouts'; +import { createAppRouter } from '../src/router'; +import type { StoredSession } from '../src/types'; import { savedSessionFixture } from './fixtures/saved-session'; const render = (element: React.ReactNode) => renderToStaticMarkup(element); +const renderApp = async (initialSession?: StoredSession) => { + const router = createAppRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + initialSession, + }); + await router.load(); + return render(); +}; const enabledEndSessionButton = /]*disabled)[^>]*>End session<\/button>/; const solidChartBoundaries = /d="M0 14H100 M0 90H100"[^>]*stroke="#3a4654"(?![^>]*stroke-dasharray)/; @@ -473,11 +483,9 @@ 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.'); + expect(html).not.toContain('Connect the trainer before shifting gears.'); const disabled = render( undefined} />); - expect(disabled).not.toContain( - 'Connect the trainer and controllers before shifting gears.' - ); + expect(disabled).not.toContain('Connect the trainer before shifting gears.'); expect(disabled.match(/disabled=""/g)).toHaveLength(2); }); @@ -770,7 +778,7 @@ describe('view components', () => { expect(html).toContain('chrome://flags/'); }); - test('composes the application dashboard', () => { + test('composes the application dashboard', async () => { Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { @@ -779,7 +787,7 @@ describe('view components', () => { setItem: () => undefined, }, }); - const html = render(); + const html = await renderApp(); expect(html).toContain('Resistance control'); expect(html).not.toContain('Import GPX'); expect(html).toContain('Pair devices'); @@ -807,19 +815,15 @@ describe('view components', () => { expect(html).toMatch(enabledEndSessionButton); }); - test('shows manual virtual shifting for a terrain workout without Click controllers', () => { + test('shows manual virtual shifting for a terrain workout without Click controllers', async () => { const [course] = WORKOUT_COURSES; if (!course) { throw new Error('Expected a built-in workout course'); } - const html = render( - - ); + const html = await renderApp({ + ...emptySession, + workout: { course }, + }); expect(html).toContain('Virtual shifting'); expect(html).toContain('Shift to an easier gear'); expect(html).toContain('Shift to a harder gear'); diff --git a/tests/control-mode.test.ts b/tests/control-mode.test.ts index 281f694..364b5d6 100644 --- a/tests/control-mode.test.ts +++ b/tests/control-mode.test.ts @@ -4,6 +4,7 @@ import { trainingControlMode, virtualShiftingConnectionReady, } from '../src/lib/control-mode'; +import { SHIFTING_CONNECTION_MESSAGE } from '../src/lib/gears'; describe('training control mode', () => { test('uses virtual gears for Click or terrain workouts', () => { @@ -13,28 +14,13 @@ describe('training control mode', () => { expect(trainingControlMode(false, false)).toBe(CONTROL_MODE.RESISTANCE); }); - test('enables virtual shifting without waiting for every Click controller', () => { - for (const [clickPairedCount, clickConnectedCount] of [ - [0, 0], - [1, 0], - [2, 0], - [2, 1], - [2, 2], - ]) { - expect( - virtualShiftingConnectionReady({ - clickConnectedCount, - clickPairedCount, - trainerConnected: true, - }) - ).toBeTrue(); - } + test('requires only the trainer for virtual shifting', () => { + expect(virtualShiftingConnectionReady({ trainerConnected: true })).toBeTrue(); expect( virtualShiftingConnectionReady({ - clickConnectedCount: 2, - clickPairedCount: 2, trainerConnected: false, }) ).toBeFalse(); + expect(SHIFTING_CONNECTION_MESSAGE).toBe('Connect the trainer before shifting gears.'); }); }); diff --git a/vite.config.ts b/vite.config.ts index 0fe88fa..72d2d8c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,20 @@ const buildPrUrl = 'https://github.com/RideControlOrg/RideControl/pulls?q=is%3Apr+is%3Aclosed'; export default defineConfig({ + build: { + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { + name: 'tanstack-router', + test: /node_modules[/]@tanstack[/](?:history|react-router|router-core)/, + }, + ], + }, + }, + }, + }, define: { 'import.meta.env.RIDE_CONTROL_BUILD_PR_URL': JSON.stringify(buildPrUrl), 'import.meta.env.RIDE_CONTROL_BUILD_TIMESTAMP_UTC': JSON.stringify(buildTimestampUtc), From 8300d9c94c0d97a2b7307ac2f6728f6c944569c3 Mon Sep 17 00:00:00 2001 From: Ride Control Date: Wed, 22 Jul 2026 23:56:29 -0700 Subject: [PATCH 2/2] Improve trainer recovery and terrain gearing --- README.md | 20 +-- src/app.tsx | 3 + src/components/device-pairing.tsx | 42 +++--- src/components/session-chart.tsx | 11 +- src/constants.ts | 1 + src/hooks/use-heart-rate-monitor.ts | 80 ++++++++-- src/hooks/use-trainer-connection.ts | 196 ++++++++++++++++++------- src/hooks/use-workout.ts | 16 +- src/hooks/use-zwift-click.ts | 22 +-- src/lib/bikegpx.ts | 2 - src/lib/bluetooth-operation.ts | 26 +++- src/lib/chart.ts | 15 ++ src/lib/gears.ts | 16 +- src/lib/latest-value-scheduler.ts | 68 +++++++++ src/lib/reconnect-controller.ts | 9 +- src/lib/session-workout-snapshots.ts | 1 - src/lib/tcx-import.ts | 1 - src/lib/tcx.ts | 1 - src/lib/trainer-device.ts | 26 +++- src/lib/workout-file.ts | 8 +- src/lib/workouts.ts | 19 +-- src/types.ts | 1 - src/workouts/cedar-circuit.json | 1 - src/workouts/granite-switchbacks.json | 1 - src/workouts/harbor-ring.json | 1 - src/workouts/highland-loop.json | 1 - src/workouts/prairie-roll.json | 3 +- src/workouts/ridgeline-time-trial.json | 1 - tests/bikegpx.test.ts | 1 - tests/bluetooth.test.ts | 22 +++ tests/components.test.tsx | 34 ++++- tests/device-connection.test.ts | 4 +- tests/format-chart.test.ts | 15 +- tests/gears.test.ts | 29 +++- tests/latest-value-scheduler.test.ts | 72 +++++++++ tests/tcx-import.test.ts | 1 - tests/tcx.test.ts | 2 +- tests/trainer-device.test.ts | 10 ++ tests/workout-file.test.ts | 6 +- tests/workouts.test.ts | 18 +-- 40 files changed, 615 insertions(+), 191 deletions(-) create mode 100644 src/lib/latest-value-scheduler.ts create mode 100644 tests/latest-value-scheduler.test.ts diff --git a/README.md b/README.md index 0d85b5d..a8caede 100644 --- a/README.md +++ b/README.md @@ -9,18 +9,18 @@ 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. - Restores the paired-devices, terrain-workouts, or session-history side tray when the page reloads while that tray is open, and clears the remembered tray as soon as it closes. Pulsing indicators that communicate live connection or route state remain active even when Chromium reports a reduced-motion preference. - Uses TanStack Router to keep shareable deep links synchronized with the visible selection: `/bikegpx/:routeId` opens the terrain-workout tray, BikeGPX browser, map, and requested prepared route; `/workouts/:workoutId` opens and centers the requested workout; `/sessions/:sessionId` opens the complete saved-session detail; and `/devices` opens the paired-devices tray. Collection links open the corresponding tray, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface. -- Manages the smart trainer, heart rate monitor, and the physical `+` Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, one animated `Connecting...` label in device details and reconnect buttons, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Ride Control currently exposes only the reliable `+` controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical `+` button shifts up, and the blue `Y` button shifts down; the controller row briefly identifies those inputs as `+` and `−` while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than `1.2.0` with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices panel is open stay in setup and do not shift the ride. +- Manages the smart trainer, heart rate monitor, and the physical `+` Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, direct **Cancel pairing** and **Stop connecting** actions during stalled attempts, immediate local removal when **Forget** is chosen even if the Bluetooth link is failing, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Cancelling invalidates the pending attempt so a late browser selection or GATT completion cannot restore it. Ride Control currently exposes only the reliable `+` controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical `+` button shifts up, and the blue `Y` button shifts down; the controller row briefly identifies those inputs as `+` and `−` while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than `1.2.0` with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices 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 restores the trainer, heart-rate monitor, and `+` Click controller from one browser permission snapshot after a reload. Trainer pairing is based on the advertised Bluetooth FTMS capability instead of a vendor-specific name, allowing the same adapter to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Trainers and the active Click controller keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication. +- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and restores the trainer, heart-rate monitor, and `+` Click controller from one browser permission snapshot after a reload. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Trainers and the active Click controller keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication. - Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes. - Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics. -- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, and out-and-back routes; an out-and-back follows the supplied path to its turnaround, then reverses the same location and elevation data back to the start before repeating. Prairie Roll adds a non-intersecting, curving 15-mile loop of long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. Granite Switchbacks adds a sustained four-mile ascent whose hairpin corners briefly get steeper before immediately returning to the steady climbing grade, followed by a ridge and a descending sequence of five recovery rollers. Ridgeline Time Trial is a ten-mile out-and-back with a gradual five-mile, roughly 300-foot hillclimb to the turnaround and the identical terrain in reverse on the return. Every course begins flat without giving nearly level routes an unnecessarily long rollout: low-climb courses use about 0.4 km, moderate rollers use about 0.8 km, and climbing-focused courses retain a 1.5 km rollout. The course then automatically adjusts trainer resistance from the current grade, tracks the rider in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route path and pulsing position markers while pedaling, and uses clear mid-contrast preview lines with a shared elevation scale so gentle rollers remain visibly low beside genuinely mountainous routes. It shows course distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. A workout can be selected before riding or planned while viewing a completed session; ending a workout keeps it selected and previews it at 0% for the next session unless the rider clears or replaces it. A newly planned workout immediately replaces the prior course on the dashboard at 0% progress without changing the completed ride's recorded data. It then remains locked from the moment riding begins until that session ends; definition refreshes for that same workout remain allowed without opening a path to switch courses. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior. -- Downloads terrain workouts as standard GPX 1.1 files with ordinary geographic and elevation data plus Ride Control metadata for stable ids, difficulty, resistance, exact distance, starting location, and route type. Valid GPX tracks or routes can be imported through the file picker or by dropping a file anywhere in the workout tray, then saved into the current device's custom library with every valid route point preserved. The map-first BikeGPX browser lists only fully prepared public routes, searches them by name, place, distance, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, shows the selected route's elevation profile beside its finalized statistics, and imports it in one click. Every visible route already includes finalized distance, climbing, maximum grade, and difficulty, and the first matching route previews automatically. Imported route descriptions can open an in-app map with start and finish markers and an animated bicycle; routes with genuinely nearby endpoints become loops while other routes remain point-to-point. Stable fingerprints prevent duplicate imports. The workout library supports immediate filtering, renaming, removing, and vertical drag reordering with persistent order. The terrain tray and BikeGPX browser remember their open state, scroll positions, searches, filters, and selected route across reloads. Missing descriptions use a cached starting-city lookup that is saved with the workout. +- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, and out-and-back routes; an out-and-back follows the supplied path to its turnaround, then reverses the same location and elevation data back to the start before repeating. Prairie Roll adds a non-intersecting, curving 15-mile loop of repeated gradual climbs and descents. Granite Switchbacks adds a sustained four-mile ascent whose hairpin corners briefly get steeper before immediately returning to the steady climbing grade, followed by a ridge and a descending sequence of five recovery rollers. Ridgeline Time Trial is a ten-mile out-and-back with a gradual five-mile, roughly 300-foot hillclimb to the turnaround and the identical terrain in reverse on the return. Every course begins flat without giving nearly level routes an unnecessarily long rollout: low-climb courses use about 0.4 km, moderate rollers use about 0.8 km, and climbing-focused courses retain a 1.5 km rollout. The course then automatically adjusts trainer resistance from the current grade, tracks the rider in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route path and pulsing position markers while pedaling, and uses clear mid-contrast preview lines with a shared elevation scale so gentle rollers remain visibly low beside genuinely mountainous routes. It shows course distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. A workout can be selected before riding or planned while viewing a completed session; ending a workout keeps it selected and previews it at 0% for the next session unless the rider clears or replaces it. A newly planned workout immediately replaces the prior course on the dashboard at 0% progress without changing the completed ride's recorded data. It then remains locked from the moment riding begins until that session ends; definition refreshes for that same workout remain allowed without opening a path to switch courses. Routes contain geometry and elevation rather than an arbitrary resistance baseline; one shared terrain engine derives the load from grade before virtual gearing is applied. +- Downloads terrain workouts as standard GPX 1.1 files with ordinary geographic and elevation data plus Ride Control metadata for stable ids, difficulty, exact distance, starting location, and route type. Valid GPX tracks or routes can be imported through the file picker or by dropping a file anywhere in the workout tray, then saved into the current device's custom library with every valid route point preserved. The map-first BikeGPX browser lists only fully prepared public routes, searches them by name, place, distance, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, shows the selected route's elevation profile beside its finalized statistics, and imports it in one click. Every visible route already includes finalized distance, climbing, maximum grade, and difficulty, and the first matching route previews automatically. Imported route descriptions can open an in-app map with start and finish markers and an animated bicycle; routes with genuinely nearby endpoints become loops while other routes remain point-to-point. Stable fingerprints prevent duplicate imports. The workout library supports immediate filtering, renaming, removing, and vertical drag reordering with persistent order. The terrain tray and BikeGPX browser remember their open state, scroll positions, searches, filters, and selected route across reloads. Missing descriptions use a cached starting-city lookup that is saved with the workout. - Keeps BikeGPX analysis presentation stable by listing only fully prepared routes. Every visible route already has finalized difficulty, distance, climbing, maximum grade, and map data; routes with unusable coordinate or elevation data never appear. -- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever the `+` Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, and keyboard down/up arrows remain usable. The physical `+` button shifts up and its blue `Y` button shifts down. These controls step through the 24 physical combinations of 39/53-tooth chainrings and a 12-speed 12–24-tooth cassette, sorted by actual drivetrain ratio rather than equal percentage intervals. This gives gear 24 the full 53/12 top end for pushing on descents while retaining 39/24 as gear 1 for climbing. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer resistance. +- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever the `+` Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, and keyboard down/up arrows remain usable. The physical `+` button shifts up and its blue `Y` button shifts down. These controls step through the 24 physical combinations of 39/53-tooth chainrings and a 12-speed 12–24-tooth cassette, sorted by actual drivetrain ratio rather than equal percentage intervals. The prepared route grade produces one stable terrain target, then a calibrated load curve spreads the physical drivetrain ratios across a useful trainer range: gear 12 preserves the terrain target, gear 1 substantially unloads it for climbing, and gear 24 provides the full 53/12 top end. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer resistance. - Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects. -- Tracks every time-series sample plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with no duration-based truncation during recording or FIT/TCX import. Large, high-visibility numbers appear in space-efficient live metric and ride-summary cards, with oversized ride totals and subdued unit labels. Focused or combined charts use a responsive display-only sample of long histories without changing the complete data retained for summaries and exports. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. Workout elevation is recorded across the entire ride, so the course profile repeats for every completed loop. Saved sessions reference immutable, content-addressed workout snapshots in a separate IndexedDB store: identical course definitions share one snapshot, edited definitions retain their historical versions, and deleting a workout from the selectable library cannot break an older session's maps or terrain details. +- Tracks every time-series sample plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with no duration-based truncation during recording or FIT/TCX import. Large, high-visibility numbers appear in space-efficient live metric and ride-summary cards, with oversized ride totals and subdued unit labels. Focused or combined charts use a responsive display-only sample of long histories without changing the complete data retained for summaries and exports. The resistance chart starts at a useful 50% scale and expands in ten-point steps as samples approach its ceiling. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. Workout elevation is recorded across the entire ride, so the course profile repeats for every completed loop. Saved sessions reference immutable, content-addressed workout snapshots in a separate IndexedDB store: identical course definitions share one snapshot, edited definitions retain their historical versions, and deleting a workout from the selectable library cannot break an older session's maps or terrain details. - 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 and in-progress sessions use browser-managed IndexedDB storage, and active rides are checkpointed in small sample chunks so recovery does not repeatedly rewrite the complete history. Existing localStorage recovery data is migrated once and removed only after IndexedDB has accepted it. Saved sessions support optional comments and ride feeling, and persistent browser storage is requested when supported. - Opens saved rides from the dashboard's Sessions button and organizes them by local date and time in a slide-out Sessions tray with a compact inline session count, clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. The tray restores the selected session, the session-list scroll position, and each session's independent detail-pane scroll position after a page reload, falling back to the newest available session when a remembered ride no longer exists. - Downloads saved rides as standards-compliant FIT activities for direct upload to Strava and other fitness services, including indoor-cycling and creator metadata, UTC and local timestamps, distance, speed, power, cadence, estimated crank revolutions and work, heart rate, resistance, elevation, calories, and ride totals. Each FIT filename includes a stable session token for reliable upload identity. TCX export remains available for the richer Ride Control round trip, including virtual gear, terrain workout metadata, ride feeling, comments, and the original session identifier. @@ -64,11 +64,13 @@ uses an explicit overlay state for mutually exclusive trays, renders every side animated and accessible shell, and delegates save/discard/start/continue transitions to a store-backed session workflow. Temporary form inputs remain local React state. Terrain workouts are an independent course domain layered over session distance. Each bundled -course keeps its editable metadata, map geometry, elevation, and resistance baseline in an +course keeps its editable metadata, map geometry, and elevation in an individual JSON definition under `src/workouts`; shared factories derive the normalized runtime course geometry and terrain behavior. Course geometry -produces grade, elevation, current and completed course counts, map position, and a bounded resistance -target. Virtual gearing applies the ordered ratios of the modeled 2×12 drivetrain to that target, so terrain +produces grade, elevation, current and completed course counts, and map position. A shared terrain +engine turns grade into one bounded resistance target, independent of which route supplied it. +Virtual gearing applies a calibrated load curve across the modeled 2×12 ratios without feeding +the trainer's resulting speed, power, and cadence back into the same resistance target, so terrain changes ramp smoothly while button-driven gear changes remain immediate. Recorded grade, resistance, and elevation appear alongside the other session graphs for the full ride, and the course profile repeats on every loop or out-and-back trip while point-to-point routes stop at their finish; route diff --git a/src/app.tsx b/src/app.tsx index 8d71eba..62b64ba 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -624,6 +624,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored void; onDisconnect: () => void; onForget: () => void | Promise; onPair: () => void | Promise; @@ -165,19 +166,6 @@ function SlowReconnectNotice() { ); } -function DeviceConnectionAction({ - busy, - disconnecting, -}: { - busy: boolean; - disconnecting: boolean; -}) { - if (busy) { - return ; - } - return disconnecting ? 'Disconnect' : 'Reconnect'; -} - function ClickConnectionStatus({ click, waiting }: { click: ClickSlot; waiting: boolean }) { if (!click.connectionActive && click.pairedCount) { return <>Reconnects when the session resumes; @@ -224,14 +212,24 @@ function StatusDot({ function DeviceActions({ slot }: { slot: DeviceSlot }) { const actionBusy = slot.busy; if (!slot.paired) { + if (actionBusy) { + return ( + + ); + } return ( ); } @@ -240,15 +238,23 @@ function DeviceActions({ slot }: { slot: DeviceSlot }) { function ConnectedDeviceActions({ slot }: { slot: Omit }) { const disconnecting = slot.connected && !slot.busy; + let connectionAction = slot.onReconnect; + let connectionLabel = 'Reconnect'; + if (slot.busy) { + connectionAction = slot.onCancel; + connectionLabel = 'Stop connecting'; + } else if (disconnecting) { + connectionAction = slot.onDisconnect; + connectionLabel = 'Disconnect'; + } return (
'); expect(panel).not.toContain('>Reconnect'); - expect(panel.match(/Connecting\.\.\.<\/span>/g)).toHaveLength(2); - expect(panel.match(/connecting-dot/g)).toHaveLength(6); + expect(panel.match(/Connecting\.\.\.<\/span>/g)).toHaveLength(1); + expect(panel.match(/connecting-dot/g)).toHaveLength(3); expect(panel.match(/connection-status-pulse/g)).toHaveLength(2); expect(panel).toContain('shadow-[0_0_16px_rgba(56,189,248,.95)]'); expect(panel).toContain('Automatic reconnect in Chrome'); @@ -367,6 +369,32 @@ 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 pairingPanel = render( + undefined, + onPairController: () => undefined, + pairedCount: 0, + reconnecting: false, + }} + heartRate={common} + onClose={() => undefined} + open + trainer={{ + ...common, + busy: true, + phase: 'pairing', + status: 'Pairing…', + }} + /> + ); + expect(pairingPanel).toContain('>Cancel pairing'); + expect(pairingPanel).not.toContain('disabled=""'); const inactiveClickPanel = render( { expect(panel.match(/Download GPX/g)).toHaveLength(6); expect(panel).toContain('10.0 mi out & back'); expect(panel).toContain('15.0 mi loop'); - expect(panel).toContain('15–25% resistance'); + expect(panel).toContain('repeated gradual climbs and descents'); expect(panel).toContain('49 ft climbing'); expect(panel).not.toContain('15 m climbing'); expect(panel).toContain('stroke="#64748b"'); diff --git a/tests/device-connection.test.ts b/tests/device-connection.test.ts index 48ce528..dc858ec 100644 --- a/tests/device-connection.test.ts +++ b/tests/device-connection.test.ts @@ -260,7 +260,7 @@ describe('reconnect controller', () => { expect(watchSignal?.aborted).toBeTrue(); }); - test('preserves an advertisement received during an in-flight attempt', async () => { + test('preserves retry backoff after an advertisement during an in-flight attempt', async () => { const callbacks: Array<() => void | Promise> = []; const delays: number[] = []; let finishAttempt: ((connected: boolean) => void) | undefined; @@ -284,7 +284,7 @@ describe('reconnect controller', () => { finishAttempt?.(false); await firstAttempt; - expect(delays).toEqual([500, 1]); + expect(delays).toEqual([500, 500]); expect(controller.isPending('heart-rate')).toBeTrue(); }); }); diff --git a/tests/format-chart.test.ts b/tests/format-chart.test.ts index 911c94d..5c98749 100644 --- a/tests/format-chart.test.ts +++ b/tests/format-chart.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { chartPath, roundedChartMaximum, storedChartMode } from '../src/lib/chart'; +import { + chartPath, + resistanceChartMaximum, + roundedChartMaximum, + storedChartMode, +} from '../src/lib/chart'; import { aggregateMaximum, formatAggregateAverage, @@ -55,6 +60,14 @@ describe('chart utilities', () => { expect(roundedChartMaximum(20, 100, 50)).toBe(100); }); + test('starts resistance charts at 50 percent and expands with headroom', () => { + expect(resistanceChartMaximum(0)).toBe(50); + expect(resistanceChartMaximum(44)).toBe(50); + expect(resistanceChartMaximum(45)).toBe(60); + expect(resistanceChartMaximum(52)).toBe(70); + expect(resistanceChartMaximum(95)).toBe(100); + }); + test('restores only supported chart modes', () => { expect(storedChartMode({ getItem: () => 'power' })).toBe('power'); expect(storedChartMode({ getItem: () => 'resistance' })).toBe('resistance'); diff --git a/tests/gears.test.ts b/tests/gears.test.ts index 35d2b88..9e0c4c2 100644 --- a/tests/gears.test.ts +++ b/tests/gears.test.ts @@ -11,8 +11,11 @@ import { shiftedGear, storedGear, VIRTUAL_GEAR_COMBINATIONS, + virtualGearLoadMultiplier, virtualGearRatio, } from '../src/lib/gears'; +import { KILOMETERS_PER_MILE } from '../src/lib/units'; +import { WORKOUT_COURSES, workoutTerrainAtDistance } from '../src/lib/workouts'; describe('virtual gears', () => { test('clamps gear positions to the supported 1–24 range', () => { @@ -74,17 +77,31 @@ describe('virtual gears', () => { ); }); - test('scales terrain resistance from the easiest physical ratio and clamps targets', () => { - expect(resistanceForVirtualGear(30, MIN_GEAR)).toBe(30); - expect(resistanceForVirtualGear(30, DEFAULT_GEAR)).toBe(48); - expect(resistanceForVirtualGear(30, MAX_GEAR)).toBe(81.5); + test('spreads prepared terrain load across the complete physical drivetrain', () => { + expect(resistanceForVirtualGear(30, MIN_GEAR)).toBe(11.7); + expect(resistanceForVirtualGear(30, DEFAULT_GEAR)).toBe(30); + expect(resistanceForVirtualGear(30, MAX_GEAR)).toBe(86.6); expect(resistanceForVirtualGear(80, MAX_GEAR)).toBe(100); + expect(virtualGearLoadMultiplier(DEFAULT_GEAR)).toBe(1); + }); + + test('keeps a modest Prairie Roll climb easy in gear one', () => { + const prairieRoll = WORKOUT_COURSES.find((course) => course.id === 'prairie-roll'); + if (!prairieRoll) { + throw new Error('Expected the Prairie Roll workout course'); + } + const terrain = workoutTerrainAtDistance(prairieRoll, 1.3 * KILOMETERS_PER_MILE); + expect(terrain.grade).toBeCloseTo(1.8, 1); + expect(terrain.resistance).toBe(16); + expect(resistanceForVirtualGear(terrain.resistance, MIN_GEAR)).toBe(6.3); + expect(resistanceForVirtualGear(terrain.resistance, DEFAULT_GEAR)).toBe(16); + expect(resistanceForVirtualGear(terrain.resistance, MAX_GEAR)).toBe(46.2); }); test('applies each physical ratio change to consecutive free-ride shifts', () => { const harder = resistanceAfterGearShift(30, 12, 13); - expect(harder).toBe(30.6); + expect(harder).toBe(31.2); expect(resistanceAfterGearShift(harder, 13, 12)).toBeCloseTo(30, 1); - expect(resistanceAfterGearShift(3, 12, 1)).toBe(1.9); + expect(resistanceAfterGearShift(3, 12, 1)).toBe(1.2); }); }); diff --git a/tests/latest-value-scheduler.test.ts b/tests/latest-value-scheduler.test.ts new file mode 100644 index 0000000..2875921 --- /dev/null +++ b/tests/latest-value-scheduler.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { createLatestValueScheduler } from '../src/lib/latest-value-scheduler'; + +async function flushPromises() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('latest-value scheduler', () => { + test('keeps only the newest value while a command is in flight', async () => { + let now = 0; + const timers: Array<{ callback: () => void; delay: number }> = []; + const sent: number[] = []; + const releases: Array<() => void> = []; + const scheduler = createLatestValueScheduler({ + minimumIntervalMs: 500, + now: () => now, + send: (value: number) => { + sent.push(value); + return new Promise((resolve) => releases.push(resolve)); + }, + setTimer: ((callback: () => void, delay: number) => { + timers.push({ callback, delay }); + return timers.length; + }) as typeof setTimeout, + }); + + scheduler.push(10); + scheduler.push(20); + scheduler.push(30); + expect(sent).toEqual([10]); + + releases.shift()?.(); + await flushPromises(); + expect(timers.map(({ delay }) => delay)).toEqual([500]); + + now = 500; + timers.shift()?.callback(); + expect(sent).toEqual([10, 30]); + }); + + test('clears delayed work and lets a new connection send immediately', async () => { + let now = 0; + const cleared: unknown[] = []; + const timers: Array<() => void> = []; + const sent: number[] = []; + const scheduler = createLatestValueScheduler({ + clearTimer: (timer) => cleared.push(timer), + minimumIntervalMs: 500, + now: () => now, + send: (value: number) => { + sent.push(value); + return Promise.resolve(); + }, + setTimer: ((callback: () => void) => { + timers.push(callback); + return timers.length; + }) as typeof setTimeout, + }); + + scheduler.push(10); + await flushPromises(); + now = 100; + scheduler.push(20); + expect(timers).toHaveLength(1); + + scheduler.clear(); + scheduler.push(30); + expect(cleared).toEqual([1]); + expect(sent).toEqual([10, 30]); + }); +}); diff --git a/tests/tcx-import.test.ts b/tests/tcx-import.test.ts index 98a3a21..4faad1e 100644 --- a/tests/tcx-import.test.ts +++ b/tests/tcx-import.test.ts @@ -113,7 +113,6 @@ describe('TCX import', () => { return; } expect(imported.workout.course).toMatchObject({ - baseResistance: course.baseResistance, description: course.description, difficulty: course.difficulty, distance: course.distance, diff --git a/tests/tcx.test.ts b/tests/tcx.test.ts index 5719b08..0d9c18f 100644 --- a/tests/tcx.test.ts +++ b/tests/tcx.test.ts @@ -57,7 +57,7 @@ describe('TCX export', () => { expect(tcx).toContain(''); expect(tcx).toContain('1'); expect(tcx).toContain('cedar-circuit'); - expect(tcx).toContain('12.0'); + expect(tcx).not.toContain(''); expect(tcx).toContain('loop'); expect(tcx).toContain('82.50'); expect(tcx).toContain('30.25'); diff --git a/tests/trainer-device.test.ts b/tests/trainer-device.test.ts index b8ed229..39510b8 100644 --- a/tests/trainer-device.test.ts +++ b/tests/trainer-device.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { + BLUETOOTH_OPERATION_TIMEOUT_MS, + BLUETOOTH_TRAINER_SETUP_TIMEOUT_MS, CONTROL_POINT, CYCLING_POWER, CYCLING_POWER_MEASUREMENT, @@ -52,6 +54,11 @@ function notificationCharacteristic() { } describe('trainer device connection', () => { + test('allows slow trainer setup without weakening runtime command recovery', () => { + expect(BLUETOOTH_TRAINER_SETUP_TIMEOUT_MS).toBe(30_000); + expect(BLUETOOTH_TRAINER_SETUP_TIMEOUT_MS).toBeGreaterThan(BLUETOOTH_OPERATION_TIMEOUT_MS); + }); + test('discovers trainers by FTMS service instead of vendor name', () => { const options = trainerRequestOptions(); if (!('filters' in options)) { @@ -148,6 +155,9 @@ describe('trainer device connection', () => { ); connection.cleanup(); + await expect( + connection.sendControlCommand([FTMS_CONTROL_OPCODE.REQUEST_CONTROL]) + ).rejects.toThrow('Trainer connection closed during a control command.'); releasePowerService?.(power); await new Promise((resolve) => setTimeout(resolve, 0)); expect(optionalPower.listeners.size).toBe(0); diff --git a/tests/workout-file.test.ts b/tests/workout-file.test.ts index 994f6f2..9f90f88 100644 --- a/tests/workout-file.test.ts +++ b/tests/workout-file.test.ts @@ -92,10 +92,10 @@ describe('workout GPX files', () => { expect(contents).toStartWith(''); expect(contents).toContain('2'); + expect(contents).toContain('3'); expect(contents).toContain(''); - expect(contents).toContain('12.0'); + expect(contents).not.toContain(''); expect(contents).toContain('loop'); expect(contents).toContain('Santa Cruz'); expect(contents).not.toContain('elevationGain'); @@ -105,7 +105,6 @@ describe('workout GPX files', () => { new DOMParser() as unknown as globalThis.DOMParser ); expect(parsed).toMatchObject({ - baseResistance: workout.baseResistance, description: workout.description, difficulty: workout.difficulty, distance: workout.distance, @@ -130,7 +129,6 @@ describe('workout GPX files', () => { const first = parseWorkoutFile(thirdPartyGpx(), parser); const second = parseWorkoutFile(thirdPartyGpx('Renamed metadata'), parser); expect(first).toMatchObject({ - baseResistance: 12, description: 'A real GPX loop', difficulty: 'moderate', name: 'Neighborhood loop', diff --git a/tests/workouts.test.ts b/tests/workouts.test.ts index 5f05460..46b3d8f 100644 --- a/tests/workouts.test.ts +++ b/tests/workouts.test.ts @@ -213,7 +213,7 @@ describe('terrain workouts', () => { expect(workoutMapProgressPath(pointToPoint, finish)).toBe(workoutMapPath(pointToPoint)); }); - test('offers a fifteen-mile course with long rollers centered on 20% resistance', () => { + test('offers a fifteen-mile course whose rollers use the universal grade load', () => { const rollingCourse = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll'); if (!rollingCourse) { throw new Error('Expected the Prairie Roll workout course'); @@ -236,10 +236,9 @@ describe('terrain workouts', () => { const average = resistances.reduce((sum, resistance) => sum + resistance, 0) / resistances.length; expect(rollingCourse.distance).toBeCloseTo(24.140_16); - expect(rollingCourse.baseResistance).toBe(20); - expect(Math.min(...resistances)).toBeWithin(14, 17); - expect(Math.max(...resistances)).toBeWithin(24, 26); - expect(average).toBeWithin(19, 21); + expect(Math.min(...resistances)).toBeWithin(4, 9); + expect(Math.max(...resistances)).toBeWithin(15, 18); + expect(average).toBeWithin(11, 13); }); test('keeps gentle elevation profiles visually low beside climbing courses', () => { @@ -368,7 +367,6 @@ describe('terrain workouts', () => { const pointCount = 10_001; const distance = 10; const detailedCourse = restoreWorkoutCourse({ - baseResistance: 12, description: 'A detailed course', difficulty: 'moderate', distance, @@ -441,10 +439,12 @@ describe('terrain workouts', () => { expect( restoreWorkoutCourse({ ...course, routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK }) ).toBeUndefined(); - expect(restoreWorkoutCourse({ ...course, baseResistance: undefined })).toMatchObject({ - baseResistance: 12, + const restoredLegacyBaseResistance = restoreWorkoutCourse({ + ...course, + baseResistance: 101, }); - expect(restoreWorkoutCourse({ ...course, baseResistance: 101 })).toBeUndefined(); + expect(restoredLegacyBaseResistance).toBeDefined(); + expect(restoredLegacyBaseResistance).not.toHaveProperty('baseResistance'); expect(restoreWorkoutCourse({ ...course, distance: 'far' })).toBeUndefined(); expect(restoreSessionWorkout({ course: { ...course, points: [] } })).toBeUndefined(); expect(restoreWorkoutCourse({ ...course, id: ' ' })).toBeUndefined();