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
22 changes: 12 additions & 10 deletions README.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
136 changes: 91 additions & 45 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -106,56 +109,92 @@ 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),
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 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<AppRoute>(initialAppNavigation.route);
const [activeOverlay, setActiveOverlayState] = useState<AppOverlay | undefined>(
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) => {
Expand All @@ -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), []);
Expand All @@ -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>(
Expand Down Expand Up @@ -581,6 +624,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
<DevicePairingPanel
click={{
...click,
onCancel: click.disconnect,
onDisconnect: click.disconnect,
onForget: click.forget,
onForgetController: click.forgetDevice,
Expand All @@ -589,6 +633,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
}}
heartRate={{
...heartRate,
onCancel: heartRate.cancelConnection,
onDisconnect: heartRate.disconnect,
onForget: heartRate.forget,
onPair: heartRate.pair,
Expand All @@ -600,6 +645,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
busy: trainer.connectionBusy,
connected: trainer.connected,
name: trainer.pairedDeviceName,
onCancel: trainer.cancelConnection,
onDisconnect: trainer.disconnect,
onForget: trainer.forget,
onPair: trainer.connect,
Expand Down
42 changes: 24 additions & 18 deletions src/components/device-pairing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { SideTray } from './side-tray';
interface DeviceSlot extends DeviceConnectionView {
battery?: number;
name?: string;
onCancel: () => void;
onDisconnect: () => void;
onForget: () => void | Promise<void>;
onPair: () => void | Promise<void>;
Expand Down Expand Up @@ -165,19 +166,6 @@ function SlowReconnectNotice() {
);
}

function DeviceConnectionAction({
busy,
disconnecting,
}: {
busy: boolean;
disconnecting: boolean;
}) {
if (busy) {
return <ConnectingLabel />;
}
return disconnecting ? 'Disconnect' : 'Reconnect';
}

function ClickConnectionStatus({ click, waiting }: { click: ClickSlot; waiting: boolean }) {
if (!click.connectionActive && click.pairedCount) {
return <>Reconnects when the session resumes</>;
Expand Down Expand Up @@ -224,14 +212,24 @@ function StatusDot({
function DeviceActions({ slot }: { slot: DeviceSlot }) {
const actionBusy = slot.busy;
if (!slot.paired) {
if (actionBusy) {
return (
<button
className="h-9 rounded-lg border border-line px-3 font-semibold text-slate-300 text-xs transition hover:border-slate-500 hover:text-white"
onClick={slot.onCancel}
type="button"
>
Cancel pairing
</button>
);
}
return (
<button
className="h-9 rounded-lg bg-lime px-3 font-bold text-ink text-xs transition hover:bg-[#e4ff9c] disabled:opacity-50"
disabled={actionBusy}
onClick={slot.onPair}
type="button"
>
{actionBusy ? 'Pairing…' : 'Pair'}
Pair
</button>
);
}
Expand All @@ -240,15 +238,23 @@ function DeviceActions({ slot }: { slot: DeviceSlot }) {

function ConnectedDeviceActions({ slot }: { slot: Omit<DeviceSlot, 'onPair'> }) {
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 (
<div className="flex flex-wrap justify-end gap-2">
<button
className="h-9 rounded-lg border border-line px-3 font-semibold text-slate-300 text-xs transition hover:border-slate-500 hover:text-white disabled:opacity-50"
disabled={slot.busy}
onClick={disconnecting ? slot.onDisconnect : slot.onReconnect}
onClick={connectionAction}
type="button"
>
<DeviceConnectionAction busy={slot.busy} disconnecting={disconnecting} />
{connectionLabel}
</button>
<button
className="h-9 rounded-lg border border-rose-400/25 px-3 font-semibold text-rose-300 text-xs transition hover:border-rose-400/60 hover:bg-rose-400/5"
Expand Down
Loading