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 ;
+ const columns = Children.toArray(children).length > 1 ? 'xl:grid-cols-[1.45fr_.55fr]' : '';
+ return ;
}
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 ? (
+
- {effectiveMode === 'all' && index < visibleSeries.length - 1 ? (
-
- ) : null}
-
- ))
- )}
+ ) : null}
+
+ ))}
diff --git a/src/components/session-controls.tsx b/src/components/session-controls.tsx
index 53770a5..1ebf417 100644
--- a/src/components/session-controls.tsx
+++ b/src/components/session-controls.tsx
@@ -5,20 +5,41 @@ export function SessionControls({
isRiding,
manuallyPaused,
onEnd,
+ onOpenWorkouts,
onRequestNew,
onSave,
onTogglePause,
saveResolved,
+ workoutName,
+ workoutSelectionLocked,
}: {
ended: boolean;
isRiding: boolean;
manuallyPaused: boolean;
onEnd: () => void;
+ onOpenWorkouts: () => void;
onRequestNew: () => void;
onSave: () => void;
onTogglePause: () => void;
saveResolved: boolean;
+ workoutName?: string;
+ workoutSelectionLocked: boolean;
}) {
+ const workoutButton = (
+
+ );
if (ended) {
return (
@@ -38,6 +59,7 @@ export function SessionControls({
>
Start new session
+ {workoutButton}
);
}
@@ -69,6 +91,7 @@ export function SessionControls({
>
End session
+ {workoutButton}
);
}
diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx
index 51dd684..3efc081 100644
--- a/src/components/session-detail.tsx
+++ b/src/components/session-detail.tsx
@@ -11,10 +11,12 @@ import {
isImportedSession,
} from '../lib/saved-sessions';
import { downloadSessionTcx } from '../lib/tcx';
+import { workoutTerrainAtDistance } from '../lib/workouts';
import type { SavedSession, SpeedUnit } from '../types';
import { SessionMetric } from './metrics';
import { SessionChart } from './session-chart';
import { SessionSummary } from './session-summary';
+import { WorkoutProgress } from './workout-progress';
export function DeleteSessionDialog({
deleting,
@@ -99,6 +101,9 @@ export function SessionDetail({
}) {
const usesGear = session.controlMode === CONTROL_MODE.GEAR;
const imported = isImportedSession(session);
+ const workoutTerrain = session.workout
+ ? workoutTerrainAtDistance(session.workout.course, session.distance)
+ : undefined;
const controlMetric = usesGear
? {
accent: 'mint',
@@ -198,6 +203,15 @@ export function SessionDetail({
timeLabel="RECORDED"
/>
+ {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}
');
+ expect(html).toContain('28 m');
+ expect(html).toContain('stroke="#fb923c"');
+ expect(html.match(/data-chart-separator="true"/g)).toHaveLength(5);
+ const imperialHtml = render(
+
+ );
+ expect(imperialHtml).toContain('92 ft');
+ expect(imperialHtml).not.toContain('28 m');
+ });
+
test('graphs gear instead of resistance during virtual shifting', () => {
const html = render(
{
controlMode: 'resistance',
distance: 10,
elapsedSeconds: 3600,
+ elevationTotals: emptySession.elevationTotals,
endedAt: Date.now(),
history: [],
maximums: emptyMetrics,
@@ -680,6 +859,24 @@ describe('view components', () => {
expect(newSession).toContain('Save & start new');
});
+ test('places workout planning after starting a new session', () => {
+ const html = render(
+ undefined}
+ onOpenWorkouts={() => undefined}
+ onRequestNew={() => undefined}
+ onSave={() => undefined}
+ onTogglePause={() => undefined}
+ saveResolved
+ workoutSelectionLocked={false}
+ />
+ );
+ expect(html.indexOf('Start new session')).toBeLessThan(html.indexOf('Workouts'));
+ });
+
test('renders an empty session history', () => {
const html = render(
{
controlMode: 'resistance',
distance: 0,
elapsedSeconds: 7200,
+ elevationTotals: emptySession.elevationTotals,
endedAt,
history: [],
id: 'overnight-session',
@@ -829,6 +1027,7 @@ describe('view components', () => {
controlMode: 'resistance',
distance: 0,
elapsedSeconds: 0,
+ elevationTotals: emptySession.elevationTotals,
endedAt: Date.now(),
history: [],
id: 'empty-session',
@@ -863,6 +1062,7 @@ describe('view components', () => {
controlMode: 'gear',
distance: 0,
elapsedSeconds: 2,
+ elevationTotals: emptySession.elevationTotals,
endedAt: Date.now(),
history: [],
id: 'gear-session',
diff --git a/tests/elevation.test.ts b/tests/elevation.test.ts
new file mode 100644
index 0000000..12f9503
--- /dev/null
+++ b/tests/elevation.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ addElevationChange,
+ elevationTotalsForSamples,
+ restoreElevationTotals,
+} from '../src/lib/elevation';
+
+describe('elevation totals', () => {
+ test('tracks cumulative climbing and downhill independently', () => {
+ const totals = elevationTotalsForSamples([
+ { elevation: 10 },
+ { elevation: 15 },
+ { elevation: 12 },
+ {},
+ { elevation: 20 },
+ ]);
+ expect(totals).toEqual({ ascent: 13, descent: 3 });
+ expect(addElevationChange(totals, 20, 18)).toEqual({ ascent: 13, descent: 5 });
+ });
+
+ test('restores saved totals and derives a fallback for older sessions', () => {
+ const samples = [{ elevation: 20 }, { elevation: 28 }, { elevation: 23 }];
+ expect(restoreElevationTotals({ ascent: 40, descent: 25 }, samples)).toEqual({
+ ascent: 40,
+ descent: 25,
+ });
+ expect(restoreElevationTotals(undefined, samples)).toEqual({ ascent: 8, descent: 5 });
+ });
+});
diff --git a/tests/fixtures/saved-session.ts b/tests/fixtures/saved-session.ts
index 413a874..bfae91b 100644
--- a/tests/fixtures/saved-session.ts
+++ b/tests/fixtures/saved-session.ts
@@ -17,6 +17,7 @@ export const savedSessionFixture: SavedSession = {
controlMode: CONTROL_MODE.RESISTANCE,
distance: 1.5,
elapsedSeconds: 2,
+ elevationTotals: { ascent: 0, descent: 0 },
endedAt: SAVED_SESSION_STARTED_AT + 2000,
feeling: 'good',
history: [
diff --git a/tests/format-chart.test.ts b/tests/format-chart.test.ts
index f5a0c78..d3f020a 100644
--- a/tests/format-chart.test.ts
+++ b/tests/format-chart.test.ts
@@ -5,6 +5,7 @@ import {
formatAggregateAverage,
formatChartSeconds,
formatDuration,
+ formatGrade,
} from '../src/lib/format';
describe('format utilities', () => {
@@ -23,6 +24,14 @@ describe('format utilities', () => {
expect(aggregateMaximum({ count: 2, maximum: 8, sum: 11 })).toBe(8);
expect(aggregateMaximum({ count: 2, sum: 11 })).toBe(0);
});
+
+ test('formats flat grades without an unstable sign', () => {
+ expect(formatGrade(0)).toBe('0%');
+ expect(formatGrade(0.04)).toBe('0%');
+ expect(formatGrade(-0.04)).toBe('0%');
+ expect(formatGrade(3.14)).toBe('+3.1%');
+ expect(formatGrade(-2.96)).toBe('-3.0%');
+ });
});
describe('chart utilities', () => {
diff --git a/tests/gpx.test.ts b/tests/gpx.test.ts
index b9c4e23..a8ede52 100644
--- a/tests/gpx.test.ts
+++ b/tests/gpx.test.ts
@@ -1,12 +1,18 @@
import { describe, expect, test } from 'bun:test';
+import { DOMParser } from '@xmldom/xmldom';
import { distanceBetween, parseGpx } from '../src/lib/gpx';
-function point(lat: string, lon: string, elevation?: string) {
- return {
- getAttribute: (name: string) => (name === 'lat' ? lat : lon),
- querySelector: () => (elevation === undefined ? null : { textContent: elevation }),
- } as unknown as Element;
-}
+const TRACK_GPX = `
+
+
+ Test route
+
+ 10
+ 20
+ 10
+
+
+`;
describe('GPX utilities', () => {
test('computes great-circle distance', () => {
@@ -14,14 +20,23 @@ describe('GPX utilities', () => {
expect(distanceBetween(34, -118, 34, -118)).toBe(0);
});
- test('parses route points and cumulative kilometers', () => {
- const points = [point('0', '0', '10'), point('0', '1', '20')];
- const parser = {
- parseFromString: () => ({ querySelectorAll: () => points }),
- } as unknown as DOMParser;
- const route = parseGpx('', parser);
- expect(route[0]).toEqual({ distance: 0, elevation: 10 });
- expect(route[1]?.distance).toBeCloseTo(111.195, 2);
+ test('parses geographic route points, elevation, and cumulative kilometers', () => {
+ const route = parseGpx(TRACK_GPX, new DOMParser() as unknown as globalThis.DOMParser);
+ expect(route[0]).toEqual({
+ distance: 0,
+ elevation: 10,
+ latitude: 37,
+ longitude: -122,
+ });
+ expect(route[1]?.distance).toBeCloseTo(0.111, 2);
expect(route[1]?.elevation).toBe(20);
+ expect(route[2]?.distance).toBeCloseTo(0.222, 2);
+ });
+
+ test('requires elevation data on every GPX route point', () => {
+ const missingElevation = TRACK_GPX.replace('20', '');
+ expect(() =>
+ parseGpx(missingElevation, new DOMParser() as unknown as globalThis.DOMParser)
+ ).toThrow('Every GPX route point must include valid coordinates and elevation data.');
});
});
diff --git a/tests/saved-sessions.test.ts b/tests/saved-sessions.test.ts
index e25d298..ee59b62 100644
--- a/tests/saved-sessions.test.ts
+++ b/tests/saved-sessions.test.ts
@@ -17,6 +17,7 @@ import {
sessionListAfterDelete,
sessionSummary,
} from '../src/lib/saved-sessions';
+import { WORKOUT_COURSES } from '../src/lib/workouts';
import type { SavedSession, SavedSessionSummary, SessionSnapshot } from '../src/types';
const snapshot: SessionSnapshot = {
@@ -25,6 +26,7 @@ const snapshot: SessionSnapshot = {
controlMode: 'resistance',
distance: 10,
elapsedSeconds: 1800,
+ elevationTotals: emptySession.elevationTotals,
endedAt: new Date(2026, 6, 18, 9).getTime(),
history: [
{
@@ -79,6 +81,13 @@ describe('saved session utilities', () => {
expect(sessionSummary({ ...session, importedAt: 5678 }).importedAt).toBe(5678);
expect(isImportedSession({ id: 'tcx:legacy-import' })).toBe(true);
expect(isImportedSession({ id: session.id })).toBe(false);
+ const [workout] = WORKOUT_COURSES;
+ if (!workout) {
+ throw new Error('Expected a built-in workout course');
+ }
+ expect(sessionSummary({ ...session, workout: { course: workout } }).workoutName).toBe(
+ workout.name
+ );
});
test('writes and deletes both full and summary session records', () => {
diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts
index 5669c86..e07e449 100644
--- a/tests/session-store.test.ts
+++ b/tests/session-store.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { emptyMetrics, emptySession } from '../src/constants';
+import { CONTROL_MODE } from '../src/lib/control-mode';
+import { WORKOUT_COURSES } from '../src/lib/workouts';
import {
createSessionStore,
persistSessionState,
@@ -109,6 +111,80 @@ describe('session store', () => {
expect(gearStore.get().aggregates.gear.maximum).toBe(20);
});
+ test('records selected workout terrain atomically with ride samples', () => {
+ const workout = WORKOUT_COURSES.find((course) => course.id === 'cedar-circuit');
+ if (!workout) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const store = createSessionStore(restoredSession(), 1000);
+ store.actions.selectWorkout(workout);
+ expect(store.get().controlMode).toBe(CONTROL_MODE.RESISTANCE);
+ store.actions.syncRiding(true);
+ store.actions.selectWorkout(WORKOUT_COURSES[0]);
+ expect(store.get().workout?.course.id).toBe(workout.id);
+ store.actions.recordTick({
+ control: { gear: 12, mode: 'resistance', resistance: 20 },
+ distanceDelta: 0.4,
+ metrics: liveMetrics,
+ seconds: 1,
+ });
+ store.actions.recordTick({
+ control: { gear: 12, mode: 'resistance', resistance: 30 },
+ distanceDelta: 2,
+ metrics: liveMetrics,
+ seconds: 1,
+ });
+ store.actions.recordTick({
+ control: { gear: 12, mode: 'resistance', resistance: 20 },
+ distanceDelta: 0.8,
+ metrics: liveMetrics,
+ seconds: 1,
+ });
+
+ expect(store.get().workout?.course.id).toBe(workout.id);
+ expect(store.get().history[0]).toMatchObject({
+ workoutDistance: 0.4,
+ workoutLap: 1,
+ });
+ expect(store.get().history[0]?.elevation).toBeGreaterThan(0);
+ expect(store.get().history[0]?.grade).toBeCloseTo(0);
+ expect(store.get().elevationTotals.ascent).toBeCloseTo(36);
+ expect(store.get().elevationTotals.descent).toBeCloseTo(24);
+
+ store.actions.selectWorkout(WORKOUT_COURSES[0]);
+ expect(store.get().workout?.course.id).toBe(workout.id);
+ const snapshotWorkout = sessionSnapshotFromState(store.get()).workout;
+ const storedWorkout = storedSessionFromState(store.get()).workout;
+ if (!(snapshotWorkout && storedWorkout)) {
+ throw new Error('Expected workout metadata in session persistence');
+ }
+ expect(snapshotWorkout.course.id).toBe(workout.id);
+ expect(storedWorkout.course.id).toBe(workout.id);
+ expect(sessionSnapshotFromState(store.get()).elevationTotals).toEqual(
+ store.get().elevationTotals
+ );
+ expect(storedSessionFromState(store.get()).elevationTotals).toEqual(
+ store.get().elevationTotals
+ );
+ });
+
+ test('replaces an unstarted workout when its saved definition is revised', () => {
+ const workout = WORKOUT_COURSES.find((course) => course.id === 'cedar-circuit');
+ if (!workout) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const staleDefinition = {
+ ...workout,
+ points: workout.points.map((point) => ({ ...point, x: point.x / 2 })),
+ };
+ const store = createSessionStore(restoredSession(), 1000);
+ store.actions.selectWorkout(staleDefinition);
+ expect(store.get().workout?.course).toBe(staleDefinition);
+
+ store.actions.selectWorkout(workout);
+ expect(store.get().workout?.course).toBe(workout);
+ });
+
test('coordinates pause, end, reset, and continuation transitions', () => {
const store = createSessionStore(restoredSession(), 1000);
store.actions.syncRiding(true);
@@ -140,6 +216,7 @@ describe('session store', () => {
controlMode: 'resistance',
distance: 12,
elapsedSeconds: 900,
+ elevationTotals: { ascent: 120, descent: 80 },
endedAt: 4000,
history: [],
maximums: emptyMetrics,
@@ -149,6 +226,7 @@ describe('session store', () => {
calories: 100,
distance: 12,
elapsedSeconds: 900,
+ elevationTotals: { ascent: 120, descent: 80 },
ended: false,
endedAt: 0,
startedAt: 2000,
@@ -156,6 +234,39 @@ describe('session store', () => {
expect(store.get().savedSessionId).toBeUndefined();
});
+ test('plans a workout for the next session without changing the completed ride', () => {
+ const [completedCourse, plannedCourse] = WORKOUT_COURSES;
+ if (!(completedCourse && plannedCourse)) {
+ throw new Error('Expected built-in workout courses');
+ }
+ const store = createSessionStore(
+ restoredSession({
+ elapsedSeconds: 60,
+ ended: true,
+ workout: { course: completedCourse },
+ }),
+ 1000
+ );
+
+ store.actions.selectWorkout(plannedCourse);
+ expect(store.get().workout?.course.id).toBe(completedCourse.id);
+ expect(store.get().plannedWorkout?.course.id).toBe(plannedCourse.id);
+ const snapshot = sessionSnapshotFromState(store.get());
+ const stored = storedSessionFromState(store.get());
+ if (!(snapshot.workout && stored.plannedWorkout)) {
+ throw new Error('Expected current and planned workouts to be retained');
+ }
+ expect(snapshot.workout.course.id).toBe(completedCourse.id);
+ expect(snapshot).not.toHaveProperty('plannedWorkout');
+ expect(stored.plannedWorkout.course.id).toBe(plannedCourse.id);
+
+ store.actions.reset(CONTROL_MODE.GEAR, 5000);
+ expect(store.get().ended).toBe(false);
+ expect(store.get().controlMode).toBe(CONTROL_MODE.RESISTANCE);
+ expect(store.get().workout?.course.id).toBe(plannedCourse.id);
+ expect(store.get().plannedWorkout).toBeUndefined();
+ });
+
test('records an intentional discard until the session is reset or saved', () => {
const store = createSessionStore(restoredSession(), 1000);
store.actions.endSession(5000);
diff --git a/tests/session.test.ts b/tests/session.test.ts
index 3adbfbc..e561a40 100644
--- a/tests/session.test.ts
+++ b/tests/session.test.ts
@@ -12,6 +12,7 @@ import {
sessionNeedsUnloadWarning,
storedResistance,
} from '../src/lib/session';
+import { WORKOUT_COURSES } from '../src/lib/workouts';
const storageWith = (value: string | null) => ({
getItem: () => value,
@@ -32,6 +33,7 @@ describe('session utilities', () => {
controlMode: 'resistance' as const,
distance: 14,
elapsedSeconds: 1800,
+ elevationTotals: { ascent: 220, descent: 180 },
endedAt: 5000,
history: [
{
@@ -136,41 +138,69 @@ describe('session utilities', () => {
});
test('loads and sanitizes a stored session', () => {
+ const [workout] = WORKOUT_COURSES;
+ if (!workout) {
+ throw new Error('Expected a built-in workout course');
+ }
const stored = JSON.stringify({
calories: -10,
distance: 12,
elapsedSeconds: 65,
+ elevationTotals: { ascent: 120, descent: 80 },
ended: true,
endedAt: 5000,
history: [
{
cadence: 90,
elapsedSeconds: 65,
+ elevation: 24,
+ grade: 2.4,
heartRate: 150,
power: 200,
resistance: 42,
speed: Number.NaN,
+ workoutDistance: 1.2,
+ workoutLap: 2,
},
],
maximums: { cadence: 95, heartRate: 160, power: 250, speed: 35 },
+ plannedWorkout: { course: workout },
savedSessionId: 'saved-session',
startedAt: 1000,
+ workout: { course: workout },
});
const session = loadStoredSession(storageWith(stored));
expect(session.calories).toBe(0);
expect(session.distance).toBe(12);
+ expect(session.elevationTotals).toEqual({ ascent: 120, descent: 80 });
expect(session.discarded).toBe(false);
expect(session.ended).toBe(true);
expect(session.endedAt).toBe(5000);
expect(session.history[0]?.speed).toBe(0);
expect(session.history[0]?.resistance).toBe(42);
expect(session.history[0]?.gear).toBeUndefined();
+ expect(session.history[0]).toMatchObject({
+ elevation: 24,
+ grade: 2.4,
+ workoutDistance: 1.2,
+ workoutLap: 2,
+ });
expect(session.controlMode).toBe('resistance');
expect(session.aggregates.power).toEqual({ count: 1, maximum: 200, sum: 200 });
expect(session.aggregates.resistance).toEqual({ count: 1, maximum: 42, sum: 42 });
expect(session.maximums.speed).toBe(35);
+ const { plannedWorkout } = session;
+ if (!plannedWorkout) {
+ throw new Error('Expected the planned workout to be restored');
+ }
+ expect(plannedWorkout.course.id).toBe(workout.id);
expect(session.savedSessionId).toBe('saved-session');
expect(session.startedAt).toBe(1000);
+ const restoredWorkout = session.workout;
+ if (!restoredWorkout) {
+ throw new Error('Expected the stored workout to be restored');
+ }
+ expect(restoredWorkout.course.id).toBe(workout.id);
});
test('uses an empty session for absent or malformed storage', () => {
diff --git a/tests/tcx-import.test.ts b/tests/tcx-import.test.ts
index 31e3e79..db15d3c 100644
--- a/tests/tcx-import.test.ts
+++ b/tests/tcx-import.test.ts
@@ -4,6 +4,7 @@ import { strToU8, zipSync } from 'fflate';
import { CONTROL_MODE } from '../src/lib/control-mode';
import { sessionToTcx } from '../src/lib/tcx';
import { importTcxUpload, parseTcxSessions, tcxImportResultMessage } from '../src/lib/tcx-import';
+import { WORKOUT_COURSES, workoutTerrainAtDistance } from '../src/lib/workouts';
import type { SavedSession } from '../src/types';
import { savedSessionFixture } from './fixtures/saved-session';
@@ -53,6 +54,50 @@ describe('TCX import', () => {
expect(second?.id).toBe(first?.id);
});
+ test('round trips terrain workout definitions and progress samples', () => {
+ const course = WORKOUT_COURSES.find((workout) => workout.id === 'highland-loop');
+ expect(course).toBeDefined();
+ if (!course) {
+ return;
+ }
+ const workoutSession: SavedSession = {
+ ...session,
+ elevationTotals: { ascent: 205.5, descent: 91.25 },
+ history: session.history.map((sample, index) => {
+ const terrain = workoutTerrainAtDistance(course, index + 1);
+ return {
+ ...sample,
+ elevation: terrain.elevation,
+ grade: terrain.grade,
+ workoutDistance: terrain.distance,
+ workoutLap: terrain.lap,
+ };
+ }),
+ workout: { course },
+ };
+ const [imported] = parseTcxSessions(sessionToTcx(workoutSession));
+ expect(imported?.workout?.course).toMatchObject({
+ baseResistance: course.baseResistance,
+ description: course.description,
+ difficulty: course.difficulty,
+ distance: course.distance,
+ id: course.id,
+ name: course.name,
+ });
+ expect(imported?.workout?.course.points).toHaveLength(course.points.length);
+ expect(imported?.workout?.course.points[1]?.latitude).toBeCloseTo(
+ course.points[1]?.latitude ?? 0,
+ 7
+ );
+ expect(imported?.history[0]).toMatchObject({
+ workoutDistance: 1,
+ workoutLap: 1,
+ });
+ expect(imported?.history[0]?.elevation).toBeNumber();
+ expect(imported?.history[0]?.grade).toBeNumber();
+ expect(imported?.elevationTotals).toEqual({ ascent: 205.5, descent: 91.25 });
+ });
+
test('imports TCX files in nested ZIP folders and skips duplicate sessions', async () => {
const tcx = strToU8(sessionToTcx(session));
const archive = zipSync({
diff --git a/tests/tcx.test.ts b/tests/tcx.test.ts
index 7611c6f..bd42267 100644
--- a/tests/tcx.test.ts
+++ b/tests/tcx.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { sessionTcxFilename, sessionToTcx } from '../src/lib/tcx';
+import { WORKOUT_COURSES, workoutTerrainAtDistance } from '../src/lib/workouts';
import type { SavedSession } from '../src/types';
import { savedSessionFixture as session } from './fixtures/saved-session';
@@ -17,6 +18,8 @@ describe('TCX export', () => {
expect(tcx).toContain('210');
expect(tcx).toContain('45.0');
expect(tcx).toContain('saved-session');
+ expect(tcx).toContain('0.00');
+ expect(tcx).toContain('0.00');
expect(tcx).toContain('42.5');
expect(tcx).toContain('45.0');
expect(tcx).toContain('Comments: Hard & fun <again>');
@@ -27,6 +30,40 @@ describe('TCX export', () => {
expect(sessionTcxFilename(session)).toBe('ride-control-2026-07-18T16-00-00.000Z.tcx');
});
+ test('exports terrain workout metadata and samples', () => {
+ const course = WORKOUT_COURSES.find((workout) => workout.id === 'cedar-circuit');
+ expect(course).toBeDefined();
+ if (!course) {
+ return;
+ }
+ const workoutSession: SavedSession = {
+ ...session,
+ elevationTotals: { ascent: 82.5, descent: 30.25 },
+ history: session.history.map((sample, index) => {
+ const terrain = workoutTerrainAtDistance(course, index + 0.5);
+ return {
+ ...sample,
+ elevation: terrain.elevation,
+ grade: terrain.grade,
+ workoutDistance: terrain.distance,
+ workoutLap: terrain.lap,
+ };
+ }),
+ workout: { course },
+ };
+ const tcx = sessionToTcx(workoutSession);
+ expect(tcx).toContain('');
+ expect(tcx).toContain('');
+ expect(tcx).toContain('');
+ expect(tcx).toContain('1');
+ expect(tcx).toContain('cedar-circuit');
+ expect(tcx).toContain('12.0');
+ expect(tcx).toContain('82.50');
+ expect(tcx).toContain('30.25');
+ expect(tcx).toContain('Cedar Circuit');
+ expect(tcx).toContain('');
+ });
+
test('exports gear instead of resistance for a virtual shifting session', () => {
const gearSession: SavedSession = {
...session,
diff --git a/tests/units.test.ts b/tests/units.test.ts
index 2edd624..32d61f6 100644
--- a/tests/units.test.ts
+++ b/tests/units.test.ts
@@ -2,9 +2,12 @@ import { describe, expect, test } from 'bun:test';
import {
averageSpeed,
convertDistance,
+ convertElevation,
convertSpeed,
formatDistance,
+ formatDistanceProgress,
formatDistanceValue,
+ formatElevation,
kilometersForMeters,
kilometersTraveled,
metersForKilometers,
@@ -17,10 +20,15 @@ import {
describe('unit conversions', () => {
test('converts metric ride values to imperial display values', () => {
expect(convertDistance(1.609_344, 'mph')).toBeCloseTo(1);
+ expect(convertElevation(304.8, 'mph')).toBeCloseTo(1000);
expect(convertSpeed(32.186_88, 'mph')).toBeCloseTo(20);
expect(formatDistance(16.093_44, 'mph')).toBe('10.00 mi');
expect(formatDistance(16.093_44, 'kmh')).toBe('16.09 km');
+ expect(formatDistanceProgress(5.793_638_4, 9.656_064, 'mph')).toBe('3.6 / 6 mi');
+ expect(formatDistanceProgress(3.6, 6, 'kmh')).toBe('3.6 / 6 km');
expect(formatDistanceValue(16.093_44, 'mph')).toBe('10.00');
+ expect(formatElevation(304.8, 'mph')).toBe('1000 ft');
+ expect(formatElevation(304.8, 'kmh')).toBe('305 m');
});
test('converts stored SI and ride timing values consistently', () => {
diff --git a/tests/workout-file.test.ts b/tests/workout-file.test.ts
new file mode 100644
index 0000000..c202c14
--- /dev/null
+++ b/tests/workout-file.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, test } from 'bun:test';
+import { DOMParser } from '@xmldom/xmldom';
+import {
+ addCustomWorkout,
+ CUSTOM_WORKOUTS_STORAGE_KEY,
+ loadCustomWorkouts,
+ parseWorkoutFile,
+ readWorkoutFile,
+ saveCustomWorkouts,
+ WORKOUT_GPX_EXTENSION_NAMESPACE,
+ withoutCustomWorkout,
+ workoutFileContents,
+ workoutFilename,
+} from '../src/lib/workout-file';
+import { WORKOUT_COURSES } from '../src/lib/workouts';
+import type { WorkoutCourse } from '../src/types';
+
+Object.defineProperty(globalThis, 'DOMParser', { configurable: true, value: DOMParser });
+
+function customWorkout(): WorkoutCourse {
+ const [builtIn] = WORKOUT_COURSES;
+ if (!builtIn) {
+ throw new Error('Expected a built-in workout course');
+ }
+ return {
+ ...builtIn,
+ id: 'ridge-river-test',
+ name: 'Ridge & River / Test',
+ };
+}
+
+function thirdPartyGpx(name = 'Neighborhood loop'): string {
+ return `
+
+
+ ${name}
+ A real GPX loop
+
+ 12
+ 22
+ 18
+ 12
+
+
+`;
+}
+
+describe('workout GPX files', () => {
+ test('round trips geographic workout source data through standard GPX', async () => {
+ const workout = customWorkout();
+ const contents = workoutFileContents(workout);
+ expect(contents).toStartWith('');
+ expect(contents).toContain('');
+ expect(contents).toContain('12.0');
+ expect(contents).not.toContain('elevationGain');
+ expect(contents).not.toContain('');
+ const parsed = parseWorkoutFile(
+ contents,
+ new DOMParser() as unknown as globalThis.DOMParser
+ );
+ expect(parsed).toMatchObject({
+ baseResistance: workout.baseResistance,
+ description: workout.description,
+ difficulty: workout.difficulty,
+ distance: workout.distance,
+ id: workout.id,
+ name: workout.name,
+ });
+ expect(parsed.points).toHaveLength(workout.points.length);
+ expect(parsed.points[1]?.latitude).toBeCloseTo(workout.points[1]?.latitude ?? 0, 7);
+ expect(await readWorkoutFile({ name: 'route.gpx', text: async () => contents })).toEqual(
+ parsed
+ );
+ expect(workoutFilename(workout)).toBe('ride-control-ridge-river-test.gpx');
+ expect(workoutFilename({ id: 'safe-fallback', name: '///' })).toBe(
+ 'ride-control-safe-fallback.gpx'
+ );
+ });
+
+ test('imports ordinary GPX loops with a stable generated identifier', () => {
+ const parser = new DOMParser() as unknown as globalThis.DOMParser;
+ const first = parseWorkoutFile(thirdPartyGpx(), parser);
+ const second = parseWorkoutFile(thirdPartyGpx('Renamed metadata'), parser);
+ expect(first).toMatchObject({
+ baseResistance: 12,
+ description: 'A real GPX loop',
+ difficulty: 'moderate',
+ name: 'Neighborhood loop',
+ });
+ expect(first.id).toStartWith('gpx-');
+ expect(second.id).toBe(first.id);
+ });
+
+ test('rejects malformed, open, and built-in workout imports', async () => {
+ await expect(
+ readWorkoutFile({ name: 'broken.gpx', text: async () => '12\n\t\t',
+ '12\n\t\t'
+ );
+ expect(() =>
+ parseWorkoutFile(openRoute, new DOMParser() as unknown as globalThis.DOMParser)
+ ).toThrow('The GPX route must be a closed loop');
+ const [builtIn] = WORKOUT_COURSES;
+ if (!builtIn) {
+ throw new Error('Expected a built-in workout course');
+ }
+ expect(() => addCustomWorkout([], builtIn)).toThrow(
+ `${builtIn.name} is already included with Ride Control.`
+ );
+ });
+
+ test('persists, restores, rejects duplicates, and removes custom workouts by stable id', () => {
+ const workout = customWorkout();
+ let saved = '';
+ const storage = {
+ getItem: (key: string) => (key === CUSTOM_WORKOUTS_STORAGE_KEY ? saved : null),
+ setItem: (key: string, value: string) => {
+ if (key === CUSTOM_WORKOUTS_STORAGE_KEY) {
+ saved = value;
+ }
+ },
+ };
+ const imported = addCustomWorkout([], workout);
+ saveCustomWorkouts(imported.courses, storage);
+ expect(loadCustomWorkouts(storage)).toEqual([workout]);
+
+ const renamed = { ...workout, name: 'Renamed route' };
+ expect(() => addCustomWorkout(imported.courses, renamed)).toThrow(
+ `${workout.name} has already been imported.`
+ );
+ expect(withoutCustomWorkout(imported.courses, workout.id)).toEqual([]);
+ });
+});
diff --git a/tests/workouts.test.ts b/tests/workouts.test.ts
new file mode 100644
index 0000000..1b18212
--- /dev/null
+++ b/tests/workouts.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ restoreSessionWorkout,
+ restoreWorkoutCourse,
+ WORKOUT_COURSES,
+ WORKOUT_FLAT_START_DISTANCE,
+ workoutCompletedLaps,
+ workoutElevationTotalsAtDistance,
+ workoutLap,
+ workoutMapPath,
+ workoutMapProgressPath,
+ workoutMaximumGrade,
+ workoutProfilePath,
+ workoutProgress,
+ workoutSelectionLocked,
+ workoutTerrainAtDistance,
+} from '../src/lib/workouts';
+
+const course = WORKOUT_COURSES.find((workout) => workout.id === 'cedar-circuit');
+
+describe('terrain workouts', () => {
+ test('defines original closed-loop courses with useful terrain metadata', () => {
+ expect(WORKOUT_COURSES).toHaveLength(4);
+ for (const workout of WORKOUT_COURSES) {
+ expect(workout.distance).toBeGreaterThan(0);
+ expect(workout.elevationGain).toBeGreaterThan(0);
+ expect(workout.points[0]).toMatchObject({
+ x: workout.points.at(-1)?.x,
+ y: workout.points.at(-1)?.y,
+ });
+ expect(workout.points.at(-1)?.distance).toBe(workout.distance);
+ expect(workoutMaximumGrade(workout)).toBeGreaterThan(0);
+ }
+ });
+
+ test('offers a fifteen-mile course with long rollers centered on 20% resistance', () => {
+ const rollingCourse = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll');
+ if (!rollingCourse) {
+ throw new Error('Expected the Prairie Roll workout course');
+ }
+ const resistances = Array.from(
+ { length: 500 },
+ (_, index) =>
+ workoutTerrainAtDistance(rollingCourse, (rollingCourse.distance * index) / 499)
+ .resistance
+ );
+ const average =
+ resistances.reduce((sum, resistance) => sum + resistance, 0) / resistances.length;
+ expect(rollingCourse.distance).toBeCloseTo(24.140_16);
+ expect(rollingCourse.baseResistance).toBe(20);
+ expect(Math.min(...resistances)).toBeWithin(14, 17);
+ expect(Math.max(...resistances)).toBeWithin(24, 26);
+ expect(average).toBeWithin(19, 21);
+ });
+
+ test('starts every course with a flat rollout before the first hill', () => {
+ for (const workout of WORKOUT_COURSES) {
+ const startElevation = workout.points[0]?.elevation;
+ const rolloutPoints = workout.points.filter(
+ (point) => point.distance <= WORKOUT_FLAT_START_DISTANCE
+ );
+ expect(rolloutPoints.length).toBeGreaterThan(1);
+ for (const point of rolloutPoints) {
+ expect(point.elevation).toBe(startElevation);
+ }
+ expect(
+ workoutTerrainAtDistance(workout, WORKOUT_FLAT_START_DISTANCE / 2).grade
+ ).toBeCloseTo(0);
+ }
+ });
+
+ test('loops progress and advances lap counts from total ride distance', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ expect(workoutProgress(course, course.distance / 2)).toBeCloseTo(0.5);
+ expect(workoutCompletedLaps(course, course.distance - 0.01)).toBe(0);
+ expect(workoutLap(course, course.distance - 0.01)).toBe(1);
+ expect(workoutCompletedLaps(course, course.distance)).toBe(1);
+ expect(workoutLap(course, course.distance)).toBe(2);
+ expect(workoutCompletedLaps(course, course.distance * 2.25)).toBe(2);
+ expect(workoutProgress(course, course.distance * 2.25)).toBeCloseTo(0.25);
+ });
+
+ test('repeats the elevation profile across the complete ride history', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const firstLap = [0.4, 2.3, 5.7].map(
+ (distance) => workoutTerrainAtDistance(course, distance).elevation
+ );
+ const thirdLap = [0.4, 2.3, 5.7].map(
+ (distance) => workoutTerrainAtDistance(course, course.distance * 2 + distance).elevation
+ );
+ for (const [index, elevation] of thirdLap.entries()) {
+ expect(elevation).toBeCloseTo(firstLap[index] ?? 0);
+ }
+ });
+
+ test('derives climbing totals from course distance without exceeding the lap climb', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ for (let step = 0; step < 100; step += 1) {
+ const totals = workoutElevationTotalsAtDistance(course, (course.distance * step) / 100);
+ expect(totals.ascent).toBeLessThanOrEqual(course.elevationGain);
+ }
+ expect(workoutElevationTotalsAtDistance(course, 3.2)).toEqual({
+ ascent: 36,
+ descent: 24,
+ });
+ expect(workoutElevationTotalsAtDistance(course, course.distance * 2)).toEqual({
+ ascent: course.elevationGain * 2,
+ descent: course.elevationGain * 2,
+ });
+ });
+
+ test('locks workout selection from the first riding state until the session ends', () => {
+ expect(workoutSelectionLocked({ elapsedSeconds: 0, ended: false, isRiding: false })).toBe(
+ false
+ );
+ expect(workoutSelectionLocked({ elapsedSeconds: 0, ended: false, isRiding: true })).toBe(
+ true
+ );
+ expect(workoutSelectionLocked({ elapsedSeconds: 30, ended: false, isRiding: false })).toBe(
+ true
+ );
+ expect(workoutSelectionLocked({ elapsedSeconds: 30, ended: true, isRiding: false })).toBe(
+ false
+ );
+ });
+
+ test('interpolates terrain and maps grade to bounded resistance', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const climb = workoutTerrainAtDistance(course, 1.8);
+ const descent = workoutTerrainAtDistance(course, 2.8);
+ expect(climb.grade).toBeGreaterThan(0);
+ expect(climb.resistance).toBeGreaterThan(descent.resistance);
+ expect(climb.progress).toBeCloseTo(1.8 / course.distance);
+ expect(climb.completedLaps).toBe(0);
+ expect(climb.lap).toBe(1);
+ expect(climb.x).toBeWithin(0, 100);
+ expect(climb.y).toBeWithin(0, 100);
+ });
+
+ test('creates reusable top-down and side-profile paths', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ expect(workoutMapPath(course)).toStartWith('M ');
+ expect(workoutMapPath(course)).toContain('C ');
+ expect(workoutMapPath(course)).not.toContain(' L ');
+ expect(workoutProfilePath(course)).toStartWith('M 0 ');
+ expect(workoutProfilePath(course)).toContain('C ');
+ expect(workoutProfilePath(course)).not.toContain(' L ');
+ const terrain = workoutTerrainAtDistance(course, 2.8);
+ const progressPath = workoutMapProgressPath(course, terrain);
+ expect(progressPath).toContain('C ');
+ expect(progressPath).toEndWith(
+ `${Number(terrain.x.toFixed(3))} ${Number(terrain.y.toFixed(3))}`
+ );
+ });
+
+ test('validates persisted workout definitions at the storage boundary', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ expect(restoreWorkoutCourse(course)).toEqual(course);
+ expect(restoreSessionWorkout({ course })).toEqual({ course });
+ expect(restoreWorkoutCourse({ ...course, baseResistance: undefined })).toMatchObject({
+ baseResistance: 12,
+ });
+ expect(restoreWorkoutCourse({ ...course, baseResistance: 101 })).toBeUndefined();
+ expect(restoreWorkoutCourse({ ...course, distance: 'far' })).toBeUndefined();
+ expect(restoreSessionWorkout({ course: { ...course, points: [] } })).toBeUndefined();
+ expect(restoreWorkoutCourse({ ...course, id: ' ' })).toBeUndefined();
+ expect(
+ restoreWorkoutCourse({
+ ...course,
+ points: [
+ course.points[0],
+ course.points[2],
+ course.points[1],
+ ...course.points.slice(3),
+ ],
+ })
+ ).toBeUndefined();
+ expect(
+ restoreWorkoutCourse({
+ ...course,
+ points: course.points.map((point, index) =>
+ index === course.points.length - 1
+ ? { ...point, longitude: point.longitude + 1 }
+ : point
+ ),
+ })
+ ).toBeUndefined();
+ });
+});
From eada9d80f92828a3e98115e86c8e2096f606a359 Mon Sep 17 00:00:00 2001
From: Public Profile
Date: Sun, 19 Jul 2026 16:01:46 -0700
Subject: [PATCH 3/6] docs: describe terrain workouts and dashboard readability
---
AGENTS.md | 1 +
README.md | 13 ++++++++++---
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1786071..e2cfc79 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,6 +4,7 @@
- `bun run ci` must run the Ultracite-configured Biome checks and Tailwind CSS language-server diagnostics automatically, followed by the unit tests, TypeScript check, and production build.
- Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception.
- Keep the codebase as DRY as practical: before adding constants, calculations, formatting, parsing, labels, or state-derivation logic, search for an existing domain utility and reuse or extend it. Consolidate meaningful duplication into clearly named domain modules, but do not introduce generic abstractions that hide simple behavior or couple unrelated concepts solely because their implementations look similar.
+- Make dashboard numbers as large, high-contrast, and easy to read at a glance as the available layout practically allows. Numeric ride status must be visually dominant over its label; do not use caption-sized values when space exists for prominent dashboard typography.
- Do not scatter raw string literals for shared domain modes through comparisons or state construction. Define or reuse a named `as const` domain object, derive its union type from those values, centralize validation of persisted or external strings, and reference the named members in application code. Prefer an exhaustive `switch` when every variant has distinct behavior. Raw strings remain appropriate for display text, object property names, browser or protocol values, and boundary-focused tests.
- Treat TypeScript types as authoritative after data crosses a validated boundary. Do not add scattered inline runtime `typeof` checks for typed domain data; use ordinary presence checks for optional typed fields. Validate JSON, storage, browser, protocol, and other untyped inputs once through named reusable guards in `src/lib/type-guards.ts` or a focused domain parser. Runtime `typeof` checks belong inside those guards; type-level queries such as `typeof CONSTANT` remain appropriate.
- Never include AI assistant, product, vendor, or model names (such as `codex` or `openai`) in branch names, commit messages, pull request titles or descriptions, issue titles or descriptions, tags, release notes, or any other repository-visible metadata or content.
diff --git a/README.md b/README.md
index 23462d7..ef518f6 100644
--- a/README.md
+++ b/README.md
@@ -13,12 +13,14 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and automatically reconnects when possible.
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics.
-- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance.
+- Offers original terrain workouts built as repeatable loop courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Prairie Roll adds a 15-mile course of long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. Every course begins with a flat three-to-five-minute rollout before its first hill, then automatically adjusts trainer resistance from the current grade, tracks the rider on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap path and pulsing position markers while pedaling, shows lap percentage, current grade, and terrain resistance directly on the map, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. A workout can be selected before riding or planned while viewing a completed session, then remains locked from the moment riding begins until that session ends. Paired shift controllers can remain connected and are ignored while a workout controls resistance. Workout terrain is modeled separately from trainer control so virtual shifting can be integrated later without changing course data.
+- Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, and exact workout distance. Valid closed-loop GPX tracks or routes from other tools can be imported into a custom library saved only on the current device; their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor.
+- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired and no terrain workout is active; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance.
- Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives.
-- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics.
+- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop.
- Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported.
- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion.
-- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, comments, and a unique Ride Control session identifier for reliable duplicate detection.
+- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, terrain workout metadata and elevation samples, calories, ride feeling, comments, and a unique Ride Control session identifier for reliable duplicate detection.
- Imports an individual TCX file or every TCX file inside nested folders in a ZIP directly into local session history, preserves compatible ride data and Ride Control session identifiers, detects duplicates by identifier or stable ride data for legacy exports, and continues past individual invalid files in a batch; imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes.
- Downloads every locally saved ride at once as a compressed ZIP containing a folder of individual TCX files, with collision-safe filenames when sessions share the same start time.
- Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time.
@@ -48,6 +50,11 @@ outside shared application state. The application component coordinates focused
uses an explicit overlay state for mutually exclusive trays, renders every side tray through one
animated and accessible shell, and delegates save/discard/start/continue transitions to a
store-backed session workflow. Temporary form inputs remain local React state.
+Terrain workouts are an independent course domain layered over session distance: course geometry
+produces grade, elevation, current and completed lap counts, map position, and a bounded resistance
+target. Recorded elevation appears alongside the other session graphs for the full ride and repeats
+the course profile on every lap, while route progress stays portable in saved sessions and TCX
+files and leaves room to compose the same terrain with virtual gearing later.
Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation,
and repeated dialog and keyboard behavior so those rules stay consistent across views and exports.
From 8bd4b7d0782ed5bfec33ade576c59cad9021dde1 Mon Sep 17 00:00:00 2001
From: Public Profile
Date: Sun, 19 Jul 2026 16:50:20 -0700
Subject: [PATCH 4/6] feat: expand terrain workout course model
---
src/lib/tcx-import.ts | 1 +
src/lib/tcx.ts | 1 +
src/lib/workout-file.ts | 64 ++++++-
src/lib/workout-schema.ts | 13 ++
src/lib/workouts.ts | 331 +++++++++++++++++++++++++++++++-----
src/stores/session-store.ts | 7 +-
src/types.ts | 3 +-
tests/session-store.test.ts | 12 ++
tests/tcx-import.test.ts | 1 +
tests/tcx.test.ts | 1 +
tests/workout-file.test.ts | 67 +++++++-
tests/workouts.test.ts | 165 +++++++++++++++++-
12 files changed, 599 insertions(+), 67 deletions(-)
diff --git a/src/lib/tcx-import.ts b/src/lib/tcx-import.ts
index 3beee37..9fbdbba 100644
--- a/src/lib/tcx-import.ts
+++ b/src/lib/tcx-import.ts
@@ -188,6 +188,7 @@ function activityWorkout(activity: Element): SessionWorkout | undefined {
id: text(child(workout, 'CourseId')),
name: text(child(workout, 'Name')),
points,
+ routeType: text(child(workout, 'CourseType')) || undefined,
},
});
}
diff --git a/src/lib/tcx.ts b/src/lib/tcx.ts
index c12a60d..a281bbf 100644
--- a/src/lib/tcx.ts
+++ b/src/lib/tcx.ts
@@ -103,6 +103,7 @@ function workoutSummaryXml(workout?: SessionWorkout): string {
${xmlEscape(course.description)}
${course.difficulty}
${course.baseResistance.toFixed(1)}
+ ${course.routeType}
${course.distance.toFixed(3)}${points}
`;
}
diff --git a/src/lib/workout-file.ts b/src/lib/workout-file.ts
index 3838cd6..de4bd3f 100644
--- a/src/lib/workout-file.ts
+++ b/src/lib/workout-file.ts
@@ -3,20 +3,34 @@ import { evenlySample } from './arrays';
import { downloadBrowserFile } from './download';
import { parseGpxDocument } from './gpx';
import { isRecord } from './type-guards';
-import { isWorkoutDifficulty, WORKOUT_DIFFICULTY, type WorkoutDifficulty } from './workout-schema';
-import { restoreWorkoutCourse, WORKOUT_COURSES } from './workouts';
+import {
+ isWorkoutDifficulty,
+ isWorkoutRouteType,
+ WORKOUT_DIFFICULTY,
+ WORKOUT_ROUTE_TYPE,
+ type WorkoutDifficulty,
+ type WorkoutRouteType,
+} from './workout-schema';
+import {
+ outAndBackRoutePoints,
+ restoreWorkoutCourse,
+ WORKOUT_COURSES,
+ workoutRouteCloses,
+} from './workouts';
import { xmlDescendant, xmlEscape, xmlNumber, xmlText } from './xml';
export const CUSTOM_WORKOUTS_STORAGE_KEY = 'ride-control-custom-workouts';
export const WORKOUT_GPX_EXTENSION_NAMESPACE =
'https://github.com/lookfirst/RideControl/xmlschemas/WorkoutExtension/v1';
-export const WORKOUT_GPX_FORMAT_VERSION = 1;
+export const WORKOUT_GPX_FORMAT_VERSION = 2;
const WORKOUT_LIBRARY_FORMAT = 'ride-control-workout-library';
const WORKOUT_LIBRARY_VERSION = 1;
const WORKOUT_MIME_TYPE = 'application/gpx+xml';
const MAX_CUSTOM_WORKOUTS = 50;
const MAX_WORKOUT_FILE_POINTS = 200;
+const MAX_LOOP_SOURCE_POINTS = MAX_WORKOUT_FILE_POINTS - 1;
+const MAX_OUT_AND_BACK_SOURCE_POINTS = Math.floor((MAX_WORKOUT_FILE_POINTS - 1) / 2);
const NON_FILENAME_CHARACTERS = /[^a-z0-9]+/g;
const EDGE_HYPHENS = /^-+|-+$/g;
const GPX_FILE_EXTENSION = /(?:\.workout)?\.gpx$/i;
@@ -82,14 +96,21 @@ function routeFingerprint(points: GeographicRoutePoint[]): string {
function workoutMetadata(
container: Element,
points: GeographicRoutePoint[]
-): { baseResistance?: number; difficulty: WorkoutDifficulty; id: string } {
+): {
+ baseResistance?: number;
+ difficulty: WorkoutDifficulty;
+ id: string;
+ routeType?: WorkoutRouteType;
+} {
const difficultyValue = xmlText(xmlDescendant(container, 'Difficulty'));
+ const routeTypeValue = xmlText(xmlDescendant(container, 'CourseType'));
return {
baseResistance: xmlNumber(xmlDescendant(container, 'BaseResistance')),
difficulty: isWorkoutDifficulty(difficultyValue)
? difficultyValue
: WORKOUT_DIFFICULTY.MODERATE,
id: xmlText(xmlDescendant(container, 'WorkoutId')) || routeFingerprint(points),
+ routeType: isWorkoutRouteType(routeTypeValue) ? routeTypeValue : undefined,
};
}
@@ -97,6 +118,26 @@ function workoutNameFromFile(fileName: string): string {
return fileName.replace(GPX_FILE_EXTENSION, '').trim() || 'Imported GPX workout';
}
+function outAndBackPoints(
+ points: GeographicRoutePoint[],
+ sourceCloses: boolean
+): GeographicRoutePoint[] {
+ let outbound = points;
+ if (sourceCloses) {
+ const halfway = (points.at(-1)?.distance ?? 0) / 2;
+ const turnaroundIndex = points.reduce(
+ (nearestIndex, point, index) =>
+ Math.abs(point.distance - halfway) <
+ Math.abs((points[nearestIndex]?.distance ?? 0) - halfway)
+ ? index
+ : nearestIndex,
+ 0
+ );
+ outbound = points.slice(0, turnaroundIndex + 1);
+ }
+ return outAndBackRoutePoints(evenlySample(outbound, MAX_OUT_AND_BACK_SOURCE_POINTS));
+}
+
export function workoutIsBuiltIn(id: string): boolean {
return WORKOUT_COURSES.some((course) => course.id === id);
}
@@ -118,6 +159,7 @@ export function workoutFileContents(course: WorkoutCourse): string {
${xmlEscape(course.id)}
${course.difficulty}
${course.baseResistance.toFixed(1)}
+ ${course.routeType}
${points}
@@ -147,8 +189,17 @@ export function parseWorkoutFile(
throw new Error('The GPX file does not contain a track or route.');
}
const sourcePoints = parsed.points;
- const points = evenlySample(sourcePoints, MAX_WORKOUT_FILE_POINTS);
const metadata = workoutMetadata(container, sourcePoints);
+ const sourceCloses = workoutRouteCloses(sourcePoints);
+ const routeType =
+ metadata.routeType ??
+ (sourceCloses ? WORKOUT_ROUTE_TYPE.LOOP : WORKOUT_ROUTE_TYPE.OUT_AND_BACK);
+ let points: GeographicRoutePoint[];
+ if (routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK) {
+ points = outAndBackPoints(sourcePoints, sourceCloses);
+ } else {
+ points = evenlySample(sourcePoints, MAX_LOOP_SOURCE_POINTS);
+ }
const distance = points.at(-1)?.distance ?? 0;
const course = restoreWorkoutCourse({
baseResistance: metadata.baseResistance,
@@ -158,10 +209,11 @@ export function parseWorkoutFile(
id: metadata.id,
name: parsed.name || fallbackName,
points,
+ routeType,
});
if (!course) {
throw new Error(
- 'The GPX route must be a closed loop with increasing distance and valid elevation data.'
+ 'The GPX route must describe a valid loop or out-and-back course with increasing distance and elevation data.'
);
}
return course;
diff --git a/src/lib/workout-schema.ts b/src/lib/workout-schema.ts
index 4d41cb5..5b53655 100644
--- a/src/lib/workout-schema.ts
+++ b/src/lib/workout-schema.ts
@@ -12,6 +12,19 @@ export function isWorkoutDifficulty(value: unknown): value is WorkoutDifficulty
return WORKOUT_DIFFICULTIES.has(value);
}
+export const WORKOUT_ROUTE_TYPE = {
+ LOOP: 'loop',
+ OUT_AND_BACK: 'out-and-back',
+} as const;
+
+export type WorkoutRouteType = (typeof WORKOUT_ROUTE_TYPE)[keyof typeof WORKOUT_ROUTE_TYPE];
+
+const WORKOUT_ROUTE_TYPES = new Set(Object.values(WORKOUT_ROUTE_TYPE));
+
+export function isWorkoutRouteType(value: unknown): value is WorkoutRouteType {
+ return WORKOUT_ROUTE_TYPES.has(value);
+}
+
export const WORKOUT_VIEW = {
MAP: 'map',
PROFILE: 'profile',
diff --git a/src/lib/workouts.ts b/src/lib/workouts.ts
index 178cb16..811eb67 100644
--- a/src/lib/workouts.ts
+++ b/src/lib/workouts.ts
@@ -1,3 +1,4 @@
+import { emptyElevationTotals } from '../constants';
import type {
ElevationTotals,
GeographicRoutePoint,
@@ -12,13 +13,21 @@ import { distanceBetween } from './gpx';
import { clamp, nonNegativeNumber } from './numbers';
import { clampResistance } from './resistance';
import { isFiniteNumber, isRecord, isString } from './type-guards';
-import { isWorkoutDifficulty, WORKOUT_DIFFICULTY, type WorkoutDifficulty } from './workout-schema';
+import {
+ isWorkoutDifficulty,
+ isWorkoutRouteType,
+ WORKOUT_DIFFICULTY,
+ WORKOUT_ROUTE_TYPE,
+ type WorkoutDifficulty,
+ type WorkoutRouteType,
+} from './workout-schema';
const DEFAULT_TERRAIN_RESISTANCE = 12;
const RESISTANCE_PER_GRADE_PERCENT = 2.25;
const MIN_TERRAIN_RESISTANCE = 4;
const MAX_TERRAIN_RESISTANCE = 55;
const MAX_SAVED_WORKOUT_POINTS = 200;
+const MAX_OUT_AND_BACK_OUTBOUND_POINTS = Math.floor((MAX_SAVED_WORKOUT_POINTS + 1) / 2);
const MIN_ROUTE_POINTS = 3;
const MAP_MINIMUM = 0;
const MAP_MAXIMUM = 100;
@@ -26,9 +35,16 @@ const MAP_PADDING = 8;
const GENERATED_COURSE_LATITUDE = 39;
const GENERATED_COURSE_LONGITUDE = -105;
const METERS_PER_LATITUDE_DEGREE = 111_320;
-const LOOP_CLOSURE_METERS = 100;
-const LOOP_ELEVATION_TOLERANCE_METERS = 20;
+const COURSE_CLOSURE_METERS = 100;
+const COURSE_ELEVATION_TOLERANCE_METERS = 20;
+const OUT_AND_BACK_MATCH_METERS = 1;
+const OUT_AND_BACK_ELEVATION_TOLERANCE_METERS = 0.1;
const ROUTE_VALUE_EPSILON = 0.000_001;
+const LOW_CLIMB_ELEVATION_GAIN_METERS = 50;
+const MODERATE_CLIMB_ELEVATION_GAIN_METERS = 150;
+const PROFILE_REFERENCE_ELEVATION_SPAN_METERS = 200;
+export const WORKOUT_SHORT_FLAT_START_DISTANCE = 0.4;
+export const WORKOUT_MODERATE_FLAT_START_DISTANCE = 0.8;
export const WORKOUT_FLAT_START_DISTANCE = 1.5;
interface CourseMapPoint extends RoutePoint {
@@ -36,23 +52,127 @@ interface CourseMapPoint extends RoutePoint {
y: number;
}
+interface WorkoutDashboardSource {
+ distance: number;
+ elevationTotals: ElevationTotals;
+ ended: boolean;
+ selectedWorkout?: SessionWorkout;
+ workout?: SessionWorkout;
+}
+
+export function workoutDashboardPreview(source: WorkoutDashboardSource) {
+ const plannedWorkout = source.ended ? source.selectedWorkout : undefined;
+ return plannedWorkout
+ ? {
+ distance: 0,
+ elevationTotals: emptyElevationTotals,
+ workout: plannedWorkout,
+ }
+ : {
+ distance: source.distance,
+ elevationTotals: source.elevationTotals,
+ workout: source.workout,
+ };
+}
+
function approximatelyEqual(left: number, right: number): boolean {
return Math.abs(left - right) <= ROUTE_VALUE_EPSILON;
}
-function elevationGain(points: WorkoutRoutePoint[]): number {
+function elevationGain(points: RoutePoint[]): number {
return elevationTotalsForSamples(points).ascent;
}
-function withFlatCourseStart(points: T[]): T[] {
- const startElevation = points[0]?.elevation;
- return startElevation === undefined
- ? points
- : points.map((point) =>
- point.distance <= WORKOUT_FLAT_START_DISTANCE
- ? { ...point, elevation: startElevation }
- : point
- );
+function flatStartDistanceForElevationGain(elevationGainMeters: number): number {
+ if (elevationGainMeters < LOW_CLIMB_ELEVATION_GAIN_METERS) {
+ return WORKOUT_SHORT_FLAT_START_DISTANCE;
+ }
+ if (elevationGainMeters < MODERATE_CLIMB_ELEVATION_GAIN_METERS) {
+ return WORKOUT_MODERATE_FLAT_START_DISTANCE;
+ }
+ return WORKOUT_FLAT_START_DISTANCE;
+}
+
+export function workoutFlatStartDistance(course: Pick): number {
+ return flatStartDistanceForElevationGain(course.elevationGain);
+}
+
+function flatRouteStart(
+ points: GeographicRoutePoint[],
+ rolloutDistance: number,
+ maximumPoints = MAX_SAVED_WORKOUT_POINTS
+): GeographicRoutePoint[] {
+ const [first] = points;
+ const last = points.at(-1);
+ if (!(first && last && rolloutDistance > 0 && rolloutDistance < last.distance)) {
+ return points;
+ }
+ const rightIndex = points.findIndex((point) => point.distance >= rolloutDistance);
+ if (rightIndex <= 0) {
+ return points;
+ }
+ const flattened = points.map((point) =>
+ point.distance <= rolloutDistance ? { ...point, elevation: first.elevation } : point
+ );
+ const right = points[rightIndex];
+ if (!(right && !approximatelyEqual(right.distance, rolloutDistance))) {
+ return flattened;
+ }
+ const left = points[rightIndex - 1];
+ if (!left || right.distance <= left.distance) {
+ return flattened;
+ }
+ const progress = (rolloutDistance - left.distance) / (right.distance - left.distance);
+ const boundary: GeographicRoutePoint = {
+ distance: rolloutDistance,
+ elevation: first.elevation,
+ latitude: left.latitude + (right.latitude - left.latitude) * progress,
+ longitude: left.longitude + (right.longitude - left.longitude) * progress,
+ };
+ if (flattened.length >= maximumPoints) {
+ return [...flattened.slice(0, rightIndex), boundary, ...flattened.slice(rightIndex + 1)];
+ }
+ return [...flattened.slice(0, rightIndex), boundary, ...flattened.slice(rightIndex)];
+}
+
+function withFlatCourseStart(
+ points: GeographicRoutePoint[],
+ distance: number,
+ routeType: WorkoutRouteType,
+ rolloutDistance: number
+): GeographicRoutePoint[] {
+ if (routeType === WORKOUT_ROUTE_TYPE.LOOP) {
+ return flatRouteStart(points, rolloutDistance);
+ }
+ const outbound = points.filter((point) => point.distance <= distance / 2 + ROUTE_VALUE_EPSILON);
+ return outAndBackRoutePoints(
+ flatRouteStart(outbound, rolloutDistance, MAX_OUT_AND_BACK_OUTBOUND_POINTS)
+ );
+}
+
+export function workoutRouteCloses(points: GeographicRoutePoint[]): boolean {
+ const [first] = points;
+ const last = points.at(-1);
+ return Boolean(
+ first &&
+ last &&
+ distanceBetween(first.latitude, first.longitude, last.latitude, last.longitude) <=
+ COURSE_CLOSURE_METERS
+ );
+}
+
+export function outAndBackRoutePoints(points: GeographicRoutePoint[]): GeographicRoutePoint[] {
+ const turnaroundDistance = points.at(-1)?.distance ?? 0;
+ return [
+ ...points,
+ ...points
+ .slice(0, -1)
+ .reverse()
+ .map((point) => ({
+ ...point,
+ distance: turnaroundDistance * 2 - point.distance,
+ })),
+ ];
}
function mapCoordinates(points: GeographicRoutePoint[]): WorkoutRoutePoint[] {
@@ -112,9 +232,11 @@ function createGeographicCourse(
difficulty: WorkoutDifficulty,
distance: number,
points: GeographicRoutePoint[],
- baseResistance = DEFAULT_TERRAIN_RESISTANCE
+ baseResistance = DEFAULT_TERRAIN_RESISTANCE,
+ routeType: WorkoutRouteType = WORKOUT_ROUTE_TYPE.LOOP
): WorkoutCourse {
const [first] = points;
+ const rolloutDistance = flatStartDistanceForElevationGain(elevationGain(points));
const terrainPoints = mapCoordinates(
withFlatCourseStart(
first
@@ -128,7 +250,10 @@ function createGeographicCourse(
}
: point
)
- : points
+ : points,
+ distance,
+ routeType,
+ rolloutDistance
)
);
return {
@@ -140,6 +265,7 @@ function createGeographicCourse(
id,
name,
points: terrainPoints,
+ routeType,
};
}
@@ -172,8 +298,8 @@ export const WORKOUT_COURSES: WorkoutCourse[] = [
6.4,
[
{ distance: 0, elevation: 18, x: 18, y: 40 },
- { distance: 0.8, elevation: 18, x: 33, y: 18 },
- { distance: 1.6, elevation: 18, x: 60, y: 12 },
+ { distance: 0.8, elevation: 21, x: 33, y: 18 },
+ { distance: 1.6, elevation: 24, x: 60, y: 12 },
{ distance: 2.4, elevation: 28, x: 86, y: 27 },
{ distance: 3.2, elevation: 24, x: 68, y: 45 },
{ distance: 4, elevation: 20, x: 88, y: 70 },
@@ -189,16 +315,16 @@ export const WORKOUT_COURSES: WorkoutCourse[] = [
WORKOUT_DIFFICULTY.GENTLE,
24.140_16,
[
- { distance: 0, elevation: 30, x: 50, y: 50 },
- { distance: 1.5, elevation: 30, x: 34, y: 17 },
- { distance: 4, elevation: 69, x: 9, y: 29 },
- { distance: 6.5, elevation: 30, x: 17, y: 70 },
- { distance: 9.25, elevation: 69, x: 40, y: 87 },
- { distance: 12, elevation: 30, x: 50, y: 50 },
- { distance: 15, elevation: 69, x: 66, y: 15 },
- { distance: 18, elevation: 30, x: 91, y: 31 },
- { distance: 21, elevation: 69, x: 82, y: 75 },
- { distance: 24.140_16, elevation: 30, x: 50, y: 50 },
+ { distance: 0, elevation: 30, x: 12, y: 52 },
+ { distance: 1.5, elevation: 30, x: 18, y: 27 },
+ { distance: 4, elevation: 69, x: 38, y: 13 },
+ { distance: 6.5, elevation: 30, x: 55, y: 25 },
+ { distance: 9.25, elevation: 69, x: 76, y: 12 },
+ { distance: 12, elevation: 30, x: 92, y: 36 },
+ { distance: 15, elevation: 69, x: 76, y: 55 },
+ { distance: 18, elevation: 30, x: 90, y: 77 },
+ { distance: 21, elevation: 69, x: 52, y: 88 },
+ { distance: 24.140_16, elevation: 30, x: 12, y: 52 },
],
20
),
@@ -211,7 +337,7 @@ export const WORKOUT_COURSES: WorkoutCourse[] = [
[
{ distance: 0, elevation: 46, x: 15, y: 24 },
{ distance: 0.8, elevation: 46, x: 34, y: 12 },
- { distance: 1.6, elevation: 46, x: 56, y: 18 },
+ { distance: 1.6, elevation: 60, x: 56, y: 18 },
{ distance: 2.4, elevation: 82, x: 78, y: 10 },
{ distance: 3.2, elevation: 58, x: 92, y: 28 },
{ distance: 4, elevation: 91, x: 77, y: 45 },
@@ -250,8 +376,53 @@ export const WORKOUT_COURSES: WorkoutCourse[] = [
{ distance: 12, elevation: 74, x: 50, y: 50 },
]
),
+ createCourse(
+ 'granite-switchbacks',
+ 'Granite Switchbacks',
+ 'A sustained four-mile switchback climb with steep hairpins, a high ridge, and a sweeping descent.',
+ WORKOUT_DIFFICULTY.CHALLENGING,
+ 18,
+ [
+ { distance: 0, elevation: 80, x: 12, y: 88 },
+ { distance: 0.75, elevation: 80, x: 10, y: 80 },
+ { distance: 1.5, elevation: 80, x: 16, y: 72 },
+ { distance: 2.2, elevation: 108, x: 76, y: 72 },
+ { distance: 2.32, elevation: 120, x: 86, y: 67 },
+ { distance: 2.47, elevation: 126, x: 76, y: 62 },
+ { distance: 3.15, elevation: 153, x: 24, y: 62 },
+ { distance: 3.27, elevation: 165, x: 14, y: 57 },
+ { distance: 3.42, elevation: 171, x: 24, y: 52 },
+ { distance: 4.1, elevation: 198, x: 76, y: 52 },
+ { distance: 4.22, elevation: 210, x: 86, y: 47 },
+ { distance: 4.37, elevation: 216, x: 76, y: 42 },
+ { distance: 5.05, elevation: 243, x: 24, y: 42 },
+ { distance: 5.17, elevation: 255, x: 14, y: 37 },
+ { distance: 5.32, elevation: 261, x: 24, y: 32 },
+ { distance: 6, elevation: 288, x: 76, y: 32 },
+ { distance: 6.12, elevation: 300, x: 86, y: 27 },
+ { distance: 6.27, elevation: 306, x: 76, y: 22 },
+ { distance: 6.95, elevation: 333, x: 24, y: 22 },
+ { distance: 7.07, elevation: 345, x: 14, y: 17 },
+ { distance: 7.22, elevation: 351, x: 24, y: 12 },
+ { distance: 7.94, elevation: 380, x: 68, y: 12 },
+ { distance: 8.6, elevation: 388, x: 82, y: 15 },
+ { distance: 9.5, elevation: 340, x: 94, y: 28 },
+ { distance: 10.6, elevation: 280, x: 96, y: 48 },
+ { distance: 11.8, elevation: 200, x: 94, y: 70 },
+ { distance: 13, elevation: 130, x: 82, y: 90 },
+ { distance: 14.2, elevation: 105, x: 60, y: 94 },
+ { distance: 15.4, elevation: 90, x: 36, y: 92 },
+ { distance: 16.5, elevation: 82, x: 20, y: 95 },
+ { distance: 17.3, elevation: 80, x: 10, y: 90 },
+ { distance: 18, elevation: 80, x: 12, y: 88 },
+ ]
+ ),
];
+const BUILT_IN_WORKOUT_COURSES_BY_ID = new Map(
+ WORKOUT_COURSES.map((course) => [course.id, course])
+);
+
function loopDistance(courseDistance: number, totalDistance: number): number {
if (courseDistance <= 0) {
return 0;
@@ -416,15 +587,19 @@ export function workoutMapProgressPath(course: WorkoutCourse, terrain: WorkoutTe
if (!first) {
return '';
}
+ const progressDistance =
+ course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK
+ ? Math.min(terrain.distance, course.distance / 2)
+ : terrain.distance;
const curves: string[] = [];
for (const segment of workoutMapSegments(course)) {
- if (segment.endDistance <= terrain.distance) {
+ if (segment.endDistance <= progressDistance) {
curves.push(curvePathCommand(segment));
continue;
}
- if (segment.startDistance < terrain.distance) {
+ if (segment.startDistance < progressDistance) {
const progress =
- (terrain.distance - segment.startDistance) /
+ (progressDistance - segment.startDistance) /
(segment.endDistance - segment.startDistance);
curves.push(curvePathCommand(partialCurveSegment(segment, progress)));
}
@@ -533,12 +708,34 @@ function mapCoordinateTangents(
});
}
+function workoutMapCourse(course: WorkoutCourse): WorkoutCourse {
+ if (course.routeType === WORKOUT_ROUTE_TYPE.LOOP) {
+ return course;
+ }
+ const turnaroundDistance = course.distance / 2;
+ return {
+ ...course,
+ distance: turnaroundDistance,
+ points: course.points.filter(
+ (point) => point.distance <= turnaroundDistance + ROUTE_VALUE_EPSILON
+ ),
+ };
+}
+
+function workoutMapDistance(course: WorkoutCourse, distance: number): number {
+ const position = loopDistance(course.distance, distance);
+ return course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK && position > course.distance / 2
+ ? course.distance - position
+ : position;
+}
+
function workoutMapSegments(course: WorkoutCourse): MapCurveSegment[] {
- const xTangents = mapCoordinateTangents(course, (point) => point.x);
- const yTangents = mapCoordinateTangents(course, (point) => point.y);
+ const mapCourse = workoutMapCourse(course);
+ const xTangents = mapCoordinateTangents(mapCourse, (point) => point.x);
+ const yTangents = mapCoordinateTangents(mapCourse, (point) => point.y);
const tension = 0.75;
- return course.points.slice(0, -1).flatMap((from, index) => {
- const to = course.points[index + 1];
+ return mapCourse.points.slice(0, -1).flatMap((from, index) => {
+ const to = mapCourse.points[index + 1];
if (!to) {
return [];
}
@@ -563,7 +760,7 @@ function workoutMapSegments(course: WorkoutCourse): MapCurveSegment[] {
}
function workoutMapPosition(course: WorkoutCourse, distance: number): CurvePoint {
- const position = loopDistance(course.distance, distance);
+ const position = workoutMapDistance(course, distance);
const segments = workoutMapSegments(course);
const segment =
segments.find((candidate) => candidate.endDistance >= position) ?? segments.at(-1);
@@ -581,7 +778,7 @@ function workoutProfilePoints(course: WorkoutCourse): CurvePoint[] {
const elevations = course.points.map((point) => point.elevation);
const minimum = Math.min(...elevations);
const maximum = Math.max(...elevations);
- const span = maximum - minimum || 1;
+ const span = Math.max(maximum - minimum, PROFILE_REFERENCE_ELEVATION_SPAN_METERS);
return course.points.map((point) => ({
x: (point.distance / course.distance) * 100,
y: 88 - ((point.elevation - minimum) / span) * 72,
@@ -750,7 +947,7 @@ function geographicPoints(
}
}
-function isValidLoop(points: GeographicRoutePoint[], distance: number): boolean {
+function isValidClosedCourse(points: GeographicRoutePoint[], distance: number): boolean {
const [first] = points;
const last = points.at(-1);
if (!(first && last)) {
@@ -764,17 +961,54 @@ function isValidLoop(points: GeographicRoutePoint[], distance: number): boolean
distancesIncrease &&
approximatelyEqual(first.distance, 0) &&
approximatelyEqual(last.distance, distance) &&
- Math.abs(first.elevation - last.elevation) <= LOOP_ELEVATION_TOLERANCE_METERS &&
- distanceBetween(first.latitude, first.longitude, last.latitude, last.longitude) <=
- LOOP_CLOSURE_METERS
+ Math.abs(first.elevation - last.elevation) <= COURSE_ELEVATION_TOLERANCE_METERS &&
+ workoutRouteCloses(points)
);
}
+function isValidOutAndBack(points: GeographicRoutePoint[], distance: number): boolean {
+ if (!(isValidClosedCourse(points, distance) && points.length % 2 === 1)) {
+ return false;
+ }
+ return points.every((point, index) => {
+ const mirrored = points.at(-(index + 1));
+ return Boolean(
+ mirrored &&
+ approximatelyEqual(point.distance + mirrored.distance, distance) &&
+ Math.abs(point.elevation - mirrored.elevation) <=
+ OUT_AND_BACK_ELEVATION_TOLERANCE_METERS &&
+ distanceBetween(
+ point.latitude,
+ point.longitude,
+ mirrored.latitude,
+ mirrored.longitude
+ ) <= OUT_AND_BACK_MATCH_METERS
+ );
+ });
+}
+
+function isValidCourse(
+ points: GeographicRoutePoint[],
+ distance: number,
+ routeType: WorkoutRouteType
+): boolean {
+ return routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK
+ ? isValidOutAndBack(points, distance)
+ : isValidClosedCourse(points, distance);
+}
+
export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined {
if (!isRecord(value)) {
return;
}
- const { baseResistance, description, difficulty, distance, id, name, points } = value;
+ const { baseResistance, description, difficulty, distance, id, name, points, routeType } =
+ value;
+ let restoredRouteType: WorkoutRouteType | undefined;
+ if (routeType === undefined) {
+ restoredRouteType = WORKOUT_ROUTE_TYPE.LOOP;
+ } else if (isWorkoutRouteType(routeType)) {
+ restoredRouteType = routeType;
+ }
if (
!(
isString(description) &&
@@ -783,6 +1017,7 @@ export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined
isString(id) &&
isString(name) &&
Array.isArray(points) &&
+ restoredRouteType &&
id.trim().length > 0 &&
name.trim().length > 0
)
@@ -810,7 +1045,7 @@ export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined
!restoredPoints ||
restoredPoints.length < MIN_ROUTE_POINTS ||
distance <= 0 ||
- !isValidLoop(restoredPoints, distance)
+ !isValidCourse(restoredPoints, distance, restoredRouteType)
) {
return;
}
@@ -821,7 +1056,8 @@ export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined
difficulty,
distance,
restoredPoints,
- restoredBaseResistance
+ restoredBaseResistance,
+ restoredRouteType
);
}
@@ -830,7 +1066,12 @@ export function restoreSessionWorkout(value: unknown): SessionWorkout | undefine
return;
}
const restoredCourse = restoreWorkoutCourse(value.course);
- return restoredCourse ? { course: restoredCourse } : undefined;
+ if (!restoredCourse) {
+ return;
+ }
+ return {
+ course: BUILT_IN_WORKOUT_COURSES_BY_ID.get(restoredCourse.id) ?? restoredCourse,
+ };
}
export function workoutDifficultyLabel(difficulty: WorkoutDifficulty): string {
diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts
index d9fdfcd..7eef6af 100644
--- a/src/stores/session-store.ts
+++ b/src/stores/session-store.ts
@@ -78,7 +78,12 @@ function selectWorkoutForState(
if (current.ended) {
return selectPlannedWorkout(current, course);
}
- return workoutSelectionLocked(current) ? current : selectActiveWorkout(current, course);
+ if (!workoutSelectionLocked(current)) {
+ return selectActiveWorkout(current, course);
+ }
+ return current.workout?.course.id === course?.id
+ ? selectActiveWorkout(current, course)
+ : current;
}
function elevationTotalsAfterTick(current: SessionStoreState, distance: number) {
diff --git a/src/types.ts b/src/types.ts
index 7a43ec4..fff7258 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,5 @@
import type { ControlMode } from './lib/control-mode';
-import type { WorkoutDifficulty } from './lib/workout-schema';
+import type { WorkoutDifficulty, WorkoutRouteType } from './lib/workout-schema';
export type { ChartMode } from './lib/chart-mode';
export type { ControlMode } from './lib/control-mode';
@@ -37,6 +37,7 @@ export interface WorkoutCourse {
id: string;
name: string;
points: WorkoutRoutePoint[];
+ routeType: WorkoutRouteType;
}
export interface SessionWorkout {
diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts
index e07e449..99cd7c7 100644
--- a/tests/session-store.test.ts
+++ b/tests/session-store.test.ts
@@ -183,6 +183,18 @@ describe('session store', () => {
store.actions.selectWorkout(workout);
expect(store.get().workout?.course).toBe(workout);
+
+ store.actions.selectWorkout(staleDefinition);
+ store.actions.syncRiding(true);
+ store.actions.recordTick({
+ control: { gear: 12, mode: 'resistance', resistance: 20 },
+ metrics: liveMetrics,
+ seconds: 1,
+ });
+ store.actions.selectWorkout(workout);
+ expect(store.get().workout?.course).toBe(workout);
+ store.actions.selectWorkout(WORKOUT_COURSES[0]);
+ expect(store.get().workout?.course).toBe(workout);
});
test('coordinates pause, end, reset, and continuation transitions', () => {
diff --git a/tests/tcx-import.test.ts b/tests/tcx-import.test.ts
index db15d3c..aeecf16 100644
--- a/tests/tcx-import.test.ts
+++ b/tests/tcx-import.test.ts
@@ -83,6 +83,7 @@ describe('TCX import', () => {
distance: course.distance,
id: course.id,
name: course.name,
+ routeType: course.routeType,
});
expect(imported?.workout?.course.points).toHaveLength(course.points.length);
expect(imported?.workout?.course.points[1]?.latitude).toBeCloseTo(
diff --git a/tests/tcx.test.ts b/tests/tcx.test.ts
index bd42267..55422f8 100644
--- a/tests/tcx.test.ts
+++ b/tests/tcx.test.ts
@@ -58,6 +58,7 @@ describe('TCX export', () => {
expect(tcx).toContain('1');
expect(tcx).toContain('cedar-circuit');
expect(tcx).toContain('12.0');
+ expect(tcx).toContain('loop');
expect(tcx).toContain('82.50');
expect(tcx).toContain('30.25');
expect(tcx).toContain('Cedar Circuit');
diff --git a/tests/workout-file.test.ts b/tests/workout-file.test.ts
index c202c14..2f1dbc5 100644
--- a/tests/workout-file.test.ts
+++ b/tests/workout-file.test.ts
@@ -12,6 +12,7 @@ import {
workoutFileContents,
workoutFilename,
} from '../src/lib/workout-file';
+import { WORKOUT_ROUTE_TYPE } from '../src/lib/workout-schema';
import { WORKOUT_COURSES } from '../src/lib/workouts';
import type { WorkoutCourse } from '../src/types';
@@ -45,6 +46,22 @@ function thirdPartyGpx(name = 'Neighborhood loop'): string {
`;
}
+function openThirdPartyGpx(): string {
+ return `
+
+
+ Ridgeline turnaround
+ A point-to-point GPX track
+
+ 12
+ 22
+ 18
+ 12
+
+
+`;
+}
+
describe('workout GPX files', () => {
test('round trips geographic workout source data through standard GPX', async () => {
const workout = customWorkout();
@@ -52,9 +69,11 @@ describe('workout GPX files', () => {
expect(contents).toStartWith('');
expect(contents).toContain('2');
expect(contents).toContain('');
expect(contents).toContain('12.0');
+ expect(contents).toContain('loop');
expect(contents).not.toContain('elevationGain');
expect(contents).not.toContain('');
const parsed = parseWorkoutFile(
@@ -68,6 +87,7 @@ describe('workout GPX files', () => {
distance: workout.distance,
id: workout.id,
name: workout.name,
+ routeType: WORKOUT_ROUTE_TYPE.LOOP,
});
expect(parsed.points).toHaveLength(workout.points.length);
expect(parsed.points[1]?.latitude).toBeCloseTo(workout.points[1]?.latitude ?? 0, 7);
@@ -89,22 +109,53 @@ describe('workout GPX files', () => {
description: 'A real GPX loop',
difficulty: 'moderate',
name: 'Neighborhood loop',
+ routeType: WORKOUT_ROUTE_TYPE.LOOP,
});
expect(first.id).toStartWith('gpx-');
expect(second.id).toBe(first.id);
});
- test('rejects malformed, open, and built-in workout imports', async () => {
+ test('imports open GPX tracks as complete out-and-back courses', () => {
+ const parser = new DOMParser() as unknown as globalThis.DOMParser;
+ const workout = parseWorkoutFile(openThirdPartyGpx(), parser);
+ const turnaroundIndex = (workout.points.length - 1) / 2;
+ const turnaround = workout.points[turnaroundIndex];
+ expect(workout).toMatchObject({
+ name: 'Ridgeline turnaround',
+ routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK,
+ });
+ expect(workout.points).toHaveLength(9);
+ expect(turnaround?.distance).toBeCloseTo(workout.distance / 2);
+ expect(workout.points.at(-1)).toMatchObject({
+ elevation: workout.points[0]?.elevation,
+ latitude: workout.points[0]?.latitude,
+ longitude: workout.points[0]?.longitude,
+ });
+ expect(workout.points.map((point) => point.elevation)).toEqual([
+ 12, 12, 22, 18, 12, 18, 22, 12, 12,
+ ]);
+
+ const exported = workoutFileContents(workout);
+ expect(exported).toContain('out-and-back');
+ const roundTripped = parseWorkoutFile(exported, parser);
+ expect(roundTripped).toMatchObject({
+ description: workout.description,
+ id: workout.id,
+ name: workout.name,
+ routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK,
+ });
+ expect(roundTripped.distance).toBeCloseTo(workout.distance, 5);
+ expect(roundTripped.points).toHaveLength(workout.points.length);
+ for (const [index, point] of roundTripped.points.entries()) {
+ expect(point.distance).toBeCloseTo(workout.points[index]?.distance ?? 0, 5);
+ expect(point.elevation).toBeCloseTo(workout.points[index]?.elevation ?? 0);
+ }
+ });
+
+ test('rejects malformed and built-in workout imports', async () => {
await expect(
readWorkoutFile({ name: 'broken.gpx', text: async () => '12\n\t\t',
- '12\n\t\t'
- );
- expect(() =>
- parseWorkoutFile(openRoute, new DOMParser() as unknown as globalThis.DOMParser)
- ).toThrow('The GPX route must be a closed loop');
const [builtIn] = WORKOUT_COURSES;
if (!builtIn) {
throw new Error('Expected a built-in workout course');
diff --git a/tests/workouts.test.ts b/tests/workouts.test.ts
index 1b18212..fbb441a 100644
--- a/tests/workouts.test.ts
+++ b/tests/workouts.test.ts
@@ -1,16 +1,23 @@
import { describe, expect, test } from 'bun:test';
+import { WORKOUT_ROUTE_TYPE } from '../src/lib/workout-schema';
import {
+ outAndBackRoutePoints,
restoreSessionWorkout,
restoreWorkoutCourse,
WORKOUT_COURSES,
WORKOUT_FLAT_START_DISTANCE,
+ WORKOUT_MODERATE_FLAT_START_DISTANCE,
+ WORKOUT_SHORT_FLAT_START_DISTANCE,
workoutCompletedLaps,
+ workoutDashboardPreview,
workoutElevationTotalsAtDistance,
+ workoutFlatStartDistance,
workoutLap,
workoutMapPath,
workoutMapProgressPath,
workoutMaximumGrade,
workoutProfilePath,
+ workoutProfilePosition,
workoutProgress,
workoutSelectionLocked,
workoutTerrainAtDistance,
@@ -19,9 +26,45 @@ import {
const course = WORKOUT_COURSES.find((workout) => workout.id === 'cedar-circuit');
describe('terrain workouts', () => {
+ test('previews a newly planned workout instead of the completed course', () => {
+ const [completedCourse, plannedCourse] = WORKOUT_COURSES;
+ if (!(completedCourse && plannedCourse)) {
+ throw new Error('Expected built-in workout courses');
+ }
+ const completedWorkout = { course: completedCourse };
+ const plannedWorkout = { course: plannedCourse };
+ expect(
+ workoutDashboardPreview({
+ distance: 4.2,
+ elevationTotals: { ascent: 80, descent: 60 },
+ ended: true,
+ selectedWorkout: plannedWorkout,
+ workout: completedWorkout,
+ })
+ ).toEqual({
+ distance: 0,
+ elevationTotals: { ascent: 0, descent: 0 },
+ workout: plannedWorkout,
+ });
+ expect(
+ workoutDashboardPreview({
+ distance: 4.2,
+ elevationTotals: { ascent: 80, descent: 60 },
+ ended: false,
+ selectedWorkout: completedWorkout,
+ workout: completedWorkout,
+ })
+ ).toMatchObject({ distance: 4.2, workout: completedWorkout });
+ expect(workoutTerrainAtDistance(plannedCourse, 0)).toMatchObject({
+ distance: 0,
+ progress: 0,
+ });
+ });
+
test('defines original closed-loop courses with useful terrain metadata', () => {
- expect(WORKOUT_COURSES).toHaveLength(4);
+ expect(WORKOUT_COURSES).toHaveLength(5);
for (const workout of WORKOUT_COURSES) {
+ expect(workout.routeType).toBe(WORKOUT_ROUTE_TYPE.LOOP);
expect(workout.distance).toBeGreaterThan(0);
expect(workout.elevationGain).toBeGreaterThan(0);
expect(workout.points[0]).toMatchObject({
@@ -33,11 +76,66 @@ describe('terrain workouts', () => {
}
});
+ test('makes switchback corners briefly steeper during a four-mile climb', () => {
+ const switchbacks = WORKOUT_COURSES.find((workout) => workout.id === 'granite-switchbacks');
+ if (!switchbacks) {
+ throw new Error('Expected the Granite Switchbacks workout course');
+ }
+ const climbStart = 1.5;
+ const climbEnd = 7.94;
+ const straightGrade = workoutTerrainAtDistance(switchbacks, 2.7).grade;
+ const cornerGrade = workoutTerrainAtDistance(switchbacks, 2.2).grade;
+ const smoothedGrade = workoutTerrainAtDistance(switchbacks, 2.47).grade;
+ expect((climbEnd - climbStart) / 1.609_344).toBeCloseTo(4, 2);
+ expect(cornerGrade).toBeGreaterThan(straightGrade + 2);
+ expect(smoothedGrade).toBeCloseTo(straightGrade, 0);
+ expect(workoutMaximumGrade(switchbacks)).toBeWithin(9.5, 10.5);
+ });
+
+ test('traverses out-and-back courses to the turnaround and back to the start', () => {
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const outbound = course.points.slice(0, 6).map(({ x: _x, y: _y, ...point }) => point);
+ const points = outAndBackRoutePoints(outbound);
+ const distance = points.at(-1)?.distance ?? 0;
+ const outAndBack = restoreWorkoutCourse({
+ ...course,
+ distance,
+ id: 'ridge-out-and-back',
+ points,
+ routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK,
+ });
+ if (!outAndBack) {
+ throw new Error('Expected a valid out-and-back workout course');
+ }
+ const outboundPosition = workoutTerrainAtDistance(outAndBack, 2.4);
+ const returnPosition = workoutTerrainAtDistance(outAndBack, distance - 2.4);
+ expect(outAndBack.distance).toBeCloseTo((outbound.at(-1)?.distance ?? 0) * 2);
+ expect(outAndBack.points).toHaveLength(outbound.length * 2 - 1);
+ expect(outboundPosition.elevation).toBeCloseTo(returnPosition.elevation);
+ expect(outboundPosition.x).toBeCloseTo(returnPosition.x);
+ expect(outboundPosition.y).toBeCloseTo(returnPosition.y);
+ expect(workoutProgress(outAndBack, distance / 2)).toBeCloseTo(0.5);
+ expect(workoutCompletedLaps(outAndBack, distance)).toBe(1);
+ expect(workoutTerrainAtDistance(outAndBack, distance).distance).toBe(0);
+ expect(workoutMapPath(outAndBack)).toStartWith('M ');
+ });
+
test('offers a fifteen-mile course with long rollers centered on 20% resistance', () => {
const rollingCourse = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll');
if (!rollingCourse) {
throw new Error('Expected the Prairie Roll workout course');
}
+ const [start] = rollingCourse.points;
+ if (!start) {
+ throw new Error('Expected Prairie Roll route points');
+ }
+ expect(
+ rollingCourse.points
+ .slice(1, -1)
+ .every((point) => Math.hypot(point.x - start.x, point.y - start.y) > 1)
+ ).toBeTrue();
const resistances = Array.from(
{ length: 500 },
(_, index) =>
@@ -53,19 +151,49 @@ describe('terrain workouts', () => {
expect(average).toBeWithin(19, 21);
});
- test('starts every course with a flat rollout before the first hill', () => {
+ test('keeps gentle elevation profiles visually low beside climbing courses', () => {
+ const prairie = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll');
+ const highland = WORKOUT_COURSES.find((workout) => workout.id === 'highland-loop');
+ if (!(prairie && highland)) {
+ throw new Error('Expected the Prairie Roll and Highland Loop workout courses');
+ }
+ const prairiePeak = prairie.points.reduce((peak, point) =>
+ point.elevation > peak.elevation ? point : peak
+ );
+ const highlandPeak = highland.points.reduce((peak, point) =>
+ point.elevation > peak.elevation ? point : peak
+ );
+ const prairiePosition = workoutProfilePosition(
+ prairie,
+ workoutTerrainAtDistance(prairie, prairiePeak.distance)
+ );
+ const highlandPosition = workoutProfilePosition(
+ highland,
+ workoutTerrainAtDistance(highland, highlandPeak.distance)
+ );
+ expect(prairiePosition.y).toBeGreaterThan(70);
+ expect(highlandPosition.y).toBeLessThan(20);
+ });
+
+ test('scales the flat rollout to total course climbing before the first hill', () => {
+ const harbor = WORKOUT_COURSES.find((workout) => workout.id === 'harbor-ring');
+ const cedar = WORKOUT_COURSES.find((workout) => workout.id === 'cedar-circuit');
+ const prairie = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll');
+ expect(harbor && workoutFlatStartDistance(harbor)).toBe(WORKOUT_SHORT_FLAT_START_DISTANCE);
+ expect(cedar && workoutFlatStartDistance(cedar)).toBe(WORKOUT_MODERATE_FLAT_START_DISTANCE);
+ expect(prairie && workoutFlatStartDistance(prairie)).toBe(WORKOUT_FLAT_START_DISTANCE);
for (const workout of WORKOUT_COURSES) {
const startElevation = workout.points[0]?.elevation;
+ const rolloutDistance = workoutFlatStartDistance(workout);
const rolloutPoints = workout.points.filter(
- (point) => point.distance <= WORKOUT_FLAT_START_DISTANCE
+ (point) => point.distance <= rolloutDistance
);
expect(rolloutPoints.length).toBeGreaterThan(1);
+ expect(rolloutPoints.at(-1)?.distance).toBeCloseTo(rolloutDistance);
for (const point of rolloutPoints) {
expect(point.elevation).toBe(startElevation);
}
- expect(
- workoutTerrainAtDistance(workout, WORKOUT_FLAT_START_DISTANCE / 2).grade
- ).toBeCloseTo(0);
+ expect(workoutTerrainAtDistance(workout, rolloutDistance / 2).grade).toBeCloseTo(0);
}
});
@@ -169,6 +297,31 @@ describe('terrain workouts', () => {
}
expect(restoreWorkoutCourse(course)).toEqual(course);
expect(restoreSessionWorkout({ course })).toEqual({ course });
+ const cedar = WORKOUT_COURSES.find((workout) => workout.id === 'cedar-circuit');
+ if (!cedar) {
+ throw new Error('Expected Cedar Circuit');
+ }
+ const roundLegacyCedar = {
+ ...cedar,
+ points: cedar.points.map((point, index, points) => {
+ const angle = (Math.PI * 2 * index) / (points.length - 1);
+ return {
+ distance: point.distance,
+ elevation: point.elevation,
+ x: 50 + Math.cos(angle) * 40,
+ y: 50 + Math.sin(angle) * 28,
+ };
+ }),
+ };
+ expect(restoreWorkoutCourse(roundLegacyCedar)).toBeDefined();
+ expect(restoreSessionWorkout({ course: roundLegacyCedar })?.course).toBe(cedar);
+ expect(restoreWorkoutCourse({ ...course, routeType: undefined })).toMatchObject({
+ routeType: WORKOUT_ROUTE_TYPE.LOOP,
+ });
+ expect(restoreWorkoutCourse({ ...course, routeType: 'somewhere-else' })).toBeUndefined();
+ expect(
+ restoreWorkoutCourse({ ...course, routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK })
+ ).toBeUndefined();
expect(restoreWorkoutCourse({ ...course, baseResistance: undefined })).toMatchObject({
baseResistance: 12,
});
From e04b4ccdedc39611e6c8967d9f34179e95f56504 Mon Sep 17 00:00:00 2001
From: Public Profile
Date: Sun, 19 Jul 2026 16:50:33 -0700
Subject: [PATCH 5/6] feat: refine workout dashboard and ride completion
---
README.md | 10 ++---
src/app.tsx | 41 ++++++++++++-------
src/components/workout-panel.tsx | 15 ++++---
src/components/workout-progress.tsx | 10 +++--
.../workout-route-visualization.tsx | 10 +++--
src/hooks/use-session-workflow.ts | 20 +++++----
src/hooks/use-trainer.ts | 19 ++++++++-
src/hooks/use-workout.ts | 21 +++-------
src/lib/session-workflow.ts | 5 +++
tests/components.test.tsx | 31 ++++++++++++--
tests/session-workflow.test.ts | 15 ++++++-
11 files changed, 137 insertions(+), 60 deletions(-)
diff --git a/README.md b/README.md
index ef518f6..266eefb 100644
--- a/README.md
+++ b/README.md
@@ -13,10 +13,10 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Connects to compatible bike trainers and standard Bluetooth heart rate monitors through Web Bluetooth, remembers authorized devices, and automatically reconnects when possible.
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics.
-- Offers original terrain workouts built as repeatable loop courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Prairie Roll adds a 15-mile course of long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. Every course begins with a flat three-to-five-minute rollout before its first hill, then automatically adjusts trainer resistance from the current grade, tracks the rider on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap path and pulsing position markers while pedaling, shows lap percentage, current grade, and terrain resistance directly on the map, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. A workout can be selected before riding or planned while viewing a completed session, then remains locked from the moment riding begins until that session ends. Paired shift controllers can remain connected and are ignored while a workout controls resistance. Workout terrain is modeled separately from trainer control so virtual shifting can be integrated later without changing course data.
-- Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, and exact workout distance. Valid closed-loop GPX tracks or routes from other tools can be imported into a custom library saved only on the current device; their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor.
+- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops and out-and-back routes; an out-and-back follows the supplied path to its turnaround, then reverses the same location and elevation data back to the start before repeating. Prairie Roll adds a non-intersecting, curving 15-mile loop of long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. Granite Switchbacks adds a sustained four-mile ascent whose hairpin corners briefly get steeper before immediately returning to the steady climbing grade, followed by a ridge and sweeping descent. Every course begins flat without giving nearly level routes an unnecessarily long rollout: low-climb courses use about 0.4 km, moderate rollers use about 0.8 km, and climbing-focused courses retain a 1.5 km rollout. The course then automatically adjusts trainer resistance from the current grade, tracks the rider on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap or ridden-this-trip path and pulsing position markers while pedaling, and uses clear mid-contrast preview lines with a shared elevation scale so gentle rollers remain visibly low beside genuinely mountainous routes. It shows course percentage, current grade, and terrain resistance directly on the map, and derives cumulative ride climbing and downhill from course distance so those totals remain aligned with the advertised full-course climb. Elevation appears in feet with MPH or meters with KM/H, and terrain totals and progress are recorded with the session and preserved in saved history and TCX import/export. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; a newly planned workout immediately replaces the prior course on the dashboard at 0% progress without changing the completed ride's recorded data. It then remains locked from the moment riding begins until that session ends; definition refreshes for that same workout remain allowed without opening a path to switch courses. Paired shift controllers can remain connected and are ignored while a workout controls resistance. Workout terrain is modeled separately from trainer control so virtual shifting can be integrated later without changing course data.
+- Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, exact workout distance, and loop or out-and-back course type. Valid GPX tracks or routes from other tools can be imported into a custom library saved only on the current device; closed paths become loops automatically, while open paths become out-and-back workouts with a generated return leg. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor.
- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired and no terrain workout is active; shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance.
-- Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives.
+- Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects.
- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop.
- Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported.
- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion.
@@ -51,9 +51,9 @@ uses an explicit overlay state for mutually exclusive trays, renders every side
animated and accessible shell, and delegates save/discard/start/continue transitions to a
store-backed session workflow. Temporary form inputs remain local React state.
Terrain workouts are an independent course domain layered over session distance: course geometry
-produces grade, elevation, current and completed lap counts, map position, and a bounded resistance
+produces grade, elevation, current and completed course counts, map position, and a bounded resistance
target. Recorded elevation appears alongside the other session graphs for the full ride and repeats
-the course profile on every lap, while route progress stays portable in saved sessions and TCX
+the course profile on every loop or out-and-back trip, while route progress stays portable in saved sessions and TCX
files and leaves room to compose the same terrain with virtual gearing later.
Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation,
and repeated dialog and keyboard behavior so those rules stay consistent across views and exports.
diff --git a/src/app.tsx b/src/app.tsx
index 268e496..50785b1 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -20,7 +20,7 @@ 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 { useWorkoutResistance } 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';
@@ -29,7 +29,11 @@ import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib
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 {
+ workoutDashboardPreview,
+ workoutSelectionLocked,
+ workoutTerrainAtDistance,
+} from './lib/workouts';
import { MAX_CLICK_CONTROLLERS } from './lib/zwift-click';
import { preferencesStore } from './stores/preferences-store';
import type { Metrics, SavedSession, WorkoutCourse } from './types';
@@ -94,17 +98,26 @@ export function App() {
trainer.lastPedalingAt,
trainer.trainerReportsDistance
);
- const workoutTerrain = useWorkout({
+ const dashboardWorkout = workoutDashboardPreview({
+ distance: session.rideDistance,
+ elevationTotals: session.elevationTotals,
+ ended: session.ended,
+ selectedWorkout: session.selectedWorkout,
+ workout: session.workout,
+ });
+ const workoutTerrain = dashboardWorkout.workout
+ ? workoutTerrainAtDistance(dashboardWorkout.workout.course, dashboardWorkout.distance)
+ : undefined;
+ useWorkoutResistance({
active: !session.ended,
connected: trainer.connected,
- distance: session.rideDistance,
onResistanceChange: trainer.updateProgramResistance,
onRestoreResistance: trainer.restoreManualResistance,
- workout: session.workout,
+ terrain: workoutTerrain,
});
- const workflow = useSessionWorkflow(session, trainer.setNotice);
+ const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide);
const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen;
- const virtualShiftingActive = click.paired && !session.workout;
+ const virtualShiftingActive = click.paired && !dashboardWorkout.workout;
clickShiftRef.current = shiftHandlerUnlessBlocked(
gearControl.shiftGear,
!(dashboardKeyboardEnabled && virtualShiftingActive)
@@ -137,7 +150,7 @@ export function App() {
}, [warnBeforeUnload]);
useEffect(() => {
- trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled && !session.workout);
+ trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled && !dashboardWorkout.workout);
trainer.setGearControlsEnabled(virtualShiftingActive);
gearControl.setKeyboardControlsEnabled(dashboardKeyboardEnabled && virtualShiftingActive);
}, [
@@ -145,7 +158,7 @@ export function App() {
gearControl.setKeyboardControlsEnabled,
trainer.setGearControlsEnabled,
trainer.setKeyboardControlsEnabled,
- session.workout,
+ dashboardWorkout.workout,
virtualShiftingActive,
]);
@@ -220,7 +233,7 @@ export function App() {
const selectedWorkoutId = selectedWorkoutCourse?.id;
const workoutLocked = workoutSelectionLocked(session);
useEffect(() => {
- if (!(selectedWorkoutCourse && !workoutLocked)) {
+ if (!selectedWorkoutCourse) {
return;
}
const currentDefinition = workoutLibrary.courses.find(
@@ -229,7 +242,7 @@ export function App() {
if (currentDefinition && currentDefinition !== selectedWorkoutCourse) {
session.selectWorkout(currentDefinition);
}
- }, [selectedWorkoutCourse, session.selectWorkout, workoutLibrary.courses, workoutLocked]);
+ }, [selectedWorkoutCourse, session.selectWorkout, workoutLibrary.courses]);
const removeWorkout = useCallback(
(courseId: string) => {
if (selectedWorkoutId === courseId) {
@@ -287,13 +300,13 @@ export function App() {
rideDistance={session.rideDistance}
speedUnit={speedUnit}
/>
- {session.workout && workoutTerrain ? (
+ {dashboardWorkout.workout && workoutTerrain ? (
) : null}
diff --git a/src/components/workout-panel.tsx b/src/components/workout-panel.tsx
index 626ef83..95cf0c6 100644
--- a/src/components/workout-panel.tsx
+++ b/src/components/workout-panel.tsx
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { errorMessage } from '../lib/errors';
import { formatDistance, formatElevation } from '../lib/units';
import { downloadWorkoutFile } from '../lib/workout-file';
-import { WORKOUT_VIEW } from '../lib/workout-schema';
+import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW } from '../lib/workout-schema';
import { workoutDifficultyLabel, workoutMaximumGrade } from '../lib/workouts';
import type { SpeedUnit, WorkoutCourse } from '../types';
import { SideTray } from './side-tray';
@@ -65,7 +65,12 @@ function WorkoutCourseCard({
-
{formatDistance(course.distance, speedUnit, 1)} loop
+
+ {formatDistance(course.distance, speedUnit, 1)}{' '}
+ {course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK
+ ? 'out & back'
+ : 'loop'}
+
{formatElevation(course.elevationGain, speedUnit)} climbing
Up to +{workoutMaximumGrade(course).toFixed(1)}%
@@ -89,7 +94,7 @@ function WorkoutCourseCard({
Resistance follows the climbs and descents while your position moves
- around a loop.
+ along the route.
diff --git a/src/components/workout-progress.tsx b/src/components/workout-progress.tsx
index d449f56..eb160d9 100644
--- a/src/components/workout-progress.tsx
+++ b/src/components/workout-progress.tsx
@@ -1,6 +1,6 @@
import { formatGrade } from '../lib/format';
import { formatDistanceProgress, formatElevation } from '../lib/units';
-import { WORKOUT_VIEW } from '../lib/workout-schema';
+import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW } from '../lib/workout-schema';
import type { ElevationTotals, SessionWorkout, SpeedUnit, WorkoutTerrain } from '../types';
import { WorkoutRouteVisualization } from './workout-route-visualization';
@@ -52,6 +52,8 @@ export function WorkoutProgress({
workout: SessionWorkout;
}) {
const { course } = workout;
+ const outAndBack = course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK;
+ const completionUnit = outAndBack ? 'trip' : 'lap';
const elevationStats = [
{ label: 'Course climb', value: formatElevation(course.elevationGain, speedUnit) },
{ label: 'Climbed', value: formatElevation(elevationTotals.ascent, speedUnit) },
@@ -72,15 +74,15 @@ export function WorkoutProgress({
{course.name}
- Ridden this lap
+ Ridden this {completionUnit}
- Laps completed
+ {outAndBack ? 'Trips completed' : 'Laps completed'}