diff --git a/README.md b/README.md index c46add3..ee81736 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 ## Features -- Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; visitors outside the tested Google Chrome environment also see a prominent compatibility warning and official Chrome download link, and the welcome screen remains available from the Ride Control footer link. +- Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; visitors outside the tested Google Chrome environment first see a prominent top-of-welcome compatibility warning and official browser download link, and the welcome screen remains available from the Ride Control footer link. - Restores the paired-devices, terrain-workouts, or session-history side tray when the page reloads while that tray is open, and clears the remembered tray as soon as it closes. Pulsing indicators that communicate live connection or route state remain active even when Chromium reports a reduced-motion preference. - Uses TanStack Router to keep shareable deep links synchronized with the visible selection: `/bikegpx/:routeId` opens the terrain-workout tray, BikeGPX browser, map, and requested prepared route; `/workouts/:workoutId` opens and centers the requested workout; `/sessions/:sessionId` opens the complete saved-session detail; `/devices` opens the paired-devices tray; and `/profile` opens the rider-and-bike profile tray. Collection links open the corresponding tray, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface. - Manages the smart trainer, heart rate monitor, and the physical `+` Zwift Click V2 controller independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, direct **Cancel pairing** and **Stop connecting** actions during stalled attempts, immediate local removal when **Forget** is chosen even if the Bluetooth link is failing, delayed recovery guidance for unusually long reconnects only while Chrome automatic reconnect is configured and a remembered device remains disconnected, and a green indicator once every paired device is ready. Cancelling invalidates the pending attempt so a late browser selection or GATT completion cannot restore it. Ride Control currently exposes only the reliable `+` controller while retaining an extensible controller-slot model for future hardware support. Its role-specific Bluetooth filter selects the advertised right-side controller, the physical `+` button shifts up, and the blue `Y` button shifts down; the controller row briefly identifies those inputs as `+` and `−` while they are pressed. Pairing reads and remembers the controller's standard firmware revision and battery level when available, live Zwift battery notifications keep the percentage current, and the panel flags versions other than `1.2.0` with a direct link to the official Zwift Companion update instructions. The saved controller reconnects during any open session, including its initial or inactivity-triggered auto-pause, and keeps retrying after sleep so virtual shifts are ready when riding resumes. It may disconnect during an explicit manual pause or after the session ends to preserve its battery. The controller is not reported ready until its notification stream produces data, and Click presses made while the paired-devices panel is open stay in setup and do not shift the ride. @@ -23,7 +23,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. Reloading while riding or explicitly paused retains the browser safety confirmation, while an inactivity-triggered auto-pause can reload immediately because its complete checkpoint is already stored locally. Finishing a ride smoothly returns a connected trainer to 10% resistance; if it is disconnected, 10% is remembered and applied when it reconnects. - Tracks every time-series sample plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with no duration-based truncation during recording or FIT/TCX import. Large, high-visibility numbers appear in space-efficient live metric and ride-summary cards, with oversized ride totals and subdued unit labels. Focused or combined charts use a responsive display-only sample of long histories without changing the complete data retained for summaries and exports. The resistance chart starts at a useful 50% scale and expands in ten-point steps as samples approach its ceiling. 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. Workout elevation is recorded across the entire ride, so the course profile repeats 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. - Keeps the complete dashboard usable at phone widths: ride totals reflow when necessary, chart controls and plots shrink within the viewport, virtual shifting uses the available width, and the footer remains below the controls with device safe-area spacing. -- Provides clear footer access to email contact, the privacy policy, terms of service, and the current deployment version in responsive in-app dialogs. Production version details include links and merge dates for the ten most recent frontend pull requests. +- Provides clear footer access to email contact, the privacy policy, terms of service, and the current deployment version in responsive in-app dialogs. The legal dialogs explain today's local-only storage and briefly disclose the planned optional paid premium cloud-storage features that will receive expanded terms and privacy details before launch. Production version details include links and merge dates for the ten most recent frontend pull requests. - 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 and in-progress sessions use browser-managed IndexedDB storage, and active rides are checkpointed in small sample chunks so recovery does not repeatedly rewrite the complete history. Existing localStorage recovery data is migrated once and removed only after IndexedDB has accepted it. Saved sessions support an optional 500-character description with a live character count plus ride feeling, and persistent browser storage is requested when supported. - Opens saved rides from the dashboard's Sessions button in a slide-out tray with Calendar, List, and Statistics views. The month calendar marks every day with rides and makes each event directly selectable, while the virtualized chronological list retains paginated loading for very large histories. Statistics are updated transactionally whenever a session is saved, replaced, imported, or deleted, then read from compact IndexedDB rollups instead of rescanning telemetry. All-time totals cover rides, distance, time, climbing, downhill, calories, speed, power, cadence, and heart rate; their responsive cards use at most three columns and always show complete numeric values instead of truncating them. The statistics view also graphs the same canonical profile-weight history shown in Profile, with values converted into the selected display unit. Personal-best cards open their source sessions, and dedicated weekly, monthly, and yearly graphs show distance, time, elevation, calories, ride count, average speed, power, cadence, and heart rate. Detailed session metrics and charts, clear date ranges for rides that span midnight, keyboard navigation with grouped shortcut help, and permanent deletion remain available. The tray remembers its active view, selected session, list scroll position, and each session's independent detail-pane scroll position after a page reload. - 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, session description, and the original session identifier. diff --git a/src/components/legal-dialog.tsx b/src/components/legal-dialog.tsx index b45f7ee..7615d06 100644 --- a/src/components/legal-dialog.tsx +++ b/src/components/legal-dialog.tsx @@ -109,6 +109,17 @@ export function PrivacyPolicyDialog({ onClose, open }: { onClose: () => void; op

