From f943be322a1b61943bf1252b042b72b8b92076e3 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 22:37:53 -0700 Subject: [PATCH] feat: restore session detail scroll positions --- README.md | 2 +- src/components/session-detail.tsx | 13 ++++++++++++- src/lib/session-history-preferences.ts | 6 ++++++ tests/components.test.tsx | 1 + tests/session-history-preferences.test.ts | 10 ++++++++++ 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 59d8dfa..1c7ef45 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx index a0c5fd6..a61d753 100644 --- a/src/components/session-detail.tsx +++ b/src/components/session-detail.tsx @@ -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'; @@ -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'; @@ -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 @@ -140,7 +146,12 @@ export function SessionDetail({ }); return ( -
+
diff --git a/src/lib/session-history-preferences.ts b/src/lib/session-history-preferences.ts index acd281f..09b3755 100644 --- a/src/lib/session-history-preferences.ts +++ b/src/lib/session-history-preferences.ts @@ -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 = localStorage diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 2c709f9..8e81888 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -1123,6 +1123,7 @@ describe('view components', () => { expect(list).not.toContain('>Imported<'); expect(list).not.toContain('ring-cyan-400/70'); const detail = render(); + expect(detail).toContain('data-testid="session-detail"'); expect(detail).toContain('>Imported<'); expect(detail).not.toContain('Imported TCX'); expect(detail).toContain('MAX45'); diff --git a/tests/session-history-preferences.test.ts b/tests/session-history-preferences.test.ts index 3de6ea8..5b3007f 100644 --- a/tests/session-history-preferences.test.ts +++ b/tests/session-history-preferences.test.ts @@ -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(); const storage = {