From 63a472d64c332b7e4c491fc4890ad1d6f37c690a Mon Sep 17 00:00:00 2001
From: Ride Control
Date: Tue, 21 Jul 2026 11:39:43 -0700
Subject: [PATCH 1/7] refactor: deduplicate saved workout snapshots
---
src/lib/indexed-db.ts | 14 ++
src/lib/saved-sessions.ts | 192 ++++++++++++++++++++++-----
src/lib/session-workout-snapshots.ts | 76 +++++++++++
tests/saved-sessions.test.ts | 46 +++++++
4 files changed, 294 insertions(+), 34 deletions(-)
create mode 100644 src/lib/indexed-db.ts
create mode 100644 src/lib/session-workout-snapshots.ts
diff --git a/src/lib/indexed-db.ts b/src/lib/indexed-db.ts
new file mode 100644
index 0000000..a1d00b1
--- /dev/null
+++ b/src/lib/indexed-db.ts
@@ -0,0 +1,14 @@
+export function indexedDbRequestResult(request: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ request.addEventListener('success', () => resolve(request.result), { once: true });
+ request.addEventListener('error', () => reject(request.error), { once: true });
+ });
+}
+
+export function indexedDbTransactionComplete(transaction: IDBTransaction): Promise {
+ return new Promise((resolve, reject) => {
+ transaction.addEventListener('complete', () => resolve(), { once: true });
+ transaction.addEventListener('abort', () => reject(transaction.error), { once: true });
+ transaction.addEventListener('error', () => reject(transaction.error), { once: true });
+ });
+}
diff --git a/src/lib/saved-sessions.ts b/src/lib/saved-sessions.ts
index b6d3eb2..6dc5102 100644
--- a/src/lib/saved-sessions.ts
+++ b/src/lib/saved-sessions.ts
@@ -4,23 +4,33 @@ import type {
SessionFeeling,
SessionMetadata,
SessionSnapshot,
+ SessionWorkout,
} from '../types';
import { restoreElevationTotals } from './elevation';
+import { indexedDbRequestResult, indexedDbTransactionComplete } from './indexed-db';
import {
aggregateGear,
aggregateResistance,
controlModeForHistory,
restoreAggregate,
} from './session';
+import {
+ createSessionWorkoutSnapshot,
+ restoreSessionWorkoutSnapshot,
+ restoreSnapshotWorkout,
+ type SessionWorkoutSnapshot,
+} from './session-workout-snapshots';
import { IMPORTED_TCX_ID_PREFIX } from './tcx-schema';
-import { isFiniteNumber, isString } from './type-guards';
+import { isFiniteNumber, isRecord, isString } from './type-guards';
import { restoreSessionWorkout } from './workouts';
const DATABASE_NAME = 'ridecontrol-sessions';
-const DATABASE_VERSION = 1;
+const DATABASE_VERSION = 2;
const SESSION_STORE = 'sessions';
const SUMMARY_STORE = 'session-summaries';
+const WORKOUT_STORE = 'session-workouts';
const ENDED_AT_INDEX = 'endedAt';
+const WORKOUT_SNAPSHOT_INDEX = 'workoutSnapshotId';
const MERIDIEM_SUFFIX = /\s*(AM|PM)$/i;
const SESSION_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' });
const SESSION_IMPORT_FORMATTER = new Intl.DateTimeFormat(undefined, {
@@ -38,20 +48,43 @@ export const SESSION_FEELING_OPTIONS: { label: string; value: SessionFeeling }[]
type SessionTiming = Pick;
+export type SavedSessionRecord = Omit & {
+ workout?: SessionWorkout;
+ workoutSnapshotId?: string;
+};
+
let databasePromise: Promise | undefined;
-function requestResult(request: IDBRequest): Promise {
- return new Promise((resolve, reject) => {
- request.addEventListener('success', () => resolve(request.result), { once: true });
- request.addEventListener('error', () => reject(request.error), { once: true });
- });
+function storedSessionRecords(session: SavedSession): {
+ session: SavedSessionRecord;
+ snapshot?: SessionWorkoutSnapshot;
+} {
+ const snapshot = createSessionWorkoutSnapshot(session.workout);
+ const { workout: _workout, ...record } = session;
+ return {
+ session: snapshot ? { ...record, workoutSnapshotId: snapshot.id } : record,
+ snapshot,
+ };
}
-function transactionComplete(transaction: IDBTransaction): Promise {
- return new Promise((resolve, reject) => {
- transaction.addEventListener('complete', () => resolve(), { once: true });
- transaction.addEventListener('abort', () => reject(transaction.error), { once: true });
- transaction.addEventListener('error', () => reject(transaction.error), { once: true });
+function migrateLegacySessionWorkouts(sessions: IDBObjectStore, workouts: IDBObjectStore): void {
+ const request = sessions.openCursor();
+ request.addEventListener('success', () => {
+ const cursor = request.result;
+ if (!cursor) {
+ return;
+ }
+ const value: unknown = cursor.value;
+ if (isRecord(value) && !isString(value.workoutSnapshotId)) {
+ const workout = restoreSnapshotWorkout(value.workout);
+ const snapshot = createSessionWorkoutSnapshot(workout);
+ if (snapshot) {
+ const { workout: _workout, ...record } = value;
+ workouts.put(snapshot);
+ cursor.update({ ...record, workoutSnapshotId: snapshot.id });
+ }
+ }
+ cursor.continue();
});
}
@@ -63,15 +96,25 @@ function openDatabase(): Promise {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
request.addEventListener(
'upgradeneeded',
- () => {
+ (event) => {
const database = request.result;
- if (!database.objectStoreNames.contains(SESSION_STORE)) {
- database.createObjectStore(SESSION_STORE, { keyPath: 'id' });
+ const { oldVersion } = event as IDBVersionChangeEvent;
+ const sessions = database.objectStoreNames.contains(SESSION_STORE)
+ ? request.transaction?.objectStore(SESSION_STORE)
+ : database.createObjectStore(SESSION_STORE, { keyPath: 'id' });
+ if (sessions && !sessions.indexNames.contains(WORKOUT_SNAPSHOT_INDEX)) {
+ sessions.createIndex(WORKOUT_SNAPSHOT_INDEX, WORKOUT_SNAPSHOT_INDEX);
}
if (!database.objectStoreNames.contains(SUMMARY_STORE)) {
const summaries = database.createObjectStore(SUMMARY_STORE, { keyPath: 'id' });
summaries.createIndex(ENDED_AT_INDEX, ENDED_AT_INDEX);
}
+ const workouts = database.objectStoreNames.contains(WORKOUT_STORE)
+ ? request.transaction?.objectStore(WORKOUT_STORE)
+ : database.createObjectStore(WORKOUT_STORE, { keyPath: 'id' });
+ if (oldVersion < 2 && sessions && workouts) {
+ migrateLegacySessionWorkouts(sessions, workouts);
+ }
},
{ once: true }
);
@@ -121,6 +164,30 @@ function normalizedImportedAt(importedAt: unknown): number | undefined {
return isFiniteNumber(importedAt) && importedAt >= 0 ? importedAt : undefined;
}
+async function getSessionWorkoutSnapshot(
+ database: IDBDatabase,
+ id: string
+): Promise {
+ const transaction = database.transaction(WORKOUT_STORE, 'readonly');
+ const completed = indexedDbTransactionComplete(transaction);
+ const value: unknown = await indexedDbRequestResult(
+ transaction.objectStore(WORKOUT_STORE).get(id)
+ );
+ await completed;
+ return restoreSessionWorkoutSnapshot(value);
+}
+
+export function normalizeSavedSessionRecord(
+ record: SavedSessionRecord,
+ snapshot?: SessionWorkoutSnapshot
+): SavedSession {
+ const { workoutSnapshotId: _workoutSnapshotId, ...session } = record;
+ return normalizeSavedSession({
+ ...session,
+ workout: snapshot?.workout ?? restoreSessionWorkout(record.workout),
+ });
+}
+
export function normalizeSavedSessionSummary(session: SavedSessionSummary): SavedSessionSummary {
return {
...session,
@@ -140,9 +207,14 @@ type StoreGetter = (name: string) => T;
export function saveSessionRecords(
getStore: StoreGetter>,
session: SavedSession
-): void {
- getStore(SESSION_STORE).put(session);
+): { snapshotId?: string } {
+ const records = storedSessionRecords(session);
+ getStore(SESSION_STORE).put(records.session);
getStore(SUMMARY_STORE).put(sessionSummary(session));
+ if (records.snapshot) {
+ getStore(WORKOUT_STORE).put(records.snapshot);
+ }
+ return { snapshotId: records.snapshot?.id };
}
export function deleteSessionRecords(
@@ -155,32 +227,65 @@ export function deleteSessionRecords(
export async function saveSession(session: SavedSession): Promise {
const database = await openDatabase();
- const transaction = database.transaction([SESSION_STORE, SUMMARY_STORE], 'readwrite');
- const completed = transactionComplete(transaction);
- saveSessionRecords((name) => transaction.objectStore(name), session);
+ const transaction = database.transaction(
+ [SESSION_STORE, SUMMARY_STORE, WORKOUT_STORE],
+ 'readwrite'
+ );
+ const completed = indexedDbTransactionComplete(transaction);
+ const sessions = transaction.objectStore(SESSION_STORE);
+ const previous = await indexedDbRequestResult(
+ sessions.get(session.id) as IDBRequest
+ );
+ const { snapshotId } = saveSessionRecords((name) => transaction.objectStore(name), session);
+ if (previous?.workoutSnapshotId && previous.workoutSnapshotId !== snapshotId) {
+ const remaining = await indexedDbRequestResult(
+ sessions.index(WORKOUT_SNAPSHOT_INDEX).count(previous.workoutSnapshotId)
+ );
+ if (remaining === 0) {
+ transaction.objectStore(WORKOUT_STORE).delete(previous.workoutSnapshotId);
+ }
+ }
await completed;
}
export async function deleteSavedSession(id: string): Promise {
const database = await openDatabase();
- const transaction = database.transaction([SESSION_STORE, SUMMARY_STORE], 'readwrite');
- const completed = transactionComplete(transaction);
+ const transaction = database.transaction(
+ [SESSION_STORE, SUMMARY_STORE, WORKOUT_STORE],
+ 'readwrite'
+ );
+ const completed = indexedDbTransactionComplete(transaction);
+ const sessions = transaction.objectStore(SESSION_STORE);
+ const record = await indexedDbRequestResult(
+ sessions.get(id) as IDBRequest
+ );
deleteSessionRecords((name) => transaction.objectStore(name), id);
+ if (record?.workoutSnapshotId) {
+ const remaining = await indexedDbRequestResult(
+ sessions.index(WORKOUT_SNAPSHOT_INDEX).count(record.workoutSnapshotId)
+ );
+ if (remaining === 0) {
+ transaction.objectStore(WORKOUT_STORE).delete(record.workoutSnapshotId);
+ }
+ }
await completed;
}
export async function getSavedSession(id: string): Promise {
const database = await openDatabase();
const transaction = database.transaction(SESSION_STORE, 'readonly');
- const completed = transactionComplete(transaction);
- const session = await requestResult(
- transaction.objectStore(SESSION_STORE).get(id) as IDBRequest
+ const completed = indexedDbTransactionComplete(transaction);
+ const record = await indexedDbRequestResult(
+ transaction.objectStore(SESSION_STORE).get(id) as IDBRequest
);
await completed;
- if (!session) {
+ if (!record) {
return;
}
- return normalizeSavedSession(session);
+ const snapshot = record.workoutSnapshotId
+ ? await getSessionWorkoutSnapshot(database, record.workoutSnapshotId)
+ : undefined;
+ return normalizeSavedSessionRecord(record, snapshot);
}
export function normalizeSavedSession(session: SavedSession): SavedSession {
@@ -207,8 +312,8 @@ export function normalizeSavedSession(session: SavedSession): SavedSession {
export async function countSavedSessions(): Promise {
const database = await openDatabase();
const transaction = database.transaction(SUMMARY_STORE, 'readonly');
- const completed = transactionComplete(transaction);
- const count = await requestResult(transaction.objectStore(SUMMARY_STORE).count());
+ const completed = indexedDbTransactionComplete(transaction);
+ const count = await indexedDbRequestResult(transaction.objectStore(SUMMARY_STORE).count());
await completed;
return count;
}
@@ -219,7 +324,7 @@ export async function listSavedSessions(
): Promise {
const database = await openDatabase();
const transaction = database.transaction(SUMMARY_STORE, 'readonly');
- const completed = transactionComplete(transaction);
+ const completed = indexedDbTransactionComplete(transaction);
const index = transaction.objectStore(SUMMARY_STORE).index(ENDED_AT_INDEX);
const range =
beforeEndedAt === undefined ? undefined : IDBKeyRange.upperBound(beforeEndedAt, true);
@@ -244,12 +349,31 @@ export async function listSavedSessions(
export async function listAllSavedSessions(): Promise {
const database = await openDatabase();
const transaction = database.transaction(SESSION_STORE, 'readonly');
- const completed = transactionComplete(transaction);
- const sessions = await requestResult(
- transaction.objectStore(SESSION_STORE).getAll() as IDBRequest
+ const completed = indexedDbTransactionComplete(transaction);
+ const records = await indexedDbRequestResult(
+ transaction.objectStore(SESSION_STORE).getAll() as IDBRequest
);
await completed;
- return sessions.map(normalizeSavedSession).sort((left, right) => right.endedAt - left.endedAt);
+ const workoutTransaction = database.transaction(WORKOUT_STORE, 'readonly');
+ const workoutsCompleted = indexedDbTransactionComplete(workoutTransaction);
+ const snapshotValues: unknown[] = await indexedDbRequestResult(
+ workoutTransaction.objectStore(WORKOUT_STORE).getAll()
+ );
+ await workoutsCompleted;
+ const snapshots = new Map(
+ snapshotValues.flatMap((value) => {
+ const snapshot = restoreSessionWorkoutSnapshot(value);
+ return snapshot ? [[snapshot.id, snapshot] as const] : [];
+ })
+ );
+ return records
+ .map((record) =>
+ normalizeSavedSessionRecord(
+ record,
+ record.workoutSnapshotId ? snapshots.get(record.workoutSnapshotId) : undefined
+ )
+ )
+ .sort((left, right) => right.endedAt - left.endedAt);
}
export interface SessionGroup {
diff --git a/src/lib/session-workout-snapshots.ts b/src/lib/session-workout-snapshots.ts
new file mode 100644
index 0000000..f94b992
--- /dev/null
+++ b/src/lib/session-workout-snapshots.ts
@@ -0,0 +1,76 @@
+import type { SessionWorkout, WorkoutCourse } from '../types';
+import { isRecord, isString } from './type-guards';
+import { restoreWorkoutCourse } from './workouts';
+
+const SNAPSHOT_ID_PREFIX = 'session-workout:';
+const FNV_PRIME = 16_777_619;
+const FNV_OFFSET_BASIS = 2_166_136_261;
+const SECOND_HASH_SEED = 3_332_611_411;
+
+export interface SessionWorkoutSnapshot {
+ id: string;
+ workout: SessionWorkout;
+}
+
+function hashSource(source: string, seed: number): string {
+ let hash = seed;
+ for (let index = 0; index < source.length; index += 1) {
+ hash ^= source.charCodeAt(index);
+ hash = Math.imul(hash, FNV_PRIME);
+ }
+ return (hash >>> 0).toString(16).padStart(8, '0');
+}
+
+function workoutSnapshotSource(course: WorkoutCourse): string {
+ return JSON.stringify({
+ baseResistance: course.baseResistance,
+ description: course.description,
+ descriptionAttribution: course.descriptionAttribution ?? null,
+ difficulty: course.difficulty,
+ distance: course.distance,
+ elevationGain: course.elevationGain,
+ id: course.id,
+ name: course.name,
+ points: course.points.map((point) => [
+ point.distance,
+ point.elevation,
+ point.latitude,
+ point.longitude,
+ point.x,
+ point.y,
+ ]),
+ routeType: course.routeType,
+ startingLocation: course.startingLocation ?? null,
+ });
+}
+
+export function createSessionWorkoutSnapshot(
+ workout: SessionWorkout | undefined
+): SessionWorkoutSnapshot | undefined {
+ if (!workout) {
+ return;
+ }
+ const source = workoutSnapshotSource(workout.course);
+ return {
+ id: `${SNAPSHOT_ID_PREFIX}${hashSource(source, FNV_OFFSET_BASIS)}${hashSource(source, SECOND_HASH_SEED)}-${source.length}`,
+ workout,
+ };
+}
+
+export function restoreSnapshotWorkout(value: unknown): SessionWorkout | undefined {
+ if (!isRecord(value)) {
+ return;
+ }
+ const course = restoreWorkoutCourse(value.course);
+ return course ? { course } : undefined;
+}
+
+export function restoreSessionWorkoutSnapshot(value: unknown): SessionWorkoutSnapshot | undefined {
+ if (!(isRecord(value) && isString(value.id))) {
+ return;
+ }
+ const workout = restoreSnapshotWorkout(value.workout);
+ return value.id.startsWith(SNAPSHOT_ID_PREFIX) && workout
+ ? { id: value.id, workout }
+ : undefined;
+}
diff --git a/tests/saved-sessions.test.ts b/tests/saved-sessions.test.ts
index ee59b62..39c46b5 100644
--- a/tests/saved-sessions.test.ts
+++ b/tests/saved-sessions.test.ts
@@ -12,11 +12,16 @@ import {
groupSessionsByDate,
isImportedSession,
normalizeSavedSession,
+ normalizeSavedSessionRecord,
requestPersistentSessionStorage,
saveSessionRecords,
sessionListAfterDelete,
sessionSummary,
} from '../src/lib/saved-sessions';
+import {
+ createSessionWorkoutSnapshot,
+ restoreSessionWorkoutSnapshot,
+} from '../src/lib/session-workout-snapshots';
import { WORKOUT_COURSES } from '../src/lib/workouts';
import type { SavedSession, SavedSessionSummary, SessionSnapshot } from '../src/types';
@@ -127,6 +132,47 @@ describe('saved session utilities', () => {
);
});
+ test('deduplicates immutable workout snapshots outside session records', () => {
+ const [workout] = WORKOUT_COURSES;
+ if (!workout) {
+ throw new Error('Expected a built-in workout course');
+ }
+ const session = {
+ ...createSavedSession(snapshot, { comments: '' }, 1234, 'session-with-workout'),
+ workout: { course: workout },
+ };
+ const writes = new Map();
+ const { snapshotId } = saveSessionRecords(
+ (name) =>
+ ({
+ put: (value: unknown) => {
+ writes.set(name, [...(writes.get(name) ?? []), value]);
+ return {} as IDBRequest;
+ },
+ }) as Pick,
+ session
+ );
+ const storedSession = writes.get('sessions')?.[0];
+ const storedWorkout = writes.get('session-workouts')?.[0];
+ expect(storedSession).toMatchObject({ workoutSnapshotId: snapshotId });
+ expect('workout' in (storedSession as Record)).toBeFalse();
+ expect(storedWorkout).toMatchObject({ id: snapshotId, workout: session.workout });
+ expect(
+ normalizeSavedSessionRecord(
+ storedSession as Parameters[0],
+ storedWorkout as Parameters[1]
+ ).workout
+ ).toEqual(session.workout);
+ expect(createSessionWorkoutSnapshot({ course: { ...workout } })?.id).toBe(snapshotId);
+ const revisedSnapshot = createSessionWorkoutSnapshot({
+ course: { ...workout, name: `${workout.name} revised` },
+ });
+ expect(revisedSnapshot?.id).not.toBe(snapshotId);
+ expect(restoreSessionWorkoutSnapshot(revisedSnapshot)).toMatchObject({
+ workout: { course: { name: `${workout.name} revised` } },
+ });
+ });
+
test('restores resistance aggregates for legacy saved sessions', () => {
const session = createSavedSession(snapshot, { comments: '' }, 1234, 'legacy');
const legacy = {
From 92ddbbb305efbd046ada00b25974cd884b0394b8 Mon Sep 17 00:00:00 2001
From: Ride Control
Date: Tue, 21 Jul 2026 11:39:49 -0700
Subject: [PATCH 2/7] feat: browse backend-powered BikeGPX routes
---
README.md | 12 +-
bun.lock | 5 +
package.json | 1 +
src/app.tsx | 1 +
src/build-env.d.ts | 1 +
src/components/bikegpx-browser-dialog.tsx | 827 ++++++++++++++++++++++
src/components/workout-map-dialog.tsx | 161 +----
src/components/workout-panel.tsx | 133 ++--
src/components/workout-route-map.tsx | 162 +++++
src/hooks/use-bikegpx-catalog.ts | 77 ++
src/hooks/use-workout-library.ts | 15 +-
src/lib/bikegpx.ts | 221 ++++++
src/lib/workout-metrics.ts | 15 +
src/lib/workouts.ts | 14 -
tests/bikegpx.test.ts | 105 +++
tests/components.test.tsx | 8 +-
tests/workouts.test.ts | 2 +-
17 files changed, 1530 insertions(+), 230 deletions(-)
create mode 100644 src/components/bikegpx-browser-dialog.tsx
create mode 100644 src/components/workout-route-map.tsx
create mode 100644 src/hooks/use-bikegpx-catalog.ts
create mode 100644 src/lib/bikegpx.ts
create mode 100644 src/lib/workout-metrics.ts
create mode 100644 tests/bikegpx.test.ts
diff --git a/README.md b/README.md
index 1c7ef45..1212f94 100644
--- a/README.md
+++ b/README.md
@@ -14,11 +14,11 @@ 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 restores the trainer, heart-rate monitor, and both Click controllers from one browser permission snapshot after a reload. The trainer and heart-rate monitor begin reconnecting immediately and independently; remembered Click controllers join those parallel attempts only during an active ride session. Trainers and active Click controllers keep advertisement discovery active through the GATT handshake so Chrome can react as soon as they broadcast, while heart-rate monitors use direct GATT retries because common HRMs do not reliably surface advertisements through Chrome's watcher. A shared coordinator deduplicates requests to the same physical device without letting a slow sensor block the others, and each device's service and notification setup stays sequential for reliable GATT communication.
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics.
-- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, 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 in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route 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 distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, 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. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior.
-- 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, saved starting location, and point-to-point, loop, or out-and-back course type. Valid GPX tracks or routes from other tools can be imported through the file picker or by dropping a GPX anywhere in the workout tray, then saved into a custom library on the current device; each newly imported workout appears immediately at the top and keeps that position after reloading. The tray also links to BikeGPX's collection of thousands of downloadable routes. An imported route's linked “Starts in” description opens an in-app map dialog beside the workout tray, fits its complete path with start and finish markers over an attributed OpenStreetMap base map, and continuously animates a standard bicycle marker along the route. Routes whose start and finish are genuinely near each other become loops automatically; other GPX routes remain one-way, start-to-finish courses without a generated return leg. The workout library filters immediately by course name or displayed difficulty, and every card keeps an up/down-arrow drag handle in the top-right corner; dragging moves the complete workout card strictly vertically, accepts a drop anywhere over another workout, and animates the surrounding cards out of the way to open a full-card-sized insertion space before saving the preferred order across browser reloads. The terrain tray remembers both that it was open and its workout-list scroll position across page reloads. Import, rename, and reorder feedback stays in a fixed bottom status area beside the selected-workout clearing action, preventing the workout list from shifting. Imported workout names can be clicked and renamed without changing the route's stable duplicate-detection identifier. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and a route without its own description uses its first coordinate to label the starting city through a cached OpenStreetMap lookup, then saves that location directly with the workout so restoring or re-importing a Ride Control GPX does not repeat the request. Missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor.
+- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, 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 in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route 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 distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, 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. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. 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. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior.
+- 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, saved starting location, and point-to-point, loop, or out-and-back course type. Valid GPX tracks or routes from other tools can be imported through the file picker or by dropping a GPX anywhere in the workout tray, then saved into a custom library on the current device; each newly imported workout appears immediately at the top and keeps that position after reloading. A map-first BikeGPX browser is built directly into the terrain tray: it searches more than six thousand public routes by name, place, distance in kilometers or miles, or difficulty; filters by country, minimum and maximum distance in the rider's current units, and terrain-based difficulty; continuously scrolls an efficiently virtualized list; previews the complete GPX over OpenStreetMap; and imports the selected route into Ride Control in one click. The separate Cloudflare Worker backend refreshes the public catalog once per day, stores it in Workers KV, downloads GPX files directly from BikeGPX without a third-party proxy, calculates route geometry and terrain difficulty, and caches each processed route in KV. The frontend receives ready-to-use courses and validated analyses through `/api`; visible routes are requested with bounded concurrency so their server-calculated ratings appear progressively. The same route fingerprint used for uploaded files prevents duplicate imports. An imported route's linked “Starts in” description opens an in-app map dialog beside the workout tray, fits its complete path with start and finish markers over an attributed OpenStreetMap base map, and continuously animates a standard bicycle marker along the route. Routes whose start and finish are genuinely near each other become loops automatically; other GPX routes remain one-way, start-to-finish courses without a generated return leg. The workout library filters immediately by course name or displayed difficulty, and every card keeps an up/down-arrow drag handle in the top-right corner; dragging moves the complete workout card strictly vertically, accepts a drop anywhere over another workout, and animates the surrounding cards out of the way to open a full-card-sized insertion space before saving the preferred order across browser reloads. The terrain tray remembers both that it was open and its workout-list scroll position across page reloads. Import, rename, and reorder feedback stays in a fixed bottom status area beside the selected-workout clearing action, preventing the workout list from shifting. Imported workout names can be clicked and renamed without changing the route's stable duplicate-detection identifier. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and a route without its own description uses its first coordinate to label the starting city through a cached OpenStreetMap lookup, then saves that location directly with the workout so restoring or re-importing a Ride Control GPX does not repeat the request. 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, including during terrain workouts, and restores the standard resistance control as soon as both controllers are forgotten. 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 step through the 24 physical combinations of 39/53-tooth chainrings and a 12-speed 12–24-tooth cassette, sorted by actual drivetrain ratio rather than equal percentage intervals. This gives gear 24 the full 53/12 top end for pushing on descents while retaining 39/24 as gear 1 for climbing. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer 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, resistance, and virtual gear, with large, high-visibility numbers in space-efficient live metric and ride-summary cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. The larger chart tabs distribute across the complete chart width. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop.
+- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with large, high-visibility numbers in space-efficient live metric and ride-summary cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. The larger chart tabs distribute across the complete chart width. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop. Saved sessions reference immutable, content-addressed workout snapshots in a separate IndexedDB store: identical course definitions share one snapshot, edited definitions retain their historical versions, and deleting a workout from the selectable library cannot break an older session's maps or terrain details.
- 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.
- Opens saved rides from the dashboard's Sessions button and organizes them by local date and time in a slide-out Sessions tray with a compact inline session count, clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. The tray restores the selected session, the session-list scroll position, and each session's independent detail-pane scroll position after a page reload, falling back to the newest available session when a remembered ride no longer exists.
- 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.
@@ -28,7 +28,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session.
- Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions.
- Displays connection and application notices with a visible 15-second countdown and automatic dismissal.
-- Keeps ride and session data local to the current browser profile; no account is required. When an imported GPX route has no description, only its first coordinate is sent to OpenStreetMap's Nominatim service to find a starting-city label, and that result is cached in the browser and stored with the workout. The resulting location sentence links to OpenStreetMap in a new tab, frames the complete route area, and marks the starting coordinate.
+- Keeps ride and session data local to the current browser profile; no account is required. When an imported GPX route has no description, only its first coordinate is sent to OpenStreetMap's Nominatim service to find a starting-city label, and that result is cached in the browser and stored with the workout. The resulting location sentence links to OpenStreetMap in a new tab, frames the complete route area, and marks the starting coordinate. The frontend sends only public BikeGPX route ids to the Ride Control backend; it does not send ride history, paired-device details, or locally saved workouts.
## Run
@@ -39,6 +39,10 @@ bun run dev
Open in current Chrome.
+The app calls `/api` by default. For local development with the separate Worker on port 8787,
+start Vite with `VITE_RIDECONTROL_API_URL=http://localhost:8787/api`. Set the same variable to the
+deployed Worker API origin in the frontend deployment environment.
+
## Architecture
Ride session data is held in a per-app TanStack Store and changed through atomic domain actions.
diff --git a/bun.lock b/bun.lock
index 09f5768..303c4f0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -10,6 +10,7 @@
"@dnd-kit/sortable": "10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-store": "^0.11.0",
+ "@tanstack/react-virtual": "^3.14.7",
"@vitejs/plugin-react": "^6.0.3",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
@@ -175,8 +176,12 @@
"@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="],
+ "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.7", "", { "dependencies": { "@tanstack/virtual-core": "3.17.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-11uSrj77IDijNBqizD4lY4y1laMyRrqMLSxjnWy5CvWkCjyRDW+gGmxYq0lwQKVas/sq7zyzYWXbL/BvBzR32g=="],
+
"@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="],
+ "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.5", "", {}, "sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg=="],
+
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
diff --git a/package.json b/package.json
index 8090ba9..a64225c 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@dnd-kit/sortable": "10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-store": "^0.11.0",
+ "@tanstack/react-virtual": "^3.14.7",
"@vitejs/plugin-react": "^6.0.3",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
diff --git a/src/app.tsx b/src/app.tsx
index 58fa2fa..fed4711 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -416,6 +416,7 @@ export function App() {
courses={workoutLibrary.courses}
customCourseIds={workoutLibrary.customCourseIds}
onClose={() => setActiveOverlay(undefined)}
+ onImportCourse={async (course) => workoutLibrary.importCourse(course)}
onImportFile={workoutLibrary.importFile}
onRemoveCourse={removeWorkout}
onRenameCourse={workoutLibrary.renameCourse}
diff --git a/src/build-env.d.ts b/src/build-env.d.ts
index c2dea93..6143f68 100644
--- a/src/build-env.d.ts
+++ b/src/build-env.d.ts
@@ -1,6 +1,7 @@
interface ImportMetaEnv {
readonly RIDE_CONTROL_BUILD_PR_URL: string;
readonly RIDE_CONTROL_BUILD_TIMESTAMP_UTC: string;
+ readonly VITE_RIDECONTROL_API_URL?: string;
}
interface ImportMeta {
diff --git a/src/components/bikegpx-browser-dialog.tsx b/src/components/bikegpx-browser-dialog.tsx
new file mode 100644
index 0000000..1841a53
--- /dev/null
+++ b/src/components/bikegpx-browser-dialog.tsx
@@ -0,0 +1,827 @@
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
+import { useCloseOnEscape } from '../hooks/use-dialog-behavior';
+import {
+ BIKEGPX_ROUTES_URL,
+ type BikeGpxCatalog,
+ type BikeGpxRouteAnalysis,
+ type BikeGpxRouteResult,
+ type BikeGpxRouteSummary,
+ bikeGpxRouteMatchesQuery,
+ bikeGpxRouteUrl,
+ fetchBikeGpxRoute,
+} from '../lib/bikegpx';
+import { errorMessage } from '../lib/errors';
+import { convertDistance, distanceUnitLabel, formatDistance, formatElevation } from '../lib/units';
+import {
+ isWorkoutDifficulty,
+ WORKOUT_DIFFICULTY,
+ type WorkoutDifficulty,
+} from '../lib/workout-schema';
+import { workoutDifficultyLabel } from '../lib/workouts';
+import type { SpeedUnit, WorkoutCourse } from '../types';
+import { WorkoutRouteMap } from './workout-route-map';
+
+const ESTIMATED_ROUTE_ROW_HEIGHT = 88;
+const ROUTE_LIST_OVERSCAN = 6;
+const ROUTE_ANALYSIS_CONCURRENCY = 2;
+const EMPTY_ROUTE_ANALYSES: Record = {};
+const CATALOG_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+});
+const routeResultCache = new Map();
+const routeResultRequests = new Map>();
+
+function requestRouteCourse(
+ route: BikeGpxRouteSummary,
+ useCache = true
+): Promise {
+ if (!useCache) {
+ routeResultCache.delete(route.id);
+ }
+ const cached = useCache ? routeResultCache.get(route.id) : undefined;
+ if (cached) {
+ return Promise.resolve(cached);
+ }
+ const pending = routeResultRequests.get(route.id);
+ if (pending) {
+ return pending;
+ }
+ const request = fetchBikeGpxRoute(route)
+ .then((result) => {
+ routeResultCache.set(route.id, result);
+ return result;
+ })
+ .finally(() => {
+ routeResultRequests.delete(route.id);
+ });
+ routeResultRequests.set(route.id, request);
+ return request;
+}
+
+function matchingRoutes(
+ routes: BikeGpxRouteSummary[],
+ query: string,
+ country: string,
+ difficulty: WorkoutDifficulty | undefined,
+ minimumDistance: string,
+ maximumDistance: string,
+ speedUnit: SpeedUnit,
+ analyses: Record
+): BikeGpxRouteSummary[] {
+ const minimum = optionalDistance(minimumDistance);
+ const maximum = optionalDistance(maximumDistance);
+ return routes.filter((route) => {
+ const displayedDistance = convertDistance(route.distanceKm, speedUnit);
+ const analysis = analyses[route.id];
+ return (
+ (!country || route.country === country) &&
+ (!(difficulty && analysis) || analysis.difficulty === difficulty) &&
+ (minimum === undefined || displayedDistance >= minimum) &&
+ (maximum === undefined || displayedDistance <= maximum) &&
+ bikeGpxRouteMatchesQuery(route, query, analysis)
+ );
+ });
+}
+
+function optionalDistance(value: string): number | undefined {
+ const normalized = value.trim();
+ if (!normalized) {
+ return;
+ }
+ const distance = Number(normalized);
+ return Number.isFinite(distance) && distance >= 0 ? distance : undefined;
+}
+
+function useRouteAnalyzer(
+ analyses: Record,
+ onAnalyzeRoute: (routeId: string, analysis: BikeGpxRouteAnalysis) => void
+) {
+ const activeCount = useRef(0);
+ const alive = useRef(true);
+ const failedRouteIds = useRef(new Set());
+ const onAnalyzeRouteRef = useRef(onAnalyzeRoute);
+ const pumpRef = useRef<() => void>(() => undefined);
+ const queuedRouteIds = useRef(new Set());
+ const routesToAnalyze = useRef([]);
+ onAnalyzeRouteRef.current = onAnalyzeRoute;
+
+ useEffect(() => {
+ alive.current = true;
+ return () => {
+ alive.current = false;
+ };
+ }, []);
+
+ const pump = useCallback(() => {
+ while (
+ alive.current &&
+ activeCount.current < ROUTE_ANALYSIS_CONCURRENCY &&
+ routesToAnalyze.current.length > 0
+ ) {
+ const route = routesToAnalyze.current.shift();
+ if (!route) {
+ break;
+ }
+ activeCount.current += 1;
+ requestRouteCourse(route)
+ .then(({ analysis }) => {
+ if (alive.current) {
+ onAnalyzeRouteRef.current(route.id, analysis);
+ }
+ })
+ .catch(() => {
+ failedRouteIds.current.add(route.id);
+ })
+ .finally(() => {
+ activeCount.current -= 1;
+ queuedRouteIds.current.delete(route.id);
+ pumpRef.current();
+ });
+ }
+ }, []);
+ pumpRef.current = pump;
+
+ return useCallback(
+ (routes: BikeGpxRouteSummary[]) => {
+ for (const route of routes) {
+ if (
+ analyses[route.id] ||
+ queuedRouteIds.current.has(route.id) ||
+ failedRouteIds.current.has(route.id)
+ ) {
+ continue;
+ }
+ queuedRouteIds.current.add(route.id);
+ routesToAnalyze.current.push(route);
+ }
+ pump();
+ },
+ [analyses, pump]
+ );
+}
+
+function RouteListItem({
+ analysis,
+ onSelect,
+ route,
+ selected,
+ speedUnit,
+}: {
+ analysis?: BikeGpxRouteAnalysis;
+ onSelect: () => void;
+ route: BikeGpxRouteSummary;
+ selected: boolean;
+ speedUnit: SpeedUnit;
+}) {
+ const difficulty = analysis ? workoutDifficultyLabel(analysis.difficulty) : 'Analyzing';
+ return (
+
+ );
+}
+
+function RouteSidebar({
+ analyses,
+ catalog,
+ catalogError,
+ catalogLoading,
+ countries,
+ country,
+ difficulty,
+ maximumDistance,
+ minimumDistance,
+ onCountryChange,
+ onDifficultyChange,
+ onMaximumDistanceChange,
+ onMinimumDistanceChange,
+ onQueryChange,
+ onAnalyzeRoutes,
+ onRefreshCatalog,
+ onSelectRoute,
+ query,
+ routes,
+ selectedRouteId,
+ speedUnit,
+}: {
+ analyses: Record;
+ catalog?: BikeGpxCatalog;
+ catalogError: string;
+ catalogLoading: boolean;
+ countries: string[];
+ country: string;
+ difficulty?: WorkoutDifficulty;
+ maximumDistance: string;
+ minimumDistance: string;
+ onCountryChange: (country: string) => void;
+ onDifficultyChange: (difficulty: WorkoutDifficulty | undefined) => void;
+ onMaximumDistanceChange: (distance: string) => void;
+ onMinimumDistanceChange: (distance: string) => void;
+ onQueryChange: (query: string) => void;
+ onAnalyzeRoutes: (routes: BikeGpxRouteSummary[]) => void;
+ onRefreshCatalog: () => Promise;
+ onSelectRoute: (route: BikeGpxRouteSummary) => void;
+ query: string;
+ routes: BikeGpxRouteSummary[];
+ selectedRouteId: string;
+ speedUnit: SpeedUnit;
+}) {
+ const routeListRef = useRef(null);
+ const routeKey = useCallback((index: number) => routes[index]?.id ?? index, [routes]);
+ const routeVirtualizer = useVirtualizer({
+ count: routes.length,
+ estimateSize: () => ESTIMATED_ROUTE_ROW_HEIGHT,
+ getItemKey: routeKey,
+ getScrollElement: () => routeListRef.current,
+ overscan: ROUTE_LIST_OVERSCAN,
+ useFlushSync: false,
+ });
+ const selectedIndex = routes.findIndex((route) => route.id === selectedRouteId);
+ const virtualRoutes = routeVirtualizer
+ .getVirtualItems()
+ .flatMap((item) => routes[item.index] ?? []);
+
+ useEffect(() => {
+ onAnalyzeRoutes(virtualRoutes);
+ });
+
+ useEffect(() => {
+ if (selectedIndex >= 0) {
+ routeVirtualizer.scrollToIndex(selectedIndex, { align: 'auto' });
+ }
+ }, [routeVirtualizer, selectedIndex]);
+
+ const distanceUnit = distanceUnitLabel(speedUnit);
+
+ return (
+
+ );
+}
+
+function useRoutePreview(
+ route: BikeGpxRouteSummary | undefined,
+ onAnalyzeRoute: (routeId: string, analysis: BikeGpxRouteAnalysis) => void
+) {
+ const activeRequest = useRef(0);
+ const [analysis, setAnalysis] = useState();
+ const [course, setCourse] = useState();
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+ const load = useCallback(
+ (nextRoute: BikeGpxRouteSummary | undefined, useCache = true) => {
+ const requestId = activeRequest.current + 1;
+ activeRequest.current = requestId;
+ if (!nextRoute) {
+ setAnalysis(undefined);
+ setCourse(undefined);
+ return;
+ }
+ setAnalysis(undefined);
+ setCourse(undefined);
+ setError('');
+ setLoading(true);
+ requestRouteCourse(nextRoute, useCache)
+ .then((result) => {
+ onAnalyzeRoute(nextRoute.id, result.analysis);
+ if (activeRequest.current === requestId) {
+ setAnalysis(result.analysis);
+ setCourse(result.course);
+ }
+ })
+ .catch((nextError) => {
+ if (activeRequest.current === requestId) {
+ setError(errorMessage(nextError));
+ }
+ })
+ .finally(() => {
+ if (activeRequest.current === requestId) {
+ setLoading(false);
+ }
+ });
+ },
+ [onAnalyzeRoute]
+ );
+
+ useEffect(() => {
+ activeRequest.current += 1;
+ setAnalysis(undefined);
+ setCourse(undefined);
+ setError('');
+ setLoading(Boolean(route));
+ const previewDelay = window.setTimeout(() => load(route), 250);
+ return () => {
+ window.clearTimeout(previewDelay);
+ activeRequest.current += 1;
+ };
+ }, [load, route]);
+
+ return { analysis, course, error, loading, retry: () => load(route, false) };
+}
+
+function importButtonLabel(alreadyImported: boolean, importing: boolean): string {
+ if (alreadyImported) {
+ return 'Already imported';
+ }
+ return importing ? 'Importing…' : 'Import route';
+}
+
+function RoutePreviewDetails({
+ alreadyImported,
+ analysis,
+ course,
+ importError,
+ importing,
+ onImport,
+ route,
+ speedUnit,
+ status,
+}: {
+ alreadyImported: boolean;
+ analysis?: BikeGpxRouteAnalysis;
+ course?: WorkoutCourse;
+ importError: string;
+ importing: boolean;
+ onImport: () => void;
+ route: BikeGpxRouteSummary;
+ speedUnit: SpeedUnit;
+ status: string;
+}) {
+ return (
+
+ Search thousands of public routes, preview the complete course, then
+ import it directly into Ride Control. Thanks to BikeGPX for making this
+ public route data available.
+
@@ -655,6 +663,7 @@ export function WorkoutPanel({
setMappedCourse(undefined)}
+ speedUnit={speedUnit}
/>
) : null}
diff --git a/src/lib/bikegpx.ts b/src/lib/bikegpx.ts
index ca9f7b8..6829cc3 100644
--- a/src/lib/bikegpx.ts
+++ b/src/lib/bikegpx.ts
@@ -7,6 +7,7 @@ export const BIKEGPX_ROUTES_URL = 'https://bikegpx.com/bike_routes/';
const SEARCH_WHITESPACE = /\s+/u;
const NUMERIC_ROUTE_ID = /^\d+$/;
const API_ROOT = (import.meta.env.VITE_RIDECONTROL_API_URL || '/api').replace(/\/$/u, '');
+const ROUTE_QUEUE_STATUS = 'queued';
export interface BikeGpxRouteSummary {
country: string;
@@ -33,6 +34,12 @@ export interface BikeGpxRouteResult {
course: WorkoutCourse;
}
+interface BikeGpxRouteQueued {
+ position: number;
+ retryAfterSeconds: number;
+ status: typeof ROUTE_QUEUE_STATUS;
+}
+
export function bikeGpxRouteMatchesQuery(
route: BikeGpxRouteSummary,
query: string,
@@ -184,17 +191,66 @@ function restoreBikeGpxRouteResult(value: unknown): BikeGpxRouteResult | undefin
return analysis && course ? { analysis, course } : undefined;
}
-async function apiJson(path: string, signal?: AbortSignal): Promise {
+function restoreQueuedRoute(value: unknown): BikeGpxRouteQueued | undefined {
+ if (
+ !(
+ isRecord(value) &&
+ value.status === ROUTE_QUEUE_STATUS &&
+ isFiniteNumber(value.position) &&
+ value.position >= 1 &&
+ isFiniteNumber(value.retryAfterSeconds) &&
+ value.retryAfterSeconds >= 0
+ )
+ ) {
+ return;
+ }
+ return {
+ position: value.position,
+ retryAfterSeconds: value.retryAfterSeconds,
+ status: value.status,
+ };
+}
+
+async function apiResponse(
+ path: string,
+ signal?: AbortSignal
+): Promise<{ response: Response; value: unknown }> {
const response = await fetch(`${API_ROOT}${path}`, { signal });
const value: unknown = await response.json();
+ return { response, value };
+}
+
+function backendError(value: unknown): Error {
+ const message =
+ isRecord(value) && isString(value.error) ? value.error : 'Backend request failed.';
+ return new Error(message);
+}
+
+async function apiJson(path: string, signal?: AbortSignal): Promise {
+ const { response, value } = await apiResponse(path, signal);
if (!response.ok) {
- const message =
- isRecord(value) && isString(value.error) ? value.error : 'Backend request failed.';
- throw new Error(message);
+ throw backendError(value);
}
return value;
}
+function waitForRoute(retryAfterSeconds: number, signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ return Promise.reject(signal.reason);
+ }
+ return new Promise((resolve, reject) => {
+ const onAbort = () => {
+ clearTimeout(timeout);
+ reject(signal?.reason);
+ };
+ const timeout = setTimeout(() => {
+ signal?.removeEventListener('abort', onAbort);
+ resolve();
+ }, retryAfterSeconds * 1000);
+ signal?.addEventListener('abort', onAbort, { once: true });
+ });
+}
+
export async function fetchBikeGpxCatalog(signal?: AbortSignal): Promise {
const catalog = restoreBikeGpxCatalog(await apiJson('/bikegpx/routes', signal));
if (!catalog) {
@@ -207,13 +263,22 @@ export async function fetchBikeGpxRoute(
route: BikeGpxRouteSummary,
signal?: AbortSignal
): Promise {
- const result = restoreBikeGpxRouteResult(
- await apiJson(`/bikegpx/routes/${encodeURIComponent(route.id)}`, signal)
- );
- if (!result) {
- throw new Error('The Ride Control backend returned an invalid BikeGPX route.');
+ const path = `/bikegpx/routes/${encodeURIComponent(route.id)}`;
+ for (;;) {
+ const { response, value } = await apiResponse(path, signal);
+ if (!response.ok) {
+ throw backendError(value);
+ }
+ const result = restoreBikeGpxRouteResult(value);
+ if (result) {
+ return result;
+ }
+ const queued = restoreQueuedRoute(value);
+ if (!queued) {
+ throw new Error('The Ride Control backend returned an invalid BikeGPX route.');
+ }
+ await waitForRoute(queued.retryAfterSeconds, signal);
}
- return result;
}
export function bikeGpxRouteUrl(routeId: string): string {
diff --git a/src/lib/units.ts b/src/lib/units.ts
index 359e136..23c1f61 100644
--- a/src/lib/units.ts
+++ b/src/lib/units.ts
@@ -8,6 +8,7 @@ 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';
+const DESCRIPTION_DISTANCE_SUFFIX = /(?:—|-)\s*\d+(?:[.,]\d+)?\s*(?:km|mi)\s*$/iu;
export const SPEED_UNIT_OPTIONS: { label: string; value: SpeedUnit }[] = [
{ label: 'KM/H', value: 'kmh' },
@@ -54,6 +55,17 @@ export function formatDistance(kilometers: number, unit: SpeedUnit, decimals = 2
return `${formatDistanceValue(kilometers, unit, decimals)} ${distanceUnitLabel(unit)}`;
}
+export function formatDescriptionDistance(
+ description: string,
+ kilometers: number,
+ unit: SpeedUnit
+): string {
+ return description.replace(
+ DESCRIPTION_DISTANCE_SUFFIX,
+ `— ${formatDistance(kilometers, unit, 0)}`
+ );
+}
+
export function formatDistanceProgress(
currentKilometers: number,
totalKilometers: number,
diff --git a/tests/bikegpx.test.ts b/tests/bikegpx.test.ts
index 76a952c..5e04212 100644
--- a/tests/bikegpx.test.ts
+++ b/tests/bikegpx.test.ts
@@ -96,6 +96,24 @@ describe('BikeGPX backend client', () => {
expect(fetchMock).toHaveBeenCalledWith('/api/bikegpx/routes/2635', { signal: undefined });
});
+ test('waits for an explicitly queued route to finish processing', async () => {
+ const result = {
+ analysis: catalog.analyses[route.id],
+ course,
+ };
+ const fetchMock = mock(async () =>
+ fetchMock.mock.calls.length === 1
+ ? Response.json(
+ { position: 1, retryAfterSeconds: 0, status: 'queued' },
+ { status: 202 }
+ )
+ : Response.json(result)
+ );
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
+ expect(await fetchBikeGpxRoute(route)).toEqual(result);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
test('surfaces backend errors', async () => {
globalThis.fetch = mock(async () =>
Response.json({ error: 'BikeGPX is unavailable.' }, { status: 502 })
diff --git a/tests/units.test.ts b/tests/units.test.ts
index 5951217..904bf7a 100644
--- a/tests/units.test.ts
+++ b/tests/units.test.ts
@@ -4,6 +4,7 @@ import {
convertDistance,
convertElevation,
convertSpeed,
+ formatDescriptionDistance,
formatDistance,
formatDistanceProgress,
formatDistanceValue,
@@ -31,6 +32,18 @@ describe('unit conversions', () => {
expect(formatElevation(304.8, 'kmh')).toBe('305 m');
});
+ test('formats a route description distance in the selected dashboard unit', () => {
+ expect(formatDescriptionDistance('Near Saltvik → Near Finström — 11 km', 11, 'mph')).toBe(
+ 'Near Saltvik → Near Finström — 7 mi'
+ );
+ expect(formatDescriptionDistance('Near Saltvik → Near Finström — 11 km', 11, 'kmh')).toBe(
+ 'Near Saltvik → Near Finström — 11 km'
+ );
+ expect(formatDescriptionDistance('Original terrain workout', 11, 'mph')).toBe(
+ 'Original terrain workout'
+ );
+ });
+
test('converts stored SI and ride timing values consistently', () => {
expect(metersForKilometers(2.5)).toBe(2500);
expect(kilometersForMeters(2500)).toBe(2.5);
From 6775bf10bce52d909ee02ab10c5d78167ebb216f Mon Sep 17 00:00:00 2001
From: Ride Control
Date: Tue, 21 Jul 2026 14:52:07 -0700
Subject: [PATCH 7/7] Add FIT activity import and export
---
README.md | 11 +-
bun.lock | 3 +
package.json | 1 +
src/components/session-detail.tsx | 14 ++
src/components/session-history-list.tsx | 6 +-
src/components/session-history.tsx | 35 +++-
src/components/welcome-dialog.tsx | 5 +-
src/hooks/use-session-history.ts | 25 ++-
src/lib/activity-file.ts | 63 +++++++
src/lib/activity-import.ts | 191 +++++++++++++++++++++
src/lib/fit-archive.ts | 59 +++++++
src/lib/fit-import.ts | 191 +++++++++++++++++++++
src/lib/fit.ts | 216 ++++++++++++++++++++++++
src/lib/saved-sessions.ts | 7 +-
src/lib/tcx-import.ts | 156 +----------------
src/lib/tcx.ts | 34 +---
tests/components.test.tsx | 16 +-
tests/fit-archive.test.ts | 49 ++++++
tests/fit.test.ts | 96 +++++++++++
tests/saved-sessions.test.ts | 1 +
tests/tcx-import.test.ts | 43 +++--
21 files changed, 999 insertions(+), 223 deletions(-)
create mode 100644 src/lib/activity-file.ts
create mode 100644 src/lib/activity-import.ts
create mode 100644 src/lib/fit-archive.ts
create mode 100644 src/lib/fit-import.ts
create mode 100644 src/lib/fit.ts
create mode 100644 tests/fit-archive.test.ts
create mode 100644 tests/fit.test.ts
diff --git a/README.md b/README.md
index 3820e90..ced1e6d 100644
--- a/README.md
+++ b/README.md
@@ -15,15 +15,15 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- Shows live speed, power, cadence, heart rate, elapsed time, distance, and estimated calories, with MPH and KM/H display modes.
- Provides direct resistance control with buttons, a slider, and keyboard shortcuts with matching button feedback, shows smoothing progress inside the slider thumb, and records resistance changes alongside the other ride metrics.
- Offers original terrain workouts built as repeatable courses, with gentle, rolling, and climbing options and distinctive winding top-down route shapes. Courses explicitly support loops, point-to-point routes, 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 in compact, vertically aligned top-down and elevation views with a clearly labelled ridden-this-lap, ridden-this-trip, or ridden-this-route 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 distance progress to two decimal places alongside course percentage, current grade, and effective trainer resistance directly on the map, with grade and resistance values matching their graph colors, 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. Currently open in-progress sessions resolve bundled workout IDs to the latest course definition, preventing stale geometry from lingering before a ride is saved; saved history keeps the exact workout snapshot used by that ride. 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. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior.
-- 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, saved starting location, and point-to-point, loop, or out-and-back course type. Valid GPX tracks or routes from other tools can be imported through the file picker or by dropping a GPX anywhere in the workout tray, then saved into a custom library on the current device; each newly imported workout appears immediately at the top and keeps that position after reloading. A map-first BikeGPX browser is built directly into the terrain tray: it searches more than six thousand public routes by name, place, distance in kilometers or miles, or difficulty; filters by country, minimum and maximum distance in the rider's current units, and terrain-based difficulty; continuously scrolls an efficiently virtualized list; previews the complete GPX over OpenStreetMap; and imports the selected route into Ride Control in one click. Difficulty is calculated from distance, total climbing, climbing density, and maximum grade, with route ratings appearing progressively as routes load. The same route fingerprint used for uploaded files prevents duplicate imports. An imported route's linked “Starts in” description opens an in-app map dialog beside the workout tray, fits its complete path with start and finish markers over an attributed OpenStreetMap base map, and continuously animates a standard bicycle marker along the route. Routes whose start and finish are genuinely near each other become loops automatically; other GPX routes remain one-way, start-to-finish courses without a generated return leg. The workout library filters immediately by course name or displayed difficulty, and every card keeps an up/down-arrow drag handle in the top-right corner; dragging moves the complete workout card strictly vertically, accepts a drop anywhere over another workout, and animates the surrounding cards out of the way to open a full-card-sized insertion space before saving the preferred order across browser reloads. The terrain tray remembers both that it was open and its workout-list scroll position across page reloads. Import, rename, and reorder feedback stays in a fixed bottom status area beside the selected-workout clearing action, preventing the workout list from shifting. Imported workout names can be clicked and renamed without changing the route's stable duplicate-detection identifier. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and a route without its own description uses its first coordinate to label the starting city through a cached OpenStreetMap lookup, then saves that location directly with the workout so restoring or re-importing a Ride Control GPX does not repeat the request. 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.
+- 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, saved starting location, and point-to-point, loop, or out-and-back course type. Valid GPX tracks or routes from other tools can be imported through the file picker or by dropping a GPX anywhere in the workout tray, then saved into a custom library on the current device; each newly imported workout appears immediately at the top and keeps that position after reloading. A map-first BikeGPX browser is built directly into the terrain tray: it searches more than six thousand public routes by name, place, distance in kilometers or miles, or difficulty; filters by country, minimum and maximum distance in the rider's current units, and terrain-based difficulty; continuously scrolls an efficiently virtualized list; previews the complete GPX over OpenStreetMap; and imports the selected route into Ride Control in one click. Routes are prepared only when explicitly selected, then their cached difficulty rating is calculated from distance, total climbing, climbing density, and maximum grade. Route distance badges and descriptions always follow the dashboard's MPH or KM/H selection. The same route fingerprint used for uploaded files prevents duplicate imports. An imported route's linked “Starts in” description opens an in-app map dialog beside the workout tray, fits its complete path with start and finish markers over an attributed OpenStreetMap base map, and continuously animates a standard bicycle marker along the route. Routes whose start and finish are genuinely near each other become loops automatically; other GPX routes remain one-way, start-to-finish courses without a generated return leg. The workout library filters immediately by course name or displayed difficulty, and every card keeps an up/down-arrow drag handle in the top-right corner; dragging moves the complete workout card strictly vertically, accepts a drop anywhere over another workout, and animates the surrounding cards out of the way to open a full-card-sized insertion space before saving the preferred order across browser reloads. The terrain tray remembers both that it was open and its workout-list scroll position across page reloads. Import, rename, and reorder feedback stays in a fixed bottom status area beside the selected-workout clearing action, preventing the workout list from shifting. Imported workout names can be clicked and renamed without changing the route's stable duplicate-detection identifier. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and a route without its own description uses its first coordinate to label the starting city through a cached OpenStreetMap lookup, then saves that location directly with the workout so restoring or re-importing a Ride Control GPX does not repeat the request. 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, including during terrain workouts, and restores the standard resistance control as soon as both controllers are forgotten. 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 step through the 24 physical combinations of 39/53-tooth chainrings and a 12-speed 12–24-tooth cassette, sorted by actual drivetrain ratio rather than equal percentage intervals. This gives gear 24 the full 53/12 top end for pushing on descents while retaining 39/24 as gear 1 for climbing. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record both the selected gear and applied trainer 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, resistance, and virtual gear, with large, high-visibility numbers in space-efficient live metric and ride-summary cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. The larger chart tabs distribute across the complete chart width. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop. Saved sessions reference immutable, content-addressed workout snapshots in a separate IndexedDB store: identical course definitions share one snapshot, edited definitions retain their historical versions, and deleting a workout from the selectable library cannot break an older session's maps or terrain details.
- 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.
- Opens saved rides from the dashboard's Sessions button and organizes them by local date and time in a slide-out Sessions tray with a compact inline session count, clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. The tray restores the selected session, the session-list scroll position, and each session's independent detail-pane scroll position after a page reload, falling back to the newest available session when a remembered ride no longer exists.
-- 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.
+- Downloads saved rides as standards-compliant FIT activities for direct upload to Strava and other fitness services, including indoor-cycling and creator metadata, UTC and local timestamps, distance, speed, power, cadence, estimated crank revolutions and work, heart rate, resistance, elevation, calories, and ride totals. Each FIT filename includes a stable session token for reliable upload identity. TCX export remains available for the richer Ride Control round trip, including virtual gear, terrain workout metadata, ride feeling, comments, and the original session identifier.
+- Imports individual FIT or TCX activities, or every supported activity inside nested folders in a mixed-format ZIP, directly into local session history. Compatible ride data is preserved, duplicates are detected across formats by identifier or stable activity data, and invalid files do not stop the rest of 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 of individual FIT or TCX files, with FIT selected by default and 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.
- Protects recorded active rides with a browser confirmation before refresh or close, and presents the save workflow before starting or continuing another session.
- Includes contextual keyboard help for dashboard and history actions, including pausing, ending, starting, navigating, viewing history, and deleting sessions.
@@ -67,7 +67,8 @@ target. Virtual gearing applies the ordered ratios of the modeled 2×12 drivetra
changes ramp smoothly while button-driven gear changes remain immediate. Recorded grade, resistance,
and elevation appear alongside the other session graphs for the full ride, and the course profile
repeats on every loop or out-and-back trip while point-to-point routes stop at their finish; route
-progress, selected gear, and applied resistance stay portable in saved sessions and TCX files.
+progress, selected gear, and applied resistance stay portable in saved sessions and TCX files;
+standard ride metrics also stay portable through FIT files.
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/bun.lock b/bun.lock
index 10cafc9..776b42f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,6 +8,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "10.0.0",
+ "@garmin/fitsdk": "^21.208.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-store": "^0.11.0",
"@tanstack/react-virtual": "^3.14.7",
@@ -158,6 +159,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
+ "@garmin/fitsdk": ["@garmin/fitsdk@21.208.0", "", {}, "sha512-mEmPHyzqaS3bb8n+yaOmgmBo/0CUrsLsN5J4viAUN5Kh6+YiwdvPh5OAzb2Jgy2IaI23q7mGtNr/8WRJC1+HoA=="],
+
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
diff --git a/package.json b/package.json
index a4aa7ff..2805b57 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "10.0.0",
+ "@garmin/fitsdk": "^21.208.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-store": "^0.11.0",
"@tanstack/react-virtual": "^3.14.7",
diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx
index a61d753..ee0a4f6 100644
--- a/src/components/session-detail.tsx
+++ b/src/components/session-detail.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { EMPTY_ROUTE } from '../constants';
import { usePersistentScrollPosition } from '../hooks/use-persistent-scroll-position';
import { CONTROL_MODE } from '../lib/control-mode';
+import { downloadSessionFit } from '../lib/fit';
import { aggregateMaximum, formatAggregateAverage, formatWholeNumber } from '../lib/format';
import { resistanceForVirtualGear } from '../lib/gears';
import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation';
@@ -170,6 +171,19 @@ export function SessionDetail({