From e33a3774548b02931721524215ea8b595b823061 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 16:01:30 -0700 Subject: [PATCH 1/6] fix: connect trainers before optional service discovery --- src/lib/trainer-device.ts | 40 ++++++++++---- tests/trainer-device.test.ts | 102 +++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 tests/trainer-device.test.ts diff --git a/src/lib/trainer-device.ts b/src/lib/trainer-device.ts index e437faf..11a4167 100644 --- a/src/lib/trainer-device.ts +++ b/src/lib/trainer-device.ts @@ -116,19 +116,39 @@ export async function connectTrainerDevice( } catch { // Use the generic range. } - const [removePower, removeCadence] = await Promise.all([ + device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); + const optionalCleanups: Array<() => void> = []; + let cleanedUp = false; + const cleanupRequiredServices = combineBluetoothCleanups( + removeBikeData, + removeControlPoint, + () => device.removeEventListener('gattserverdisconnected', onDisconnect) + ); + const cleanup = () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + cleanupRequiredServices(); + combineBluetoothCleanups(...optionalCleanups)(); + optionalCleanups.length = 0; + }; + Promise.all([ optionalPowerSubscription(server, onMetrics), optionalCadenceSubscription(server, onMetrics), - ]); - device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); + ]).then((cleanups) => { + if (cleanedUp) { + combineBluetoothCleanups(...cleanups)(); + return; + } + for (const optionalCleanup of cleanups) { + if (optionalCleanup) { + optionalCleanups.push(optionalCleanup); + } + } + }); return { - cleanup: combineBluetoothCleanups( - removeBikeData, - removeControlPoint, - removePower, - removeCadence, - () => device.removeEventListener('gattserverdisconnected', onDisconnect) - ), + cleanup, controlPoint, resistanceRange, }; diff --git a/tests/trainer-device.test.ts b/tests/trainer-device.test.ts new file mode 100644 index 0000000..6d3ee80 --- /dev/null +++ b/tests/trainer-device.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test'; +import { + CONTROL_POINT, + CYCLING_POWER, + CYCLING_POWER_MEASUREMENT, + CYCLING_SPEED_AND_CADENCE, + FITNESS_MACHINE, + FITNESS_MACHINE_STATUS, + INDOOR_BIKE_DATA, + SUPPORTED_RESISTANCE_LEVEL_RANGE, +} from '../src/constants'; +import { connectTrainerDevice } from '../src/lib/trainer-device'; + +function notificationCharacteristic() { + const listeners = new Set(); + return { + characteristic: { + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => + listeners.add(listener), + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => + listeners.delete(listener), + startNotifications: async () => undefined, + } as unknown as BluetoothRemoteGATTCharacteristic, + listeners, + }; +} + +describe('trainer device connection', () => { + test('does not block a ready trainer on optional service discovery', async () => { + const bikeData = notificationCharacteristic(); + const controlPoint = notificationCharacteristic(); + const machineStatus = notificationCharacteristic(); + const optionalPower = notificationCharacteristic(); + let releasePowerService: ((service: BluetoothRemoteGATTService) => void) | undefined; + const powerService = new Promise((resolve) => { + releasePowerService = resolve; + }); + const range = new DataView(new ArrayBuffer(4)); + range.setInt16(0, 0, true); + range.setInt16(2, 1000, true); + const fitnessService = { + getCharacteristic: (uuid: BluetoothServiceUUID) => { + if (uuid === INDOOR_BIKE_DATA) { + return bikeData.characteristic; + } + if (uuid === CONTROL_POINT) { + return controlPoint.characteristic; + } + if (uuid === FITNESS_MACHINE_STATUS) { + return machineStatus.characteristic; + } + if (uuid === SUPPORTED_RESISTANCE_LEVEL_RANGE) { + return { readValue: async () => range } as BluetoothRemoteGATTCharacteristic; + } + throw new Error(`Unexpected characteristic ${uuid}`); + }, + } as unknown as BluetoothRemoteGATTService; + const power = { + getCharacteristic: (uuid: BluetoothServiceUUID) => { + expect(uuid).toBe(CYCLING_POWER_MEASUREMENT); + return optionalPower.characteristic; + }, + } as unknown as BluetoothRemoteGATTService; + const server = { + getPrimaryService: (uuid: BluetoothServiceUUID) => { + if (uuid === FITNESS_MACHINE) { + return fitnessService; + } + if (uuid === CYCLING_POWER) { + return powerService; + } + if (uuid === CYCLING_SPEED_AND_CADENCE) { + throw new Error('Optional cadence service unavailable'); + } + throw new Error(`Unexpected service ${uuid}`); + }, + } as BluetoothRemoteGATTServer; + const device = { + addEventListener: () => undefined, + gatt: { connect: async () => server }, + removeEventListener: () => undefined, + } as unknown as BluetoothDevice; + + const connection = await connectTrainerDevice( + device, + true, + { max: 100, min: 0 }, + { + onControlRejected: () => undefined, + onDisconnect: () => undefined, + onMetrics: () => undefined, + } + ); + expect(connection.controlPoint).toBe(controlPoint.characteristic); + expect(connection.resistanceRange).toEqual({ max: 100, min: 0 }); + + connection.cleanup(); + releasePowerService?.(power); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(optionalPower.listeners.size).toBe(0); + }); +}); From 9e86505276ede43c5a8cb068f6cfb0832b4a74a0 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Sun, 19 Jul 2026 16:01:37 -0700 Subject: [PATCH 2/6] feat: add GPX-backed terrain workouts --- src/app.tsx | 152 +++- src/components/dashboard-layout.tsx | 5 +- src/components/session-chart.tsx | 113 ++- src/components/session-controls.tsx | 23 + src/components/session-detail.tsx | 16 +- src/components/session-history-list.tsx | 1 + src/components/session-overview.tsx | 6 +- src/components/session-save-dialog.tsx | 1 + src/components/workout-panel.tsx | 247 +++++ src/components/workout-progress.tsx | 133 +++ .../workout-route-visualization.tsx | 138 +++ src/constants.ts | 8 +- src/hooks/use-session.ts | 33 +- src/hooks/use-trainer.ts | 25 +- src/hooks/use-workout-library.ts | 50 ++ src/hooks/use-workout.ts | 44 + src/lib/app-overlay.ts | 9 + src/lib/arrays.ts | 9 + src/lib/chart-mode.ts | 28 + src/lib/chart.ts | 18 +- src/lib/elevation.ts | 45 + src/lib/format.ts | 5 + src/lib/gpx.ts | 149 ++- src/lib/metric-presentation.ts | 5 + src/lib/saved-sessions.ts | 13 +- src/lib/session.ts | 19 + src/lib/tcx-import.ts | 102 ++- src/lib/tcx.ts | 64 +- src/lib/units.ts | 24 + src/lib/workout-file.ts | 229 +++++ src/lib/workout-schema.ts | 20 + src/lib/workouts.ts | 847 ++++++++++++++++++ src/lib/xml.ts | 47 + src/stores/session-store.ts | 108 ++- src/types.ts | 64 +- tests/components.test.tsx | 200 +++++ tests/elevation.test.ts | 29 + tests/fixtures/saved-session.ts | 1 + tests/format-chart.test.ts | 9 + tests/gpx.test.ts | 43 +- tests/saved-sessions.test.ts | 9 + tests/session-store.test.ts | 111 +++ tests/session.test.ts | 30 + tests/tcx-import.test.ts | 45 + tests/tcx.test.ts | 37 + tests/units.test.ts | 8 + tests/workout-file.test.ts | 138 +++ tests/workouts.test.ts | 201 +++++ 48 files changed, 3430 insertions(+), 231 deletions(-) create mode 100644 src/components/workout-panel.tsx create mode 100644 src/components/workout-progress.tsx create mode 100644 src/components/workout-route-visualization.tsx create mode 100644 src/hooks/use-workout-library.ts create mode 100644 src/hooks/use-workout.ts create mode 100644 src/lib/app-overlay.ts create mode 100644 src/lib/arrays.ts create mode 100644 src/lib/chart-mode.ts create mode 100644 src/lib/elevation.ts create mode 100644 src/lib/workout-file.ts create mode 100644 src/lib/workout-schema.ts create mode 100644 src/lib/workouts.ts create mode 100644 src/lib/xml.ts create mode 100644 tests/elevation.test.ts create mode 100644 tests/workout-file.test.ts create mode 100644 tests/workouts.test.ts diff --git a/src/app.tsx b/src/app.tsx index e01aea0..268e496 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -13,22 +13,26 @@ import { SessionOverview } from './components/session-overview'; import { SessionSaveDialog } from './components/session-save-dialog'; import { TrainingControl } from './components/training-control'; import { WelcomeDialog } from './components/welcome-dialog'; +import { WorkoutPanel } from './components/workout-panel'; +import { WorkoutProgress } from './components/workout-progress'; import { useGearControl } from './hooks/use-gear-control'; import { useHeartRateMonitor } from './hooks/use-heart-rate-monitor'; import { useSession } from './hooks/use-session'; import { useSessionWorkflow } from './hooks/use-session-workflow'; import { useTrainer } from './hooks/use-trainer'; +import { useWorkout } from './hooks/use-workout'; +import { useWorkoutLibrary } from './hooks/use-workout-library'; import { useZwiftClick } from './hooks/use-zwift-click'; +import { APP_OVERLAY, type AppOverlay } from './lib/app-overlay'; import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; +import { workoutSelectionLocked } from './lib/workouts'; import { MAX_CLICK_CONTROLLERS } from './lib/zwift-click'; import { preferencesStore } from './stores/preferences-store'; -import type { Metrics, SavedSession } from './types'; - -type AppOverlay = 'devices' | 'history' | 'shortcuts' | 'welcome'; +import type { Metrics, SavedSession, WorkoutCourse } from './types'; function shouldIgnoreShortcut(event: KeyboardEvent) { return ( @@ -56,9 +60,9 @@ function controlModeForClick(paired: boolean): ControlMode { export function App() { const trainer = useTrainer(); const [activeOverlay, setActiveOverlay] = useState(() => - shouldShowWelcome() ? 'welcome' : undefined + shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined ); - const devicesOpen = activeOverlay === 'devices'; + const devicesOpen = activeOverlay === APP_OVERLAY.DEVICES; const clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); const click = useZwiftClick(handleClickShift, trainer.setNotice, devicesOpen); @@ -70,6 +74,7 @@ export function App() { ); const { connected } = trainer; const speedUnit = useSelector(preferencesStore, (preferences) => preferences.speedUnit); + const workoutLibrary = useWorkoutLibrary(); const virtualShiftingReady = trainer.connected && click.connectedCount === MAX_CLICK_CONTROLLERS; const gearControl = useGearControl({ @@ -89,11 +94,20 @@ export function App() { trainer.lastPedalingAt, trainer.trainerReportsDistance ); + const workoutTerrain = useWorkout({ + active: !session.ended, + connected: trainer.connected, + distance: session.rideDistance, + onResistanceChange: trainer.updateProgramResistance, + onRestoreResistance: trainer.restoreManualResistance, + workout: session.workout, + }); const workflow = useSessionWorkflow(session, trainer.setNotice); const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen; + const virtualShiftingActive = click.paired && !session.workout; clickShiftRef.current = shiftHandlerUnlessBlocked( gearControl.shiftGear, - !dashboardKeyboardEnabled + !(dashboardKeyboardEnabled && virtualShiftingActive) ); const handleNewSessionShortcut = useCallback( (event: KeyboardEvent) => { @@ -123,15 +137,16 @@ export function App() { }, [warnBeforeUnload]); useEffect(() => { - trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled); - trainer.setGearControlsEnabled(click.paired); - gearControl.setKeyboardControlsEnabled(dashboardKeyboardEnabled); + trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled && !session.workout); + trainer.setGearControlsEnabled(virtualShiftingActive); + gearControl.setKeyboardControlsEnabled(dashboardKeyboardEnabled && virtualShiftingActive); }, [ - click.paired, dashboardKeyboardEnabled, gearControl.setKeyboardControlsEnabled, trainer.setGearControlsEnabled, trainer.setKeyboardControlsEnabled, + session.workout, + virtualShiftingActive, ]); useEffect(() => { @@ -145,7 +160,7 @@ export function App() { }, history: (event) => { event.preventDefault(); - setActiveOverlay('history'); + setActiveOverlay(APP_OVERLAY.HISTORY); }, newSession: handleNewSessionShortcut, pause: (event) => { @@ -154,7 +169,7 @@ export function App() { }, shortcuts: (event) => { event.preventDefault(); - setActiveOverlay('shortcuts'); + setActiveOverlay(APP_OVERLAY.SHORTCUTS); }, }; const handleShortcut = (event: KeyboardEvent) => { @@ -194,10 +209,41 @@ export function App() { }, [workflow.requestContinuation] ); + const selectWorkout = useCallback( + (course?: WorkoutCourse) => { + session.selectWorkout(course); + setActiveOverlay(undefined); + }, + [session.selectWorkout] + ); + const selectedWorkoutCourse = session.selectedWorkout?.course; + const selectedWorkoutId = selectedWorkoutCourse?.id; + const workoutLocked = workoutSelectionLocked(session); + useEffect(() => { + if (!(selectedWorkoutCourse && !workoutLocked)) { + return; + } + const currentDefinition = workoutLibrary.courses.find( + (course) => course.id === selectedWorkoutCourse.id + ); + if (currentDefinition && currentDefinition !== selectedWorkoutCourse) { + session.selectWorkout(currentDefinition); + } + }, [selectedWorkoutCourse, session.selectWorkout, workoutLibrary.courses, workoutLocked]); + const removeWorkout = useCallback( + (courseId: string) => { + if (selectedWorkoutId === courseId) { + session.selectWorkout(undefined); + } + workoutLibrary.removeCourse(courseId); + }, + [selectedWorkoutId, session.selectWorkout, workoutLibrary.removeCourse] + ); const connectedDeviceCount = Number(trainer.connected) + Number(heartRate.connected) + click.connectedCount; const pairedDeviceCount = Number(trainer.paired) + Number(heartRate.paired) + click.pairedCount; + const workoutName = selectedWorkoutCourse?.name; const devicesConnecting = [ trainer.connectionBusy, heartRate.busy, @@ -214,17 +260,20 @@ export function App() { isRiding={session.isRiding} manuallyPaused={session.manuallyPaused} onEnd={workflow.endSession} + onOpenWorkouts={() => setActiveOverlay(APP_OVERLAY.WORKOUTS)} onRequestNew={workflow.requestNewSession} onSave={workflow.openSaveDialog} onTogglePause={session.togglePause} saveResolved={workflow.sessionIsResolved} + workoutName={workoutName} + workoutSelectionLocked={workoutLocked} /> setActiveOverlay('devices')} - onOpenHistory={() => setActiveOverlay('history')} - onOpenShortcuts={() => setActiveOverlay('shortcuts')} + onOpenDevices={() => setActiveOverlay(APP_OVERLAY.DEVICES)} + onOpenHistory={() => setActiveOverlay(APP_OVERLAY.HISTORY)} + onOpenShortcuts={() => setActiveOverlay(APP_OVERLAY.SHORTCUTS)} onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit} pairedDeviceCount={pairedDeviceCount} speedUnit={speedUnit} @@ -238,6 +287,15 @@ export function App() { rideDistance={session.rideDistance} speedUnit={speedUnit} /> + {session.workout && workoutTerrain ? ( + + ) : null} - + {workoutTerrain ? null : ( + + )} - setActiveOverlay('welcome')} /> + setActiveOverlay(APP_OVERLAY.WELCOME)} /> setActiveOverlay(undefined)} onStartNew={continueFromHistory} - open={activeOverlay === 'history'} + open={activeOverlay === APP_OVERLAY.HISTORY} + speedUnit={speedUnit} + /> + setActiveOverlay(undefined)} + onImportFile={workoutLibrary.importFile} + onRemoveCourse={removeWorkout} + onSelect={selectWorkout} + open={activeOverlay === APP_OVERLAY.WORKOUTS} + selectionLocked={workoutLocked} speedUnit={speedUnit} /> setActiveOverlay(undefined)} - open={activeOverlay === 'shortcuts'} - shortcuts={click.paired ? gearingKeyboardShortcuts : undefined} + open={activeOverlay === APP_OVERLAY.SHORTCUTS} + shortcuts={virtualShiftingActive ? gearingKeyboardShortcuts : undefined} /> - + ); } diff --git a/src/components/dashboard-layout.tsx b/src/components/dashboard-layout.tsx index 357614b..beee4dd 100644 --- a/src/components/dashboard-layout.tsx +++ b/src/components/dashboard-layout.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { Children, type ReactNode } from 'react'; export function Dashboard({ children }: { children: ReactNode }) { return
{children}
; @@ -9,5 +9,6 @@ export function DashboardToolbar({ children }: { children: ReactNode }) { } export function DashboardWorkspace({ children }: { children: ReactNode }) { - return
{children}
; + const columns = Children.toArray(children).length > 1 ? 'xl:grid-cols-[1.45fr_.55fr]' : ''; + return
{children}
; } diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index f3f20a6..355854c 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -1,13 +1,24 @@ import { useSelector } from '@tanstack/react-store'; import { Fragment, useCallback, useEffect, useMemo } from 'react'; import { chartModesForControl, chartPath, roundedChartMaximum } from '../lib/chart'; +import { CHART_MODE } from '../lib/chart-mode'; import { CONTROL_MODE, isControlMode } from '../lib/control-mode'; import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { formatChartSeconds } from '../lib/format'; import { MAX_GEAR, MIN_GEAR } from '../lib/gears'; -import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; +import { + ELEVATION_METRIC_PRESENTATION, + METRIC_PRESENTATION, + STANDARD_METRIC_KEYS, +} from '../lib/metric-presentation'; import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; -import { convertSpeed, minimumSpeedChartMaximum, speedUnitLabel } from '../lib/units'; +import { + convertElevation, + convertSpeed, + elevationUnitLabel, + minimumSpeedChartMaximum, + speedUnitLabel, +} from '../lib/units'; import { preferencesStore } from '../stores/preferences-store'; import type { ChartMode, ControlMode, MetricSample, RoutePoint, SpeedUnit } from '../types'; @@ -107,11 +118,12 @@ export function SessionChart({ ? resolvedControlMode : selectedMode; const effectiveMode = - modeForAvailableControl === 'elevation' && route.length === 0 - ? 'all' + modeForAvailableControl === CHART_MODE.ELEVATION && route.length === 0 + ? CHART_MODE.ALL : modeForAvailableControl; const series = useMemo(() => { const speedValues = history.map((sample) => convertSpeed(sample.speed, speedUnit)); + const routeElevations = route.map((point) => convertElevation(point.elevation, speedUnit)); const standardSeries = STANDARD_METRIC_KEYS.map((key) => { const presentation = METRIC_PRESENTATION[key]; const values = history.map((sample) => sample[key]); @@ -152,6 +164,24 @@ export function SessionChart({ unit: '%', values: history.map((sample) => sample.resistance), }; + const elevationSeries = route.length + ? [ + { + chartMaximum: Math.max(...routeElevations), + color: ELEVATION_METRIC_PRESENTATION.chartColor, + decimals: 0, + key: CHART_MODE.ELEVATION, + label: ELEVATION_METRIC_PRESENTATION.label, + minimum: Math.min(...routeElevations), + unit: elevationUnitLabel(speedUnit), + values: history.map((sample) => + sample.elevation === undefined + ? undefined + : convertElevation(sample.elevation, speedUnit) + ), + }, + ] + : []; return [ { chartMaximum: roundedChartMaximum( @@ -161,7 +191,7 @@ export function SessionChart({ ), color: METRIC_PRESENTATION.speed.chartColor, decimals: 1, - key: 'speed' as const, + key: CHART_MODE.SPEED, label: METRIC_PRESENTATION.speed.label, minimum: 0, unit: speedUnitLabel(speedUnit), @@ -169,23 +199,23 @@ export function SessionChart({ }, ...standardSeries, controlSeries, + ...elevationSeries, ]; - }, [history, resolvedControlMode, speedUnit]); + }, [history, resolvedControlMode, route, speedUnit]); const visibleSeries = - effectiveMode === 'all' ? series : series.filter((item) => item.key === effectiveMode); + effectiveMode === CHART_MODE.ALL + ? series + : series.filter((item) => item.key === effectiveMode); const availableModes = useMemo( () => route.length ? [ ...chartModesForControl(resolvedControlMode), - { label: 'Elevation', value: 'elevation' as const }, + { label: 'Elevation', value: CHART_MODE.ELEVATION }, ] : chartModesForControl(resolvedControlMode), [resolvedControlMode, route.length] ); - const elevationValues = route.map((point) => point.elevation); - const elevationMinimum = elevationValues.length ? Math.min(...elevationValues) : 0; - const elevationMaximum = elevationValues.length ? Math.max(...elevationValues) : 1; const historyPositions = history.map((sample) => sample.elapsedSeconds); const historyStart = history[0]?.elapsedSeconds ?? 0; const historySeconds = @@ -236,7 +266,7 @@ export function SessionChart({ onClick={() => selectMode(mode.value)} type="button" > - {mode.value === 'all' ? null : ( + {mode.value === CHART_MODE.ALL ? null : (
- {(effectiveMode === 'elevation' ? route.length : history.length) === 0 ? ( + {history.length === 0 ? (
Connect and pedal to graph live session data
) : null} - {effectiveMode === 'elevation' ? ( - point.distance)} - title="Route elevation" - unit="m" - values={elevationValues} - /> - ) : ( - visibleSeries.map((item, index) => ( - - ( + + + {effectiveMode === CHART_MODE.ALL && + index < visibleSeries.length - 1 ? ( + + {session.workout && workoutTerrain ? ( + + ) : null}
{[...standardMetrics, controlMetric].map((metric) => ( @@ -223,7 +237,7 @@ export function SessionDetail({ controlMode={session.controlMode} history={session.history} keyboardEnabled={chartKeyboardEnabled} - route={EMPTY_ROUTE} + route={session.workout?.course.points ?? EMPTY_ROUTE} speedUnit={speedUnit} />
diff --git a/src/components/session-history-list.tsx b/src/components/session-history-list.tsx index 6b9a457..3d572ee 100644 --- a/src/components/session-history-list.tsx +++ b/src/components/session-history-list.tsx @@ -74,6 +74,7 @@ export function SessionHistoryList({ {session.feeling ? ` · ${feelingLabel(session.feeling)}` : null} + {session.workoutName ? ` · ${session.workoutName}` : null}

{imported ? ( @@ -35,7 +37,7 @@ export function SessionOverview({ controlMode={controlMode} history={history} keyboardEnabled={keyboardEnabled} - route={EMPTY_ROUTE} + route={workout?.course.points ?? EMPTY_ROUTE} speedUnit={speedUnit} />
diff --git a/src/components/session-save-dialog.tsx b/src/components/session-save-dialog.tsx index 8ffa172..6cb7018 100644 --- a/src/components/session-save-dialog.tsx +++ b/src/components/session-save-dialog.tsx @@ -82,6 +82,7 @@ export function SessionSaveDialog({

Started at {formatSessionTime(session.startedAt)} + {session.workout ? ` · ${session.workout.course.name}` : null}

+ {custom ? ( + + ) : null} + + + + + + ); +} + +export function WorkoutPanel({ + activeCourse, + courses, + customCourseIds, + ended, + onClose, + onImportFile, + onRemoveCourse, + onSelect, + open, + selectionLocked, + speedUnit, +}: { + activeCourse?: WorkoutCourse; + courses: WorkoutCourse[]; + customCourseIds: ReadonlySet; + ended: boolean; + onClose: () => void; + onImportFile: (file: File) => Promise; + onRemoveCourse: (courseId: string) => void; + onSelect: (course?: WorkoutCourse) => void; + open: boolean; + selectionLocked: boolean; + speedUnit: SpeedUnit; +}) { + const importInput = useRef(null); + const [importing, setImporting] = useState(false); + const [importStatus, setImportStatus] = useState(''); + const [importError, setImportError] = useState(''); + let notice = + 'Choose a loop before you start riding. The course repeats until the session ends.'; + if (ended) { + notice = 'Choose a workout for your next session, then start it when you are ready.'; + } else if (selectionLocked) { + notice = 'End the current session before changing the workout.'; + } + + const importWorkout = async (file: File) => { + setImporting(true); + setImportStatus(''); + setImportError(''); + try { + const course = await onImportFile(file); + setImportStatus(`${course.name} imported and saved on this device.`); + } catch (error) { + setImportError(errorMessage(error)); + } finally { + setImporting(false); + } + }; + + return ( + +
+
+
+

+ Terrain workouts +

+

+ Resistance follows the climbs and descents while your position moves + around a loop. +

+
+
+ { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (file) { + importWorkout(file); + } + }} + ref={importInput} + type="file" + /> + + +
+
+
+

{notice}

+ {importStatus ? ( +

+ {importStatus} +

+ ) : null} + {importError ? ( +

+ {importError} +

+ ) : null} +
+
+ {courses.map((course) => ( + onRemoveCourse(course.id)} + onSelect={() => onSelect(course)} + selected={activeCourse?.id === course.id} + speedUnit={speedUnit} + /> + ))} +
+ {activeCourse && !selectionLocked ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/src/components/workout-progress.tsx b/src/components/workout-progress.tsx new file mode 100644 index 0000000..d449f56 --- /dev/null +++ b/src/components/workout-progress.tsx @@ -0,0 +1,133 @@ +import { formatGrade } from '../lib/format'; +import { formatDistanceProgress, formatElevation } from '../lib/units'; +import { WORKOUT_VIEW } from '../lib/workout-schema'; +import type { ElevationTotals, SessionWorkout, SpeedUnit, WorkoutTerrain } from '../types'; +import { WorkoutRouteVisualization } from './workout-route-visualization'; + +interface WorkoutStat { + label: string; + value: string; +} + +function WorkoutStats({ + highlighted = false, + stats, +}: { + highlighted?: boolean; + stats: WorkoutStat[]; +}) { + const labelSize = highlighted ? 'text-[10px]' : 'text-[9px]'; + const valueSize = highlighted ? 'text-3xl sm:text-4xl' : 'text-xl sm:text-2xl'; + return ( +
+ {stats.map((stat) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+
+ ))} +
+ ); +} + +export function WorkoutProgress({ + elevationTotals, + isRiding, + speedUnit, + terrain, + workout, +}: { + elevationTotals: ElevationTotals; + isRiding: boolean; + speedUnit: SpeedUnit; + terrain: WorkoutTerrain; + workout: SessionWorkout; +}) { + const { course } = workout; + const elevationStats = [ + { label: 'Course climb', value: formatElevation(course.elevationGain, speedUnit) }, + { label: 'Climbed', value: formatElevation(elevationTotals.ascent, speedUnit) }, + { label: 'Downhill', value: formatElevation(elevationTotals.descent, speedUnit) }, + ]; + const mapStats = [ + { label: 'Progress', value: `${Math.round(terrain.progress * 100)}%` }, + { + label: 'Grade', + value: formatGrade(terrain.grade), + }, + { label: 'Resistance', value: `${terrain.resistance}%` }, + ]; + return ( +
+
+
+

{course.name}

+ + + Ridden this lap + +
+
+

+ Laps completed +

+ + {terrain.completedLaps} + +
+
+
+
+
+
+

+ Course map +

+

+ {formatDistanceProgress( + terrain.distance, + course.distance, + speedUnit + )} +

+
+ +
+ +
+
+
+

+ Elevation profile +

+ +
+ +
+
+
+ ); +} diff --git a/src/components/workout-route-visualization.tsx b/src/components/workout-route-visualization.tsx new file mode 100644 index 0000000..7894fac --- /dev/null +++ b/src/components/workout-route-visualization.tsx @@ -0,0 +1,138 @@ +import { useId } from 'react'; +import { WORKOUT_VIEW, type WorkoutView } from '../lib/workout-schema'; +import { + workoutMapPath, + workoutMapProgressPath, + workoutProfilePath, + workoutProfilePosition, +} from '../lib/workouts'; +import type { WorkoutCourse, WorkoutTerrain } from '../types'; + +interface RouteMarker { + x: number; + y: number; +} + +function WorkoutRouteMarker({ + isMap, + isRiding, + marker, +}: { + isMap: boolean; + isRiding: boolean; + marker?: RouteMarker; +}) { + if (!marker) { + return null; + } + if (isMap) { + return ( + + + + ); + } + return ( +