diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11b17dd..72fbaa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,13 @@ jobs: build_pr_url="https://github.com/${GITHUB_REPOSITORY}/pulls?q=is%3Apr+is%3Aclosed" fi echo "VITE_BUILD_PR_URL=${build_pr_url}" >> "$GITHUB_ENV" + { + echo "VITE_BUILD_RECENT_PRS<> "$GITHUB_ENV" - name: Build Worker run: bun run build - name: Deploy Worker diff --git a/README.md b/README.md index 6cdcb19..3b02412 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - Shows each deployment's build time in the viewer's local timezone and links it to the GitHub pull request that produced the build, falling back to the closed pull-request list when no associated PR is available. - 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 `+` Click controller from one browser permission snapshot after a reload. Each browser chooser filters by the required advertised service, so trainer pairing shows FTMS hardware while heart-rate pairing shows standard heart-rate monitors. The trainer adapter is based on capability instead of a vendor-specific name, allowing the same path to support Wahoo, Elite, and other standards-compliant trainers while keeping one active trainer for a ride. FTMS control commands wait for the trainer's matching acknowledgement and establish control with the standard Request Control and Start/Resume procedures before resistance is restored. Runtime resistance updates are coalesced to the newest target and sent at most twice per second, preventing ramps and live terrain feedback from building a stale command backlog on slower trainers. A timed-out control response or disconnected GATT write invalidates the old command path and triggers a clean automatic reconnect instead of repeatedly writing through a dead characteristic. The trainer and heart-rate monitor begin reconnecting immediately and independently; the remembered Click controller joins those parallel attempts while a session is open and not manually paused. Trainers and the active Click controller 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. +- Keeps an optional rider profile in IndexedDB with name, profile image, rider and bike weight, an inclusive free-form sex or gender identity field, and editable front-chainring and rear-cassette tooth counts. Weight follows the dashboard's pounds or kilograms display, while calculations use canonical kilograms. Rider and bike mass scale terrain load, and the configured tooth counts define up to 24 ordered virtual gear ratios; identity, name, and image never affect workout calculations. - 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 repeated gradual climbs and descents. 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; ending a workout keeps it selected and previews it at 0% for the next session unless the rider clears or replaces it. 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. Routes contain geometry and elevation rather than an arbitrary resistance baseline; one shared terrain engine derives the load from grade before virtual gearing is applied. - Downloads terrain workouts as standard GPX 1.1 files with ordinary geographic and elevation data plus Ride Control metadata for stable ids, difficulty, exact distance, starting location, and route type. Valid GPX tracks or routes can be imported through the file picker or by dropping a file anywhere in the workout tray, then saved into the current device's custom library with every valid route point preserved. The map-first BikeGPX browser lists only fully prepared public routes, searches them by name, place, distance, or difficulty, filters in the dashboard's current units, continuously scrolls a virtualized list, previews complete routes over OpenStreetMap, shows the selected route's elevation profile beside its finalized statistics, and imports it in one click. Every visible route already includes finalized distance, climbing, maximum grade, and difficulty, and the first matching route previews automatically. Imported route descriptions can open an in-app map with start and finish markers and an animated bicycle; routes with genuinely nearby endpoints become loops while other routes remain point-to-point. Stable fingerprints prevent duplicate imports. The workout library supports immediate filtering, renaming, removing, and vertical drag reordering with persistent order. The terrain tray and BikeGPX browser remember their open state, scroll positions, searches, filters, and selected route across reloads. Missing descriptions use a cached starting-city lookup that is saved with the workout. @@ -22,6 +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. 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. - 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 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 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. @@ -72,7 +74,10 @@ produces grade, elevation, current and completed course counts, and map position engine turns grade into one bounded resistance target, independent of which route supplied it. Virtual gearing applies a calibrated load curve across the modeled 2×12 ratios without feeding the trainer's resulting speed, power, and cadence back into the same resistance target, so terrain -changes ramp smoothly while button-driven gear changes remain immediate. Recorded grade, resistance, +changes ramp smoothly while button-driven gear changes remain immediate. An optional IndexedDB +profile supplies total rider-and-bike mass and the configured drivetrain ratios to that calculation; +the existing 53/39 chainrings, 12-speed 12–24 cassette, and neutral reference mass remain the +defaults. 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; diff --git a/src/app.tsx b/src/app.tsx index b90848a..0d053c8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,11 +2,14 @@ import { useNavigate, useRouterState } from '@tanstack/react-router'; import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AppFooter } from './components/app-footer'; +import { BuildDetailsDialog } from './components/build-details-dialog'; import { Dashboard, DashboardToolbar, DashboardWorkspace } from './components/dashboard-layout'; import { DashboardTools } from './components/dashboard-tools'; import { DevicePairingPanel } from './components/device-pairing'; import { KeyboardShortcutsDialog } from './components/keyboard-shortcuts-dialog'; +import { PrivacyPolicyDialog, TermsOfServiceDialog } from './components/legal-dialog'; import { Notification } from './components/notification'; +import { ProfileDialog } from './components/profile-dialog'; import { RideMetrics } from './components/ride-metrics'; import { SessionControls } from './components/session-controls'; import { SessionHistory } from './components/session-history'; @@ -19,6 +22,7 @@ import { WorkoutProgress } from './components/workout-progress'; import { emptySession } from './constants'; import { useGearControl } from './hooks/use-gear-control'; import { useHeartRateMonitor } from './hooks/use-heart-rate-monitor'; +import { useProfile } from './hooks/use-profile'; import { useRememberedBluetoothDevices } from './hooks/use-remembered-bluetooth-devices'; import { useSession } from './hooks/use-session'; import { useSessionWorkflow } from './hooks/use-session-workflow'; @@ -53,8 +57,9 @@ import { } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; import { unreachable } from './lib/errors'; -import { resistanceForVirtualGear } from './lib/gears'; +import { maximumGear, resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; +import { profileTotalMassKg, type RiderProfile } from './lib/profile'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; import { @@ -85,6 +90,27 @@ function shiftHandlerUnlessBlocked(handler: (change: number) => void, blocked: b return blocked ? () => undefined : handler; } +function personalizedWorkoutResistance({ + gear, + profile, + profileReady, + terrainResistance, + virtualShiftingActive, +}: { + gear: number; + profile: RiderProfile; + profileReady: boolean; + terrainResistance?: number; + virtualShiftingActive: boolean; +}): number | undefined { + if (!(profileReady && terrainResistance !== undefined)) { + return; + } + return virtualShiftingActive + ? resistanceForVirtualGear(terrainResistance, gear, profile, profileTotalMassKg(profile)) + : terrainResistance; +} + interface InitialNavigation { overlay?: AppOverlay; route: AppRoute; @@ -141,7 +167,8 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored ); const appRoute = restoringRoute.current ? initialAppNavigation.route : matchedAppRoute; const rememberedDevices = useRememberedBluetoothDevices(); - const trainer = useTrainer(rememberedDevices); + const riderProfile = useProfile(); + const trainer = useTrainer(rememberedDevices, riderProfile.profile); const [activeOverlay, setActiveOverlayState] = useState( initialAppNavigation.overlay ); @@ -254,6 +281,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const virtualShiftingReady = virtualShiftingConnectionReady({ trainerConnected: trainer.connected, }); + const profileMaximumGear = maximumGear(riderProfile.profile); const gearResistanceRef = useRef<(fromGear: number, toGear: number) => void>( trainer.shiftResistanceForGears ); @@ -263,6 +291,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored ); const gearControl = useGearControl({ active: true, + maximumGear: profileMaximumGear, onGearChange: handleGearChange, ready: virtualShiftingReady, setNotice: trainer.setNotice, @@ -292,18 +321,26 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const workoutSelected = Boolean(dashboardWorkout.workout); const virtualShiftingActive = click.paired || workoutSelected; const activeControlMode = trainingControlMode(click.paired, workoutSelected); - let workoutResistance = workoutTerrain?.resistance; - if (workoutTerrain && virtualShiftingActive) { - workoutResistance = resistanceForVirtualGear(workoutTerrain.resistance, gearControl.gear); - } + const workoutResistance = personalizedWorkoutResistance({ + gear: gearControl.gear, + profile: riderProfile.profile, + profileReady: riderProfile.ready, + terrainResistance: workoutTerrain?.resistance, + virtualShiftingActive, + }); gearResistanceRef.current = workoutTerrain ? (_fromGear, toGear) => trainer.updateProgramShiftResistance( - resistanceForVirtualGear(workoutTerrain.resistance, toGear) + resistanceForVirtualGear( + workoutTerrain.resistance, + toGear, + riderProfile.profile, + profileTotalMassKg(riderProfile.profile) + ) ) : trainer.shiftResistanceForGears; useWorkoutResistance({ - active: !session.ended, + active: !session.ended && riderProfile.ready, connected: trainer.connected, onResistanceChange: trainer.updateProgramResistance, onRestoreResistance: trainer.restoreManualResistance, @@ -560,6 +597,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored virtualShiftingActive ? { gear: gearControl.gear, + maximumGear: profileMaximumGear, mode: CONTROL_MODE.GEAR, onShift: gearControl.shiftGear, shiftFlash: gearControl.shiftFlash, @@ -575,7 +613,13 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored /> - setActiveOverlay(APP_OVERLAY.WELCOME)} /> + setActiveOverlay(APP_OVERLAY.PRIVACY)} + onOpenProfile={() => setActiveOverlay(APP_OVERLAY.PROFILE)} + onOpenTerms={() => setActiveOverlay(APP_OVERLAY.TERMS)} + onOpenVersion={() => setActiveOverlay(APP_OVERLAY.BUILD)} + onOpenWelcome={() => setActiveOverlay(APP_OVERLAY.WELCOME)} + /> + setActiveOverlay(undefined)} + open={activeOverlay === APP_OVERLAY.BUILD} + /> + setActiveOverlay(undefined)} + open={activeOverlay === APP_OVERLAY.PRIVACY} + /> + setActiveOverlay(undefined)} + open={activeOverlay === APP_OVERLAY.TERMS} + /> + setActiveOverlay(undefined)} + onSave={riderProfile.save} + open={activeOverlay === APP_OVERLAY.PROFILE} + profile={riderProfile.profile} + speedUnit={speedUnit} + storageError={riderProfile.storageError} + /> ); diff --git a/src/build-env.d.ts b/src/build-env.d.ts index 6143f68..6294411 100644 --- a/src/build-env.d.ts +++ b/src/build-env.d.ts @@ -1,5 +1,6 @@ interface ImportMetaEnv { readonly RIDE_CONTROL_BUILD_PR_URL: string; + readonly RIDE_CONTROL_BUILD_RECENT_PRS: string; readonly RIDE_CONTROL_BUILD_TIMESTAMP_UTC: string; readonly VITE_RIDECONTROL_API_URL?: string; } diff --git a/src/components/app-footer.tsx b/src/components/app-footer.tsx index a2ee94a..36330fb 100644 --- a/src/components/app-footer.tsx +++ b/src/components/app-footer.tsx @@ -1,10 +1,25 @@ -import { BUILD_PR_URL, BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from '../lib/build-info'; +import { BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from '../lib/build-info'; -export function AppFooter({ onOpenWelcome }: { onOpenWelcome: () => void }) { +const linkClass = + 'rounded-sm transition hover:text-slate-200 focus-visible:outline-2 focus-visible:outline-mint focus-visible:outline-offset-2'; + +export function AppFooter({ + onOpenPrivacy, + onOpenProfile, + onOpenTerms, + onOpenVersion, + onOpenWelcome, +}: { + onOpenPrivacy: () => void; + onOpenProfile: () => void; + onOpenTerms: () => void; + onOpenVersion: () => void; + onOpenWelcome: () => void; +}) { return ( -