+
+

Future premium cloud storage

+

+ Ride Control plans to offer optional paid premium features for storing and + synchronizing profile and ride data in the cloud. These features are not + available today. We will expand this policy before they launch to explain what + data is uploaded, how it is protected, how long it is retained, and the choices + available to you. +

+
+

Optional features and external services

+
+

Future premium services

+

+ Ride Control plans to offer optional paid premium features for storing and + synchronizing your data in the cloud. These services are not available today. We + will expand these terms before they launch with the applicable storage, pricing, + billing, cancellation, and data provisions. +

+
+

Third-party services and content

diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index 369ae29..a553e88 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -114,9 +114,9 @@ export function SessionMetric({ {unit}

- AVERAGE + AVG {maximum === undefined ? null : ( - + MAX diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index d0d2c93..3a3f670 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -113,6 +113,7 @@ export function SessionChart({ route, selectedChartMode, speedUnit, + variant = 'dashboard', }: { controlMode?: ControlMode; history: MetricSample[]; @@ -121,6 +122,7 @@ export function SessionChart({ route: readonly RoutePoint[]; selectedChartMode?: ChartMode; speedUnit: SpeedUnit; + variant?: 'dashboard' | 'session'; }) { const preferredChartMode = useSelector( preferencesStore, @@ -265,6 +267,13 @@ export function SessionChart({ const historyStart = chartHistory.at(0)?.elapsedSeconds ?? 0; const historySeconds = chartHistory.length > 1 ? (chartHistory.at(-1)?.elapsedSeconds ?? 0) - historyStart : 0; + const sessionControls = variant === 'session'; + const controlsClassName = sessionControls + ? 'grid w-full gap-1 rounded-lg bg-[#0d1217] p-1' + : 'scrollbar-hidden flex w-full gap-1 overflow-x-auto rounded-lg bg-[#0d1217] p-1'; + const controlClassName = sessionControls + ? 'inline-flex min-w-0 w-full items-center justify-center gap-1 whitespace-nowrap rounded-md px-1 py-2 font-semibold text-[9px] transition sm:text-[11px] xl:text-[13px]' + : 'inline-flex min-w-max flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-1.5 py-2 font-semibold text-[11px] transition sm:text-[13px]'; const selectMode = useCallback( (mode: ChartMode) => (onSelectChartMode ?? preferencesStore.actions.selectChartMode)(mode), @@ -302,10 +311,19 @@ export function SessionChart({ return (
-
+
{availableModes.map((mode) => ( - - {onStartNew ? ( +
+
- ) : null} - {onDelete ? ( +
+ {onStartNew || onDelete ? ( +
+ {onStartNew ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
) : null}
{onCancelDelete && onConfirmDelete ? ( @@ -238,21 +251,21 @@ export function SessionDetail({ timeLabel="RECORDED" />
+
+ {[...standardMetrics, ...controlMetrics].map((metric) => ( + + ))} +
{session.workout && workoutTerrain ? ( ) : null} -
- {[...standardMetrics, controlMetric].map((metric) => ( - - ))} -

FELT

@@ -277,6 +290,7 @@ export function SessionDetail({ route={session.workout ? session.workout.course.points : EMPTY_ROUTE} selectedChartMode={selectedChartMode} speedUnit={speedUnit} + variant="session" />
); diff --git a/src/components/welcome-dialog.tsx b/src/components/welcome-dialog.tsx index e68d826..4f65046 100644 --- a/src/components/welcome-dialog.tsx +++ b/src/components/welcome-dialog.tsx @@ -68,26 +68,26 @@ export function WelcomeDialog({
-

- Pair your trainer, heart rate monitor, and Zwift Click over Bluetooth, then - adjust resistance or shift virtual gears while keeping detailed records of every - ride—all from your browser. -

{testedChromeBrowser ? null : ( -

- Ride Control is only tested with Google Chrome and likely only works - correctly in Chrome.{' '} +

+ Ride Control is only tested with Google Chrome and may not work correctly in + other browsers.{' '} - Download Chrome + Download it .

)} +

+ Pair your trainer, heart rate monitor, and Zwift Click over Bluetooth, then + adjust resistance or shift virtual gears while keeping detailed records of every + ride—all from your browser. +

Ride Control is a freely available, open-source GPLv3 application. View the{' '}

Everything runs locally, and all ride data stays in your browser. We don't - upload it anywhere, although we may add an opt-in feature in the future that - would only upload data with your permission. + upload it anywhere. In the future, we plan to offer optional paid premium + features for storing and synchronizing your data in the cloud.

From the history, you can download your rides as Strava-compatible FIT files or diff --git a/src/components/workout-progress.tsx b/src/components/workout-progress.tsx index 378d4f8..63fef27 100644 --- a/src/components/workout-progress.tsx +++ b/src/components/workout-progress.tsx @@ -12,10 +12,14 @@ interface WorkoutStat { color?: string; label: string; value: string; + valueClassName?: string; } const WORKOUT_MAP_PANEL_CLASS = 'bg-[#12171d] px-4 pt-4 pb-2 sm:px-5 sm:pt-5'; const WORKOUT_MAP_VISUALIZATION_CLASS = 'mt-1 h-36'; +const WORKOUT_PANEL_HEADER_CLASS = 'flex min-h-6 items-start justify-between gap-3'; +const WORKOUT_PANEL_TITLE_CLASS = + 'shrink-0 font-bold text-[10px] text-slate-500 uppercase tracking-[.14em]'; function workoutCompletionLabels(routeType: WorkoutRouteType): { completed: string; @@ -35,26 +39,32 @@ function workoutCompletionLabels(routeType: WorkoutRouteType): { } function WorkoutStats({ + compact = false, highlighted = false, stats, }: { + compact?: boolean; highlighted?: boolean; stats: WorkoutStat[]; }) { + const columnClass = stats.length === 2 ? 'grid-cols-2' : 'grid-cols-3'; const labelSize = highlighted ? 'text-[10px]' : 'text-[9px]'; const valueSize = highlighted ? 'text-3xl sm:text-4xl' : 'text-xl sm:text-2xl'; const defaultValueColor = highlighted ? 'text-mint' : 'text-white'; + const gridClassName = compact + ? `grid w-full gap-2 text-center tabular-nums lg:gap-5 ${columnClass}` + : 'grid grid-cols-3 gap-5 text-center tabular-nums'; return ( -

+
{stats.map((stat) => ( -
+

{stat.label}

{stat.value} @@ -68,9 +78,10 @@ function WorkoutStats({ export function WorkoutProgress({ elevationTotals, isRiding, - targetResistance, speedUnit, + targetResistance, terrain, + variant = 'dashboard', workout, }: { elevationTotals: ElevationTotals; @@ -78,9 +89,11 @@ export function WorkoutProgress({ speedUnit: SpeedUnit; targetResistance?: number; terrain: WorkoutTerrain; + variant?: 'dashboard' | 'session'; workout: SessionWorkout; }) { const { course } = workout; + const sessionSummary = variant === 'session'; const completion = workoutCompletionLabels(course.routeType); const elevationStats = [ { label: 'Course climb', value: formatElevation(course.elevationGain, speedUnit) }, @@ -88,17 +101,25 @@ export function WorkoutProgress({ { label: 'Downhill', value: formatElevation(elevationTotals.descent, speedUnit) }, ]; const mapStats = [ - { label: 'Progress', value: `${Math.round(terrain.progress * 100)}%` }, + { + label: 'Progress', + value: `${Math.round(terrain.progress * 100)}%`, + valueClassName: 'text-mint', + }, { color: GRADE_METRIC_PRESENTATION.chartColor, label: 'Grade', value: formatGrade(terrain.grade), }, - { - color: RESISTANCE_METRIC_PRESENTATION.chartColor, - label: 'Resistance', - value: `${Math.round(targetResistance ?? terrain.resistance)}%`, - }, + ...(sessionSummary + ? [] + : [ + { + color: RESISTANCE_METRIC_PRESENTATION.chartColor, + label: 'Resistance', + value: `${Math.round(targetResistance ?? terrain.resistance)}%`, + }, + ]), ]; return (

@@ -124,21 +145,39 @@ export function WorkoutProgress({
-
-
-

- Course map -

-

- {formatDistanceProgress( - terrain.distance, - course.distance, - speedUnit - )} -

+ {sessionSummary ? ( + <> +
+

Course map

+

+ {formatDistanceProgress( + terrain.distance, + course.distance, + speedUnit + )} +

+
+
+ +
+ + ) : ( +
+
+

+ Course map +

+

+ {formatDistanceProgress( + terrain.distance, + course.distance, + speedUnit + )} +

+
+
- -
+ )}
-
-

- Elevation profile -

- -
+ {sessionSummary ? ( + <> +
+

Elevation profile

+
+
+ +
+ + ) : ( +
+

+ Elevation profile +

+ +
+ )} { expect(html).toContain('bg-yellow-400'); expect(html).toContain('bolt'); expect(html.match(/>W<\/span>/g)).toHaveLength(1); + expect(html).toContain('>AVG'); + expect(html).not.toContain('>AVERAGE'); expect(html).toContain('MAX300'); + expect(html).toContain('pr-1 text-right text-slate-400'); const averageOnly = render( { expect(selectedPanel).not.toContain('Ride without a workout'); const terrain = workoutTerrainAtDistance(course, course.distance * 2 + 2); - const shiftedWorkoutResistance = 42.4; const progress = render( ); @@ -792,13 +794,15 @@ describe('view components', () => { expect(progress).toContain('Grade'); expect(progress).toContain(formatGrade(terrain.grade)); expect(progress).toContain('style="color:#e879f9"'); - expect(progress).toContain('Resistance'); - expect(progress).toContain(`${Math.round(shiftedWorkoutResistance)}%`); - expect(progress).toContain('style="color:#2dd4bf"'); - expect(progress).not.toContain(`${terrain.resistance}%`); - expect(progress.match(/sm:text-4xl/g)).toHaveLength(3); - expect(progress.match(/sm:text-2xl/g)).toHaveLength(3); - expect(progress).toContain('sm:text-lg'); + expect(progress).not.toContain('Resistance'); + expect(progress).not.toContain('style="color:#2dd4bf"'); + expect(progress).not.toContain('sm:text-4xl'); + expect(progress.match(/sm:text-2xl/g)).toHaveLength(5); + expect(progress.match(/min-h-6 items-start justify-between/g)).toHaveLength(2); + expect(progress.match(/mt-3 min-h-16/g)).toHaveLength(2); + expect(progress).toContain('grid-cols-2'); + expect(progress).toContain('grid-cols-3'); + expect(progress.match(/text-\[9px\]/g)).toHaveLength(6); expect(progress.match(/px-4 pt-4 pb-2 sm:px-5 sm:pt-5/g)).toHaveLength(2); expect(progress.match(/mt-1 h-36/g)).toHaveLength(2); expect(progress).toContain('Ridden this lap'); @@ -808,6 +812,26 @@ describe('view components', () => { expect(progress.match(/data-route-progress="true"/g)).toHaveLength(2); expect(progress).not.toContain('stroke-dasharray'); expect(progress).not.toContain('Terrain resistance'); + const dashboardProgress = render( + + ); + expect(dashboardProgress).toContain('Resistance'); + expect(dashboardProgress).toContain('42%'); + expect(dashboardProgress).toContain('style="color:#2dd4bf"'); + expect(dashboardProgress.match(/sm:text-4xl/g)).toHaveLength(3); + expect(dashboardProgress.match(/grid grid-cols-3 gap-5/g)).toHaveLength(2); + expect(dashboardProgress).not.toContain('grid w-full gap-2'); + expect(dashboardProgress).not.toContain('min-h-6 items-start justify-between'); + expect(dashboardProgress).toContain( + 'flex min-h-14 flex-wrap items-start justify-between gap-3' + ); const metricProgress = render( { expect(html).toContain('Effective July 23, 2026'); expect(html).toContain('Ride Control does not create an account'); expect(html).toContain('does not use advertising or behavioral analytics cookies'); + expect(html).toContain('Future premium cloud storage'); + expect(html).toContain('optional paid premium features'); + expect(html).toContain('We will expand this policy before they launch'); expect(html).toContain('href="mailto:hello@ridecontrol.xyz"'); expect(html).toContain('aria-label="Close privacy policy"'); }); @@ -1011,6 +1038,9 @@ describe('view components', () => { expect(html).toContain('href="https://github.com/RideControlOrg/RideControl"'); expect(html).toContain('The backend component is closed source'); expect(html).toContain('optional paid additions'); + expect(html).toContain('Future premium services'); + expect(html).toContain('storing and synchronizing your data in the cloud'); + expect(html).toContain('We will expand these terms before they launch'); expect(html).toContain('aria-label="Close terms of service"'); }); @@ -1301,17 +1331,22 @@ describe('view components', () => { expect(html).toContain('href="https://github.com/RideControlOrg/RideControl"'); expect(html).toContain('all ride data stays in your browser'); expect(html).toContain('We don't upload it anywhere'); - expect(html).toContain('would only upload data with your permission'); + expect(html).toContain('optional paid premium'); + expect(html).toContain('storing and synchronizing your data in the cloud'); expect(html).toContain( 'From the history, you can download your rides as Strava-compatible FIT files' ); expect(html).toContain('only tested with Google Chrome'); - expect(html).toContain('likely only works correctly in Chrome'); + expect(html).toContain('may not work correctly in'); + expect(html.match(/Chrome/g)).toHaveLength(1); expect(html).toContain('href="https://www.google.com/chrome/"'); - expect(html).toContain('>Download Chrome'); + expect(html).toContain('>Download it
'); + expect(html.indexOf('only tested with Google Chrome')).toBeLessThan( + html.indexOf('Pair your trainer') + ); expect( render( undefined} open testedChromeBrowser />) - ).not.toContain('Download Chrome'); + ).not.toContain('Download it'); }); test('renders the keyboard controls reference', () => { @@ -1381,7 +1416,9 @@ describe('view components', () => { expect(html).toContain('pointer-events-none relative h-full w-12 shrink-0'); expect(html).toContain('h-full min-w-0 flex-1 overflow-hidden'); expect(html).toContain('class="block h-full w-full"'); - expect(html).toContain('scrollbar-hidden flex w-full gap-1 overflow-x-auto'); + expect(html).toContain( + 'scrollbar-hidden flex w-full gap-1 overflow-x-auto rounded-lg bg-[#0d1217]' + ); expect(html).toContain('min-w-max flex-1'); expect(html).toContain('h-1.5 w-1.5 shrink-0 rounded-full'); expect(html).toContain('text-[11px] transition sm:text-[13px]'); @@ -1446,8 +1483,13 @@ describe('view components', () => { ]} route={course.points} speedUnit="kmh" + variant="session" /> ); + expect(html).toContain('grid w-full gap-1 rounded-lg bg-[#0d1217]'); + expect(html).toContain('grid-template-columns:repeat(8, minmax(0, 1fr))'); + expect(html).toContain('min-w-0 w-full'); + expect(html).toContain('text-[9px] transition sm:text-[11px] xl:text-[13px]'); expect(html).toContain('Elevation over time'); expect(html).toContain('Elevation'); expect(html).toContain('28 m'); @@ -1929,6 +1971,29 @@ describe('view components', () => { ) ); expect(html).toContain('11:00pm – 1:00am'); + expect(html).toContain('data-session-date-time="true"'); + expect(html).toContain('font-bold text-base text-mint tracking-widest'); + expect(html).toContain('whitespace-nowrap font-bold text-base tabular-nums'); + }); + + test('orders recorded totals, ride metrics, and the saved route map', () => { + const [course] = WORKOUT_COURSES; + if (!course) { + throw new Error('Expected a built-in workout course'); + } + const html = render( + + ); + expect(html).toContain('Course map'); + expect(html).toContain('RECORDED'); + expect(html.indexOf('RECORDED')).toBeLessThan(html.indexOf('POWER')); + expect(html.indexOf('POWER')).toBeLessThan(html.indexOf('Course map')); }); test('styles an unrecorded feeling like the comments value', () => { @@ -1961,6 +2026,10 @@ describe('view components', () => { expect(html).toContain('Start new session'); expect(html).toContain('Download TCX'); expect(html).toContain('Download FIT'); + expect(html).toContain('data-session-file-downloads="true"'); + expect(html).toContain('data-session-actions="true"'); + expect(html).toContain('gap-x-4 gap-y-2 border-line border-t pt-4'); + expect(html).toContain('ml-auto flex flex-wrap justify-end gap-2'); expect(html).toContain('No recorded samples to export'); expect(html).toContain('role="alertdialog"'); expect(html).not.toContain('until'); @@ -1969,13 +2038,14 @@ describe('view components', () => { ); }); - test('shows gear instead of resistance in a virtual shifting session summary', () => { + test('shows both gear and applied resistance in a virtual shifting session summary', () => { const html = render( { ); expect(html).toContain('GEAR'); expect(html).toContain('MAX14'); - expect(html).not.toContain('RESISTANCE'); + expect(html).toContain('RESISTANCE'); + expect(html).toContain('MAX42'); + expect(html).toContain('xl:grid-cols-5'); }); });