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
33 changes: 19 additions & 14 deletions README.md

Large diffs are not rendered by default.

109 changes: 95 additions & 14 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,19 @@ import { DevicePairingPanel } from './components/device-pairing';
import { KeyboardShortcutsDialog } from './components/keyboard-shortcuts-dialog';
import { PrivacyPolicyDialog, TermsOfServiceDialog } from './components/legal-dialog';
import { Notification } from './components/notification';
import { ProfileDialog } from './components/profile-dialog';
import { ProfilePanel } from './components/profile-panel';
import { RideMetrics } from './components/ride-metrics';
import { SessionControls } from './components/session-controls';
import { SessionHistory } from './components/session-history';
import { SessionOverview } from './components/session-overview';
import {
SessionRecoveryNotice,
sessionRecoveryConnectionsReady,
useAutoDismissSessionRecoveryNotice,
} from './components/session-recovery-notice';
import { SessionSaveDialog } from './components/session-save-dialog';
import { TrainingControl } from './components/training-control';
import { DeploymentVersionUpdateNotice } from './components/version-update-notice';
import { WelcomeDialog } from './components/welcome-dialog';
import { WorkoutPanel } from './components/workout-panel';
import { WorkoutProgress } from './components/workout-progress';
Expand Down Expand Up @@ -59,8 +65,9 @@ import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib
import { unreachable } from './lib/errors';
import { maximumGear, resistanceForVirtualGear } from './lib/gears';
import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard';
import { profileTotalMassKg, type RiderProfile } from './lib/profile';
import { sessionNeedsUnloadWarning } from './lib/session';
import { activeRiderPhysicsProfile, type RiderPhysicsProfile } from './lib/profile';
import type { ProfileTab } from './lib/profile-tab';
import { sessionHasRecordedData, sessionNeedsUnloadWarning } from './lib/session';
import { loadSessionHistoryView, type SessionHistoryView } from './lib/session-history-view';
import { requestUnloadConfirmation } from './lib/unload';
import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome';
Expand Down Expand Up @@ -100,7 +107,7 @@ function personalizedWorkoutResistance({
virtualShiftingActive,
}: {
gear: number;
profile: RiderProfile;
profile: RiderPhysicsProfile;
profileReady: boolean;
terrainResistance?: number;
virtualShiftingActive: boolean;
Expand All @@ -109,7 +116,12 @@ function personalizedWorkoutResistance({
return;
}
return virtualShiftingActive
? resistanceForVirtualGear(terrainResistance, gear, profile, profileTotalMassKg(profile))
? resistanceForVirtualGear(
terrainResistance,
gear,
profile,
profile.riderWeightKg + profile.bikeWeightKg
)
: terrainResistance;
}

Expand All @@ -128,6 +140,9 @@ function restoredRoute(overlay: AppOverlay | undefined): AppRoute {
kind: APP_ROUTE_KIND.SESSION,
};
}
if (overlay === APP_OVERLAY.PROFILE) {
return { kind: APP_ROUTE_KIND.PROFILE };
}
if (overlay === APP_OVERLAY.WORKOUTS) {
if (loadBikeGpxBrowserOpen()) {
return {
Expand Down Expand Up @@ -165,6 +180,14 @@ function sessionRouteSearch(
};
}

function profileRouteSearch(profileTab?: ProfileTab): { tab?: ProfileTab } {
return profileTab ? { tab: profileTab } : {};
}

function profileRouteRequest(route: AppRoute): ProfileTab | undefined {
return route.kind === APP_ROUTE_KIND.PROFILE ? route.profileTab : undefined;
}

function sessionRouteRequest(route: AppRoute): {
calendarMonth?: string;
historyView?: SessionHistoryView;
Expand All @@ -183,6 +206,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
const matchedAppRoute = useMemo(() => appRouteFromRouterMatch(routerMatch), [routerMatch]);
const navigate = useNavigate();
const [initialAppNavigation] = useState(() => initialNavigation(matchedAppRoute, pathname));
const [showRestoredSessionNotice, setShowRestoredSessionNotice] = useState(() =>
sessionHasRecordedData(initialSession.ended, initialSession.elapsedSeconds)
);
const dismissRestoredSessionNotice = useCallback(() => setShowRestoredSessionNotice(false), []);
const restoringRoute = useRef(
pathname === APP_ROUTE_PATH.HOME &&
initialAppNavigation.route.kind !== APP_ROUTE_KIND.HOME &&
Expand All @@ -191,7 +218,11 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
const appRoute = restoringRoute.current ? initialAppNavigation.route : matchedAppRoute;
const rememberedDevices = useRememberedBluetoothDevices();
const riderProfile = useProfile();
const trainer = useTrainer(rememberedDevices, riderProfile.profile);
const riderPhysicsProfile = useMemo(
() => activeRiderPhysicsProfile(riderProfile.profile),
[riderProfile.profile]
);
const trainer = useTrainer(rememberedDevices, riderPhysicsProfile);
const [activeOverlay, setActiveOverlayState] = useState<AppOverlay | undefined>(
initialAppNavigation.overlay
);
Expand All @@ -218,6 +249,13 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
case APP_ROUTE_KIND.HOME:
navigate({ replace, to: APP_ROUTE_PATH.HOME }).catch(() => undefined);
return;
case APP_ROUTE_KIND.PROFILE:
navigate({
replace,
search: profileRouteSearch(route.profileTab),
to: APP_ROUTE_PATH.PROFILE,
}).catch(() => undefined);
return;
case APP_ROUTE_KIND.SESSION:
if (route.sessionId) {
navigate({
Expand Down Expand Up @@ -264,6 +302,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
});
return;
}
if (overlay === APP_OVERLAY.PROFILE) {
navigateToAppRoute({ kind: APP_ROUTE_KIND.PROFILE });
return;
}
if (overlay === APP_OVERLAY.WORKOUTS) {
navigateToAppRoute({ kind: APP_ROUTE_KIND.WORKOUT });
return;
Expand Down Expand Up @@ -312,7 +354,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
const virtualShiftingReady = virtualShiftingConnectionReady({
trainerConnected: trainer.connected,
});
const profileMaximumGear = maximumGear(riderProfile.profile);
const profileMaximumGear = maximumGear(riderPhysicsProfile);
const gearResistanceRef = useRef<(fromGear: number, toGear: number) => void>(
trainer.shiftResistanceForGears
);
Expand All @@ -338,7 +380,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
trainer.lastPedalingAt,
trainer.trainerReportsDistance,
initialSession,
riderProfile.ready ? riderProfile.profile : undefined
riderProfile.ready ? riderPhysicsProfile : undefined
);
const dashboardWorkout = workoutDashboardPreview({
distance: session.rideDistance,
Expand All @@ -355,7 +397,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
const activeControlMode = trainingControlMode(click.paired, workoutSelected);
const workoutResistance = personalizedWorkoutResistance({
gear: gearControl.gear,
profile: riderProfile.profile,
profile: riderPhysicsProfile,
profileReady: riderProfile.ready,
terrainResistance: workoutTerrain?.resistance,
virtualShiftingActive,
Expand All @@ -366,8 +408,8 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
resistanceForVirtualGear(
workoutTerrain.resistance,
toGear,
riderProfile.profile,
profileTotalMassKg(riderProfile.profile)
riderPhysicsProfile,
riderPhysicsProfile.riderWeightKg + riderPhysicsProfile.bikeWeightKg
)
)
: trainer.shiftResistanceForGears;
Expand Down Expand Up @@ -404,7 +446,13 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
workflow.requestPersistentStorage();
}, [workflow.requestPersistentStorage]);

const warnBeforeUnload = sessionNeedsUnloadWarning(session.ended, session.elapsedSeconds);
const physicsSettingsLocked = sessionHasRecordedData(session.ended, session.elapsedSeconds);
const warnBeforeUnload = sessionNeedsUnloadWarning(
session.ended,
session.elapsedSeconds,
session.isRiding,
session.manuallyPaused
);
useEffect(() => {
if (!warnBeforeUnload) {
return;
Expand Down Expand Up @@ -506,6 +554,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
const selectedWorkoutId = selectedWorkoutCourse ? selectedWorkoutCourse.id : undefined;
const bikeGpxBrowserOpen = appRoute.kind === APP_ROUTE_KIND.BIKEGPX;
const bikeGpxRouteId = bikeGpxBrowserOpen ? appRoute.routeId : undefined;
const requestedProfileTab = profileRouteRequest(appRoute);
const focusedWorkoutId =
appRoute.kind === APP_ROUTE_KIND.WORKOUT ? appRoute.workoutId : undefined;
const {
Expand Down Expand Up @@ -567,6 +616,15 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
},
[navigateToAppRoute, requestedSessionCalendarMonth, requestedSessionId]
);
const selectProfileTab = useCallback(
(profileTab: ProfileTab) => {
navigateToAppRoute({
kind: APP_ROUTE_KIND.PROFILE,
profileTab,
});
},
[navigateToAppRoute]
);
useEffect(() => {
if (!selectedWorkoutCourse) {
return;
Expand Down Expand Up @@ -598,6 +656,21 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
click.busy,
click.pairingRole !== undefined,
].some(Boolean);
const recoveredSessionDevicesConnected = sessionRecoveryConnectionsReady({
clickConnectedCount: click.connectedCount,
clickPairedCount: click.pairedCount,
heartRateConnected: heartRate.connected,
heartRatePaired: heartRate.paired,
rememberedDevicesFailed: rememberedDevices.error !== undefined,
rememberedDevicesLoaded: rememberedDevices.devices !== undefined,
rememberedDevicesSupported: rememberedDevices.supported,
trainerConnected: trainer.connected,
});
useAutoDismissSessionRecoveryNotice(
showRestoredSessionNotice,
recoveredSessionDevicesConnected,
dismissRestoredSessionNotice
);

return (
<main className="flex min-h-dvh min-w-0 flex-col overflow-x-clip bg-ink selection:bg-mint/30">
Expand Down Expand Up @@ -626,6 +699,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
pairedDeviceCount={pairedDeviceCount}
/>
</DashboardToolbar>
<DeploymentVersionUpdateNotice />
{showRestoredSessionNotice ? (
<SessionRecoveryNotice onDismiss={dismissRestoredSessionNotice} />
) : null}
<RideMetrics
aggregates={session.aggregates}
elapsedSeconds={session.elapsedSeconds}
Expand Down Expand Up @@ -660,6 +737,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
control={
virtualShiftingActive
? {
drivetrain: riderPhysicsProfile,
gear: gearControl.gear,
maximumGear: profileMaximumGear,
mode: CONTROL_MODE.GEAR,
Expand Down Expand Up @@ -709,6 +787,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
requestedSessionMonth={requestedSessionCalendarMonth}
requestedView={requestedSessionHistoryView}
speedUnit={speedUnit}
weightHistory={riderProfile.profile.weightHistory}
/>
<WorkoutPanel
activeCourse={session.selectedWorkout?.course}
Expand Down Expand Up @@ -784,13 +863,15 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored
onClose={() => setActiveOverlay(undefined)}
open={activeOverlay === APP_OVERLAY.TERMS}
/>
<ProfileDialog
<ProfilePanel
onClose={() => setActiveOverlay(undefined)}
onSave={riderProfile.save}
onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit}
onSelectTab={selectProfileTab}
open={activeOverlay === APP_OVERLAY.PROFILE}
physicsSettingsLocked={warnBeforeUnload}
physicsSettingsLocked={physicsSettingsLocked}
profile={riderProfile.profile}
requestedTab={requestedProfileTab}
speedUnit={speedUnit}
storageError={riderProfile.storageError}
/>
Expand Down
4 changes: 3 additions & 1 deletion src/components/gear-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ export function GearControl({
}, [disabled, gear, heldDirection, maximumGear, stopHolding]);

const progress =
maximumGear === MIN_GEAR ? 100 : ((gear - MIN_GEAR) / (maximumGear - MIN_GEAR)) * 100;
maximumGear === MIN_GEAR
? 100
: ((gear - MIN_GEAR + 1) / (maximumGear - MIN_GEAR + 1)) * 100;
const activeDirection = heldDirection ?? shiftFlash;
const buttonClass =
'grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-line text-slate-300 transition duration-150 hover:border-mint disabled:opacity-40';
Expand Down
16 changes: 15 additions & 1 deletion src/components/gear-training-control.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
import { virtualGearCombination, virtualGearLoadMultiplier } from '../lib/gears';
import type { VirtualDrivetrain } from '../lib/profile';
import type { ResistanceAdjustmentDirection } from '../types';
import { GearControl } from './gear-control';
import { TrainingControlPanel } from './training-control-panel';

export function GearTrainingControl({
connected,
drivetrain,
gear,
maximumGear,
onShift,
shiftFlash,
}: {
connected: boolean;
drivetrain: VirtualDrivetrain;
gear: number;
maximumGear: number;
onShift: (change: number) => void;
shiftFlash?: ResistanceAdjustmentDirection;
}) {
const combination = virtualGearCombination(gear, drivetrain);
const loadMultiplier = virtualGearLoadMultiplier(gear, drivetrain);
const gearDetail = combination
? `${combination.chainringTeeth}/${combination.cassetteTeeth} · ${combination.ratio.toFixed(2)}:1`
: '—';
return (
<TrainingControlPanel title="Virtual shifting" unit={`of ${maximumGear}`} value={gear}>
<TrainingControlPanel
detail={`${gearDetail} · ${loadMultiplier.toFixed(2)}× load`}
title="Virtual shifting"
unit={`of ${maximumGear}`}
value={gear}
>
<GearControl
disabled={!connected}
gear={gear}
Expand Down
Loading