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..766d511 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.
-- 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.
+- 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 a descending sequence of five recovery rollers. Ridgeline Time Trial is a ten-mile out-and-back with a gradual five-mile, roughly 300-foot hillclimb to the turnaround and the identical terrain in reverse on the return. 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. 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.
-- 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,14 @@ 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. Each bundled
+course keeps its editable metadata, map geometry, elevation, and resistance baseline in an
+individual JSON definition under `src/workouts`; shared factories derive the normalized runtime
+course geometry and terrain behavior. Course geometry
+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 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 e01aea0..50785b1 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -13,22 +13,30 @@ 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 { 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';
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 {
+ workoutDashboardPreview,
+ workoutSelectionLocked,
+ workoutTerrainAtDistance,
+} 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 +64,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 +78,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 +98,29 @@ export function App() {
trainer.lastPedalingAt,
trainer.trainerReportsDistance
);
- const workflow = useSessionWorkflow(session, trainer.setNotice);
+ 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,
+ onResistanceChange: trainer.updateProgramResistance,
+ onRestoreResistance: trainer.restoreManualResistance,
+ terrain: workoutTerrain,
+ });
+ const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide);
const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen;
+ const virtualShiftingActive = click.paired && !dashboardWorkout.workout;
clickShiftRef.current = shiftHandlerUnlessBlocked(
gearControl.shiftGear,
- !dashboardKeyboardEnabled
+ !(dashboardKeyboardEnabled && virtualShiftingActive)
);
const handleNewSessionShortcut = useCallback(
(event: KeyboardEvent) => {
@@ -123,15 +150,16 @@ export function App() {
}, [warnBeforeUnload]);
useEffect(() => {
- trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled);
- trainer.setGearControlsEnabled(click.paired);
- gearControl.setKeyboardControlsEnabled(dashboardKeyboardEnabled);
+ trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled && !dashboardWorkout.workout);
+ trainer.setGearControlsEnabled(virtualShiftingActive);
+ gearControl.setKeyboardControlsEnabled(dashboardKeyboardEnabled && virtualShiftingActive);
}, [
- click.paired,
dashboardKeyboardEnabled,
gearControl.setKeyboardControlsEnabled,
trainer.setGearControlsEnabled,
trainer.setKeyboardControlsEnabled,
+ dashboardWorkout.workout,
+ virtualShiftingActive,
]);
useEffect(() => {
@@ -145,7 +173,7 @@ export function App() {
},
history: (event) => {
event.preventDefault();
- setActiveOverlay('history');
+ setActiveOverlay(APP_OVERLAY.HISTORY);
},
newSession: handleNewSessionShortcut,
pause: (event) => {
@@ -154,7 +182,7 @@ export function App() {
},
shortcuts: (event) => {
event.preventDefault();
- setActiveOverlay('shortcuts');
+ setActiveOverlay(APP_OVERLAY.SHORTCUTS);
},
};
const handleShortcut = (event: KeyboardEvent) => {
@@ -194,10 +222,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) {
+ return;
+ }
+ const currentDefinition = workoutLibrary.courses.find(
+ (course) => course.id === selectedWorkoutCourse.id
+ );
+ if (currentDefinition && currentDefinition !== selectedWorkoutCourse) {
+ session.selectWorkout(currentDefinition);
+ }
+ }, [selectedWorkoutCourse, session.selectWorkout, workoutLibrary.courses]);
+ 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 +273,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 +300,15 @@ export function App() {
rideDistance={session.rideDistance}
speedUnit={speedUnit}
/>
+ {dashboardWorkout.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 = (
+
+ {workoutName ?? 'Workouts'}
+
+ );
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}
void;
+ onSelect: () => void;
+ selected: boolean;
+ speedUnit: SpeedUnit;
+}) {
+ return (
+
+
+
+
+
+
{course.name}
+
+ {course.description}
+
+
+
+ {custom ? (
+
+ Imported
+
+ ) : null}
+
+ {workoutDifficultyLabel(course.difficulty)}
+
+
+
+
+
+ {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)}%
+
+ downloadWorkoutFile(course)}
+ type="button"
+ >
+ Download GPX
+
+ {custom ? (
+
+ Remove
+
+ ) : null}
+
+
+
+ {selected ? 'Selected' : 'Choose workout'}
+
+
+
+ );
+}
+
+export function WorkoutPanel({
+ activeCourse,
+ courses,
+ customCourseIds,
+ ended,
+ onClose,
+ onImportFile,
+ onRemoveCourse,
+ onSelect,
+ open,
+ selectionLocked,
+ speedUnit,
+}: {
+ activeCourse?: WorkoutCourse;
+ courses: WorkoutCourse[];
+ customCourseIds: ReadonlySet;
+ ended: boolean;
+ onClose: () => void;
+ onImportFile: (file: File) => Promise;
+ onRemoveCourse: (courseId: string) => void;
+ onSelect: (course?: WorkoutCourse) => void;
+ open: boolean;
+ selectionLocked: boolean;
+ speedUnit: SpeedUnit;
+}) {
+ const importInput = useRef(null);
+ const [importing, setImporting] = useState(false);
+ const [importStatus, setImportStatus] = useState('');
+ const [importError, setImportError] = useState('');
+ let notice =
+ 'Choose a course before you start riding. The route repeats until the session ends.';
+ if (ended) {
+ notice = 'Choose a workout for your next session, then start it when you are ready.';
+ } else if (selectionLocked) {
+ notice = 'End the current session before changing the workout.';
+ }
+
+ const importWorkout = async (file: File) => {
+ setImporting(true);
+ setImportStatus('');
+ setImportError('');
+ try {
+ const course = await onImportFile(file);
+ setImportStatus(`${course.name} imported and saved on this device.`);
+ } catch (error) {
+ setImportError(errorMessage(error));
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Terrain workouts
+
+
+ Resistance follows the climbs and descents while your position moves
+ along the route.
+
+
+
+ {
+ const file = event.currentTarget.files?.[0];
+ event.currentTarget.value = '';
+ if (file) {
+ importWorkout(file);
+ }
+ }}
+ ref={importInput}
+ type="file"
+ />
+ importInput.current?.click()}
+ type="button"
+ >
+ {importing ? 'Importing…' : 'Import GPX'}
+
+
+ ×
+
+
+
+
+
{notice}
+ {importStatus ? (
+
+ {importStatus}
+
+ ) : null}
+ {importError ? (
+
+ {importError}
+
+ ) : null}
+
+
+ {courses.map((course) => (
+ onRemoveCourse(course.id)}
+ onSelect={() => onSelect(course)}
+ selected={activeCourse?.id === course.id}
+ speedUnit={speedUnit}
+ />
+ ))}
+
+ {activeCourse && !selectionLocked ? (
+
+ onSelect(undefined)}
+ type="button"
+ >
+ Ride without a workout
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/workout-progress.tsx b/src/components/workout-progress.tsx
new file mode 100644
index 0000000..eb160d9
--- /dev/null
+++ b/src/components/workout-progress.tsx
@@ -0,0 +1,135 @@
+import { formatGrade } from '../lib/format';
+import { formatDistanceProgress, formatElevation } from '../lib/units';
+import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW } from '../lib/workout-schema';
+import type { ElevationTotals, SessionWorkout, SpeedUnit, WorkoutTerrain } from '../types';
+import { WorkoutRouteVisualization } from './workout-route-visualization';
+
+interface WorkoutStat {
+ label: string;
+ value: string;
+}
+
+function WorkoutStats({
+ highlighted = false,
+ stats,
+}: {
+ highlighted?: boolean;
+ stats: WorkoutStat[];
+}) {
+ const labelSize = highlighted ? 'text-[10px]' : 'text-[9px]';
+ const valueSize = highlighted ? 'text-3xl sm:text-4xl' : 'text-xl sm:text-2xl';
+ return (
+
+ {stats.map((stat) => (
+
+
+ {stat.label}
+
+
+ {stat.value}
+
+
+ ))}
+
+ );
+}
+
+export function WorkoutProgress({
+ elevationTotals,
+ isRiding,
+ speedUnit,
+ terrain,
+ workout,
+}: {
+ elevationTotals: ElevationTotals;
+ isRiding: boolean;
+ speedUnit: SpeedUnit;
+ terrain: WorkoutTerrain;
+ workout: SessionWorkout;
+}) {
+ const { course } = workout;
+ const 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) },
+ { label: 'Downhill', value: formatElevation(elevationTotals.descent, speedUnit) },
+ ];
+ const mapStats = [
+ { label: 'Progress', value: `${Math.round(terrain.progress * 100)}%` },
+ {
+ label: 'Grade',
+ value: formatGrade(terrain.grade),
+ },
+ { label: 'Resistance', value: `${terrain.resistance}%` },
+ ];
+ return (
+
+
+
+
+
+
+
+ Course map
+
+
+ {formatDistanceProgress(
+ terrain.distance,
+ course.distance,
+ speedUnit
+ )}
+
+
+
+
+
+
+
+
+
+ Elevation profile
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/workout-route-visualization.tsx b/src/components/workout-route-visualization.tsx
new file mode 100644
index 0000000..af81df1
--- /dev/null
+++ b/src/components/workout-route-visualization.tsx
@@ -0,0 +1,140 @@
+import { useId } from 'react';
+import { WORKOUT_VIEW, type WorkoutView } from '../lib/workout-schema';
+import {
+ workoutMapPath,
+ workoutMapProgressPath,
+ workoutProfilePath,
+ workoutProfilePosition,
+} from '../lib/workouts';
+import type { WorkoutCourse, WorkoutTerrain } from '../types';
+
+const ROUTE_STROKE = '#64748b';
+
+interface RouteMarker {
+ x: number;
+ y: number;
+}
+
+function WorkoutRouteMarker({
+ isMap,
+ isRiding,
+ marker,
+}: {
+ isMap: boolean;
+ isRiding: boolean;
+ marker?: RouteMarker;
+}) {
+ if (!marker) {
+ return null;
+ }
+ if (isMap) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+export function WorkoutRouteVisualization({
+ className = 'h-40',
+ course,
+ isRiding = false,
+ terrain,
+ view,
+}: {
+ className?: string;
+ course: WorkoutCourse;
+ isRiding?: boolean;
+ terrain?: WorkoutTerrain;
+ view: WorkoutView;
+}) {
+ const progress = terrain ? terrain.progress * 100 : 0;
+ const isMap = view === WORKOUT_VIEW.MAP;
+ const path = isMap ? workoutMapPath(course) : workoutProfilePath(course);
+ const progressPath = isMap && terrain ? workoutMapProgressPath(course, terrain) : path;
+ const progressClipId = `workout-progress-${useId().replaceAll(':', '')}`;
+ let marker: RouteMarker | undefined;
+ if (terrain) {
+ marker = isMap ? { x: terrain.x, y: terrain.y } : workoutProfilePosition(course, terrain);
+ }
+ const profileArea = `${path} L 100 92 L 0 92 Z`;
+
+ return (
+
+
+ {`${course.name} ${isMap ? 'course map' : 'elevation profile'}`}
+ {isMap ? null : (
+
+
+
+
+
+ )}
+ {isMap ? null : (
+ <>
+
+
+ >
+ )}
+
+ {terrain && progress > 0 ? (
+
+ ) : null}
+ {isMap ? : null}
+
+ {isMap ? null : (
+
+ )}
+
+ );
+}
diff --git a/src/constants.ts b/src/constants.ts
index 5c02c7a..dfd1a14 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,5 +1,5 @@
import { CONTROL_MODE } from './lib/control-mode';
-import type { Metrics, StoredSession } from './types';
+import type { ElevationTotals, Metrics, StoredSession } from './types';
export const FITNESS_MACHINE = 0x18_26;
export const CYCLING_POWER = 0x18_18;
@@ -39,6 +39,11 @@ export const emptyMetrics: Metrics = {
speed: 0,
};
+export const emptyElevationTotals: ElevationTotals = {
+ ascent: 0,
+ descent: 0,
+};
+
export const emptySession: StoredSession = {
aggregates: {
cadence: { count: 0, sum: 0 },
@@ -52,6 +57,7 @@ export const emptySession: StoredSession = {
discarded: false,
distance: 0,
elapsedSeconds: 0,
+ elevationTotals: emptyElevationTotals,
ended: false,
endedAt: 0,
history: [],
diff --git a/src/hooks/use-session-workflow.ts b/src/hooks/use-session-workflow.ts
index 50b4d80..15ea771 100644
--- a/src/hooks/use-session-workflow.ts
+++ b/src/hooks/use-session-workflow.ts
@@ -7,6 +7,7 @@ import {
saveSession,
} from '../lib/saved-sessions';
import {
+ finishRideSession,
SESSION_WORKFLOW_INTENT,
SESSION_WORKFLOW_PHASE,
type SessionWorkflowController,
@@ -17,13 +18,18 @@ import type { SavedSession, SessionMetadata } from '../types';
export function useSessionWorkflow(
session: SessionWorkflowController,
- setNotice: (notice: string) => void
+ setNotice: (notice: string) => void,
+ settleTrainerResistance: () => void
) {
const sessionIsResolved = Boolean(session.savedSessionId) || session.discarded;
const storeRef = useRef | undefined>(undefined);
storeRef.current ??= createSessionWorkflowStore(session.ended && !sessionIsResolved);
const store = storeRef.current;
const state = useSelector(store);
+ const finishSession = useCallback(
+ () => finishRideSession(session.endSession, settleTrainerResistance),
+ [session.endSession, settleTrainerResistance]
+ );
const startNewSession = useCallback(() => {
session.startNew();
@@ -70,9 +76,9 @@ export function useSessionWorkflow(
);
const endSession = useCallback(() => {
- session.endSession();
+ finishSession();
store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END });
- }, [session.endSession, store]);
+ }, [finishSession, store]);
const requestNewSession = useCallback(() => {
if (session.ended) {
@@ -84,16 +90,16 @@ export function useSessionWorkflow(
return;
}
if (session.elapsedSeconds > 0) {
- session.endSession();
+ finishSession();
store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW });
return;
}
startNewSession();
}, [
session.elapsedSeconds,
- session.endSession,
session.ended,
sessionIsResolved,
+ finishSession,
startNewSession,
store,
]);
@@ -108,16 +114,16 @@ export function useSessionWorkflow(
return;
}
if (!session.ended) {
- session.endSession();
+ finishSession();
}
store.actions.open({ kind: SESSION_WORKFLOW_INTENT.CONTINUE, session: savedSession });
},
[
continueSession,
session.elapsedSeconds,
- session.endSession,
session.ended,
sessionIsResolved,
+ finishSession,
store,
]
);
diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts
index 28eb900..35acdbb 100644
--- a/src/hooks/use-session.ts
+++ b/src/hooks/use-session.ts
@@ -1,6 +1,7 @@
import { useSelector } from '@tanstack/react-store';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { RECORDING_PAUSE_DELAY_MS } from '../constants';
+import { CONTROL_MODE } from '../lib/control-mode';
import { loadStoredSession, SESSION_STORAGE_KEY } from '../lib/session';
import { MILLISECONDS_PER_SECOND, secondsForMilliseconds } from '../lib/units';
import {
@@ -10,10 +11,13 @@ import {
} from '../stores/session-store';
import type {
ControlMode,
+ ElevationTotals,
MetricSample,
Metrics,
SessionAggregates,
SessionSnapshot,
+ SessionWorkout,
+ WorkoutCourse,
} from '../types';
interface ActivityRef {
@@ -36,6 +40,7 @@ interface SessionController {
controlMode: ControlMode;
discarded: boolean;
elapsedSeconds: number;
+ elevationTotals: ElevationTotals;
ended: boolean;
endSession: () => void;
history: MetricSample[];
@@ -47,10 +52,20 @@ interface SessionController {
rideCalories: number;
rideDistance: number;
savedSessionId?: string;
+ selectedWorkout?: SessionWorkout;
+ selectWorkout: (course?: WorkoutCourse) => void;
snapshot: SessionSnapshot;
startedAt: number;
startNew: () => void;
togglePause: () => void;
+ workout?: SessionWorkout;
+}
+
+function controlForWorkout(
+ control: SessionControlState,
+ workout?: SessionWorkout
+): SessionControlState {
+ return workout ? { ...control, mode: CONTROL_MODE.RESISTANCE } : control;
}
export function useSession(
@@ -61,8 +76,9 @@ export function useSession(
): SessionController {
const store = useMemo(() => createSessionStore(loadStoredSession()), []);
const state = useSelector(store);
+ const effectiveControl = controlForWorkout(control, state.workout);
const latestMetrics = useRef(metrics);
- const latestControl = useRef(control);
+ const latestControl = useRef(effectiveControl);
const lastTrainerDistance = useRef(undefined);
useEffect(() => {
@@ -71,9 +87,9 @@ export function useSession(
}, [metrics, store]);
useEffect(() => {
- latestControl.current = control;
- store.actions.observeControlMode(control.mode);
- }, [control, store]);
+ latestControl.current = effectiveControl;
+ store.actions.observeControlMode(effectiveControl.mode);
+ }, [effectiveControl, store]);
useEffect(() => {
persistSessionState(store.get());
@@ -150,6 +166,11 @@ export function useSession(
store.actions.reset(latestControl.current.mode, Date.now());
}, [lastPedalingAt, store]);
+ const selectWorkout = useCallback(
+ (course?: WorkoutCourse) => store.actions.selectWorkout(course),
+ [store]
+ );
+
const continueFrom = useCallback(
(sourceSession: SessionSnapshot) => {
lastTrainerDistance.current = latestMetrics.current.distance;
@@ -168,6 +189,7 @@ export function useSession(
controlMode: state.controlMode,
discarded: state.discarded,
elapsedSeconds: state.elapsedSeconds,
+ elevationTotals: state.elevationTotals,
ended: state.ended,
endSession,
history: state.history,
@@ -179,9 +201,12 @@ export function useSession(
rideCalories: state.calories,
rideDistance: state.distance,
savedSessionId: state.savedSessionId,
+ selectedWorkout: state.ended ? state.plannedWorkout : state.workout,
+ selectWorkout,
snapshot,
startedAt: state.startedAt,
startNew,
togglePause,
+ workout: state.workout,
};
}
diff --git a/src/hooks/use-trainer.ts b/src/hooks/use-trainer.ts
index 6faade7..8954d12 100644
--- a/src/hooks/use-trainer.ts
+++ b/src/hooks/use-trainer.ts
@@ -8,6 +8,7 @@ import { scheduleNoticeDismissal } from '../lib/notification';
import { clamp } from '../lib/numbers';
import {
clampResistance,
+ DEFAULT_RESISTANCE,
resistanceDirectionForKey,
resistanceRampDuration,
smoothedResistance,
@@ -24,6 +25,7 @@ export function useTrainer() {
const resistanceRampTimer = useRef(undefined);
const resistanceKeyFlashTimer = useRef(undefined);
const appliedResistance = useRef(store.get().resistance);
+ const rememberedResistance = useRef(store.get().resistance);
const resistanceTarget = useRef(store.get().resistance);
const keyboardControlsEnabled = useRef(true);
const gearControlsEnabled = useRef(false);
@@ -90,14 +92,28 @@ export function useTrainer() {
[setNotice, setResistanceRamp, trainerConnection.sendResistance]
);
- const updateResistance = useCallback(
- (value: number) => {
+ const queueResistance = useCallback(
+ (value: number, remember: boolean, applyToTrainer = true) => {
const next = clampResistance(value);
resistanceTarget.current = next;
setResistance(next);
- localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next));
+ if (remember) {
+ rememberedResistance.current = next;
+ localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next));
+ }
window.clearTimeout(resistanceTimer.current);
window.clearTimeout(resistanceRampTimer.current);
+ if (!applyToTrainer) {
+ appliedResistance.current = next;
+ setResistanceRamp({
+ current: next,
+ from: next,
+ phase: 'holding',
+ progress: 0,
+ to: next,
+ });
+ return;
+ }
const { current } = appliedResistance;
setResistanceRamp({
current,
@@ -112,6 +128,22 @@ export function useTrainer() {
},
[rampResistance, setResistance, setResistanceRamp]
);
+ const updateResistance = useCallback(
+ (value: number) => queueResistance(value, true),
+ [queueResistance]
+ );
+ const updateProgramResistance = useCallback(
+ (value: number) => queueResistance(value, false),
+ [queueResistance]
+ );
+ const restoreManualResistance = useCallback(
+ () => queueResistance(rememberedResistance.current, false),
+ [queueResistance]
+ );
+ const settleAfterRide = useCallback(
+ () => queueResistance(DEFAULT_RESISTANCE, true, connection.connected),
+ [connection.connected, queueResistance]
+ );
const shiftResistanceBy = useCallback(
(change: number) => {
@@ -120,6 +152,7 @@ export function useTrainer() {
window.clearTimeout(resistanceRampTimer.current);
resistanceTarget.current = next;
appliedResistance.current = next;
+ rememberedResistance.current = next;
setResistance(next);
setResistanceRamp({
current: next,
@@ -207,11 +240,14 @@ export function useTrainer() {
resistance: state.resistance,
resistanceKeyFlash: state.resistanceKeyFlash,
resistanceRamp: state.resistanceRamp,
+ restoreManualResistance,
setGearControlsEnabled,
setKeyboardControlsEnabled,
setNotice,
+ settleAfterRide,
shiftResistanceBy,
trainerReportsDistance: trainerConnection.trainerReportsDistance,
+ updateProgramResistance,
updateResistance,
};
}
diff --git a/src/hooks/use-workout-library.ts b/src/hooks/use-workout-library.ts
new file mode 100644
index 0000000..6e8119f
--- /dev/null
+++ b/src/hooks/use-workout-library.ts
@@ -0,0 +1,50 @@
+import { useCallback, useMemo, useRef, useState } from 'react';
+import {
+ addCustomWorkout,
+ loadCustomWorkouts,
+ readWorkoutFile,
+ saveCustomWorkouts,
+ withoutCustomWorkout,
+} from '../lib/workout-file';
+import { WORKOUT_COURSES } from '../lib/workouts';
+import type { WorkoutCourse } from '../types';
+
+export function useWorkoutLibrary() {
+ const [customCourses, setCustomCourses] = useState(loadCustomWorkouts);
+ const customCoursesRef = useRef(customCourses);
+ const courses = useMemo(() => [...WORKOUT_COURSES, ...customCourses], [customCourses]);
+ const customCourseIds = useMemo(
+ () => new Set(customCourses.map((course) => course.id)),
+ [customCourses]
+ );
+
+ const replaceCustomCourses = useCallback((next: WorkoutCourse[]) => {
+ saveCustomWorkouts(next);
+ customCoursesRef.current = next;
+ setCustomCourses(next);
+ }, []);
+
+ const importFile = useCallback(
+ async (file: File) => {
+ const course = await readWorkoutFile(file);
+ const result = addCustomWorkout(customCoursesRef.current, course);
+ replaceCustomCourses(result.courses);
+ return result.course;
+ },
+ [replaceCustomCourses]
+ );
+
+ const removeCourse = useCallback(
+ (courseId: string) => {
+ replaceCustomCourses(withoutCustomWorkout(customCoursesRef.current, courseId));
+ },
+ [replaceCustomCourses]
+ );
+
+ return {
+ courses,
+ customCourseIds,
+ importFile,
+ removeCourse,
+ };
+}
diff --git a/src/hooks/use-workout.ts b/src/hooks/use-workout.ts
new file mode 100644
index 0000000..a0f45c9
--- /dev/null
+++ b/src/hooks/use-workout.ts
@@ -0,0 +1,35 @@
+import { useEffect, useRef } from 'react';
+import type { WorkoutTerrain } from '../types';
+
+export function useWorkoutResistance({
+ active,
+ connected,
+ onResistanceChange,
+ onRestoreResistance,
+ terrain,
+}: {
+ active: boolean;
+ connected: boolean;
+ onResistanceChange: (resistance: number) => void;
+ onRestoreResistance: () => void;
+ terrain?: WorkoutTerrain;
+}) {
+ const resistance = terrain?.resistance;
+ const automatedResistance = useRef(false);
+
+ useEffect(() => {
+ if (active && connected && resistance !== undefined) {
+ onResistanceChange(resistance);
+ automatedResistance.current = true;
+ } else if (
+ automatedResistance.current &&
+ connected &&
+ (!active || resistance === undefined)
+ ) {
+ onRestoreResistance();
+ automatedResistance.current = false;
+ } else if (!connected && (!active || resistance === undefined)) {
+ automatedResistance.current = false;
+ }
+ }, [active, connected, onResistanceChange, onRestoreResistance, resistance]);
+}
diff --git a/src/lib/app-overlay.ts b/src/lib/app-overlay.ts
new file mode 100644
index 0000000..54715a6
--- /dev/null
+++ b/src/lib/app-overlay.ts
@@ -0,0 +1,9 @@
+export const APP_OVERLAY = {
+ DEVICES: 'devices',
+ HISTORY: 'history',
+ SHORTCUTS: 'shortcuts',
+ WELCOME: 'welcome',
+ WORKOUTS: 'workouts',
+} as const;
+
+export type AppOverlay = (typeof APP_OVERLAY)[keyof typeof APP_OVERLAY];
diff --git a/src/lib/arrays.ts b/src/lib/arrays.ts
new file mode 100644
index 0000000..eafcd68
--- /dev/null
+++ b/src/lib/arrays.ts
@@ -0,0 +1,9 @@
+export function evenlySample(values: T[], limit: number): T[] {
+ if (values.length <= limit) {
+ return values;
+ }
+ return Array.from({ length: limit }, (_, index) => {
+ const sourceIndex = Math.round((index * (values.length - 1)) / (limit - 1));
+ return values[sourceIndex] as T;
+ });
+}
diff --git a/src/lib/chart-mode.ts b/src/lib/chart-mode.ts
new file mode 100644
index 0000000..00fc3f2
--- /dev/null
+++ b/src/lib/chart-mode.ts
@@ -0,0 +1,28 @@
+import { CONTROL_MODE } from './control-mode';
+
+export const CHART_MODE = {
+ ALL: 'all',
+ CADENCE: 'cadence',
+ ELEVATION: 'elevation',
+ GEAR: CONTROL_MODE.GEAR,
+ HEART_RATE: 'heartRate',
+ POWER: 'power',
+ RESISTANCE: CONTROL_MODE.RESISTANCE,
+ SPEED: 'speed',
+} as const;
+
+export type ChartMode = (typeof CHART_MODE)[keyof typeof CHART_MODE];
+
+const PERSISTED_CHART_MODES = new Set([
+ CHART_MODE.ALL,
+ CHART_MODE.SPEED,
+ CHART_MODE.POWER,
+ CHART_MODE.CADENCE,
+ CHART_MODE.HEART_RATE,
+ CHART_MODE.GEAR,
+ CHART_MODE.RESISTANCE,
+]);
+
+export function isPersistedChartMode(value: unknown): value is ChartMode {
+ return PERSISTED_CHART_MODES.has(value);
+}
diff --git a/src/lib/chart.ts b/src/lib/chart.ts
index 0704fde..816e859 100644
--- a/src/lib/chart.ts
+++ b/src/lib/chart.ts
@@ -1,4 +1,4 @@
-import type { ChartMode } from '../types';
+import { CHART_MODE, type ChartMode, isPersistedChartMode } from './chart-mode';
import { CONTROL_MODE, type ControlMode } from './control-mode';
import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from './metric-presentation';
import { clamp } from './numbers';
@@ -7,8 +7,8 @@ import { isFiniteNumber } from './type-guards';
export const CHART_MODE_STORAGE_KEY = 'trainer-chart-mode';
const baseChartModes: { label: string; value: ChartMode }[] = [
- { label: 'All', value: 'all' },
- { label: METRIC_PRESENTATION.speed.label, value: 'speed' },
+ { label: 'All', value: CHART_MODE.ALL },
+ { label: METRIC_PRESENTATION.speed.label, value: CHART_MODE.SPEED },
...STANDARD_METRIC_KEYS.map((key) => ({
label: METRIC_PRESENTATION[key].label,
value: key,
@@ -26,17 +26,7 @@ export function chartModesForControl(controlMode: ControlMode) {
export function storedChartMode(storage: Pick = localStorage): ChartMode {
const saved = storage.getItem(CHART_MODE_STORAGE_KEY);
- return [
- 'all',
- 'speed',
- 'power',
- 'cadence',
- 'heartRate',
- CONTROL_MODE.GEAR,
- CONTROL_MODE.RESISTANCE,
- ].includes(saved ?? '')
- ? (saved as ChartMode)
- : 'all';
+ return isPersistedChartMode(saved) ? saved : CHART_MODE.ALL;
}
export function chartPath(
diff --git a/src/lib/elevation.ts b/src/lib/elevation.ts
new file mode 100644
index 0000000..767fab5
--- /dev/null
+++ b/src/lib/elevation.ts
@@ -0,0 +1,45 @@
+import { emptyElevationTotals } from '../constants';
+import type { ElevationTotals, MetricSample } from '../types';
+import { nonNegativeNumber } from './numbers';
+import { isFiniteNumber, isRecord } from './type-guards';
+
+export function addElevationChange(
+ totals: ElevationTotals,
+ previousElevation: number | undefined,
+ elevation: number
+): ElevationTotals {
+ if (previousElevation === undefined) {
+ return totals;
+ }
+ const change = elevation - previousElevation;
+ return change >= 0
+ ? { ...totals, ascent: totals.ascent + change }
+ : { ...totals, descent: totals.descent - change };
+}
+
+export function elevationTotalsForSamples(
+ samples: Partial>[]
+): ElevationTotals {
+ let previousElevation: number | undefined;
+ return samples.reduce((totals, sample) => {
+ if (!isFiniteNumber(sample.elevation)) {
+ return totals;
+ }
+ const next = addElevationChange(totals, previousElevation, sample.elevation);
+ previousElevation = sample.elevation;
+ return next;
+ }, emptyElevationTotals);
+}
+
+export function restoreElevationTotals(
+ value: unknown,
+ samples: Partial>[]
+): ElevationTotals {
+ if (!(isRecord(value) && isFiniteNumber(value.ascent) && isFiniteNumber(value.descent))) {
+ return elevationTotalsForSamples(samples);
+ }
+ return {
+ ascent: nonNegativeNumber(value.ascent),
+ descent: nonNegativeNumber(value.descent),
+ };
+}
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 7008960..c106899 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -32,3 +32,8 @@ export function aggregateMaximum(aggregate: MetricAggregate): number {
export function formatWholeNumber(value: number): string {
return String(Math.round(value));
}
+
+export function formatGrade(grade: number): string {
+ const rounded = Number(grade.toFixed(1));
+ return rounded === 0 ? '0%' : `${rounded > 0 ? '+' : ''}${rounded.toFixed(1)}%`;
+}
diff --git a/src/lib/gpx.ts b/src/lib/gpx.ts
index ba755e7..ffa6de6 100644
--- a/src/lib/gpx.ts
+++ b/src/lib/gpx.ts
@@ -1,29 +1,136 @@
-import type { RoutePoint } from '../types';
+import type { GeographicRoutePoint } from '../types';
import { kilometersForMeters } from './units';
+import { xmlChild, xmlDescendant, xmlDescendants, xmlNumber, xmlText } from './xml';
-export function distanceBetween(a: number, b: number, c: number, d: number) {
- const rad = Math.PI / 180;
- const x = (c - a) * rad;
- const y = (d - b) * rad;
- const q = Math.sin(x / 2) ** 2 + Math.cos(a * rad) * Math.cos(c * rad) * Math.sin(y / 2) ** 2;
- return 6_371_000 * 2 * Math.atan2(Math.sqrt(q), Math.sqrt(1 - q));
-}
-
-export function parseGpx(source: string, parser: DOMParser = new DOMParser()): RoutePoint[] {
- const xml = parser.parseFromString(source, 'text/xml');
- const points = [...xml.querySelectorAll('trkpt')];
- let total = 0;
- let previous: { lat: number; lon: number } | undefined;
- return points.map((point) => {
- const lat = Number(point.getAttribute('lat'));
- const lon = Number(point.getAttribute('lon'));
+const EARTH_RADIUS_METERS = 6_371_000;
+const MINIMUM_GPX_POINTS = 3;
+const DISTANCE_EPSILON = 0.000_001;
+
+export interface ParsedGpxTrack {
+ description: string;
+ name: string;
+ points: GeographicRoutePoint[];
+}
+
+interface GpxPointSource {
+ distance?: number;
+ elevation: number;
+ latitude: number;
+ longitude: number;
+}
+
+export function distanceBetween(
+ latitudeA: number,
+ longitudeA: number,
+ latitudeB: number,
+ longitudeB: number
+): number {
+ const radians = Math.PI / 180;
+ const latitudeDelta = (latitudeB - latitudeA) * radians;
+ const longitudeDelta = (longitudeB - longitudeA) * radians;
+ const haversine =
+ Math.sin(latitudeDelta / 2) ** 2 +
+ Math.cos(latitudeA * radians) *
+ Math.cos(latitudeB * radians) *
+ Math.sin(longitudeDelta / 2) ** 2;
+ return EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
+}
+
+function validCoordinate(latitude: number | undefined, longitude: number | undefined): boolean {
+ return (
+ latitude !== undefined &&
+ longitude !== undefined &&
+ latitude >= -90 &&
+ latitude <= 90 &&
+ longitude >= -180 &&
+ longitude <= 180
+ );
+}
+
+function gpxPoint(element: Element): GpxPointSource {
+ const latitude = Number(element.getAttribute('lat'));
+ const longitude = Number(element.getAttribute('lon'));
+ const elevation = xmlNumber(xmlChild(element, 'ele'));
+ if (!(validCoordinate(latitude, longitude) && elevation !== undefined)) {
+ throw new Error('Every GPX route point must include valid coordinates and elevation data.');
+ }
+ return {
+ distance: xmlNumber(xmlDescendant(element, 'DistanceKilometers')),
+ elevation,
+ latitude,
+ longitude,
+ };
+}
+
+function customDistancesAreValid(points: GpxPointSource[]): boolean {
+ return points.every((point, index) => {
+ if (point.distance === undefined || point.distance < 0) {
+ return false;
+ }
+ const previous = points[index - 1]?.distance;
+ return previous === undefined
+ ? point.distance <= DISTANCE_EPSILON
+ : point.distance > previous;
+ });
+}
+
+function routePoints(points: GpxPointSource[]): GeographicRoutePoint[] {
+ const useCustomDistances = customDistancesAreValid(points);
+ let totalMeters = 0;
+ return points.map((point, index) => {
+ const previous = points[index - 1];
if (previous) {
- total += distanceBetween(previous.lat, previous.lon, lat, lon);
+ totalMeters += distanceBetween(
+ previous.latitude,
+ previous.longitude,
+ point.latitude,
+ point.longitude
+ );
}
- previous = { lat, lon };
return {
- distance: kilometersForMeters(total),
- elevation: Number(point.querySelector('ele')?.textContent ?? 0),
+ distance: useCustomDistances ? (point.distance ?? 0) : kilometersForMeters(totalMeters),
+ elevation: point.elevation,
+ latitude: point.latitude,
+ longitude: point.longitude,
};
});
}
+
+function parserFailed(xml: Document): boolean {
+ return (
+ xml.documentElement?.localName === 'parsererror' ||
+ xml.getElementsByTagName('parsererror').length > 0
+ );
+}
+
+export function parseGpxDocument(xml: Document): ParsedGpxTrack {
+ if (parserFailed(xml)) {
+ throw new Error('The workout file is not valid GPX XML.');
+ }
+ const root = xml.documentElement;
+ const track = xmlDescendant(root, 'trk');
+ const route = xmlDescendant(root, 'rte');
+ const container = track ?? route;
+ if (!container) {
+ throw new Error('The GPX file does not contain a track or route.');
+ }
+ const pointName = track ? 'trkpt' : 'rtept';
+ const sourcePoints = xmlDescendants(container, pointName).map(gpxPoint);
+ if (sourcePoints.length < MINIMUM_GPX_POINTS) {
+ throw new Error('A workout GPX file needs at least three route points.');
+ }
+ const metadata = xmlDescendant(root, 'metadata');
+ return {
+ description:
+ xmlText(xmlChild(container, 'desc')) || xmlText(xmlChild(metadata ?? root, 'desc')),
+ name: xmlText(xmlChild(container, 'name')) || xmlText(xmlChild(metadata ?? root, 'name')),
+ points: routePoints(sourcePoints),
+ };
+}
+
+export function parseGpx(
+ source: string,
+ parser: DOMParser = new DOMParser()
+): GeographicRoutePoint[] {
+ return parseGpxDocument(parser.parseFromString(source, 'text/xml')).points;
+}
diff --git a/src/lib/metric-presentation.ts b/src/lib/metric-presentation.ts
index 033098b..3ad498d 100644
--- a/src/lib/metric-presentation.ts
+++ b/src/lib/metric-presentation.ts
@@ -39,6 +39,11 @@ export const METRIC_PRESENTATION = {
export const STANDARD_METRIC_KEYS = ['power', 'cadence', 'heartRate'] as const;
+export const ELEVATION_METRIC_PRESENTATION = {
+ chartColor: '#fb923c',
+ label: 'Elevation',
+} as const;
+
export function metricAccentClass(accent: string): string {
if (accent === 'sky') {
return 'bg-sky-400';
diff --git a/src/lib/saved-sessions.ts b/src/lib/saved-sessions.ts
index e08355d..b6d3eb2 100644
--- a/src/lib/saved-sessions.ts
+++ b/src/lib/saved-sessions.ts
@@ -5,6 +5,7 @@ import type {
SessionMetadata,
SessionSnapshot,
} from '../types';
+import { restoreElevationTotals } from './elevation';
import {
aggregateGear,
aggregateResistance,
@@ -12,7 +13,8 @@ import {
restoreAggregate,
} from './session';
import { IMPORTED_TCX_ID_PREFIX } from './tcx-schema';
-import { isFiniteNumber } from './type-guards';
+import { isFiniteNumber, isString } from './type-guards';
+import { restoreSessionWorkout } from './workouts';
const DATABASE_NAME = 'ridecontrol-sessions';
const DATABASE_VERSION = 1;
@@ -110,6 +112,7 @@ export function sessionSummary(session: SavedSession): SavedSessionSummary {
feeling: session.feeling,
id: session.id,
...(session.importedAt === undefined ? {} : { importedAt: session.importedAt }),
+ ...(session.workout ? { workoutName: session.workout.course.name } : {}),
startedAt: session.startedAt,
};
}
@@ -119,7 +122,11 @@ function normalizedImportedAt(importedAt: unknown): number | undefined {
}
export function normalizeSavedSessionSummary(session: SavedSessionSummary): SavedSessionSummary {
- return { ...session, importedAt: normalizedImportedAt(session.importedAt) };
+ return {
+ ...session,
+ importedAt: normalizedImportedAt(session.importedAt),
+ workoutName: isString(session.workoutName) ? session.workoutName : undefined,
+ };
}
export function isImportedSession(
@@ -191,7 +198,9 @@ export function normalizeSavedSession(session: SavedSession): SavedSession {
),
},
controlMode: controlModeForHistory(session.history, session.controlMode),
+ elevationTotals: restoreElevationTotals(session.elevationTotals, session.history),
importedAt: normalizedImportedAt(session.importedAt),
+ workout: restoreSessionWorkout(session.workout),
};
}
diff --git a/src/lib/session-workflow.ts b/src/lib/session-workflow.ts
index fd4e46b..438c97c 100644
--- a/src/lib/session-workflow.ts
+++ b/src/lib/session-workflow.ts
@@ -13,6 +13,11 @@ export interface SessionWorkflowController {
startNew: () => void;
}
+export function finishRideSession(endSession: () => void, settleTrainerResistance: () => void) {
+ endSession();
+ settleTrainerResistance();
+}
+
export const SESSION_WORKFLOW_INTENT = {
CONTINUE: 'continue',
END: 'end',
diff --git a/src/lib/session.ts b/src/lib/session.ts
index d1e40bb..c2e75aa 100644
--- a/src/lib/session.ts
+++ b/src/lib/session.ts
@@ -8,10 +8,12 @@ import type {
StoredSession,
} from '../types';
import { CONTROL_MODE, type ControlMode, isControlMode } from './control-mode';
+import { restoreElevationTotals } from './elevation';
import { clampGear, MAX_GEAR, MIN_GEAR } from './gears';
import { clamp, nonNegativeNumber } from './numbers';
import { clampResistance, DEFAULT_RESISTANCE, MAX_RESISTANCE, MIN_RESISTANCE } from './resistance';
import { isFiniteNumber, isString } from './type-guards';
+import { restoreSessionWorkout } from './workouts';
export const SESSION_STORAGE_KEY = 'trainer-session';
export const RESISTANCE_STORAGE_KEY = 'trainer-resistance-percent';
@@ -26,11 +28,13 @@ export function sessionContinuation(snapshot: SessionSnapshot): StoredSession {
discarded: false,
distance: snapshot.distance,
elapsedSeconds: snapshot.elapsedSeconds,
+ elevationTotals: snapshot.elevationTotals,
ended: false,
endedAt: 0,
history: snapshot.history,
maximums: snapshot.maximums,
startedAt: snapshot.startedAt,
+ workout: snapshot.workout,
};
}
@@ -129,6 +133,14 @@ function optionalControlValue(value: unknown, minimum: number, maximum: number)
return clamp(value, minimum, maximum);
}
+function optionalNonNegativeNumber(value: unknown): number | undefined {
+ return isFiniteNumber(value) ? nonNegativeNumber(value) : undefined;
+}
+
+function optionalWorkoutLap(value: unknown): number | undefined {
+ return isFiniteNumber(value) ? Math.max(1, Math.floor(value)) : undefined;
+}
+
export function controlModeForHistory(
history: Partial>[],
savedMode?: ControlMode
@@ -153,7 +165,9 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor
? parsed.history.slice(-MAX_SESSION_HISTORY_SAMPLES).map((sample) => ({
cadence: nonNegativeNumber(sample.cadence),
elapsedSeconds: nonNegativeNumber(sample.elapsedSeconds),
+ elevation: optionalNonNegativeNumber(sample.elevation),
gear: optionalControlValue(sample.gear, MIN_GEAR, MAX_GEAR),
+ grade: optionalControlValue(sample.grade, -15, 15),
heartRate: nonNegativeNumber(sample.heartRate),
power: nonNegativeNumber(sample.power),
resistance: optionalControlValue(
@@ -162,6 +176,8 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor
MAX_RESISTANCE
),
speed: nonNegativeNumber(sample.speed),
+ workoutDistance: optionalNonNegativeNumber(sample.workoutDistance),
+ workoutLap: optionalWorkoutLap(sample.workoutLap),
}))
: [];
const historyAggregates = history.reduce(addMetricAggregates, emptySession.aggregates);
@@ -184,6 +200,7 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor
discarded: parsed.discarded === true,
distance: nonNegativeNumber(parsed.distance),
elapsedSeconds: nonNegativeNumber(parsed.elapsedSeconds),
+ elevationTotals: restoreElevationTotals(parsed.elevationTotals, history),
ended: parsed.ended === true,
endedAt: nonNegativeNumber(parsed.endedAt),
history,
@@ -195,8 +212,10 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor
power: nonNegativeNumber(maximums.power),
speed: nonNegativeNumber(maximums.speed),
},
+ plannedWorkout: restoreSessionWorkout(parsed.plannedWorkout),
savedSessionId: isString(parsed.savedSessionId) ? parsed.savedSessionId : undefined,
startedAt: nonNegativeNumber(parsed.startedAt),
+ workout: restoreSessionWorkout(parsed.workout),
};
} catch {
return emptySession;
diff --git a/src/lib/tcx-import.ts b/src/lib/tcx-import.ts
index 619965b..9fbdbba 100644
--- a/src/lib/tcx-import.ts
+++ b/src/lib/tcx-import.ts
@@ -6,8 +6,11 @@ import type {
SavedSession,
SessionAggregates,
SessionFeeling,
+ SessionWorkout,
} from '../types';
+import { evenlySample } from './arrays';
import { CONTROL_MODE } from './control-mode';
+import { elevationTotalsForSamples } from './elevation';
import { errorMessage } from './errors';
import { clampGear } from './gears';
import { nonNegativeNumber } from './numbers';
@@ -20,13 +23,22 @@ import {
kilometersForMeters,
secondsForMilliseconds,
} from './units';
+import { restoreSessionWorkout } from './workouts';
+import {
+ xmlChild as child,
+ childElements,
+ xmlDescendant as descendant,
+ xmlDescendants as descendants,
+ elementName,
+ xmlNumber as numberValue,
+ xmlText as text,
+} from './xml';
const TCX_FILE_EXTENSION = /\.tcx$/i;
const ZIP_FILE_EXTENSION = /\.zip$/i;
const MAX_TCX_FILES_PER_IMPORT = 500;
const MAX_TCX_FILE_BYTES = 20 * 1024 * 1024;
const MAX_TCX_ARCHIVE_BYTES = 100 * 1024 * 1024;
-const ELEMENT_NODE = 1;
const LINE_BREAK = /\r?\n/;
const FEELING_NOTE = /^Feeling:\s*(.+)$/i;
const COMMENTS_NOTE_PREFIX = /^Comments:\s*/i;
@@ -59,39 +71,6 @@ const DEFAULT_IMPORT_DEPENDENCIES: ImportDependencies = {
saveSession,
};
-function elementName(element: Element): string {
- return element.localName || element.nodeName.split(':').at(-1) || element.nodeName;
-}
-
-function childElements(element: Element): Element[] {
- return Array.from(element.childNodes).filter(
- (node): node is Element => node.nodeType === ELEMENT_NODE
- );
-}
-
-function child(element: Element, name: string): Element | undefined {
- return childElements(element).find((candidate) => elementName(candidate) === name);
-}
-
-function descendants(element: Element, name: string): Element[] {
- return Array.from(element.getElementsByTagName('*')).filter(
- (candidate) => elementName(candidate) === name
- );
-}
-
-function descendant(element: Element, name: string): Element | undefined {
- return descendants(element, name)[0];
-}
-
-function text(element: Element | undefined): string {
- return element?.textContent?.trim() ?? '';
-}
-
-function numberValue(element: Element | undefined): number | undefined {
- const value = Number(text(element));
- return Number.isFinite(value) ? value : undefined;
-}
-
function dateValue(value: string): number | undefined {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : undefined;
@@ -105,16 +84,6 @@ function maximum(values: number[]): number {
return values.reduce((highest, value) => Math.max(highest, value), 0);
}
-function evenlySample(values: T[], limit: number): T[] {
- if (values.length <= limit) {
- return values;
- }
- return Array.from({ length: limit }, (_, index) => {
- const sourceIndex = Math.round((index * (values.length - 1)) / (limit - 1));
- return values[sourceIndex] as T;
- });
-}
-
function notesMetadata(activity: Element): Pick {
const notes = text(child(activity, 'Notes'));
if (!notes) {
@@ -164,12 +133,18 @@ function trackpointSample(
: 0;
const gear = numberValue(descendant(trackpoint, 'Gear'));
const resistance = numberValue(descendant(trackpoint, 'Resistance'));
+ const elevation = numberValue(child(trackpoint, 'AltitudeMeters'));
+ const grade = numberValue(descendant(trackpoint, 'Grade'));
+ const workoutDistance = numberValue(descendant(trackpoint, 'WorkoutDistance'));
+ const workoutLap = numberValue(descendant(trackpoint, 'WorkoutLap'));
return {
distanceMeters,
sample: {
cadence: Math.max(0, numberValue(child(trackpoint, 'Cadence')) ?? 0),
elapsedSeconds,
+ elevation: elevation === undefined ? undefined : Math.max(0, elevation),
gear: gear === undefined ? undefined : clampGear(gear),
+ grade,
heartRate: Math.max(
0,
numberValue(descendant(child(trackpoint, 'HeartRateBpm') ?? trackpoint, 'Value')) ??
@@ -181,11 +156,43 @@ function trackpointSample(
recordedSpeed === undefined
? calculatedSpeed
: Math.max(0, recordedSpeed) * KILOMETERS_PER_HOUR_PER_METER_PER_SECOND,
+ workoutDistance:
+ workoutDistance === undefined ? undefined : Math.max(0, workoutDistance),
+ workoutLap: workoutLap === undefined ? undefined : Math.max(1, Math.round(workoutLap)),
},
timestamp,
};
}
+function activityWorkout(activity: Element): SessionWorkout | undefined {
+ const workout = descendant(activity, 'Workout');
+ if (!workout) {
+ return;
+ }
+ const points = childElements(workout)
+ .filter((element) => elementName(element) === 'Point')
+ .map((point) => ({
+ distance: numberValue(child(point, 'Distance')),
+ elevation: numberValue(child(point, 'Elevation')),
+ latitude: numberValue(child(point, 'Latitude')),
+ longitude: numberValue(child(point, 'Longitude')),
+ x: numberValue(child(point, 'X')),
+ y: numberValue(child(point, 'Y')),
+ }));
+ return restoreSessionWorkout({
+ course: {
+ baseResistance: numberValue(child(workout, 'BaseResistance')),
+ description: text(child(workout, 'Description')),
+ difficulty: text(child(workout, 'Difficulty')),
+ distance: numberValue(child(workout, 'Distance')),
+ id: text(child(workout, 'CourseId')),
+ name: text(child(workout, 'Name')),
+ points,
+ routeType: text(child(workout, 'CourseType')) || undefined,
+ },
+ });
+}
+
function fallbackAggregate(laps: Element[], names: string[]): MetricAggregate {
const values = laps
.map((lap) =>
@@ -293,6 +300,9 @@ function parseActivity(activity: Element): SavedSession {
fallbackAggregate(laps, ['AverageHeartRateBpm'])
);
aggregates = withAggregateFallback(aggregates, 'power', fallbackAggregate(laps, ['AvgWatts']));
+ const sampledElevationTotals = elevationTotalsForSamples(allSamples);
+ const recordedAscent = numberValue(descendant(activity, 'TotalAscentMeters'));
+ const recordedDescent = numberValue(descendant(activity, 'TotalDescentMeters'));
return {
...notesMetadata(activity),
@@ -301,6 +311,10 @@ function parseActivity(activity: Element): SavedSession {
controlMode: hasGear ? CONTROL_MODE.GEAR : CONTROL_MODE.RESISTANCE,
distance: kilometersForMeters(distanceMeters),
elapsedSeconds,
+ elevationTotals: {
+ ascent: nonNegativeNumber(recordedAscent ?? sampledElevationTotals.ascent),
+ descent: nonNegativeNumber(recordedDescent ?? sampledElevationTotals.descent),
+ },
endedAt,
history: evenlySample(allSamples, MAX_SESSION_HISTORY_SAMPLES),
id:
@@ -330,6 +344,7 @@ function parseActivity(activity: Element): SavedSession {
),
},
startedAt,
+ workout: activityWorkout(activity),
};
}
diff --git a/src/lib/tcx.ts b/src/lib/tcx.ts
index d5bbb8b..a281bbf 100644
--- a/src/lib/tcx.ts
+++ b/src/lib/tcx.ts
@@ -1,4 +1,4 @@
-import type { MetricSample, SavedSession } from '../types';
+import type { MetricSample, SavedSession, SessionWorkout } from '../types';
import { CONTROL_MODE } from './control-mode';
import { downloadBrowserFile } from './download';
import { aggregateAverage, aggregateMaximum } from './format';
@@ -9,15 +9,7 @@ import {
TCX_NAMESPACE,
} from './tcx-schema';
import { metersForKilometers, metersPerSecond, millisecondsForSeconds } from './units';
-
-function xmlEscape(value: string): string {
- return value
- .replaceAll('&', '&')
- .replaceAll('<', '<')
- .replaceAll('>', '>')
- .replaceAll('"', '"')
- .replaceAll("'", ''');
-}
+import { xmlEscape } from './xml';
function trackpointDistances(session: SavedSession): number[] {
let elapsed = 0;
@@ -46,14 +38,29 @@ function trackpointDistances(session: SavedSession): number[] {
function trackpointXml(sample: MetricSample, timestamp: number, distanceMeters: number): string {
const heartRate = nonNegativeNumber(sample.heartRate);
+ const altitude =
+ sample.elevation === undefined
+ ? ''
+ : `\n\t\t\t\t\t\t${nonNegativeNumber(sample.elevation).toFixed(2)} `;
const controlExtension =
sample.gear === undefined
? `${nonNegativeNumber(sample.resistance).toFixed(1)} `
: `${Math.round(nonNegativeNumber(sample.gear))} `;
+ const workoutExtensions = [
+ sample.grade === undefined ? '' : `${sample.grade.toFixed(2)} `,
+ sample.workoutDistance === undefined
+ ? ''
+ : `${nonNegativeNumber(sample.workoutDistance).toFixed(3)} `,
+ sample.workoutLap === undefined
+ ? ''
+ : `${Math.max(1, Math.round(sample.workoutLap))} `,
+ ]
+ .filter(Boolean)
+ .join('\n\t\t\t\t\t\t\t');
return `
${new Date(timestamp).toISOString()}
- ${distanceMeters.toFixed(3)} ${
+ ${distanceMeters.toFixed(3)} ${altitude}${
heartRate > 0
? `
${Math.round(heartRate)} `
@@ -66,11 +73,41 @@ function trackpointXml(sample: MetricSample, timestamp: number, distanceMeters:
${metersPerSecond(nonNegativeNumber(sample.speed)).toFixed(3)}
${Math.round(nonNegativeNumber(sample.power))}
- ${controlExtension}
+ ${controlExtension}${workoutExtensions ? `\n\t\t\t\t\t\t\t${workoutExtensions}` : ''}
`;
}
+function workoutSummaryXml(workout?: SessionWorkout): string {
+ if (!workout) {
+ return '';
+ }
+ const { course } = workout;
+ const points = course.points
+ .map(
+ (point) => `
+
+ ${point.distance.toFixed(3)}
+ ${point.elevation.toFixed(2)}
+ ${point.latitude.toFixed(8)}
+ ${point.longitude.toFixed(8)}
+ ${point.x.toFixed(2)}
+ ${point.y.toFixed(2)}
+ `
+ )
+ .join('');
+ return `
+
+ ${xmlEscape(course.id)}
+ ${xmlEscape(course.name)}
+ ${xmlEscape(course.description)}
+ ${course.difficulty}
+ ${course.baseResistance.toFixed(1)}
+ ${course.routeType}
+ ${course.distance.toFixed(3)} ${points}
+ `;
+}
+
export function sessionToTcx(session: SavedSession): string {
const startedAt = new Date(session.startedAt).toISOString();
const distances = trackpointDistances(session);
@@ -135,7 +172,9 @@ export function sessionToTcx(session: SavedSession): string {
${xmlEscape(session.id)}
- ${controlSummary}
+ ${nonNegativeNumber(session.elevationTotals.ascent).toFixed(2)}
+ ${nonNegativeNumber(session.elevationTotals.descent).toFixed(2)}
+ ${controlSummary}${workoutSummaryXml(session.workout)}
${
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/src/lib/units.ts b/src/lib/units.ts
index 0a5056d..cb58204 100644
--- a/src/lib/units.ts
+++ b/src/lib/units.ts
@@ -4,6 +4,7 @@ export const MILLISECONDS_PER_SECOND = 1000;
export const SECONDS_PER_MINUTE = 60;
export const SECONDS_PER_HOUR = 3600;
export const METERS_PER_KILOMETER = 1000;
+export const METERS_PER_FOOT = 0.3048;
export const KILOMETERS_PER_MILE = 1.609_344;
export const KILOMETERS_PER_HOUR_PER_METER_PER_SECOND = 3.6;
export const SPEED_UNIT_STORAGE_KEY = 'speed-unit';
@@ -25,6 +26,10 @@ export function speedUnitLabel(unit: SpeedUnit): 'km/h' | 'mph' {
return unit === 'mph' ? 'mph' : 'km/h';
}
+export function elevationUnitLabel(unit: SpeedUnit): 'ft' | 'm' {
+ return unit === 'mph' ? 'ft' : 'm';
+}
+
export function minimumSpeedChartMaximum(unit: SpeedUnit): number {
return unit === 'mph' ? 20 : 30;
}
@@ -37,6 +42,10 @@ export function convertSpeed(kilometersPerHour: number, unit: SpeedUnit): number
return unit === 'mph' ? kilometersPerHour / KILOMETERS_PER_MILE : kilometersPerHour;
}
+export function convertElevation(meters: number, unit: SpeedUnit): number {
+ return unit === 'mph' ? meters / METERS_PER_FOOT : meters;
+}
+
export function formatDistanceValue(kilometers: number, unit: SpeedUnit, decimals = 2): string {
return convertDistance(kilometers, unit).toFixed(decimals);
}
@@ -45,6 +54,21 @@ export function formatDistance(kilometers: number, unit: SpeedUnit, decimals = 2
return `${formatDistanceValue(kilometers, unit, decimals)} ${distanceUnitLabel(unit)}`;
}
+export function formatDistanceProgress(
+ currentKilometers: number,
+ totalKilometers: number,
+ unit: SpeedUnit
+): string {
+ const current = formatDistanceValue(currentKilometers, unit, 1);
+ const formattedTotal = formatDistanceValue(totalKilometers, unit, 1);
+ const total = formattedTotal.endsWith('.0') ? formattedTotal.slice(0, -2) : formattedTotal;
+ return `${current} / ${total} ${distanceUnitLabel(unit)}`;
+}
+
+export function formatElevation(meters: number, unit: SpeedUnit, decimals = 0): string {
+ return `${convertElevation(meters, unit).toFixed(decimals)} ${elevationUnitLabel(unit)}`;
+}
+
export function formatSpeed(kilometersPerHour: number, unit: SpeedUnit, decimals = 1): string {
return convertSpeed(kilometersPerHour, unit).toFixed(decimals);
}
diff --git a/src/lib/workout-file.ts b/src/lib/workout-file.ts
new file mode 100644
index 0000000..de4bd3f
--- /dev/null
+++ b/src/lib/workout-file.ts
@@ -0,0 +1,281 @@
+import type { GeographicRoutePoint, WorkoutCourse } from '../types';
+import { evenlySample } from './arrays';
+import { downloadBrowserFile } from './download';
+import { parseGpxDocument } from './gpx';
+import { isRecord } from './type-guards';
+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 = 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;
+
+interface WorkoutLibraryData {
+ courses: WorkoutCourse[];
+ format: typeof WORKOUT_LIBRARY_FORMAT;
+ version: typeof WORKOUT_LIBRARY_VERSION;
+}
+
+function workoutSlug(course: Pick): string {
+ const slug = (value: string) =>
+ value
+ .normalize('NFKD')
+ .toLowerCase()
+ .replace(NON_FILENAME_CHARACTERS, '-')
+ .replace(EDGE_HYPHENS, '');
+ return slug(course.name) || slug(course.id) || 'workout';
+}
+
+function restoredCourses(value: unknown): WorkoutCourse[] {
+ if (!(isRecord(value) && Array.isArray(value.courses))) {
+ return [];
+ }
+ const uniqueIds = new Set();
+ return value.courses
+ .slice(0, MAX_CUSTOM_WORKOUTS)
+ .map(restoreWorkoutCourse)
+ .filter((course): course is WorkoutCourse => {
+ if (!course || uniqueIds.has(course.id) || workoutIsBuiltIn(course.id)) {
+ return false;
+ }
+ uniqueIds.add(course.id);
+ return true;
+ });
+}
+
+function gpxPointXml(point: GeographicRoutePoint): string {
+ return `
+
+ ${point.elevation.toFixed(2)}
+
+ ${point.distance.toFixed(6)}
+
+ `;
+}
+
+function routeFingerprint(points: GeographicRoutePoint[]): string {
+ const source = points
+ .map(
+ (point) =>
+ `${point.latitude.toFixed(6)},${point.longitude.toFixed(6)},${point.elevation.toFixed(1)}`
+ )
+ .join('|');
+ let hash = 2_166_136_261;
+ for (let index = 0; index < source.length; index += 1) {
+ hash ^= source.charCodeAt(index);
+ hash = Math.imul(hash, 16_777_619);
+ }
+ return `gpx-${(hash >>> 0).toString(16).padStart(8, '0')}`;
+}
+
+function workoutMetadata(
+ container: Element,
+ points: GeographicRoutePoint[]
+): {
+ 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,
+ };
+}
+
+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);
+}
+
+export function workoutFileContents(course: WorkoutCourse): string {
+ const points = course.points.map(gpxPointXml).join('');
+ return `
+
+
+ ${xmlEscape(course.name)}
+ ${xmlEscape(course.description)}
+
+
+ ${xmlEscape(course.name)}
+ ${xmlEscape(course.description)}
+ Cycling
+
+ ${WORKOUT_GPX_FORMAT_VERSION}
+ ${xmlEscape(course.id)}
+ ${course.difficulty}
+ ${course.baseResistance.toFixed(1)}
+ ${course.routeType}
+
+ ${points}
+
+
+
+`;
+}
+
+export function workoutFilename(course: Pick): string {
+ return `ride-control-${workoutSlug(course)}.gpx`;
+}
+
+export function downloadWorkoutFile(course: WorkoutCourse): void {
+ downloadBrowserFile(workoutFileContents(course), workoutFilename(course), WORKOUT_MIME_TYPE);
+}
+
+export function parseWorkoutFile(
+ source: string,
+ parser: DOMParser = new DOMParser(),
+ fallbackName = 'Imported GPX workout'
+): WorkoutCourse {
+ const xml = parser.parseFromString(source, 'text/xml');
+ const parsed = parseGpxDocument(xml);
+ const root = xml.documentElement;
+ const container = xmlDescendant(root, 'trk') ?? xmlDescendant(root, 'rte');
+ if (!container) {
+ throw new Error('The GPX file does not contain a track or route.');
+ }
+ const sourcePoints = parsed.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,
+ description: parsed.description || 'Imported from a GPX route with elevation data.',
+ difficulty: metadata.difficulty,
+ distance,
+ id: metadata.id,
+ name: parsed.name || fallbackName,
+ points,
+ routeType,
+ });
+ if (!course) {
+ throw new Error(
+ 'The GPX route must describe a valid loop or out-and-back course with increasing distance and elevation data.'
+ );
+ }
+ return course;
+}
+
+export async function readWorkoutFile(file: Pick): Promise {
+ return parseWorkoutFile(await file.text(), new DOMParser(), workoutNameFromFile(file.name));
+}
+
+export function loadCustomWorkouts(
+ storage: Pick = localStorage
+): WorkoutCourse[] {
+ try {
+ const saved = storage.getItem(CUSTOM_WORKOUTS_STORAGE_KEY);
+ if (!saved) {
+ return [];
+ }
+ const parsed: unknown = JSON.parse(saved);
+ if (
+ !(
+ isRecord(parsed) &&
+ parsed.format === WORKOUT_LIBRARY_FORMAT &&
+ parsed.version === WORKOUT_LIBRARY_VERSION
+ )
+ ) {
+ return [];
+ }
+ return restoredCourses(parsed);
+ } catch {
+ return [];
+ }
+}
+
+export function saveCustomWorkouts(
+ courses: WorkoutCourse[],
+ storage: Pick = localStorage
+): void {
+ const library: WorkoutLibraryData = {
+ courses: courses.slice(0, MAX_CUSTOM_WORKOUTS),
+ format: WORKOUT_LIBRARY_FORMAT,
+ version: WORKOUT_LIBRARY_VERSION,
+ };
+ storage.setItem(CUSTOM_WORKOUTS_STORAGE_KEY, JSON.stringify(library));
+}
+
+export function addCustomWorkout(
+ courses: WorkoutCourse[],
+ course: WorkoutCourse
+): { course: WorkoutCourse; courses: WorkoutCourse[] } {
+ if (workoutIsBuiltIn(course.id)) {
+ throw new Error(`${course.name} is already included with Ride Control.`);
+ }
+ const duplicate = courses.find((existing) => existing.id === course.id);
+ if (duplicate) {
+ throw new Error(`${duplicate.name} has already been imported.`);
+ }
+ return {
+ course,
+ courses: [course, ...courses].slice(0, MAX_CUSTOM_WORKOUTS),
+ };
+}
+
+export function withoutCustomWorkout(courses: WorkoutCourse[], workoutId: string): WorkoutCourse[] {
+ return courses.filter((course) => course.id !== workoutId);
+}
diff --git a/src/lib/workout-schema.ts b/src/lib/workout-schema.ts
new file mode 100644
index 0000000..5b53655
--- /dev/null
+++ b/src/lib/workout-schema.ts
@@ -0,0 +1,33 @@
+export const WORKOUT_DIFFICULTY = {
+ CHALLENGING: 'challenging',
+ GENTLE: 'gentle',
+ MODERATE: 'moderate',
+} as const;
+
+export type WorkoutDifficulty = (typeof WORKOUT_DIFFICULTY)[keyof typeof WORKOUT_DIFFICULTY];
+
+const WORKOUT_DIFFICULTIES = new Set(Object.values(WORKOUT_DIFFICULTY));
+
+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',
+} as const;
+
+export type WorkoutView = (typeof WORKOUT_VIEW)[keyof typeof WORKOUT_VIEW];
diff --git a/src/lib/workouts.ts b/src/lib/workouts.ts
new file mode 100644
index 0000000..1ce7069
--- /dev/null
+++ b/src/lib/workouts.ts
@@ -0,0 +1,983 @@
+import { emptyElevationTotals } from '../constants';
+import type {
+ ElevationTotals,
+ GeographicRoutePoint,
+ RoutePoint,
+ SessionWorkout,
+ WorkoutCourse,
+ WorkoutRoutePoint,
+ WorkoutTerrain,
+} from '../types';
+import cedarCircuitDefinition from '../workouts/cedar-circuit.json';
+import graniteSwitchbacksDefinition from '../workouts/granite-switchbacks.json';
+import harborRingDefinition from '../workouts/harbor-ring.json';
+import highlandLoopDefinition from '../workouts/highland-loop.json';
+import prairieRollDefinition from '../workouts/prairie-roll.json';
+import ridgelineTimeTrialDefinition from '../workouts/ridgeline-time-trial.json';
+import { elevationTotalsForSamples } from './elevation';
+import { distanceBetween } from './gpx';
+import { clamp, nonNegativeNumber } from './numbers';
+import { clampResistance } from './resistance';
+import { isFiniteNumber, isRecord, isString } from './type-guards';
+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;
+const MAP_PADDING = 8;
+const GENERATED_COURSE_LATITUDE = 39;
+const GENERATED_COURSE_LONGITUDE = -105;
+const METERS_PER_LATITUDE_DEGREE = 111_320;
+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 {
+ x: number;
+ y: number;
+}
+
+interface BuiltInWorkoutDefinition {
+ baseResistance: number;
+ description: string;
+ difficulty: string;
+ distance: number;
+ id: string;
+ name: string;
+ points: CourseMapPoint[];
+ routeType: string;
+}
+
+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: RoutePoint[]): number {
+ return elevationTotalsForSamples(points).ascent;
+}
+
+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[] {
+ const centerLatitude = points.reduce((sum, point) => sum + point.latitude, 0) / points.length;
+ const longitudeScale = Math.cos((centerLatitude * Math.PI) / 180);
+ const projected = points.map((point) => ({
+ point,
+ x: point.longitude * longitudeScale,
+ y: point.latitude,
+ }));
+ const xValues = projected.map(({ x }) => x);
+ const yValues = projected.map(({ y }) => y);
+ const minimumX = Math.min(...xValues);
+ const maximumX = Math.max(...xValues);
+ const minimumY = Math.min(...yValues);
+ const maximumY = Math.max(...yValues);
+ const width = maximumX - minimumX;
+ const height = maximumY - minimumY;
+ const available = MAP_MAXIMUM - MAP_MINIMUM - MAP_PADDING * 2;
+ const scale = available / Math.max(width, height, ROUTE_VALUE_EPSILON);
+ const centerX = (minimumX + maximumX) / 2;
+ const centerY = (minimumY + maximumY) / 2;
+ return projected.map(({ point, x, y }) => ({
+ ...point,
+ x: 50 + (x - centerX) * scale,
+ y: 50 - (y - centerY) * scale,
+ }));
+}
+
+function geographicPointsForMap(
+ distance: number,
+ points: CourseMapPoint[]
+): GeographicRoutePoint[] {
+ const mapLength = points.slice(1).reduce((sum, point, index) => {
+ const previous = points[index];
+ return previous ? sum + Math.hypot(point.x - previous.x, point.y - previous.y) : sum;
+ }, 0);
+ const metersPerMapUnit = (distance * 1000) / Math.max(mapLength, ROUTE_VALUE_EPSILON);
+ const metersPerLongitudeDegree =
+ METERS_PER_LATITUDE_DEGREE * Math.cos((GENERATED_COURSE_LATITUDE * Math.PI) / 180);
+ return points.map((point) => ({
+ distance: point.distance,
+ elevation: point.elevation,
+ latitude:
+ GENERATED_COURSE_LATITUDE -
+ ((point.y - 50) * metersPerMapUnit) / METERS_PER_LATITUDE_DEGREE,
+ longitude:
+ GENERATED_COURSE_LONGITUDE +
+ ((point.x - 50) * metersPerMapUnit) / metersPerLongitudeDegree,
+ }));
+}
+
+function createGeographicCourse(
+ id: string,
+ name: string,
+ description: string,
+ difficulty: WorkoutDifficulty,
+ distance: number,
+ points: GeographicRoutePoint[],
+ baseResistance = DEFAULT_TERRAIN_RESISTANCE,
+ routeType: WorkoutRouteType = WORKOUT_ROUTE_TYPE.LOOP
+): WorkoutCourse {
+ const [first] = points;
+ const rolloutDistance = flatStartDistanceForElevationGain(elevationGain(points));
+ const terrainPoints = mapCoordinates(
+ withFlatCourseStart(
+ first
+ ? points.map((point, index) =>
+ index === points.length - 1
+ ? {
+ ...point,
+ elevation: first.elevation,
+ latitude: first.latitude,
+ longitude: first.longitude,
+ }
+ : point
+ )
+ : points,
+ distance,
+ routeType,
+ rolloutDistance
+ )
+ );
+ return {
+ baseResistance,
+ description,
+ difficulty,
+ distance,
+ elevationGain: elevationGain(terrainPoints),
+ id,
+ name,
+ points: terrainPoints,
+ routeType,
+ };
+}
+
+function createBuiltInCourse(definition: BuiltInWorkoutDefinition): WorkoutCourse {
+ if (!(isWorkoutDifficulty(definition.difficulty) && isWorkoutRouteType(definition.routeType))) {
+ throw new Error(`Invalid built-in workout definition: ${definition.id}`);
+ }
+ return createGeographicCourse(
+ definition.id,
+ definition.name,
+ definition.description,
+ definition.difficulty,
+ definition.distance,
+ geographicPointsForMap(definition.distance, definition.points),
+ definition.baseResistance,
+ definition.routeType
+ );
+}
+
+const BUILT_IN_WORKOUT_DEFINITIONS = [
+ harborRingDefinition,
+ prairieRollDefinition,
+ cedarCircuitDefinition,
+ highlandLoopDefinition,
+ graniteSwitchbacksDefinition,
+ ridgelineTimeTrialDefinition,
+] satisfies BuiltInWorkoutDefinition[];
+
+export const WORKOUT_COURSES: WorkoutCourse[] =
+ BUILT_IN_WORKOUT_DEFINITIONS.map(createBuiltInCourse);
+
+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;
+ }
+ return nonNegativeNumber(totalDistance) % courseDistance;
+}
+
+function segmentAtDistance(course: WorkoutCourse, distance: number) {
+ const position = loopDistance(course.distance, distance);
+ const nextIndex = course.points.findIndex((point) => point.distance >= position);
+ const rightIndex = Math.max(1, nextIndex < 0 ? course.points.length - 1 : nextIndex);
+ const left = course.points[rightIndex - 1] ?? course.points[0];
+ const right = course.points[rightIndex] ?? course.points.at(-1);
+ if (!(left && right)) {
+ return {
+ left: {
+ distance: 0,
+ elevation: 0,
+ latitude: 0,
+ longitude: 0,
+ x: 50,
+ y: 50,
+ },
+ position: 0,
+ right: undefined,
+ };
+ }
+ return { left, position, right };
+}
+
+function coursePointAtDistance(course: WorkoutCourse, distance: number): WorkoutRoutePoint {
+ const { left, position, right } = segmentAtDistance(course, distance);
+ if (!right || right.distance === left.distance) {
+ return left;
+ }
+ const segmentProgress = (position - left.distance) / (right.distance - left.distance);
+ const mapPosition = workoutMapPosition(course, position);
+ return {
+ distance: position,
+ elevation: curveValueAtX(
+ course.points.map((point) => ({ x: point.distance, y: point.elevation })),
+ position
+ ),
+ latitude: left.latitude + (right.latitude - left.latitude) * segmentProgress,
+ longitude: left.longitude + (right.longitude - left.longitude) * segmentProgress,
+ x: mapPosition.x,
+ y: mapPosition.y,
+ };
+}
+
+export function workoutProgress(course: WorkoutCourse, totalDistance: number): number {
+ return course.distance <= 0
+ ? 0
+ : loopDistance(course.distance, totalDistance) / course.distance;
+}
+
+export function workoutSelectionLocked({
+ elapsedSeconds,
+ ended,
+ isRiding,
+}: {
+ elapsedSeconds: number;
+ ended: boolean;
+ isRiding: boolean;
+}): boolean {
+ return !ended && (isRiding || elapsedSeconds > 0);
+}
+
+export function workoutLap(course: WorkoutCourse, totalDistance: number): number {
+ return workoutCompletedLaps(course, totalDistance) + 1;
+}
+
+export function workoutCompletedLaps(course: WorkoutCourse, totalDistance: number): number {
+ return course.distance <= 0
+ ? 0
+ : Math.floor(nonNegativeNumber(totalDistance) / course.distance);
+}
+
+export function workoutElevationTotalsAtDistance(
+ course: WorkoutCourse,
+ totalDistance: number
+): ElevationTotals {
+ const completedLaps = workoutCompletedLaps(course, totalDistance);
+ const fullLap = elevationTotalsForSamples(course.points);
+ const position = loopDistance(course.distance, totalDistance);
+ const current = coursePointAtDistance(course, position);
+ const partialLap = elevationTotalsForSamples([
+ ...course.points.filter((point) => point.distance < position),
+ current,
+ ]);
+ return {
+ ascent: fullLap.ascent * completedLaps + partialLap.ascent,
+ descent: fullLap.descent * completedLaps + partialLap.descent,
+ };
+}
+
+export function workoutMaximumGrade(course: WorkoutCourse): number {
+ return course.points.reduce((maximum, point, index) => {
+ const previous = course.points[index - 1];
+ if (!previous || point.distance <= previous.distance) {
+ return maximum;
+ }
+ const grade =
+ ((point.elevation - previous.elevation) /
+ ((point.distance - previous.distance) * 1000)) *
+ 100;
+ return Math.max(maximum, grade);
+ }, 0);
+}
+
+export function workoutTerrainAtDistance(
+ course: WorkoutCourse,
+ totalDistance: number
+): WorkoutTerrain {
+ const distance = loopDistance(course.distance, totalDistance);
+ const point = coursePointAtDistance(course, distance);
+ const lookAheadDistance = Math.min(0.15, course.distance / 20);
+ const ahead = coursePointAtDistance(course, distance + lookAheadDistance);
+ const grade =
+ lookAheadDistance > 0
+ ? clamp(
+ ((ahead.elevation - point.elevation) / (lookAheadDistance * 1000)) * 100,
+ -15,
+ 15
+ )
+ : 0;
+ const resistance = clampResistance(
+ Math.round(
+ clamp(
+ course.baseResistance + grade * RESISTANCE_PER_GRADE_PERCENT,
+ MIN_TERRAIN_RESISTANCE,
+ MAX_TERRAIN_RESISTANCE
+ )
+ )
+ );
+ return {
+ completedLaps: workoutCompletedLaps(course, totalDistance),
+ distance,
+ elevation: point.elevation,
+ grade,
+ lap: workoutLap(course, totalDistance),
+ progress: workoutProgress(course, totalDistance),
+ resistance,
+ x: point.x,
+ y: point.y,
+ };
+}
+
+export function workoutMapPath(course: WorkoutCourse): string {
+ const [first] = course.points;
+ if (!first) {
+ return '';
+ }
+ return [
+ `M ${pathCoordinate(first.x)} ${pathCoordinate(first.y)}`,
+ ...workoutMapSegments(course).map(curvePathCommand),
+ ].join(' ');
+}
+
+export function workoutMapProgressPath(course: WorkoutCourse, terrain: WorkoutTerrain): string {
+ const [first] = course.points;
+ 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 <= progressDistance) {
+ curves.push(curvePathCommand(segment));
+ continue;
+ }
+ if (segment.startDistance < progressDistance) {
+ const progress =
+ (progressDistance - segment.startDistance) /
+ (segment.endDistance - segment.startDistance);
+ curves.push(curvePathCommand(partialCurveSegment(segment, progress)));
+ }
+ break;
+ }
+ return [`M ${pathCoordinate(first.x)} ${pathCoordinate(first.y)}`, ...curves].join(' ');
+}
+
+interface CurvePoint {
+ x: number;
+ y: number;
+}
+
+interface CurveSegment {
+ control1: CurvePoint;
+ control2: CurvePoint;
+ from: CurvePoint;
+ to: CurvePoint;
+}
+
+interface MapCurveSegment extends CurveSegment {
+ endDistance: number;
+ startDistance: number;
+}
+
+function pathCoordinate(value: number): number {
+ return Number(value.toFixed(3));
+}
+
+function curvePointBetween(from: CurvePoint, to: CurvePoint, progress: number): CurvePoint {
+ return {
+ x: from.x + (to.x - from.x) * progress,
+ y: from.y + (to.y - from.y) * progress,
+ };
+}
+
+function curvePointAtProgress(segment: CurveSegment, progress: number): CurvePoint {
+ return {
+ x: cubicValue(
+ segment.from.x,
+ segment.control1.x,
+ segment.control2.x,
+ segment.to.x,
+ progress
+ ),
+ y: cubicValue(
+ segment.from.y,
+ segment.control1.y,
+ segment.control2.y,
+ segment.to.y,
+ progress
+ ),
+ };
+}
+
+function partialCurveSegment(segment: CurveSegment, progress: number): CurveSegment {
+ const boundedProgress = clamp(progress, 0, 1);
+ const first = curvePointBetween(segment.from, segment.control1, boundedProgress);
+ const second = curvePointBetween(segment.control1, segment.control2, boundedProgress);
+ const third = curvePointBetween(segment.control2, segment.to, boundedProgress);
+ const firstMiddle = curvePointBetween(first, second, boundedProgress);
+ const secondMiddle = curvePointBetween(second, third, boundedProgress);
+ return {
+ control1: first,
+ control2: firstMiddle,
+ from: segment.from,
+ to: curvePointBetween(firstMiddle, secondMiddle, boundedProgress),
+ };
+}
+
+function curvePathCommand(segment: CurveSegment): string {
+ return `C ${pathCoordinate(segment.control1.x)} ${pathCoordinate(segment.control1.y)} ${pathCoordinate(segment.control2.x)} ${pathCoordinate(segment.control2.y)} ${pathCoordinate(segment.to.x)} ${pathCoordinate(segment.to.y)}`;
+}
+
+function mapCoordinateTangents(
+ course: WorkoutCourse,
+ coordinate: (point: WorkoutRoutePoint) => number
+): number[] {
+ const { points } = course;
+ const lastIndex = points.length - 1;
+ const [first, wrappedNext] = points;
+ const last = points[lastIndex];
+ const closed = first && last && first.x === last.x && first.y === last.y;
+ return points.map((point, index) => {
+ if (closed && (index === 0 || index === lastIndex)) {
+ const previous = points[lastIndex - 1];
+ if (!(previous && wrappedNext)) {
+ return 0;
+ }
+ const wrappedPreviousDistance = previous.distance - course.distance;
+ return (
+ (coordinate(wrappedNext) - coordinate(previous)) /
+ (wrappedNext.distance - wrappedPreviousDistance)
+ );
+ }
+ const previous = points[index - 1];
+ const next = points[index + 1];
+ if (previous && next) {
+ return (coordinate(next) - coordinate(previous)) / (next.distance - previous.distance);
+ }
+ const neighbor = previous ?? next;
+ if (!neighbor || neighbor.distance === point.distance) {
+ return 0;
+ }
+ return (coordinate(neighbor) - coordinate(point)) / (neighbor.distance - point.distance);
+ });
+}
+
+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 mapCourse = workoutMapCourse(course);
+ const xTangents = mapCoordinateTangents(mapCourse, (point) => point.x);
+ const yTangents = mapCoordinateTangents(mapCourse, (point) => point.y);
+ const tension = 0.75;
+ return mapCourse.points.slice(0, -1).flatMap((from, index) => {
+ const to = mapCourse.points[index + 1];
+ if (!to) {
+ return [];
+ }
+ const controlDistance = ((to.distance - from.distance) / 3) * tension;
+ return [
+ {
+ control1: {
+ x: from.x + (xTangents[index] ?? 0) * controlDistance,
+ y: from.y + (yTangents[index] ?? 0) * controlDistance,
+ },
+ control2: {
+ x: to.x - (xTangents[index + 1] ?? 0) * controlDistance,
+ y: to.y - (yTangents[index + 1] ?? 0) * controlDistance,
+ },
+ endDistance: to.distance,
+ from: { x: from.x, y: from.y },
+ startDistance: from.distance,
+ to: { x: to.x, y: to.y },
+ },
+ ];
+ });
+}
+
+function workoutMapPosition(course: WorkoutCourse, distance: number): CurvePoint {
+ const position = workoutMapDistance(course, distance);
+ const segments = workoutMapSegments(course);
+ const segment =
+ segments.find((candidate) => candidate.endDistance >= position) ?? segments.at(-1);
+ if (!segment) {
+ const [first] = course.points;
+ return first ? { x: first.x, y: first.y } : { x: 50, y: 50 };
+ }
+ const segmentDistance = segment.endDistance - segment.startDistance;
+ const progress =
+ segmentDistance > 0 ? clamp((position - segment.startDistance) / segmentDistance, 0, 1) : 0;
+ return curvePointAtProgress(segment, progress);
+}
+
+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 = 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,
+ }));
+}
+
+function curveTangents(points: CurvePoint[]): number[] {
+ const slopes = points.slice(0, -1).map((point, index) => {
+ const next = points[index + 1];
+ return next ? (next.y - point.y) / (next.x - point.x) : 0;
+ });
+ return points.map((_point, index) => {
+ const before = slopes[index - 1];
+ const after = slopes[index];
+ if (before === undefined) {
+ return after ?? 0;
+ }
+ if (after === undefined) {
+ return before;
+ }
+ if (before === 0 || after === 0 || Math.sign(before) !== Math.sign(after)) {
+ return 0;
+ }
+ return (2 * before * after) / (before + after);
+ });
+}
+
+function curveSegments(points: CurvePoint[]): CurveSegment[] {
+ const tangents = curveTangents(points);
+ return points.slice(0, -1).flatMap((from, index) => {
+ const to = points[index + 1];
+ if (!to) {
+ return [];
+ }
+ const third = (to.x - from.x) / 3;
+ return [
+ {
+ control1: { x: from.x + third, y: from.y + (tangents[index] ?? 0) * third },
+ control2: {
+ x: to.x - third,
+ y: to.y - (tangents[index + 1] ?? 0) * third,
+ },
+ from,
+ to,
+ },
+ ];
+ });
+}
+
+function cubicValue(start: number, control1: number, control2: number, end: number, t: number) {
+ const inverse = 1 - t;
+ return (
+ inverse ** 3 * start +
+ 3 * inverse ** 2 * t * control1 +
+ 3 * inverse * t ** 2 * control2 +
+ t ** 3 * end
+ );
+}
+
+function curveValueAtX(points: CurvePoint[], x: number): number {
+ const segments = curveSegments(points);
+ const segment = segments.find((candidate) => candidate.to.x >= x) ?? segments.at(-1);
+ if (!segment) {
+ return points[0].y;
+ }
+ const segmentWidth = segment.to.x - segment.from.x;
+ const progress = segmentWidth > 0 ? clamp((x - segment.from.x) / segmentWidth, 0, 1) : 0;
+ return cubicValue(
+ segment.from.y,
+ segment.control1.y,
+ segment.control2.y,
+ segment.to.y,
+ progress
+ );
+}
+
+function workoutProfileSegments(course: WorkoutCourse): CurveSegment[] {
+ return curveSegments(workoutProfilePoints(course));
+}
+
+export function workoutProfilePath(course: WorkoutCourse): string {
+ const [first] = workoutProfilePoints(course);
+ if (!first) {
+ return '';
+ }
+ const curves = workoutProfileSegments(course).map((segment) => curvePathCommand(segment));
+ return [`M ${pathCoordinate(first.x)} ${pathCoordinate(first.y)}`, ...curves].join(' ');
+}
+export function workoutProfilePosition(
+ course: WorkoutCourse,
+ terrain: WorkoutTerrain
+): { x: number; y: number } {
+ const x = terrain.progress * 100;
+ return {
+ x,
+ y: curveValueAtX(workoutProfilePoints(course), x),
+ };
+}
+
+interface RestoredRoutePoint extends RoutePoint {
+ latitude?: number;
+ longitude?: number;
+ x?: number;
+ y?: number;
+}
+
+function restoredPoint(value: unknown): RestoredRoutePoint | undefined {
+ if (!isRecord(value)) {
+ return;
+ }
+ const { distance, elevation, latitude, longitude, x, y } = value;
+ if (!(isFiniteNumber(distance) && isFiniteNumber(elevation) && distance >= 0)) {
+ return;
+ }
+ const geographic =
+ isFiniteNumber(latitude) &&
+ isFiniteNumber(longitude) &&
+ latitude >= -90 &&
+ latitude <= 90 &&
+ longitude >= -180 &&
+ longitude <= 180;
+ const legacyMap =
+ isFiniteNumber(x) &&
+ isFiniteNumber(y) &&
+ x >= MAP_MINIMUM &&
+ x <= MAP_MAXIMUM &&
+ y >= MAP_MINIMUM &&
+ y <= MAP_MAXIMUM;
+ if (!(geographic || legacyMap)) {
+ return;
+ }
+ return {
+ distance,
+ elevation,
+ latitude: geographic ? latitude : undefined,
+ longitude: geographic ? longitude : undefined,
+ x: legacyMap ? x : undefined,
+ y: legacyMap ? y : undefined,
+ };
+}
+
+function geographicPoints(
+ points: RestoredRoutePoint[],
+ distance: number
+): GeographicRoutePoint[] | undefined {
+ if (
+ points.every((point) => isFiniteNumber(point.latitude) && isFiniteNumber(point.longitude))
+ ) {
+ return points.map((point) => ({
+ distance: point.distance,
+ elevation: point.elevation,
+ latitude: point.latitude ?? 0,
+ longitude: point.longitude ?? 0,
+ }));
+ }
+ if (points.every((point) => isFiniteNumber(point.x) && isFiniteNumber(point.y))) {
+ return geographicPointsForMap(
+ distance,
+ points.map((point) => ({
+ distance: point.distance,
+ elevation: point.elevation,
+ x: point.x ?? 0,
+ y: point.y ?? 0,
+ }))
+ );
+ }
+}
+
+function isValidClosedCourse(points: GeographicRoutePoint[], distance: number): boolean {
+ const [first] = points;
+ const last = points.at(-1);
+ if (!(first && last)) {
+ return false;
+ }
+ const distancesIncrease = points.every((point, index) => {
+ const previous = points[index - 1];
+ return !previous || point.distance > previous.distance;
+ });
+ return (
+ distancesIncrease &&
+ approximatelyEqual(first.distance, 0) &&
+ approximatelyEqual(last.distance, distance) &&
+ 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, routeType } =
+ value;
+ let restoredRouteType: WorkoutRouteType | undefined;
+ if (routeType === undefined) {
+ restoredRouteType = WORKOUT_ROUTE_TYPE.LOOP;
+ } else if (isWorkoutRouteType(routeType)) {
+ restoredRouteType = routeType;
+ }
+ if (
+ !(
+ isString(description) &&
+ isWorkoutDifficulty(difficulty) &&
+ isFiniteNumber(distance) &&
+ isString(id) &&
+ isString(name) &&
+ Array.isArray(points) &&
+ restoredRouteType &&
+ id.trim().length > 0 &&
+ name.trim().length > 0
+ )
+ ) {
+ return;
+ }
+ if (points.length > MAX_SAVED_WORKOUT_POINTS) {
+ return;
+ }
+ const restoredBaseResistance =
+ baseResistance === undefined ? DEFAULT_TERRAIN_RESISTANCE : baseResistance;
+ if (
+ !isFiniteNumber(restoredBaseResistance) ||
+ restoredBaseResistance < MIN_TERRAIN_RESISTANCE ||
+ restoredBaseResistance > MAX_TERRAIN_RESISTANCE
+ ) {
+ return;
+ }
+ const restoredSources = points
+ .map(restoredPoint)
+ .filter((point): point is RestoredRoutePoint => point !== undefined);
+ const restoredPoints = geographicPoints(restoredSources, distance);
+ if (
+ restoredSources.length !== points.length ||
+ !restoredPoints ||
+ restoredPoints.length < MIN_ROUTE_POINTS ||
+ distance <= 0 ||
+ !isValidCourse(restoredPoints, distance, restoredRouteType)
+ ) {
+ return;
+ }
+ return createGeographicCourse(
+ id,
+ name,
+ description,
+ difficulty,
+ distance,
+ restoredPoints,
+ restoredBaseResistance,
+ restoredRouteType
+ );
+}
+
+export function restoreSessionWorkout(value: unknown): SessionWorkout | undefined {
+ if (!isRecord(value)) {
+ return;
+ }
+ const restoredCourse = restoreWorkoutCourse(value.course);
+ if (!restoredCourse) {
+ return;
+ }
+ return {
+ course: BUILT_IN_WORKOUT_COURSES_BY_ID.get(restoredCourse.id) ?? restoredCourse,
+ };
+}
+
+export function workoutDifficultyLabel(difficulty: WorkoutDifficulty): string {
+ switch (difficulty) {
+ case WORKOUT_DIFFICULTY.GENTLE:
+ return 'Gentle';
+ case WORKOUT_DIFFICULTY.MODERATE:
+ return 'Moderate';
+ case WORKOUT_DIFFICULTY.CHALLENGING:
+ return 'Challenging';
+ default:
+ return difficulty;
+ }
+}
diff --git a/src/lib/xml.ts b/src/lib/xml.ts
new file mode 100644
index 0000000..20776d4
--- /dev/null
+++ b/src/lib/xml.ts
@@ -0,0 +1,47 @@
+const ELEMENT_NODE = 1;
+
+export function elementName(element: Element): string {
+ return element.localName || element.nodeName.split(':').at(-1) || element.nodeName;
+}
+
+export function childElements(element: Element): Element[] {
+ return Array.from(element.childNodes).filter(
+ (node): node is Element => node.nodeType === ELEMENT_NODE
+ );
+}
+
+export function xmlChild(element: Element, name: string): Element | undefined {
+ return childElements(element).find((candidate) => elementName(candidate) === name);
+}
+
+export function xmlDescendants(element: Element, name: string): Element[] {
+ return Array.from(element.getElementsByTagName('*')).filter(
+ (candidate) => elementName(candidate) === name
+ );
+}
+
+export function xmlDescendant(element: Element, name: string): Element | undefined {
+ return xmlDescendants(element, name)[0];
+}
+
+export function xmlText(element: Element | undefined): string {
+ return element?.textContent?.trim() ?? '';
+}
+
+export function xmlNumber(element: Element | undefined): number | undefined {
+ const source = xmlText(element);
+ if (!source) {
+ return;
+ }
+ const value = Number(source);
+ return Number.isFinite(value) ? value : undefined;
+}
+
+export function xmlEscape(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts
index e207d81..7eef6af 100644
--- a/src/stores/session-store.ts
+++ b/src/stores/session-store.ts
@@ -4,7 +4,18 @@ import { CONTROL_MODE, type ControlMode } from '../lib/control-mode';
import { estimatedCyclingCalories } from '../lib/cycling-energy';
import { addMetricAggregates, SESSION_STORAGE_KEY, sessionContinuation } from '../lib/session';
import { kilometersTraveled } from '../lib/units';
-import type { Metrics, SessionSnapshot, StoredSession } from '../types';
+import {
+ workoutElevationTotalsAtDistance,
+ workoutSelectionLocked,
+ workoutTerrainAtDistance,
+} from '../lib/workouts';
+import type {
+ Metrics,
+ SessionSnapshot,
+ SessionWorkout,
+ StoredSession,
+ WorkoutCourse,
+} from '../types';
interface RecordSessionTick {
control: {
@@ -33,6 +44,54 @@ function initialSessionState(restored: StoredSession, now: number): SessionStore
};
}
+function sameWorkout(workout: SessionWorkout | undefined, course: WorkoutCourse | undefined) {
+ return workout?.course === course;
+}
+
+function selectPlannedWorkout(
+ current: SessionStoreState,
+ course: WorkoutCourse | undefined
+): SessionStoreState {
+ return sameWorkout(current.plannedWorkout, course)
+ ? current
+ : { ...current, plannedWorkout: course ? { course } : undefined };
+}
+
+function selectActiveWorkout(
+ current: SessionStoreState,
+ course: WorkoutCourse | undefined
+): SessionStoreState {
+ if (sameWorkout(current.workout, course)) {
+ return current;
+ }
+ return {
+ ...current,
+ controlMode: course ? CONTROL_MODE.RESISTANCE : current.controlMode,
+ workout: course ? { course } : undefined,
+ };
+}
+
+function selectWorkoutForState(
+ current: SessionStoreState,
+ course: WorkoutCourse | undefined
+): SessionStoreState {
+ if (current.ended) {
+ return selectPlannedWorkout(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) {
+ return current.workout
+ ? workoutElevationTotalsAtDistance(current.workout.course, distance)
+ : current.elevationTotals;
+}
+
export function sessionSnapshotFromState(state: SessionStoreState): SessionSnapshot {
return {
aggregates: state.aggregates,
@@ -40,10 +99,12 @@ export function sessionSnapshotFromState(state: SessionStoreState): SessionSnaps
controlMode: state.controlMode,
distance: state.distance,
elapsedSeconds: state.elapsedSeconds,
+ elevationTotals: state.elevationTotals,
endedAt: state.endedAt,
history: state.history,
maximums: state.maximums,
startedAt: state.startedAt,
+ workout: state.workout,
};
}
@@ -55,12 +116,15 @@ export function storedSessionFromState(state: SessionStoreState): StoredSession
discarded: state.discarded,
distance: state.distance,
elapsedSeconds: state.elapsedSeconds,
+ elevationTotals: state.elevationTotals,
ended: state.ended,
endedAt: state.endedAt,
history: state.history.slice(-MAX_SESSION_HISTORY_SAMPLES),
maximums: state.maximums,
+ plannedWorkout: state.plannedWorkout,
savedSessionId: state.savedSessionId,
startedAt: state.startedAt,
+ workout: state.workout,
};
}
@@ -136,6 +200,20 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) {
control.mode === CONTROL_MODE.GEAR
? { gear: control.gear }
: { resistance: control.resistance };
+ const distance =
+ current.distance +
+ (distanceDelta ?? kilometersTraveled(metrics.speed, seconds));
+ const terrain = current.workout
+ ? workoutTerrainAtDistance(current.workout.course, distance)
+ : undefined;
+ const workoutSample = terrain
+ ? {
+ elevation: terrain.elevation,
+ grade: terrain.grade,
+ workoutDistance: terrain.distance,
+ workoutLap: terrain.lap,
+ }
+ : {};
const sample = {
cadence: metrics.cadence,
elapsedSeconds,
@@ -143,7 +221,9 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) {
power: metrics.power,
speed: metrics.speed,
...controlSample,
+ ...workoutSample,
};
+ const elevationTotals = elevationTotalsAfterTick(current, distance);
return {
...current,
aggregates: addMetricAggregates(current.aggregates, {
@@ -152,24 +232,31 @@ export function createSessionStore(restored: StoredSession, now = Date.now()) {
}),
calories: current.calories + estimatedCyclingCalories(metrics.power, seconds),
controlMode: control.mode,
- distance:
- current.distance +
- (distanceDelta ?? kilometersTraveled(metrics.speed, seconds)),
+ distance,
elapsedSeconds,
+ elevationTotals,
history: [...current.history, sample],
};
});
},
reset: (controlMode: ControlMode, startedAt: number) => {
- setState(() => ({
- ...emptySession,
- aggregates: emptySession.aggregates,
- controlMode,
- isRiding: false,
- manuallyPaused: false,
- maximums: emptyMetrics,
- startedAt,
- }));
+ setState((current) => {
+ const workout = current.plannedWorkout;
+ return {
+ ...emptySession,
+ aggregates: emptySession.aggregates,
+ controlMode: workout ? CONTROL_MODE.RESISTANCE : controlMode,
+ isRiding: false,
+ manuallyPaused: false,
+ maximums: emptyMetrics,
+ plannedWorkout: undefined,
+ startedAt,
+ workout,
+ };
+ });
+ },
+ selectWorkout: (course?: WorkoutCourse) => {
+ setState((current) => selectWorkoutForState(current, course));
},
syncRiding: (recentlyPedaling: boolean) => {
setState((current) => {
diff --git a/src/types.ts b/src/types.ts
index cd970c9..fff7258 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,7 @@
import type { ControlMode } from './lib/control-mode';
+import type { WorkoutDifficulty, WorkoutRouteType } from './lib/workout-schema';
+export type { ChartMode } from './lib/chart-mode';
export type { ControlMode } from './lib/control-mode';
export interface Metrics {
@@ -16,6 +18,49 @@ export interface RoutePoint {
elevation: number;
}
+export interface GeographicRoutePoint extends RoutePoint {
+ latitude: number;
+ longitude: number;
+}
+
+export interface WorkoutRoutePoint extends GeographicRoutePoint {
+ x: number;
+ y: number;
+}
+
+export interface WorkoutCourse {
+ baseResistance: number;
+ description: string;
+ difficulty: WorkoutDifficulty;
+ distance: number;
+ elevationGain: number;
+ id: string;
+ name: string;
+ points: WorkoutRoutePoint[];
+ routeType: WorkoutRouteType;
+}
+
+export interface SessionWorkout {
+ course: WorkoutCourse;
+}
+
+export interface WorkoutTerrain {
+ completedLaps: number;
+ distance: number;
+ elevation: number;
+ grade: number;
+ lap: number;
+ progress: number;
+ resistance: number;
+ x: number;
+ y: number;
+}
+
+export interface ElevationTotals {
+ ascent: number;
+ descent: number;
+}
+
export interface Range {
max: number;
min: number;
@@ -35,11 +80,15 @@ export interface ResistanceRamp {
export interface MetricSample {
cadence: number;
elapsedSeconds: number;
+ elevation?: number;
gear?: number;
+ grade?: number;
heartRate: number;
power: number;
resistance?: number;
speed: number;
+ workoutDistance?: number;
+ workoutLap?: number;
}
export interface MetricAggregate {
@@ -63,12 +112,15 @@ export interface StoredSession {
discarded: boolean;
distance: number;
elapsedSeconds: number;
+ elevationTotals: ElevationTotals;
ended: boolean;
endedAt: number;
history: MetricSample[];
maximums: Metrics;
+ plannedWorkout?: SessionWorkout;
savedSessionId?: string;
startedAt: number;
+ workout?: SessionWorkout;
}
export type SessionFeeling = 'great' | 'good' | 'okay' | 'tough' | 'exhausted';
@@ -84,10 +136,12 @@ export interface SessionSnapshot {
controlMode: ControlMode;
distance: number;
elapsedSeconds: number;
+ elevationTotals: ElevationTotals;
endedAt: number;
history: MetricSample[];
maximums: Metrics;
startedAt: number;
+ workout?: SessionWorkout;
}
export interface SavedSession extends SessionSnapshot, SessionMetadata {
@@ -104,16 +158,7 @@ export interface SavedSessionSummary {
id: string;
importedAt?: number;
startedAt: number;
+ workoutName?: string;
}
-export type ChartMode =
- | 'all'
- | 'cadence'
- | 'elevation'
- | 'gear'
- | 'heartRate'
- | 'power'
- | 'resistance'
- | 'speed';
-
export type SpeedUnit = 'kmh' | 'mph';
diff --git a/src/workouts/cedar-circuit.json b/src/workouts/cedar-circuit.json
new file mode 100644
index 0000000..4a44c0d
--- /dev/null
+++ b/src/workouts/cedar-circuit.json
@@ -0,0 +1,24 @@
+{
+ "id": "cedar-circuit",
+ "name": "Cedar Circuit",
+ "description": "A flat rollout into constant rollers through a broad forest loop with two climbs.",
+ "difficulty": "moderate",
+ "routeType": "loop",
+ "distance": 9.6,
+ "baseResistance": 12,
+ "points": [
+ { "distance": 0, "elevation": 46, "x": 15, "y": 24 },
+ { "distance": 0.8, "elevation": 46, "x": 34, "y": 12 },
+ { "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 },
+ { "distance": 4.8, "elevation": 128, "x": 91, "y": 68 },
+ { "distance": 5.6, "elevation": 101, "x": 68, "y": 86 },
+ { "distance": 6.4, "elevation": 68, "x": 44, "y": 78 },
+ { "distance": 7.2, "elevation": 84, "x": 22, "y": 90 },
+ { "distance": 8, "elevation": 63, "x": 8, "y": 68 },
+ { "distance": 8.8, "elevation": 52, "x": 24, "y": 50 },
+ { "distance": 9.6, "elevation": 46, "x": 15, "y": 24 }
+ ]
+}
diff --git a/src/workouts/granite-switchbacks.json b/src/workouts/granite-switchbacks.json
new file mode 100644
index 0000000..843477b
--- /dev/null
+++ b/src/workouts/granite-switchbacks.json
@@ -0,0 +1,46 @@
+{
+ "id": "granite-switchbacks",
+ "name": "Granite Switchbacks",
+ "description": "A sustained four-mile switchback climb with steep hairpins, a high ridge, and a rolling descent.",
+ "difficulty": "challenging",
+ "routeType": "loop",
+ "distance": 18,
+ "baseResistance": 12,
+ "points": [
+ { "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.4, "elevation": 330, "x": 94, "y": 27 },
+ { "distance": 10.2, "elevation": 350, "x": 97, "y": 39 },
+ { "distance": 11, "elevation": 290, "x": 96, "y": 54 },
+ { "distance": 11.8, "elevation": 315, "x": 93, "y": 68 },
+ { "distance": 12.7, "elevation": 235, "x": 88, "y": 82 },
+ { "distance": 13.6, "elevation": 260, "x": 75, "y": 91 },
+ { "distance": 14.5, "elevation": 175, "x": 60, "y": 95 },
+ { "distance": 15.3, "elevation": 195, "x": 48, "y": 91 },
+ { "distance": 16.1, "elevation": 125, "x": 35, "y": 94 },
+ { "distance": 16.9, "elevation": 145, "x": 23, "y": 96 },
+ { "distance": 17.5, "elevation": 95, "x": 12, "y": 92 },
+ { "distance": 18, "elevation": 80, "x": 12, "y": 88 }
+ ]
+}
diff --git a/src/workouts/harbor-ring.json b/src/workouts/harbor-ring.json
new file mode 100644
index 0000000..6c621c0
--- /dev/null
+++ b/src/workouts/harbor-ring.json
@@ -0,0 +1,20 @@
+{
+ "id": "harbor-ring",
+ "name": "Harbor Ring",
+ "description": "A relaxed waterfront loop with short ramps and long recovery sections.",
+ "difficulty": "gentle",
+ "routeType": "loop",
+ "distance": 6.4,
+ "baseResistance": 12,
+ "points": [
+ { "distance": 0, "elevation": 18, "x": 18, "y": 40 },
+ { "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 },
+ { "distance": 4.8, "elevation": 25, "x": 58, "y": 87 },
+ { "distance": 5.6, "elevation": 21, "x": 27, "y": 78 },
+ { "distance": 6.4, "elevation": 18, "x": 18, "y": 40 }
+ ]
+}
diff --git a/src/workouts/highland-loop.json b/src/workouts/highland-loop.json
new file mode 100644
index 0000000..ba2b6dd
--- /dev/null
+++ b/src/workouts/highland-loop.json
@@ -0,0 +1,28 @@
+{
+ "id": "highland-loop",
+ "name": "Highland Loop",
+ "description": "A flat rollout into one long mountain ascent, a ridge, and a fast descent.",
+ "difficulty": "challenging",
+ "routeType": "loop",
+ "distance": 12,
+ "baseResistance": 12,
+ "points": [
+ { "distance": 0, "elevation": 74, "x": 50, "y": 50 },
+ { "distance": 0.75, "elevation": 74, "x": 61, "y": 29 },
+ { "distance": 1.5, "elevation": 74, "x": 78, "y": 14 },
+ { "distance": 2.25, "elevation": 119, "x": 91, "y": 23 },
+ { "distance": 3, "elevation": 176, "x": 92, "y": 43 },
+ { "distance": 3.75, "elevation": 254, "x": 80, "y": 61 },
+ { "distance": 4.5, "elevation": 325, "x": 64, "y": 66 },
+ { "distance": 5.25, "elevation": 348, "x": 54, "y": 56 },
+ { "distance": 6, "elevation": 338, "x": 50, "y": 50 },
+ { "distance": 6.75, "elevation": 278, "x": 43, "y": 44 },
+ { "distance": 7.5, "elevation": 214, "x": 28, "y": 34 },
+ { "distance": 8.25, "elevation": 157, "x": 11, "y": 43 },
+ { "distance": 9, "elevation": 121, "x": 8, "y": 63 },
+ { "distance": 9.75, "elevation": 147, "x": 20, "y": 82 },
+ { "distance": 10.5, "elevation": 112, "x": 39, "y": 88 },
+ { "distance": 11.25, "elevation": 83, "x": 51, "y": 68 },
+ { "distance": 12, "elevation": 74, "x": 50, "y": 50 }
+ ]
+}
diff --git a/src/workouts/prairie-roll.json b/src/workouts/prairie-roll.json
new file mode 100644
index 0000000..18e6314
--- /dev/null
+++ b/src/workouts/prairie-roll.json
@@ -0,0 +1,21 @@
+{
+ "id": "prairie-roll",
+ "name": "Prairie Roll",
+ "description": "Fifteen miles of long, gentle rollers moving from 15–25% resistance around a steady 20%.",
+ "difficulty": "gentle",
+ "routeType": "loop",
+ "distance": 24.14016,
+ "baseResistance": 20,
+ "points": [
+ { "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.14016, "elevation": 30, "x": 12, "y": 52 }
+ ]
+}
diff --git a/src/workouts/ridgeline-time-trial.json b/src/workouts/ridgeline-time-trial.json
new file mode 100644
index 0000000..ca5f6ff
--- /dev/null
+++ b/src/workouts/ridgeline-time-trial.json
@@ -0,0 +1,32 @@
+{
+ "id": "ridgeline-time-trial",
+ "name": "Ridgeline Time Trial",
+ "description": "A steady five-mile hillclimb with gentle rollers before turning around for the same course in reverse.",
+ "difficulty": "moderate",
+ "routeType": "out-and-back",
+ "distance": 16.09344,
+ "baseResistance": 14,
+ "points": [
+ { "distance": 0, "elevation": 100, "x": 10, "y": 82 },
+ { "distance": 0.8, "elevation": 100, "x": 18, "y": 75 },
+ { "distance": 1.6, "elevation": 110, "x": 26, "y": 66 },
+ { "distance": 2.4, "elevation": 119, "x": 38, "y": 62 },
+ { "distance": 3.2, "elevation": 116, "x": 46, "y": 54 },
+ { "distance": 4, "elevation": 127, "x": 57, "y": 49 },
+ { "distance": 4.8, "elevation": 137, "x": 63, "y": 39 },
+ { "distance": 5.6, "elevation": 134, "x": 73, "y": 35 },
+ { "distance": 6.4, "elevation": 152, "x": 79, "y": 25 },
+ { "distance": 7.2, "elevation": 167, "x": 88, "y": 20 },
+ { "distance": 8.04672, "elevation": 188, "x": 94, "y": 10 },
+ { "distance": 8.89344, "elevation": 167, "x": 88, "y": 20 },
+ { "distance": 9.69344, "elevation": 152, "x": 79, "y": 25 },
+ { "distance": 10.49344, "elevation": 134, "x": 73, "y": 35 },
+ { "distance": 11.29344, "elevation": 137, "x": 63, "y": 39 },
+ { "distance": 12.09344, "elevation": 127, "x": 57, "y": 49 },
+ { "distance": 12.89344, "elevation": 116, "x": 46, "y": 54 },
+ { "distance": 13.69344, "elevation": 119, "x": 38, "y": 62 },
+ { "distance": 14.49344, "elevation": 110, "x": 26, "y": 66 },
+ { "distance": 15.29344, "elevation": 100, "x": 18, "y": 75 },
+ { "distance": 16.09344, "elevation": 100, "x": 10, "y": 82 }
+ ]
+}
diff --git a/tests/components.test.tsx b/tests/components.test.tsx
index d414837..aa2dbe0 100644
--- a/tests/components.test.tsx
+++ b/tests/components.test.tsx
@@ -10,22 +10,28 @@ import { Metric, SessionMetric, SmallMetric } from '../src/components/metrics';
import { Notification } from '../src/components/notification';
import { ResistanceControl } from '../src/components/resistance-control';
import { SessionChart } from '../src/components/session-chart';
+import { SessionControls } from '../src/components/session-controls';
import { DeleteSessionDialog, SessionDetail } from '../src/components/session-detail';
import { SessionHistory } from '../src/components/session-history';
import { SessionHistoryList } from '../src/components/session-history-list';
import { SessionSaveDialog } from '../src/components/session-save-dialog';
import { TrainingControl } from '../src/components/training-control';
import { WelcomeDialog } from '../src/components/welcome-dialog';
+import { WorkoutPanel } from '../src/components/workout-panel';
+import { WorkoutProgress } from '../src/components/workout-progress';
import {
CHROME_BLUETOOTH_FLAGS_URL,
CHROME_BLUETOOTH_PERMISSION_MESSAGE,
emptyMetrics,
emptySession,
} from '../src/constants';
+import { formatGrade } from '../src/lib/format';
import { historyKeyboardShortcuts } from '../src/lib/keyboard';
import { metricAccentClass, metricIconClass } from '../src/lib/metric-presentation';
import { formatSessionImportTime, sessionSummary } from '../src/lib/saved-sessions';
import { SESSION_WORKFLOW_INTENT } from '../src/lib/session-workflow';
+import { WORKOUT_ROUTE_TYPE } from '../src/lib/workout-schema';
+import { WORKOUT_COURSES, workoutTerrainAtDistance } from '../src/lib/workouts';
import { savedSessionFixture } from './fixtures/saved-session';
const render = (element: React.ReactNode) => renderToStaticMarkup(element);
@@ -34,6 +40,7 @@ const solidChartBoundaries =
/d="M0 14H100 M0 90H100"[^>]*stroke="#3a4654"(?![^>]*stroke-dasharray)/;
const dashedChartGuides =
/d="M0 52H100 M25 14V90 M50 14V90 M75 14V90"[^>]*stroke-dasharray="2.5 2.5"/;
+const noCustomWorkoutIds = new Set();
describe('view components', () => {
test('renders known and fallback icons', () => {
@@ -442,6 +449,154 @@ describe('view components', () => {
expect(resistance).not.toContain('Virtual shifting');
});
+ test('renders terrain workout selection, progress, and automatic resistance', () => {
+ const [course] = WORKOUT_COURSES;
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const panel = render(
+ undefined}
+ onImportFile={() => Promise.reject(new Error('Not used in this render test'))}
+ onRemoveCourse={() => undefined}
+ onSelect={() => undefined}
+ open
+ selectionLocked={false}
+ speedUnit="mph"
+ />
+ );
+ expect(panel).toContain('Terrain workouts');
+ expect(panel).toContain('Harbor Ring');
+ expect(panel).toContain('Prairie Roll');
+ expect(panel).toContain('Cedar Circuit');
+ expect(panel).toContain('Highland Loop');
+ expect(panel).toContain('Granite Switchbacks');
+ expect(panel).toContain('Ridgeline Time Trial');
+ expect(panel).toContain('Harbor Ring course map');
+ expect(panel).toContain('Harbor Ring elevation profile');
+ expect(panel).toContain('Import GPX');
+ expect(panel.match(/Download GPX/g)).toHaveLength(6);
+ expect(panel).toContain('10.0 mi out & back');
+ expect(panel).toContain('15.0 mi loop');
+ expect(panel).toContain('15–25% resistance');
+ expect(panel).toContain('49 ft climbing');
+ expect(panel).not.toContain('15 m climbing');
+ expect(panel).toContain('stroke="#64748b"');
+ expect(panel).toContain('bg-slate-800/70');
+ expect(panel).not.toContain('bg-lime text-ink');
+ expect(panel).toContain('data-side-tray="true"');
+ const importedCourse = {
+ ...course,
+ id: 'imported-course',
+ name: 'Imported course',
+ routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK,
+ };
+ const customPanel = render(
+ undefined}
+ onImportFile={() => Promise.reject(new Error('Not used in this render test'))}
+ onRemoveCourse={() => undefined}
+ onSelect={() => undefined}
+ open
+ selectionLocked={false}
+ speedUnit="mph"
+ />
+ );
+ expect(customPanel).toContain('Imported course');
+ expect(customPanel).toContain('Imported');
+ expect(customPanel).toContain('out & back');
+ expect(customPanel).toContain('Remove');
+ expect(customPanel.match(/Download GPX/g)).toHaveLength(7);
+ const lockedPanel = render(
+ undefined}
+ onImportFile={() => Promise.reject(new Error('Not used in this render test'))}
+ onRemoveCourse={() => undefined}
+ onSelect={() => undefined}
+ open
+ selectionLocked
+ speedUnit="mph"
+ />
+ );
+ expect(lockedPanel).toContain('End the current session before changing the workout.');
+ expect(lockedPanel.match(/disabled=""/g)).toHaveLength(6);
+
+ const terrain = workoutTerrainAtDistance(course, course.distance * 2 + 2);
+ const progress = render(
+
+ );
+ expect(progress).not.toContain('Terrain workout');
+ expect(progress).not.toContain('Current lap');
+ expect(progress).toContain('Laps completed');
+ expect(progress).toContain('aria-label="2 laps completed"');
+ expect(progress).toContain('Course map');
+ expect(progress).toContain('1.2 / 4 mi');
+ expect(progress).toContain('Elevation profile');
+ expect(progress).toContain('Course climb');
+ expect(progress).toContain('49 ft');
+ expect(progress).toContain('Climbed');
+ expect(progress).toContain('98 ft');
+ expect(progress).toContain('Downhill');
+ expect(progress).toContain('39 ft');
+ expect(progress).toContain('Progress');
+ expect(progress).toContain(`${Math.round(terrain.progress * 100)}%`);
+ expect(progress).toContain('Grade');
+ expect(progress).toContain(formatGrade(terrain.grade));
+ expect(progress).toContain('Resistance');
+ expect(progress).toContain(`${terrain.resistance}%`);
+ expect(progress.match(/sm:text-4xl/g)).toHaveLength(3);
+ expect(progress.match(/sm:text-2xl/g)).toHaveLength(3);
+ expect(progress).toContain('sm:text-lg');
+ expect(progress).toContain('Ridden this lap');
+ expect(progress.match(/animate-pulse/g)).toHaveLength(2);
+ expect(progress).toContain('data-profile-marker="true"');
+ expect(progress).not.toContain('rgba(173, 245, 189, .2)');
+ expect(progress.match(/data-route-progress="true"/g)).toHaveLength(2);
+ expect(progress).not.toContain('stroke-dasharray');
+ expect(progress).not.toContain('Terrain resistance');
+ const metricProgress = render(
+
+ );
+ expect(metricProgress).toContain('15 m');
+ expect(metricProgress).toContain('30 m');
+ expect(metricProgress).toContain('12 m');
+ expect(metricProgress).not.toContain('animate-pulse');
+ const outAndBackProgress = render(
+
+ );
+ expect(outAndBackProgress).toContain('Trips completed');
+ expect(outAndBackProgress).toContain('Ridden this trip');
+ expect(outAndBackProgress).toContain('aria-label="2 trips completed"');
+ });
+
test('hides empty notifications and expands setup guidance', () => {
expect(
render( undefined} />)
@@ -586,6 +741,54 @@ describe('view components', () => {
expect(html).not.toContain('absolute top-[11%] bottom-[8%] left-1');
});
+ test('graphs recorded workout elevation with a distinct color', () => {
+ const [course] = WORKOUT_COURSES;
+ if (!course) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const html = render(
+
+ );
+ expect(html).toContain('Elevation over time');
+ expect(html).toContain('Elevation ');
+ 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 +884,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 +1052,7 @@ describe('view components', () => {
controlMode: 'resistance',
distance: 0,
elapsedSeconds: 0,
+ elevationTotals: emptySession.elevationTotals,
endedAt: Date.now(),
history: [],
id: 'empty-session',
@@ -863,6 +1087,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..99cd7c7 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,92 @@ 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);
+
+ 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', () => {
const store = createSessionStore(restoredSession(), 1000);
store.actions.syncRiding(true);
@@ -140,6 +228,7 @@ describe('session store', () => {
controlMode: 'resistance',
distance: 12,
elapsedSeconds: 900,
+ elevationTotals: { ascent: 120, descent: 80 },
endedAt: 4000,
history: [],
maximums: emptyMetrics,
@@ -149,6 +238,7 @@ describe('session store', () => {
calories: 100,
distance: 12,
elapsedSeconds: 900,
+ elevationTotals: { ascent: 120, descent: 80 },
ended: false,
endedAt: 0,
startedAt: 2000,
@@ -156,6 +246,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-workflow.test.ts b/tests/session-workflow.test.ts
index ef16046..241c306 100644
--- a/tests/session-workflow.test.ts
+++ b/tests/session-workflow.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'bun:test';
-import { SESSION_WORKFLOW_INTENT, SESSION_WORKFLOW_PHASE } from '../src/lib/session-workflow';
+import {
+ finishRideSession,
+ SESSION_WORKFLOW_INTENT,
+ SESSION_WORKFLOW_PHASE,
+} from '../src/lib/session-workflow';
import {
createSessionWorkflowStore,
initialSessionWorkflowState,
@@ -7,6 +11,15 @@ import {
import type { SavedSession } from '../src/types';
describe('session workflow store', () => {
+ test('settles trainer resistance whenever a ride session finishes', () => {
+ const actions: string[] = [];
+ finishRideSession(
+ () => actions.push('end session'),
+ () => actions.push('settle resistance')
+ );
+ expect(actions).toEqual(['end session', 'settle resistance']);
+ });
+
test('opens with the ended-session intent when an unsaved session is restored', () => {
expect(initialSessionWorkflowState(true)).toEqual({
intent: { kind: SESSION_WORKFLOW_INTENT.END },
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..aeecf16 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,51 @@ 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,
+ routeType: course.routeType,
+ });
+ 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..55422f8 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,41 @@ 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('loop ');
+ 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/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);
+ });
+});
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..2f1dbc5
--- /dev/null
+++ b/tests/workout-file.test.ts
@@ -0,0 +1,189 @@
+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_ROUTE_TYPE } from '../src/lib/workout-schema';
+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
+
+
+ `;
+}
+
+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();
+ const contents = workoutFileContents(workout);
+ 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(
+ 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,
+ routeType: WORKOUT_ROUTE_TYPE.LOOP,
+ });
+ 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',
+ routeType: WORKOUT_ROUTE_TYPE.LOOP,
+ });
+ expect(first.id).toStartWith('gpx-');
+ expect(second.id).toBe(first.id);
+ });
+
+ 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 () => ' 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..c7aeded
--- /dev/null
+++ b/tests/workouts.test.ts
@@ -0,0 +1,390 @@
+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,
+} from '../src/lib/workouts';
+
+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 built-in courses with useful terrain metadata', () => {
+ expect(WORKOUT_COURSES).toHaveLength(6);
+ 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 ten-mile time trial with a mirrored five-mile hillclimb', () => {
+ const timeTrial = WORKOUT_COURSES.find((workout) => workout.id === 'ridgeline-time-trial');
+ if (!timeTrial) {
+ throw new Error('Expected the Ridgeline Time Trial workout course');
+ }
+ const turnaroundDistance = timeTrial.distance / 2;
+ const start = workoutTerrainAtDistance(timeTrial, 0);
+ const turnaround = workoutTerrainAtDistance(timeTrial, turnaroundDistance);
+ const outbound = workoutTerrainAtDistance(timeTrial, 4.4);
+ const returning = workoutTerrainAtDistance(timeTrial, timeTrial.distance - 4.4);
+ expect(timeTrial.routeType).toBe(WORKOUT_ROUTE_TYPE.OUT_AND_BACK);
+ expect(timeTrial.distance / 1.609_344).toBeCloseTo(10, 5);
+ expect(turnaroundDistance / 1.609_344).toBeCloseTo(5, 5);
+ expect((turnaround.elevation - start.elevation) * 3.280_84).toBeWithin(285, 300);
+ expect(timeTrial.elevationGain * 3.280_84).toBeWithin(300, 335);
+ expect(workoutMaximumGrade(timeTrial)).toBeLessThan(3);
+ expect(outbound.grade).toBeGreaterThan(0);
+ expect(returning.grade).toBeLessThan(0);
+ expect(returning.x).toBeCloseTo(outbound.x, 1);
+ expect(returning.y).toBeCloseTo(outbound.y, 1);
+ });
+
+ 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('mixes rollers into the Granite Switchbacks descent', () => {
+ const switchbacks = WORKOUT_COURSES.find((workout) => workout.id === 'granite-switchbacks');
+ if (!switchbacks) {
+ throw new Error('Expected the Granite Switchbacks workout course');
+ }
+ const returnPoints = switchbacks.points.filter((point) => point.distance >= 8.6);
+ const elevationChanges = returnPoints.slice(1).map((point, index) => {
+ const previous = returnPoints[index];
+ return previous ? point.elevation - previous.elevation : 0;
+ });
+ expect(elevationChanges.filter((change) => change > 0)).toHaveLength(5);
+ expect(elevationChanges.filter((change) => change < 0).length).toBeGreaterThan(5);
+ expect(returnPoints.at(-1)?.elevation).toBeLessThan(returnPoints[0]?.elevation ?? 0);
+ });
+
+ 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) =>
+ 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('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 <= 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, rolloutDistance / 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 });
+ 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,
+ });
+ 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();
+ });
+});