diff --git a/README.md b/README.md index ca7e3d2..c20a535 100644 --- a/README.md +++ b/README.md @@ -8,6 +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. - 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/src/app.tsx b/src/app.tsx index cd4aafa..9f12a24 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -31,6 +31,19 @@ import { loadOpenSideTray, persistOpenSideTray, } from './lib/app-overlay'; +import { + APP_ROUTE_KIND, + type AppRoute, + appRouteFromPathname, + appRoutePath, + appRouteSideTray, + HOME_APP_ROUTE, +} from './lib/app-route'; +import { + loadBikeGpxBrowserOpen, + loadBikeGpxBrowserSearch, + persistBikeGpxBrowserOpen, +} from './lib/bikegpx-browser-preferences'; import { CONTROL_MODE, trainingControlMode, @@ -69,16 +82,112 @@ function shiftHandlerUnlessBlocked(handler: (change: number) => void, blocked: b return blocked ? () => undefined : handler; } +interface InitialNavigation { + overlay?: AppOverlay; + route: AppRoute; +} + +function restoredRoute(overlay: AppOverlay | undefined): AppRoute { + if (overlay === APP_OVERLAY.DEVICES) { + return { kind: APP_ROUTE_KIND.DEVICES }; + } + if (overlay === APP_OVERLAY.HISTORY) { + return { kind: APP_ROUTE_KIND.SESSION }; + } + if (overlay === APP_OVERLAY.WORKOUTS) { + if (loadBikeGpxBrowserOpen()) { + return { + kind: APP_ROUTE_KIND.BIKEGPX, + routeId: loadBikeGpxBrowserSearch().selectedRouteId || undefined, + }; + } + return { kind: APP_ROUTE_KIND.WORKOUT }; + } + return HOME_APP_ROUTE; +} + +function initialNavigation(): InitialNavigation { + const pathname = globalThis.location?.pathname ?? '/'; + const linkedRoute = appRouteFromPathname(pathname); + const linkedOverlay = appRouteSideTray(linkedRoute); + if (linkedOverlay) { + return { overlay: linkedOverlay, route: linkedRoute }; + } + const restoredOverlay = loadOpenSideTray(); + return { + overlay: restoredOverlay ?? (shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined), + route: restoredRoute(restoredOverlay), + }; +} + +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 rememberedDevices = useRememberedBluetoothDevices(); const trainer = useTrainer(rememberedDevices); + const [appRoute, setAppRoute] = useState(initialAppNavigation.route); const [activeOverlay, setActiveOverlayState] = useState( - () => loadOpenSideTray() ?? (shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined) + initialAppNavigation.overlay ); - const setActiveOverlay = useCallback((overlay: AppOverlay | undefined) => { + 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); + }, + [showAppRoute] + ); + const setActiveOverlay = useCallback( + (overlay: AppOverlay | undefined) => { + if (overlay === APP_OVERLAY.DEVICES) { + navigateToAppRoute({ kind: APP_ROUTE_KIND.DEVICES }); + return; + } + if (overlay === APP_OVERLAY.HISTORY) { + navigateToAppRoute({ kind: APP_ROUTE_KIND.SESSION }); + return; + } + if (overlay === APP_OVERLAY.WORKOUTS) { + navigateToAppRoute({ kind: APP_ROUTE_KIND.WORKOUT }); + return; + } + persistBikeGpxBrowserOpen(false); + persistOpenSideTray(overlay); + setAppRoute(HOME_APP_ROUTE); + setActiveOverlayState(overlay); + updateBrowserRoute(HOME_APP_ROUTE, true); + }, + [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]); const devicesOpen = activeOverlay === APP_OVERLAY.DEVICES; const clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); @@ -283,6 +392,36 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored ? session.selectedWorkout.course : undefined; const selectedWorkoutId = selectedWorkoutCourse ? selectedWorkoutCourse.id : undefined; + const bikeGpxBrowserOpen = appRoute.kind === APP_ROUTE_KIND.BIKEGPX; + const bikeGpxRouteId = bikeGpxBrowserOpen ? appRoute.routeId : undefined; + const focusedWorkoutId = + appRoute.kind === APP_ROUTE_KIND.WORKOUT ? appRoute.workoutId : undefined; + const requestedSessionId = + appRoute.kind === APP_ROUTE_KIND.SESSION ? appRoute.sessionId : undefined; + const focusWorkout = useCallback( + (courseId: string | undefined) => { + navigateToAppRoute({ kind: APP_ROUTE_KIND.WORKOUT, workoutId: courseId }, true); + }, + [navigateToAppRoute] + ); + const openBikeGpx = useCallback(() => { + navigateToAppRoute({ kind: APP_ROUTE_KIND.BIKEGPX }); + }, [navigateToAppRoute]); + const closeBikeGpx = useCallback(() => { + navigateToAppRoute({ kind: APP_ROUTE_KIND.WORKOUT }, true); + }, [navigateToAppRoute]); + const selectBikeGpxRoute = useCallback( + (routeId: string | undefined) => { + navigateToAppRoute({ kind: APP_ROUTE_KIND.BIKEGPX, routeId }, true); + }, + [navigateToAppRoute] + ); + const selectHistorySession = useCallback( + (sessionId: string) => { + navigateToAppRoute({ kind: APP_ROUTE_KIND.SESSION, sessionId }, true); + }, + [navigateToAppRoute] + ); useEffect(() => { if (!selectedWorkoutCourse) { return; @@ -411,21 +550,30 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored /> setActiveOverlay(undefined)} + onSelectSessionId={selectHistorySession} onStartNew={continueFromHistory} open={activeOverlay === APP_OVERLAY.HISTORY} + requestedSessionId={requestedSessionId} speedUnit={speedUnit} /> setActiveOverlay(undefined)} + onCloseBikeGpx={closeBikeGpx} + onFocusCourse={focusWorkout} onImportCourse={async (course) => workoutLibrary.importCourse(course)} onImportFile={workoutLibrary.importFile} + onOpenBikeGpx={openBikeGpx} onRemoveCourse={removeWorkout} onRenameCourse={workoutLibrary.renameCourse} onReorderCourse={workoutLibrary.reorderCourse} onSelect={selectWorkout} + onSelectBikeGpxRoute={selectBikeGpxRoute} open={activeOverlay === APP_OVERLAY.WORKOUTS} selectionLocked={workoutLocked} speedUnit={speedUnit} diff --git a/src/components/bikegpx-browser-dialog.tsx b/src/components/bikegpx-browser-dialog.tsx index 8e3ae66..b6908fd 100644 --- a/src/components/bikegpx-browser-dialog.tsx +++ b/src/components/bikegpx-browser-dialog.tsx @@ -17,6 +17,8 @@ import { } from '../lib/bikegpx'; import { BIKEGPX_ROUTE_LIST_SCROLL_POSITION_STORAGE_KEY, + bikeGpxBrowserSearchForRoute, + bikeGpxBrowserSearchWithSelectedRoute, loadBikeGpxBrowserSearch, persistBikeGpxBrowserSearch, } from '../lib/bikegpx-browser-preferences'; @@ -587,6 +589,8 @@ export function BikeGpxBrowserDialog({ onClose, onImportCourse, onRefreshCatalog, + onSelectRouteId, + requestedRouteId, speedUnit, }: { catalog?: BikeGpxCatalog; @@ -596,11 +600,18 @@ export function BikeGpxBrowserDialog({ onClose: () => void; onImportCourse: (course: WorkoutCourse) => Promise; onRefreshCatalog: () => Promise; + onSelectRouteId?: (routeId: string | undefined) => void; + requestedRouteId?: string; speedUnit: SpeedUnit; }) { useCloseOnEscape(true, onClose); const closeButtonRef = useDialogInitialFocus(); - const [search, setSearchState] = useState(loadBikeGpxBrowserSearch); + const reportedRouteId = useRef(undefined); + const [search, setSearchState] = useState(() => + requestedRouteId + ? bikeGpxBrowserSearchForRoute(requestedRouteId) + : loadBikeGpxBrowserSearch() + ); const setSearch = useCallback((update: (current: typeof search) => typeof search) => { setSearchState((current) => { const next = update(current); @@ -641,10 +652,46 @@ export function BikeGpxBrowserDialog({ ] ); const selectedRoute = bikeGpxPreviewRoute(filteredRoutes, selectedRouteId); + useEffect(() => { + if (requestedRouteId === selectedRouteId) { + if (reportedRouteId.current === requestedRouteId) { + reportedRouteId.current = undefined; + } + return; + } + if (!(requestedRouteId && requestedRouteId !== selectedRouteId)) { + return; + } + const next = + reportedRouteId.current === requestedRouteId + ? bikeGpxBrowserSearchWithSelectedRoute(search, requestedRouteId) + : bikeGpxBrowserSearchForRoute(requestedRouteId); + reportedRouteId.current = undefined; + setSearchState(next); + persistBikeGpxBrowserSearch(next); + }, [requestedRouteId, search, selectedRouteId]); + const reportSelectedRoute = useCallback( + (routeId: string | undefined) => { + reportedRouteId.current = routeId; + onSelectRouteId?.(routeId); + }, + [onSelectRouteId] + ); + useEffect(() => { + if ( + !(requestedRouteId && requestedRouteId !== selectedRouteId) && + selectedRoute && + selectedRoute.id !== requestedRouteId + ) { + reportSelectedRoute(selectedRoute.id); + } + }, [reportSelectedRoute, requestedRouteId, selectedRoute, selectedRouteId]); const selectRoute = (route: BikeGpxRouteSummary) => { setSearch((current) => ({ ...current, selectedRouteId: route.id })); + reportSelectedRoute(route.id); }; + const clearSelectedRoute = () => reportSelectedRoute(undefined); return (
@@ -712,6 +759,7 @@ export function BikeGpxBrowserDialog({ country: nextCountry, selectedRouteId: '', })); + clearSelectedRoute(); }} onDifficultyChange={(nextDifficulty) => { setSearch((current) => ({ @@ -719,6 +767,7 @@ export function BikeGpxBrowserDialog({ difficulty: nextDifficulty, selectedRouteId: '', })); + clearSelectedRoute(); }} onMaximumDistanceChange={(nextDistance) => { setSearch((current) => ({ @@ -726,6 +775,7 @@ export function BikeGpxBrowserDialog({ maximumDistance: nextDistance, selectedRouteId: '', })); + clearSelectedRoute(); }} onMinimumDistanceChange={(nextDistance) => { setSearch((current) => ({ @@ -733,6 +783,7 @@ export function BikeGpxBrowserDialog({ minimumDistance: nextDistance, selectedRouteId: '', })); + clearSelectedRoute(); }} onQueryChange={(nextQuery) => { setSearch((current) => ({ @@ -740,6 +791,7 @@ export function BikeGpxBrowserDialog({ query: nextQuery, selectedRouteId: '', })); + clearSelectedRoute(); }} onRefreshCatalog={onRefreshCatalog} onSelectRoute={selectRoute} diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index e536377..c04c50f 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -34,13 +34,17 @@ function shouldIgnoreHistoryAction(event: KeyboardEvent) { export function SessionHistory({ onClose, + onSelectSessionId, onStartNew, open, + requestedSessionId, speedUnit, }: { onClose: () => void; + onSelectSessionId?: (sessionId: string) => void; onStartNew: (session: SavedSession) => void; open: boolean; + requestedSessionId?: string; speedUnit: SpeedUnit; }) { const { @@ -60,7 +64,7 @@ export function SessionHistory({ selectSession: selectHistorySession, summaries, total, - } = useSessionHistory(open); + } = useSessionHistory(open, requestedSessionId); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [historyHelpOpen, setHistoryHelpOpen] = useState(false); const [selectedChartMode, setSelectedChartMode] = useState( @@ -72,6 +76,12 @@ export function SessionHistory({ const importInput = useRef(null); const transferring = exporting || importing; + useEffect(() => { + if (open && selected) { + onSelectSessionId?.(selected.id); + } + }, [onSelectSessionId, open, selected]); + useEffect(() => { if (!open) { setDeleteConfirmationOpen(false); diff --git a/src/components/workout-panel.tsx b/src/components/workout-panel.tsx index 197e9db..d215dd5 100644 --- a/src/components/workout-panel.tsx +++ b/src/components/workout-panel.tsx @@ -19,6 +19,7 @@ import { lazy, Suspense, useCallback, + useEffect, useMemo, useRef, useState, @@ -26,10 +27,6 @@ import { import { useBikeGpxCatalog } from '../hooks/use-bikegpx-catalog'; import { useFileDrop } from '../hooks/use-file-drop'; import { usePersistentScrollPosition } from '../hooks/use-persistent-scroll-position'; -import { - loadBikeGpxBrowserOpen, - persistBikeGpxBrowserOpen, -} from '../lib/bikegpx-browser-preferences'; import { errorMessage } from '../lib/errors'; import { descriptionWithoutDistance, formatDistance, formatElevation } from '../lib/units'; import { @@ -72,6 +69,8 @@ function WorkoutCourseCard({ dragHandleAttributes, dragHandleListeners, dragged, + focused, + onFocus, onMove, onRemove, onRename, @@ -89,6 +88,8 @@ function WorkoutCourseCard({ dragHandleAttributes: DraggableAttributes; dragHandleListeners: DraggableSyntheticListeners; dragged: boolean; + focused: boolean; + onFocus: () => void; onMove: (direction: -1 | 1) => void; onRemove: () => void; onRename: () => void; @@ -111,10 +112,20 @@ function WorkoutCourseCard({ onMove(1); } }; + let emphasis = 'border-line'; + if (selected) { + emphasis = 'border-mint/50 shadow-[0_0_20px_rgba(173,245,189,.08)]'; + } else if (focused) { + emphasis = 'border-cyan-400/60 shadow-[0_0_20px_rgba(34,211,238,.08)]'; + } return (
@@ -292,37 +303,48 @@ function WorkoutDropBoundary({ index }: { index: number }) { export function WorkoutPanel({ activeCourse, + bikeGpxBrowserOpen = false, + bikeGpxRouteId, courses, customCourseIds, + focusedCourseId, onClose, + onCloseBikeGpx, + onFocusCourse, onImportCourse, onImportFile, onRemoveCourse, onRenameCourse, onReorderCourse, + onOpenBikeGpx, + onSelectBikeGpxRoute, onSelect, open, selectionLocked, speedUnit, }: { activeCourse?: WorkoutCourse; + bikeGpxBrowserOpen?: boolean; + bikeGpxRouteId?: string; courses: WorkoutCourse[]; customCourseIds: ReadonlySet; + focusedCourseId?: string; onClose: () => void; + onCloseBikeGpx?: () => void; + onFocusCourse?: (courseId: string | undefined) => void; onImportCourse: (course: WorkoutCourse) => Promise; onImportFile: (file: File) => Promise; onRemoveCourse: (courseId: string) => void; onRenameCourse: (courseId: string, name: string) => WorkoutCourse; onReorderCourse: (movedCourseId: string, destinationIndex: number) => void; + onOpenBikeGpx?: () => void; + onSelectBikeGpxRoute?: (routeId: string | undefined) => void; onSelect: (course?: WorkoutCourse) => void; open: boolean; selectionLocked: boolean; speedUnit: SpeedUnit; }) { const importInput = useRef(null); - const [bikeGpxBrowserOpen, setBikeGpxBrowserOpenState] = useState( - () => open && loadBikeGpxBrowserOpen() - ); const [importing, setImporting] = useState(false); const [libraryStatus, setLibraryStatus] = useState(''); const [importError, setImportError] = useState(''); @@ -347,10 +369,22 @@ export function WorkoutPanel({ WORKOUT_SCROLL_POSITION_STORAGE_KEY, open ); - const setBikeGpxBrowserOpen = useCallback((nextOpen: boolean) => { - persistBikeGpxBrowserOpen(nextOpen); - setBikeGpxBrowserOpenState(nextOpen); - }, []); + useEffect(() => { + if (!(open && focusedCourseId)) { + return; + } + const focusedCourse = courses.find((course) => course.id === focusedCourseId); + if (!focusedCourse) { + onFocusCourse?.(undefined); + return; + } + const frame = window.requestAnimationFrame(() => { + document + .getElementById(`workout-${encodeURIComponent(focusedCourseId)}`) + ?.scrollIntoView({ block: 'center' }); + }); + return () => window.cancelAnimationFrame(frame); + }, [courses, focusedCourseId, onFocusCourse, open]); const importWorkout = useCallback( async (file: File) => { @@ -378,7 +412,6 @@ export function WorkoutPanel({ const closePanel = () => { setRenamingCourse(undefined); setMappedCourse(undefined); - setBikeGpxBrowserOpen(false); setSearchQuery(''); onClose(); }; @@ -488,7 +521,7 @@ export function WorkoutPanel({ />