Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
152 changes: 150 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<AppRoute>(initialAppNavigation.route);
const [activeOverlay, setActiveOverlayState] = useState<AppOverlay | undefined>(
() => 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), []);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -411,21 +550,30 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
/>
<SessionHistory
onClose={() => setActiveOverlay(undefined)}
onSelectSessionId={selectHistorySession}
onStartNew={continueFromHistory}
open={activeOverlay === APP_OVERLAY.HISTORY}
requestedSessionId={requestedSessionId}
speedUnit={speedUnit}
/>
<WorkoutPanel
activeCourse={session.selectedWorkout?.course}
bikeGpxBrowserOpen={bikeGpxBrowserOpen}
bikeGpxRouteId={bikeGpxRouteId}
courses={workoutLibrary.courses}
customCourseIds={workoutLibrary.customCourseIds}
focusedCourseId={focusedWorkoutId}
onClose={() => 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}
Expand Down
54 changes: 53 additions & 1 deletion src/components/bikegpx-browser-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -587,6 +589,8 @@ export function BikeGpxBrowserDialog({
onClose,
onImportCourse,
onRefreshCatalog,
onSelectRouteId,
requestedRouteId,
speedUnit,
}: {
catalog?: BikeGpxCatalog;
Expand All @@ -596,11 +600,18 @@ export function BikeGpxBrowserDialog({
onClose: () => void;
onImportCourse: (course: WorkoutCourse) => Promise<WorkoutCourse>;
onRefreshCatalog: () => Promise<void>;
onSelectRouteId?: (routeId: string | undefined) => void;
requestedRouteId?: string;
speedUnit: SpeedUnit;
}) {
useCloseOnEscape(true, onClose);
const closeButtonRef = useDialogInitialFocus<HTMLButtonElement>();
const [search, setSearchState] = useState(loadBikeGpxBrowserSearch);
const reportedRouteId = useRef<string | undefined>(undefined);
const [search, setSearchState] = useState(() =>
requestedRouteId
? bikeGpxBrowserSearchForRoute(requestedRouteId)
: loadBikeGpxBrowserSearch()
);
const setSearch = useCallback((update: (current: typeof search) => typeof search) => {
setSearchState((current) => {
const next = update(current);
Expand Down Expand Up @@ -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 (
<div className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px]">
Expand Down Expand Up @@ -712,34 +759,39 @@ export function BikeGpxBrowserDialog({
country: nextCountry,
selectedRouteId: '',
}));
clearSelectedRoute();
}}
onDifficultyChange={(nextDifficulty) => {
setSearch((current) => ({
...current,
difficulty: nextDifficulty,
selectedRouteId: '',
}));
clearSelectedRoute();
}}
onMaximumDistanceChange={(nextDistance) => {
setSearch((current) => ({
...current,
maximumDistance: nextDistance,
selectedRouteId: '',
}));
clearSelectedRoute();
}}
onMinimumDistanceChange={(nextDistance) => {
setSearch((current) => ({
...current,
minimumDistance: nextDistance,
selectedRouteId: '',
}));
clearSelectedRoute();
}}
onQueryChange={(nextQuery) => {
setSearch((current) => ({
...current,
query: nextQuery,
selectedRouteId: '',
}));
clearSelectedRoute();
}}
onRefreshCatalog={onRefreshCatalog}
onSelectRoute={selectRoute}
Expand Down
12 changes: 11 additions & 1 deletion src/components/session-history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<ChartMode>(
Expand All @@ -72,6 +76,12 @@ export function SessionHistory({
const importInput = useRef<HTMLInputElement>(null);
const transferring = exporting || importing;

useEffect(() => {
if (open && selected) {
onSelectSessionId?.(selected.id);
}
}, [onSelectSessionId, open, selected]);

useEffect(() => {
if (!open) {
setDeleteConfirmationOpen(false);
Expand Down
Loading