Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2
- 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.
- 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 both the selected session and the session-list scroll position after a page reload, falling back to the newest available session when a remembered ride no longer exists.
- 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.
Expand Down
13 changes: 12 additions & 1 deletion src/components/session-detail.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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 { aggregateMaximum, formatAggregateAverage, formatWholeNumber } from '../lib/format';
import { resistanceForVirtualGear } from '../lib/gears';
Expand All @@ -11,6 +12,7 @@ import {
formatSessionTimeRange,
isImportedSession,
} from '../lib/saved-sessions';
import { sessionDetailScrollPositionStorageKey } from '../lib/session-history-preferences';
import { downloadSessionTcx } from '../lib/tcx';
import { workoutTerrainAtDistance } from '../lib/workouts';
import type { SavedSession, SpeedUnit } from '../types';
Expand Down Expand Up @@ -100,6 +102,10 @@ export function SessionDetail({
session: SavedSession;
speedUnit: SpeedUnit;
}) {
const detailScroll = usePersistentScrollPosition(
sessionDetailScrollPositionStorageKey(session.id),
true
);
const usesGear = session.controlMode === CONTROL_MODE.GEAR;
const imported = isImportedSession(session);
const workoutTerrain = session.workout
Expand Down Expand Up @@ -140,7 +146,12 @@ export function SessionDetail({
});

return (
<div className="min-w-0 flex-1 overflow-y-auto p-5 sm:p-6">
<div
className="min-w-0 flex-1 overflow-y-auto p-5 sm:p-6"
data-testid="session-detail"
onScroll={detailScroll.onScroll}
ref={detailScroll.ref}
>
<div className="relative flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
Expand Down
6 changes: 6 additions & 0 deletions src/lib/session-history-preferences.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export const SESSION_HISTORY_SCROLL_POSITION_STORAGE_KEY =
'ride-control-session-history-scroll-position';
export const SESSION_HISTORY_SELECTION_STORAGE_KEY = 'ride-control-selected-session';
const SESSION_DETAIL_SCROLL_POSITION_STORAGE_KEY_PREFIX =
'ride-control-session-detail-scroll-position';

export function sessionDetailScrollPositionStorageKey(sessionId: string): string {
return `${SESSION_DETAIL_SCROLL_POSITION_STORAGE_KEY_PREFIX}:${sessionId}`;
}

export function loadSelectedSessionId(
storage: Pick<Storage, 'getItem'> = localStorage
Expand Down
1 change: 1 addition & 0 deletions tests/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,7 @@ describe('view components', () => {
expect(list).not.toContain('>Imported<');
expect(list).not.toContain('ring-cyan-400/70');
const detail = render(<SessionDetail session={importedSession} speedUnit="kmh" />);
expect(detail).toContain('data-testid="session-detail"');
expect(detail).toContain('>Imported<');
expect(detail).not.toContain('Imported TCX');
expect(detail).toContain('MAX</strong>45');
Expand Down
10 changes: 10 additions & 0 deletions tests/session-history-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@ import {
loadSelectedSessionId,
SESSION_HISTORY_SELECTION_STORAGE_KEY,
saveSelectedSessionId,
sessionDetailScrollPositionStorageKey,
} from '../src/lib/session-history-preferences';

describe('session history preferences', () => {
test('gives each session detail pane an independent scroll position key', () => {
expect(sessionDetailScrollPositionStorageKey('session-1')).toBe(
'ride-control-session-detail-scroll-position:session-1'
);
expect(sessionDetailScrollPositionStorageKey('session-2')).not.toBe(
sessionDetailScrollPositionStorageKey('session-1')
);
});

test('persists, restores, and clears the selected session', () => {
const values = new Map<string, string>();
const storage = {
Expand Down