diff --git a/README.md b/README.md index 53a3f2d..c46add3 100644 --- a/README.md +++ b/README.md @@ -6,30 +6,30 @@ 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; 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 also see a prominent compatibility warning and official Chrome 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; and `/devices` opens the paired-devices tray. Collection links open the corresponding tray, invalid identifiers fall back safely, and browser back/forward navigation restores the matching nested interface. +- 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. - Detects browsers outside the currently tested Chrome environment and replaces the pairing controls with a compatibility notice, while showing Chrome's automatic-reconnect setup steps directly in the paired-devices panel only when its persistent permission capability is unavailable and confirming when it is configured correctly. -- 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. +- 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. A tiny static deployment marker is revalidated at most once per hour; when it differs from the running bundle, a persistent notice offers to reload into the latest version without automatically interrupting a ride. - 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, editable front-chainring and rear-cassette tooth counts, and the app-wide MPH or KM/H display preference. Profile images are resized and compressed to a compact WebP in the browser before storage. Weight follows the profile's selected pounds or kilograms display, while calculations use canonical kilograms. The browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight, bike weight, chainrings, and cassette before recording begins, preserves it through active-session recovery and continuation, and round-trips it through Ride Control TCX files so historical rides remain attributable to the settings that produced them. Those physics fields lock after recording begins and unlock when the session ends; name, image, identity, and display units remain editable. 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 or enter session history. +- Opens the linkable Profile view as a slide-out tray with shared, keyboard-accessible tabs that separate Personal details from Bikes while leaving room for future sections such as Premium and Teams. `/profile?tab=personal` and `/profile?tab=bikes` link directly to each section, browser history follows tab changes, and plain `/profile` safely defaults to Personal details. Switching tabs preserves every unsaved form edit. Profile data remains in IndexedDB on the current device and includes name, profile image, rider weight, an inclusive free-form sex or gender identity field, the app-wide Imperial or Metric display preference, and multiple named bikes. Every bike can store its own prepared image, manufacturer, model, color, purchase date, weight, front-chainring teeth, and rear-cassette teeth; rider and bike images share the same JPEG/PNG/WebP validation, browser-side resizing and compression, 32 MB source ceiling, 512-pixel edge, and 512 KB prepared-image ceiling. Removing a bike, profile image, or bike image requires explicit confirmation. 1×11, 1×12, 2×, and other valid drivetrains are supported up to 24 total combinations. Selecting the active bike immediately supplies that bike's mass and ordered virtual gear ratios to trainer physics. Existing single-bike and multi-bike profiles migrate automatically. Every actual rider-weight change is timestamped in the profile without adding duplicates for unchanged saves or unit conversions; the tray graphs the complete series with current weight and net change while retaining the complete local history for future encrypted sync. Weight follows the selected pounds or kilograms display while calculations use canonical kilograms, and the browser warns before reloading while the open profile contains unsaved changes. Each ride captures an immutable, physics-only snapshot of rider weight plus the active bike's identity, weight, chainrings, and cassette before recording begins, preserves it through active-session recovery and continuation, and round-trips it through Ride Control TCX files so later bike edits do not rewrite historical settings. Those physics fields and the active-bike selection lock after recording begins and unlock when the session ends; names, images, identity, display units, and descriptive bike metadata remain editable. Identity, rider name, and images never affect workout calculations or enter session history. Future cloud storage and synchronization will be offered as a premium feature. - 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. - Keeps BikeGPX analysis presentation stable by listing only fully prepared routes. Every visible route already has finalized difficulty, distance, climbing, maximum grade, and map data; routes with unusable coordinate or elevation data never appear. -- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever the `+` Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, and keyboard down/up arrows remain usable. The physical `+` button shifts up and its blue `Y` button shifts down. These controls 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. The prepared route grade produces one stable terrain target, then a calibrated load curve spreads the physical drivetrain ratios across a useful trainer range: gear 12 preserves the terrain target, gear 1 substantially unloads it for climbing, and gear 24 provides the full 53/12 top end. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. 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. +- Replaces direct resistance controls with a focused virtual shifting interface whenever the `+` Zwift Click V2 controller is paired or a terrain workout is selected. Virtual shifting becomes available as soon as the trainer is connected, regardless of whether the remembered Click controller is currently connected; available Click presses, the on-screen minus/plus buttons, and keyboard down/up arrows remain usable. The physical `+` button shifts up and its blue `Y` button shifts down. The configured chainrings and cassette determine the number of positions—11 for a 1×11, 12 for a 1×12, and up to 24 total—and define the drivetrain's easiest, neutral, and hardest ratios. Positions use equal percentage load steps on either side of the middle neutral gear, while the control identifies the selected physical chainring/cassette combination, its ratio, and its calibrated load multiplier, such as `53/15 · 3.53:1 · 2.21× load`. The progress meter retains visible fill in the easiest gear and increases at every higher position, so gear 1 remains represented while gear 2 is visibly farther along. The prepared route grade produces one stable terrain target, then the calibrated load curve progressively unloads gears below neutral and adds load above it: the middle gear preserves the terrain target at `1.00×`, gear 1 provides the easiest configured ratio, and the final gear provides the hardest configured ratio. Reported speed, power, and cadence remain measured results of the trainer's brake load instead of being fed back into that same target and destabilizing it. 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. 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. -- 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 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; 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, comments, and the original session identifier. +- 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. - 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 TCX selected by default, the rider's format choice remembered locally, 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. +- Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. Active rides are checkpointed locally and restored after a page reload; the restored dashboard explains that ride data remains safe while Bluetooth devices may need time to reconnect before riding continues, then automatically removes that notice once the trainer and every other paired ride device are connected again. - 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. @@ -72,10 +72,12 @@ individual JSON definition under `src/workouts`; shared factories derive the nor course geometry and terrain behavior. Course geometry produces grade, elevation, current and completed course counts, and map position. A shared terrain 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 +Virtual gearing applies a calibrated load curve across the active bike's modeled drivetrain ratios—including +1×11 and 1×12 setups—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. An optional IndexedDB -profile supplies total rider-and-bike mass and the configured drivetrain ratios to that calculation; +profile supplies total rider-and-bike mass and the configured drivetrain ratios to that calculation, +and retains a timestamped history whenever the rider weight actually changes; 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 @@ -95,7 +97,10 @@ and repeated dialog and keyboard behavior so those rules stay consistent across Pull requests and pushes to `main` run the complete `bun run ci` suite in GitHub Actions. After CI succeeds on `main`, a separate workflow runs `bun run build` and deploys the generated `dist` -assets to a Cloudflare Worker at [ridecontrol.xyz](https://ridecontrol.xyz). +assets to a Cloudflare Worker at [ridecontrol.xyz](https://ridecontrol.xyz). Each build emits +`version.json` beside those static assets. Running clients revalidate that marker with the browser +cache at most once per hour, so unchanged checks can use Cloudflare's asset ETag without invoking +dynamic Worker code or transferring the application bundle again. ## Automatic reconnect @@ -112,7 +117,7 @@ The paired-devices panel detects Chrome's persistent reconnect capability and re ## License -Copyright (C) 2026 Public Profile. +Copyright (C) 2026 Ride Control. Ride Control is licensed under version 3 of the GNU General Public License. See [LICENSE](LICENSE) for the complete license terms. diff --git a/src/app.tsx b/src/app.tsx index 667b465..8fe6953 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -9,13 +9,19 @@ 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 { ProfilePanel } from './components/profile-panel'; import { RideMetrics } from './components/ride-metrics'; import { SessionControls } from './components/session-controls'; import { SessionHistory } from './components/session-history'; import { SessionOverview } from './components/session-overview'; +import { + SessionRecoveryNotice, + sessionRecoveryConnectionsReady, + useAutoDismissSessionRecoveryNotice, +} from './components/session-recovery-notice'; import { SessionSaveDialog } from './components/session-save-dialog'; import { TrainingControl } from './components/training-control'; +import { DeploymentVersionUpdateNotice } from './components/version-update-notice'; import { WelcomeDialog } from './components/welcome-dialog'; import { WorkoutPanel } from './components/workout-panel'; import { WorkoutProgress } from './components/workout-progress'; @@ -59,8 +65,9 @@ import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib import { unreachable } from './lib/errors'; import { maximumGear, resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; -import { profileTotalMassKg, type RiderProfile } from './lib/profile'; -import { sessionNeedsUnloadWarning } from './lib/session'; +import { activeRiderPhysicsProfile, type RiderPhysicsProfile } from './lib/profile'; +import type { ProfileTab } from './lib/profile-tab'; +import { sessionHasRecordedData, sessionNeedsUnloadWarning } from './lib/session'; import { loadSessionHistoryView, type SessionHistoryView } from './lib/session-history-view'; import { requestUnloadConfirmation } from './lib/unload'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; @@ -100,7 +107,7 @@ function personalizedWorkoutResistance({ virtualShiftingActive, }: { gear: number; - profile: RiderProfile; + profile: RiderPhysicsProfile; profileReady: boolean; terrainResistance?: number; virtualShiftingActive: boolean; @@ -109,7 +116,12 @@ function personalizedWorkoutResistance({ return; } return virtualShiftingActive - ? resistanceForVirtualGear(terrainResistance, gear, profile, profileTotalMassKg(profile)) + ? resistanceForVirtualGear( + terrainResistance, + gear, + profile, + profile.riderWeightKg + profile.bikeWeightKg + ) : terrainResistance; } @@ -128,6 +140,9 @@ function restoredRoute(overlay: AppOverlay | undefined): AppRoute { kind: APP_ROUTE_KIND.SESSION, }; } + if (overlay === APP_OVERLAY.PROFILE) { + return { kind: APP_ROUTE_KIND.PROFILE }; + } if (overlay === APP_OVERLAY.WORKOUTS) { if (loadBikeGpxBrowserOpen()) { return { @@ -165,6 +180,14 @@ function sessionRouteSearch( }; } +function profileRouteSearch(profileTab?: ProfileTab): { tab?: ProfileTab } { + return profileTab ? { tab: profileTab } : {}; +} + +function profileRouteRequest(route: AppRoute): ProfileTab | undefined { + return route.kind === APP_ROUTE_KIND.PROFILE ? route.profileTab : undefined; +} + function sessionRouteRequest(route: AppRoute): { calendarMonth?: string; historyView?: SessionHistoryView; @@ -183,6 +206,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const matchedAppRoute = useMemo(() => appRouteFromRouterMatch(routerMatch), [routerMatch]); const navigate = useNavigate(); const [initialAppNavigation] = useState(() => initialNavigation(matchedAppRoute, pathname)); + const [showRestoredSessionNotice, setShowRestoredSessionNotice] = useState(() => + sessionHasRecordedData(initialSession.ended, initialSession.elapsedSeconds) + ); + const dismissRestoredSessionNotice = useCallback(() => setShowRestoredSessionNotice(false), []); const restoringRoute = useRef( pathname === APP_ROUTE_PATH.HOME && initialAppNavigation.route.kind !== APP_ROUTE_KIND.HOME && @@ -191,7 +218,11 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const appRoute = restoringRoute.current ? initialAppNavigation.route : matchedAppRoute; const rememberedDevices = useRememberedBluetoothDevices(); const riderProfile = useProfile(); - const trainer = useTrainer(rememberedDevices, riderProfile.profile); + const riderPhysicsProfile = useMemo( + () => activeRiderPhysicsProfile(riderProfile.profile), + [riderProfile.profile] + ); + const trainer = useTrainer(rememberedDevices, riderPhysicsProfile); const [activeOverlay, setActiveOverlayState] = useState( initialAppNavigation.overlay ); @@ -218,6 +249,13 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored case APP_ROUTE_KIND.HOME: navigate({ replace, to: APP_ROUTE_PATH.HOME }).catch(() => undefined); return; + case APP_ROUTE_KIND.PROFILE: + navigate({ + replace, + search: profileRouteSearch(route.profileTab), + to: APP_ROUTE_PATH.PROFILE, + }).catch(() => undefined); + return; case APP_ROUTE_KIND.SESSION: if (route.sessionId) { navigate({ @@ -264,6 +302,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored }); return; } + if (overlay === APP_OVERLAY.PROFILE) { + navigateToAppRoute({ kind: APP_ROUTE_KIND.PROFILE }); + return; + } if (overlay === APP_OVERLAY.WORKOUTS) { navigateToAppRoute({ kind: APP_ROUTE_KIND.WORKOUT }); return; @@ -312,7 +354,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const virtualShiftingReady = virtualShiftingConnectionReady({ trainerConnected: trainer.connected, }); - const profileMaximumGear = maximumGear(riderProfile.profile); + const profileMaximumGear = maximumGear(riderPhysicsProfile); const gearResistanceRef = useRef<(fromGear: number, toGear: number) => void>( trainer.shiftResistanceForGears ); @@ -338,7 +380,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored trainer.lastPedalingAt, trainer.trainerReportsDistance, initialSession, - riderProfile.ready ? riderProfile.profile : undefined + riderProfile.ready ? riderPhysicsProfile : undefined ); const dashboardWorkout = workoutDashboardPreview({ distance: session.rideDistance, @@ -355,7 +397,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const activeControlMode = trainingControlMode(click.paired, workoutSelected); const workoutResistance = personalizedWorkoutResistance({ gear: gearControl.gear, - profile: riderProfile.profile, + profile: riderPhysicsProfile, profileReady: riderProfile.ready, terrainResistance: workoutTerrain?.resistance, virtualShiftingActive, @@ -366,8 +408,8 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored resistanceForVirtualGear( workoutTerrain.resistance, toGear, - riderProfile.profile, - profileTotalMassKg(riderProfile.profile) + riderPhysicsProfile, + riderPhysicsProfile.riderWeightKg + riderPhysicsProfile.bikeWeightKg ) ) : trainer.shiftResistanceForGears; @@ -404,7 +446,13 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored workflow.requestPersistentStorage(); }, [workflow.requestPersistentStorage]); - const warnBeforeUnload = sessionNeedsUnloadWarning(session.ended, session.elapsedSeconds); + const physicsSettingsLocked = sessionHasRecordedData(session.ended, session.elapsedSeconds); + const warnBeforeUnload = sessionNeedsUnloadWarning( + session.ended, + session.elapsedSeconds, + session.isRiding, + session.manuallyPaused + ); useEffect(() => { if (!warnBeforeUnload) { return; @@ -506,6 +554,7 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored const selectedWorkoutId = selectedWorkoutCourse ? selectedWorkoutCourse.id : undefined; const bikeGpxBrowserOpen = appRoute.kind === APP_ROUTE_KIND.BIKEGPX; const bikeGpxRouteId = bikeGpxBrowserOpen ? appRoute.routeId : undefined; + const requestedProfileTab = profileRouteRequest(appRoute); const focusedWorkoutId = appRoute.kind === APP_ROUTE_KIND.WORKOUT ? appRoute.workoutId : undefined; const { @@ -567,6 +616,15 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored }, [navigateToAppRoute, requestedSessionCalendarMonth, requestedSessionId] ); + const selectProfileTab = useCallback( + (profileTab: ProfileTab) => { + navigateToAppRoute({ + kind: APP_ROUTE_KIND.PROFILE, + profileTab, + }); + }, + [navigateToAppRoute] + ); useEffect(() => { if (!selectedWorkoutCourse) { return; @@ -598,6 +656,21 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored click.busy, click.pairingRole !== undefined, ].some(Boolean); + const recoveredSessionDevicesConnected = sessionRecoveryConnectionsReady({ + clickConnectedCount: click.connectedCount, + clickPairedCount: click.pairedCount, + heartRateConnected: heartRate.connected, + heartRatePaired: heartRate.paired, + rememberedDevicesFailed: rememberedDevices.error !== undefined, + rememberedDevicesLoaded: rememberedDevices.devices !== undefined, + rememberedDevicesSupported: rememberedDevices.supported, + trainerConnected: trainer.connected, + }); + useAutoDismissSessionRecoveryNotice( + showRestoredSessionNotice, + recoveredSessionDevicesConnected, + dismissRestoredSessionNotice + ); return (
@@ -626,6 +699,10 @@ export function App({ initialSession = emptySession }: { initialSession?: Stored pairedDeviceCount={pairedDeviceCount} /> + + {showRestoredSessionNotice ? ( + + ) : null} setActiveOverlay(undefined)} open={activeOverlay === APP_OVERLAY.TERMS} /> - setActiveOverlay(undefined)} onSave={riderProfile.save} onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit} + onSelectTab={selectProfileTab} open={activeOverlay === APP_OVERLAY.PROFILE} - physicsSettingsLocked={warnBeforeUnload} + physicsSettingsLocked={physicsSettingsLocked} profile={riderProfile.profile} + requestedTab={requestedProfileTab} speedUnit={speedUnit} storageError={riderProfile.storageError} /> diff --git a/src/components/gear-control.tsx b/src/components/gear-control.tsx index 4542ac3..bf72df5 100644 --- a/src/components/gear-control.tsx +++ b/src/components/gear-control.tsx @@ -69,7 +69,9 @@ export function GearControl({ }, [disabled, gear, heldDirection, maximumGear, stopHolding]); const progress = - maximumGear === MIN_GEAR ? 100 : ((gear - MIN_GEAR) / (maximumGear - MIN_GEAR)) * 100; + maximumGear === MIN_GEAR + ? 100 + : ((gear - MIN_GEAR + 1) / (maximumGear - MIN_GEAR + 1)) * 100; const activeDirection = heldDirection ?? shiftFlash; const buttonClass = 'grid h-9 w-9 shrink-0 place-items-center rounded-lg border border-line text-slate-300 transition duration-150 hover:border-mint disabled:opacity-40'; diff --git a/src/components/gear-training-control.tsx b/src/components/gear-training-control.tsx index d1c8397..99cd547 100644 --- a/src/components/gear-training-control.tsx +++ b/src/components/gear-training-control.tsx @@ -1,22 +1,36 @@ +import { virtualGearCombination, virtualGearLoadMultiplier } from '../lib/gears'; +import type { VirtualDrivetrain } from '../lib/profile'; import type { ResistanceAdjustmentDirection } from '../types'; import { GearControl } from './gear-control'; import { TrainingControlPanel } from './training-control-panel'; export function GearTrainingControl({ connected, + drivetrain, gear, maximumGear, onShift, shiftFlash, }: { connected: boolean; + drivetrain: VirtualDrivetrain; gear: number; maximumGear: number; onShift: (change: number) => void; shiftFlash?: ResistanceAdjustmentDirection; }) { + const combination = virtualGearCombination(gear, drivetrain); + const loadMultiplier = virtualGearLoadMultiplier(gear, drivetrain); + const gearDetail = combination + ? `${combination.chainringTeeth}/${combination.cassetteTeeth} · ${combination.ratio.toFixed(2)}:1` + : '—'; return ( - + void; - onSave: (profile: RiderProfile) => Promise; - onSelectSpeedUnit: (unit: SpeedUnit) => void; - open: boolean; - physicsSettingsLocked: boolean; - profile: RiderProfile; - speedUnit: SpeedUnit; - storageError: string; -}) { - const closeButtonRef = useDialogInitialFocus(open); - useCloseOnEscape(open, onClose); - useBodyScrollLock(open); - const [saveError, setSaveError] = useState(''); - const [imageError, setImageError] = useState(''); - const [imagePreparing, setImagePreparing] = useState(false); - const imagePreparationGeneration = useRef(0); - const form = useForm({ - defaultValues: profileFormValues(profile, speedUnit), - onSubmit: async ({ formApi, value }) => { - setSaveError(''); - try { - const nextProfile = riderProfileFromFormValues(value); - const image = nextProfile.image - ? await prepareProfileImage(nextProfile.image) - : undefined; - await onSave({ ...nextProfile, image }); - onSelectSpeedUnit(value.speedUnit); - formApi.reset({ ...value, image }); - onClose(); - } catch { - setSaveError('Your profile could not be saved in this browser. Please try again.'); - } - }, - validators: { - onChange: profileFormSchema, - onSubmit: profileFormSchema, - }, - }); - const values = useSelector(form.store, (state) => state.values); - const canSubmit = useSelector(form.store, (state) => state.canSubmit); - const isDirty = useSelector(form.store, (state) => state.isDirty); - const isSubmitting = useSelector(form.store, (state) => state.isSubmitting); - const imageUrl = useMemo( - () => (values.image ? URL.createObjectURL(values.image) : undefined), - [values.image] - ); - const weightUnit = profileWeightUnit(values.speedUnit); - const riderRange = profileWeightRange( - values.speedUnit, - MINIMUM_RIDER_WEIGHT_KG, - MAXIMUM_RIDER_WEIGHT_KG - ); - const bikeRange = profileWeightRange( - values.speedUnit, - MINIMUM_BIKE_WEIGHT_KG, - MAXIMUM_BIKE_WEIGHT_KG - ); - const parsedFront = parsedTeeth(values.frontChainrings); - const parsedRear = parsedTeeth(values.rearCassette); - const gearCount = - parsedFront && parsedRear - ? drivetrainGearCount({ - frontChainringTeeth: parsedFront, - rearCassetteTeeth: parsedRear, - }) - : 0; - - useEffect(() => { - if (!open) { - imagePreparationGeneration.current += 1; - setImagePreparing(false); - return; - } - imagePreparationGeneration.current += 1; - form.reset(profileFormValues(profile, speedUnit)); - setImageError(''); - setImagePreparing(false); - setSaveError(''); - }, [form, open, profile, speedUnit]); - - useEffect( - () => () => { - if (imageUrl) { - URL.revokeObjectURL(imageUrl); - } - }, - [imageUrl] - ); - - useEffect(() => { - if (!(open && (imagePreparing || isDirty))) { - return; - } - const confirmUnsavedProfileExit = (event: BeforeUnloadEvent) => { - requestUnloadConfirmation(event); - }; - window.addEventListener('beforeunload', confirmUnsavedProfileExit); - return () => window.removeEventListener('beforeunload', confirmUnsavedProfileExit); - }, [imagePreparing, isDirty, open]); - - if (!open) { - return null; - } - - const selectSpeedUnit = (unit: SpeedUnit) => { - if (unit === values.speedUnit) { - return; - } - const converted = profileFormValuesForSpeedUnit(values, unit); - form.setFieldValue('bikeWeight', converted.bikeWeight); - form.setFieldValue('riderWeight', converted.riderWeight); - form.setFieldValue('speedUnit', unit); - }; - - const selectProfileImage = async ( - file: File, - onPrepared: (image: Blob | undefined) => void - ) => { - const result = profileImageSchema.safeParse(file); - if (!result.success) { - setImageError(result.error.issues[0]?.message ?? 'Choose a valid profile image.'); - return; - } - if (!result.data) { - setImageError('Choose a valid profile image.'); - return; - } - const generation = imagePreparationGeneration.current + 1; - imagePreparationGeneration.current = generation; - setImagePreparing(true); - setImageError(''); - setSaveError(''); - try { - const image = await prepareProfileImage(result.data); - if (imagePreparationGeneration.current === generation) { - onPrepared(image); - } - } catch (preparationError) { - if (imagePreparationGeneration.current === generation) { - setImageError(errorMessage(preparationError)); - } - } finally { - if (imagePreparationGeneration.current === generation) { - setImagePreparing(false); - } - } - }; - - return ( -
-
-
-

- Profile -

- -
-
{ - event.preventDefault(); - event.stopPropagation(); - form.handleSubmit(); - }} - > - - {(field) => ( -
-
- {imageUrl ? ( - { - ) : ( - - )} -
-
- - { - const [file] = Array.from(event.target.files ?? []); - if (file) { - selectProfileImage(file, (image) => - field.handleChange(image) - ); - } - event.target.value = ''; - }} - type="file" - /> - {field.state.value ? ( - - ) : null} -

- JPEG, PNG, or WebP. Resized and compressed in this browser - before storage. -

- {imageError ? ( -

- {imageError} -

- ) : null} - -
-
- )} -
- -
- - {(field) => ( - - )} - - - {(field) => ( - - )} - -
- Display units -
- {SPEED_UNIT_OPTIONS.map((option) => ( - - ))} -
-

- Controls speed, distance, elevation, and weight units. -

-
- - {(field) => ( - - )} - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - {(field) => ( - - )} - -
- - {physicsSettingsLocked ? ( -

- Weight and drivetrain settings are locked after a ride starts so its - recorded profile remains accurate. End the session to edit them for your - next ride. -

- ) : null} -

- Rider and bike weight adjust terrain load. Chainring and cassette sizes - define the virtual gear ratios. Your profile stays in IndexedDB on this - device. -

- {saveError || storageError ? ( -

- {saveError || storageError} -

- ) : null} -
- - -
-
-
-
- ); -} diff --git a/src/components/profile-panel.tsx b/src/components/profile-panel.tsx new file mode 100644 index 0000000..f238220 --- /dev/null +++ b/src/components/profile-panel.tsx @@ -0,0 +1,1246 @@ +import { useForm, useSelector } from '@tanstack/react-form'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCloseOnEscape, useDialogInitialFocus } from '../hooks/use-dialog-behavior'; +import { errorMessage, unreachable } from '../lib/errors'; +import { + drivetrainGearCount, + MAXIMUM_BIKE_COLOR_LENGTH, + MAXIMUM_BIKE_MANUFACTURER_LENGTH, + MAXIMUM_BIKE_MODEL_LENGTH, + MAXIMUM_BIKE_NAME_LENGTH, + MAXIMUM_BIKE_WEIGHT_KG, + MAXIMUM_PROFILE_BIKES, + MAXIMUM_PROFILE_IDENTITY_LENGTH, + MAXIMUM_PROFILE_NAME_LENGTH, + MAXIMUM_RIDER_WEIGHT_KG, + MINIMUM_BIKE_WEIGHT_KG, + MINIMUM_RIDER_WEIGHT_KG, + PROFILE_IDENTITY_SUGGESTIONS, + PROFILE_IMAGE_ACCEPT, + parsedTeeth, + type RiderProfile, + type RiderWeightEntry, +} from '../lib/profile'; +import { + bikeImageSchema, + newProfileBikeFormValues, + profileFormSchema, + profileFormValues, + profileFormValuesForSpeedUnit, + profileImageSchema, + profileWeightRange, + profileWeightUnit, + riderProfileFromFormValues, +} from '../lib/profile-form'; +import { prepareProfileImage } from '../lib/profile-image'; +import { PROFILE_TAB, PROFILE_TAB_OPTIONS, type ProfileTab } from '../lib/profile-tab'; +import { SPEED_UNIT_OPTIONS } from '../lib/units'; +import { requestUnloadConfirmation } from '../lib/unload'; +import type { SpeedUnit } from '../types'; +import { FormFieldError } from './form-field-error'; +import { Icon } from './icon'; +import { RiderWeightChart } from './rider-weight-chart'; +import { SideTray } from './side-tray'; +import { Tabs } from './tabs'; + +const fieldClass = + 'mt-2 w-full rounded-xl border border-line bg-[#10151a] px-3 py-2.5 text-sm outline-none placeholder:text-slate-600 focus:border-mint'; +const labelClass = 'block font-semibold text-slate-200 text-sm'; +const helpClass = 'mt-1.5 text-slate-500 text-xs leading-5'; +const IMAGE_REMOVAL_KIND = { + BIKE: 'bike', + PROFILE: 'profile', +} as const; + +type PendingImageRemoval = + | { kind: typeof IMAGE_REMOVAL_KIND.PROFILE } + | { + bikeId: string; + bikeName: string; + kind: typeof IMAGE_REMOVAL_KIND.BIKE; + }; + +function profileInitial(name: string): string { + return name.trim().charAt(0).toLocaleUpperCase() || 'R'; +} + +function profileSaveButtonLabel(imagePreparing: boolean, isSubmitting: boolean): string { + if (imagePreparing) { + return 'Preparing image…'; + } + return isSubmitting ? 'Saving…' : 'Save profile'; +} + +function bikeGearCount(frontChainrings: string, rearCassette: string): number { + const front = parsedTeeth(frontChainrings); + const rear = parsedTeeth(rearCassette); + return front && rear + ? drivetrainGearCount({ + frontChainringTeeth: front, + rearCassetteTeeth: rear, + }) + : 0; +} + +function ImageActions({ + chooseLabel, + disabled, + hasImage, + inputId, + onRemove, + preparing, + removeDialogId, +}: { + chooseLabel: string; + disabled: boolean; + hasImage: boolean; + inputId: string; + onRemove: () => void; + preparing: boolean; + removeDialogId: string; +}) { + const visibleChooseLabel = hasImage ? 'Change image' : chooseLabel; + return ( +
+ + {hasImage ? ( + + ) : null} +
+ ); +} + +function RiderWeightHistory({ + entries, + speedUnit, +}: { + entries: readonly RiderWeightEntry[]; + speedUnit: SpeedUnit; +}) { + return ( +
+

Weight history

+ {entries.length > 0 ? ( + + ) : ( +

+ Your first saved weight will start the history. +

+ )} +
+ ); +} + +function ProfileGuidance({ + physicsSettingsLocked, + profileTab, +}: { + physicsSettingsLocked: boolean; + profileTab: ProfileTab; +}) { + if (profileTab === PROFILE_TAB.BIKES && !physicsSettingsLocked) { + return null; + } + + return ( +

+ {profileTab === PROFILE_TAB.PERSONAL ? ( + <> + Your profile stays on device. + {physicsSettingsLocked + ? ' Rider weight is locked while a ride is active so its recorded profile remains accurate. End the session to change it for your next ride.' + : ''} + {' In the future we will offer cloud storage and sync, as a premium feature.'} + + ) : ( + <> + The active bike, bike weight, and drivetrain are locked while a ride is active + so its recorded profile remains accurate. End the session to change them for + your next ride. + + )} +

+ ); +} + +export function RemoveBikeDialog({ + bikeName, + onCancel, + onConfirm, + open, +}: { + bikeName: string; + onCancel: () => void; + onConfirm: () => void; + open: boolean; +}) { + const confirmButtonRef = useDialogInitialFocus(open); + useCloseOnEscape(open, onCancel); + + if (!open) { + return null; + } + + return ( +
+ + +
+ + + ); +} + +export function RemoveImageDialog({ + bikeName = '', + kind, + onCancel, + onConfirm, + open, +}: { + bikeName?: string; + kind: PendingImageRemoval['kind']; + onCancel: () => void; + onConfirm: () => void; + open: boolean; +}) { + const confirmButtonRef = useDialogInitialFocus(open); + useCloseOnEscape(open, onCancel); + + if (!open) { + return null; + } + + const isProfileImage = kind === IMAGE_REMOVAL_KIND.PROFILE; + const title = isProfileImage ? 'Remove profile image?' : 'Remove bike image?'; + const description = isProfileImage ? ( + 'Your profile image will be removed when you save your changes.' + ) : ( + <> + The image for{' '} + {bikeName || 'this bike'} will be + removed when you save your changes. + + ); + + return ( +
+ + +
+ + + ); +} + +export function ProfilePanel({ + onClose, + onSave, + onSelectSpeedUnit, + onSelectTab, + open, + physicsSettingsLocked, + profile, + requestedTab, + speedUnit, + storageError, +}: { + onClose: () => void; + onSave: (profile: RiderProfile) => Promise; + onSelectSpeedUnit: (unit: SpeedUnit) => void; + onSelectTab: (tab: ProfileTab) => void; + open: boolean; + physicsSettingsLocked: boolean; + profile: RiderProfile; + requestedTab?: ProfileTab; + speedUnit: SpeedUnit; + storageError: string; +}) { + const closeButtonRef = useDialogInitialFocus(open); + const [saveError, setSaveError] = useState(''); + const [imageError, setImageError] = useState(''); + const [imagePreparing, setImagePreparing] = useState(false); + const [bikeImageError, setBikeImageError] = useState(''); + const [bikeImagePreparingId, setBikeImagePreparingId] = useState(); + const [bikePendingRemoval, setBikePendingRemoval] = useState<{ + id: string; + name: string; + }>(); + const [imagePendingRemoval, setImagePendingRemoval] = useState(); + const imagePreparationGeneration = useRef(0); + const bikeImagePreparationGeneration = useRef(0); + const form = useForm({ + defaultValues: profileFormValues(profile, speedUnit), + onSubmit: async ({ formApi, value }) => { + setSaveError(''); + try { + const nextProfile = riderProfileFromFormValues(value, profile); + const image = nextProfile.image + ? await prepareProfileImage(nextProfile.image) + : undefined; + await onSave({ ...nextProfile, image }); + onSelectSpeedUnit(value.speedUnit); + formApi.reset({ ...value, image }); + onClose(); + } catch { + setSaveError('Your profile could not be saved in this browser. Please try again.'); + } + }, + validators: { + onChange: profileFormSchema, + onSubmit: profileFormSchema, + }, + }); + const values = useSelector(form.store, (state) => state.values); + const canSubmit = useSelector(form.store, (state) => state.canSubmit); + const isDirty = useSelector(form.store, (state) => state.isDirty); + const isSubmitting = useSelector(form.store, (state) => state.isSubmitting); + const imageUrl = useMemo( + () => (values.image ? URL.createObjectURL(values.image) : undefined), + [values.image] + ); + const weightUnit = profileWeightUnit(values.speedUnit); + const riderRange = profileWeightRange( + values.speedUnit, + MINIMUM_RIDER_WEIGHT_KG, + MAXIMUM_RIDER_WEIGHT_KG + ); + const bikeRange = profileWeightRange( + values.speedUnit, + MINIMUM_BIKE_WEIGHT_KG, + MAXIMUM_BIKE_WEIGHT_KG + ); + const activeBikeIndex = values.bikes.findIndex((bike) => bike.id === values.activeBikeId); + const activeBike = values.bikes.at(activeBikeIndex); + const bikeImageUrl = useMemo( + () => (activeBike?.image ? URL.createObjectURL(activeBike.image) : undefined), + [activeBike?.image] + ); + const bikeImagePreparing = bikeImagePreparingId === activeBike?.id; + const imagePreparationInProgress = imagePreparing || Boolean(bikeImagePreparingId); + const profileTab = requestedTab ?? PROFILE_TAB.PERSONAL; + const gearCount = activeBike + ? bikeGearCount(activeBike.frontChainrings, activeBike.rearCassette) + : 0; + const frontChainringCount = activeBike ? parsedTeeth(activeBike.frontChainrings)?.length : 0; + const rearGearCount = activeBike ? parsedTeeth(activeBike.rearCassette)?.length : 0; + const drivetrainLabel = + frontChainringCount && rearGearCount + ? `${frontChainringCount}×${rearGearCount}` + : undefined; + + useEffect(() => { + if (!open) { + imagePreparationGeneration.current += 1; + bikeImagePreparationGeneration.current += 1; + setImagePreparing(false); + setBikeImagePreparingId(undefined); + return; + } + imagePreparationGeneration.current += 1; + bikeImagePreparationGeneration.current += 1; + form.reset(profileFormValues(profile, speedUnit)); + setBikePendingRemoval(undefined); + setImagePendingRemoval(undefined); + setBikeImageError(''); + setBikeImagePreparingId(undefined); + setImageError(''); + setImagePreparing(false); + setSaveError(''); + }, [form, open, profile, speedUnit]); + + useEffect( + () => () => { + if (imageUrl) { + URL.revokeObjectURL(imageUrl); + } + }, + [imageUrl] + ); + + useEffect( + () => () => { + if (bikeImageUrl) { + URL.revokeObjectURL(bikeImageUrl); + } + }, + [bikeImageUrl] + ); + + useEffect(() => { + if (!(open && (imagePreparing || bikeImagePreparingId || isDirty))) { + return; + } + const confirmUnsavedProfileExit = (event: BeforeUnloadEvent) => { + requestUnloadConfirmation(event); + }; + window.addEventListener('beforeunload', confirmUnsavedProfileExit); + return () => window.removeEventListener('beforeunload', confirmUnsavedProfileExit); + }, [bikeImagePreparingId, imagePreparing, isDirty, open]); + + const selectSpeedUnit = (unit: SpeedUnit) => { + if (unit === values.speedUnit) { + return; + } + const converted = profileFormValuesForSpeedUnit(values, unit); + form.setFieldValue('bikes', converted.bikes); + form.setFieldValue('riderWeight', converted.riderWeight); + form.setFieldValue('speedUnit', unit); + }; + + const selectProfileImage = async ( + file: File, + onPrepared: (image: Blob | undefined) => void + ) => { + const result = profileImageSchema.safeParse(file); + if (!result.success) { + setImageError(result.error.issues[0]?.message ?? 'Choose a valid profile image.'); + return; + } + if (!result.data) { + setImageError('Choose a valid profile image.'); + return; + } + const generation = imagePreparationGeneration.current + 1; + imagePreparationGeneration.current = generation; + setImagePreparing(true); + setImageError(''); + setSaveError(''); + try { + const image = await prepareProfileImage(result.data); + if (imagePreparationGeneration.current === generation) { + onPrepared(image); + } + } catch (preparationError) { + if (imagePreparationGeneration.current === generation) { + setImageError(errorMessage(preparationError)); + } + } finally { + if (imagePreparationGeneration.current === generation) { + setImagePreparing(false); + } + } + }; + + const selectBikeImage = async ( + file: File, + bikeId: string, + onPrepared: (image: Blob | undefined) => void + ) => { + const result = bikeImageSchema.safeParse(file); + if (!result.success) { + setBikeImageError(result.error.issues[0]?.message ?? 'Choose a valid bike image.'); + return; + } + if (!result.data) { + setBikeImageError('Choose a valid bike image.'); + return; + } + const generation = bikeImagePreparationGeneration.current + 1; + bikeImagePreparationGeneration.current = generation; + setBikeImagePreparingId(bikeId); + setBikeImageError(''); + setSaveError(''); + try { + const image = await prepareProfileImage(result.data); + if (bikeImagePreparationGeneration.current === generation) { + onPrepared(image); + } + } catch (preparationError) { + if (bikeImagePreparationGeneration.current === generation) { + setBikeImageError(errorMessage(preparationError)); + } + } finally { + if (bikeImagePreparationGeneration.current === generation) { + setBikeImagePreparingId(undefined); + } + } + }; + + const addBike = () => { + if ( + physicsSettingsLocked || + bikeImagePreparingId || + values.bikes.length >= MAXIMUM_PROFILE_BIKES + ) { + return; + } + const id = crypto.randomUUID(); + form.pushFieldValue( + 'bikes', + newProfileBikeFormValues(id, `Bike ${values.bikes.length + 1}`, values.speedUnit) + ); + form.setFieldValue('activeBikeId', id); + }; + + const removePendingBike = () => { + const bikeIndex = values.bikes.findIndex((bike) => bike.id === bikePendingRemoval?.id); + if ( + physicsSettingsLocked || + bikeImagePreparingId || + bikeIndex < 0 || + values.bikes.length <= 1 + ) { + setBikePendingRemoval(undefined); + return; + } + const replacement = values.bikes.at(bikeIndex - 1) ?? values.bikes.at(bikeIndex + 1); + if (!replacement) { + setBikePendingRemoval(undefined); + return; + } + form.setFieldValue('activeBikeId', replacement.id); + form.removeFieldValue('bikes', bikeIndex); + setBikePendingRemoval(undefined); + }; + + const removePendingImage = () => { + if (!imagePendingRemoval) { + return; + } + switch (imagePendingRemoval.kind) { + case IMAGE_REMOVAL_KIND.PROFILE: + form.setFieldValue('image', undefined); + break; + case IMAGE_REMOVAL_KIND.BIKE: { + const bikeIndex = values.bikes.findIndex( + (bike) => bike.id === imagePendingRemoval.bikeId + ); + if (bikeIndex >= 0) { + form.setFieldValue(`bikes[${bikeIndex}].image`, undefined); + } + break; + } + default: + unreachable(imagePendingRemoval); + } + setImagePendingRemoval(undefined); + }; + + const closeNestedConfirmation = () => { + if (imagePendingRemoval) { + setImagePendingRemoval(undefined); + return true; + } + if (bikePendingRemoval) { + setBikePendingRemoval(undefined); + return true; + } + return false; + }; + + return ( + { + if (closeNestedConfirmation()) { + return; + } + onClose(); + }} + open={open} + panelClassName="flex max-w-3xl flex-col overflow-hidden sm:w-[min(48rem,calc(100vw-2rem))]" + > +
+
+

+ Profile +

+

+ Manage your rider details and bikes. +

+
+ +
+
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + +
+ + + + + + {saveError || storageError ? ( +

+ {saveError || storageError} +

+ ) : null} +
+
+ + +
+ + setBikePendingRemoval(undefined)} + onConfirm={removePendingBike} + open={Boolean(bikePendingRemoval)} + /> + setImagePendingRemoval(undefined)} + onConfirm={removePendingImage} + open={Boolean(imagePendingRemoval)} + /> +
+ ); +} diff --git a/src/components/rider-weight-chart.tsx b/src/components/rider-weight-chart.tsx new file mode 100644 index 0000000..e9f00e6 --- /dev/null +++ b/src/components/rider-weight-chart.tsx @@ -0,0 +1,212 @@ +import { poundsForKilograms, type RiderWeightEntry } from '../lib/profile'; +import { profileWeightUnit } from '../lib/profile-form'; +import type { SpeedUnit } from '../types'; + +const WEIGHT_FORMATTER = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, + minimumFractionDigits: 1, +}); +const WEIGHT_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', +}); +const CHART_LEFT = 4; +const CHART_RIGHT = 96; +const CHART_TOP = 5; +const CHART_BOTTOM = 49; +const CHART_HEIGHT = 54; + +interface WeightChartPoint { + date: string; + recordedAt: number; + value: number; + x: number; + y: number; +} + +function displayedWeight(weightKg: number, speedUnit: SpeedUnit): number { + return speedUnit === 'mph' ? poundsForKilograms(weightKg) : weightKg; +} + +function weightChartPoints( + entries: readonly RiderWeightEntry[], + speedUnit: SpeedUnit +): WeightChartPoint[] { + const sorted = [...entries].sort((left, right) => left.recordedAt - right.recordedAt); + if (sorted.length === 0) { + return []; + } + const values = sorted.map((entry) => displayedWeight(entry.weightKg, speedUnit)); + const minimumTime = sorted[0]?.recordedAt ?? 0; + const maximumTime = sorted.at(-1)?.recordedAt ?? minimumTime; + const minimumValue = Math.min(...values); + const maximumValue = Math.max(...values); + const valueRange = maximumValue - minimumValue; + const padding = Math.max(0.5, valueRange * 0.2); + const chartMinimum = minimumValue - padding; + const chartMaximum = maximumValue + padding; + const chartRange = chartMaximum - chartMinimum; + return sorted.map((entry, index) => ({ + date: WEIGHT_DATE_FORMATTER.format(entry.recordedAt), + recordedAt: entry.recordedAt, + value: values[index] ?? 0, + x: + maximumTime === minimumTime + ? (CHART_LEFT + CHART_RIGHT) / 2 + : CHART_LEFT + + ((entry.recordedAt - minimumTime) / (maximumTime - minimumTime)) * + (CHART_RIGHT - CHART_LEFT), + y: + CHART_BOTTOM - + (((values[index] ?? 0) - chartMinimum) / chartRange) * (CHART_BOTTOM - CHART_TOP), + })); +} + +function weightLinePath(points: readonly WeightChartPoint[]): string { + return points + .map( + (point, index) => + `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}` + ) + .join(' '); +} + +function weightAreaPath(points: readonly WeightChartPoint[]): string { + const [first] = points; + const last = points.at(-1); + if (!(first && last)) { + return ''; + } + return `${weightLinePath(points)} L${last.x.toFixed(2)} ${CHART_BOTTOM} L${first.x.toFixed(2)} ${CHART_BOTTOM} Z`; +} + +function formattedWeightChange(change: number): string { + const normalizedChange = Math.abs(change) < 0.05 ? 0 : change; + if (normalizedChange === 0) { + return WEIGHT_FORMATTER.format(0); + } + return `${normalizedChange > 0 ? '+' : '−'}${WEIGHT_FORMATTER.format( + Math.abs(normalizedChange) + )}`; +} + +export function RiderWeightChart({ + compact = false, + entries, + speedUnit, +}: { + compact?: boolean; + entries: readonly RiderWeightEntry[]; + speedUnit: SpeedUnit; +}) { + const points = weightChartPoints(entries, speedUnit); + const [first] = points; + const latest = points.at(-1); + if (!(first && latest)) { + return null; + } + const unit = profileWeightUnit(speedUnit); + const weightChange = latest.value - first.value; + const linePath = weightLinePath(points); + const endpointPoints = points.length > 1 ? [first, latest] : []; + return ( +
+
+
+

+ Current +

+

+ {WEIGHT_FORMATTER.format(latest.value)}{' '} + {unit} +

+
+
+

+ Change +

+

+ {formattedWeightChange(weightChange)}{' '} + {unit} +

+

since {first.date}

+
+
+ {points.length > 1 ? ( +
+ + {endpointPoints.map((point, index) => ( +
+ ) : ( +
+

+ Save another weight to see your trend. +

+
+ )} +
+ + {latest.recordedAt === first.recordedAt ? ( + 1 measurement + ) : ( + <> + {points.length} measurements + + + )} +
+
+ ); +} diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx index 2b74ae9..46ce428 100644 --- a/src/components/session-detail.tsx +++ b/src/components/session-detail.tsx @@ -262,10 +262,10 @@ export function SessionDetail({

- COMMENTS + DESCRIPTION

- {session.comments || 'No comments'} + {session.comments || 'No description'}

diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index e0bbfa2..4726162 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,4 +1,4 @@ -import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'; +import type { ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSessionHistory } from '../hooks/use-session-history'; import { useSessionInsights } from '../hooks/use-session-insights'; @@ -17,6 +17,7 @@ import { historyKeyboardShortcuts, historyShortcutForKey, } from '../lib/keyboard'; +import type { RiderWeightEntry } from '../lib/profile'; import { adjacentSession } from '../lib/saved-sessions'; import { sessionCalendarMonth, @@ -42,6 +43,7 @@ import { SessionDetail } from './session-detail'; import { SessionHistoryList } from './session-history-list'; import { SessionStatistics } from './session-statistics'; import { SideTray } from './side-tray'; +import { Tabs } from './tabs'; function shouldIgnoreHistoryAction(event: KeyboardEvent) { return ( @@ -62,6 +64,7 @@ export function SessionHistory({ requestedSessionMonth, requestedView, speedUnit, + weightHistory = [], }: { onClose: () => void; onSelectCalendarMonth: (month: string) => void; @@ -73,6 +76,7 @@ export function SessionHistory({ requestedSessionMonth?: string; requestedView?: SessionHistoryView; speedUnit: SpeedUnit; + weightHistory?: readonly RiderWeightEntry[]; }) { const { deleteSelectedSession: deleteHistorySession, @@ -246,39 +250,6 @@ export function SessionHistory({ }, [onSelectView] ); - const selectHistoryViewFromKeyboard = useCallback( - (event: ReactKeyboardEvent, view: SessionHistoryView) => { - const currentIndex = SESSION_HISTORY_VIEW_OPTIONS.findIndex( - (option) => option.value === view - ); - let nextIndex: number | undefined; - if (event.key === 'ArrowLeft') { - nextIndex = - (currentIndex - 1 + SESSION_HISTORY_VIEW_OPTIONS.length) % - SESSION_HISTORY_VIEW_OPTIONS.length; - } else if (event.key === 'ArrowRight') { - nextIndex = (currentIndex + 1) % SESSION_HISTORY_VIEW_OPTIONS.length; - } else if (event.key === 'Home') { - nextIndex = 0; - } else if (event.key === 'End') { - nextIndex = SESSION_HISTORY_VIEW_OPTIONS.length - 1; - } - const nextView = - nextIndex === undefined - ? undefined - : SESSION_HISTORY_VIEW_OPTIONS[nextIndex]?.value; - if (!nextView) { - return; - } - event.preventDefault(); - selectHistoryView(nextView); - event.currentTarget.ownerDocument - .getElementById(`session-history-tab-${nextView}`) - ?.focus(); - }, - [selectHistoryView] - ); - const selectStatisticsSession = useCallback( (id: string) => { selectHistorySession(id); @@ -431,34 +402,13 @@ export function SessionHistory({ -
- {SESSION_HISTORY_VIEW_OPTIONS.map((option) => ( - - ))} -
+
) : ( <> diff --git a/src/components/session-recovery-notice.tsx b/src/components/session-recovery-notice.tsx new file mode 100644 index 0000000..e13c88e --- /dev/null +++ b/src/components/session-recovery-notice.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; + +export interface SessionRecoveryConnections { + clickConnectedCount: number; + clickPairedCount: number; + heartRateConnected: boolean; + heartRatePaired: boolean; + rememberedDevicesFailed: boolean; + rememberedDevicesLoaded: boolean; + rememberedDevicesSupported: boolean; + trainerConnected: boolean; +} + +export function sessionRecoveryConnectionsReady({ + clickConnectedCount, + clickPairedCount, + heartRateConnected, + heartRatePaired, + rememberedDevicesFailed, + rememberedDevicesLoaded, + rememberedDevicesSupported, + trainerConnected, +}: SessionRecoveryConnections): boolean { + const rememberedDevicesReady = + !rememberedDevicesSupported || rememberedDevicesLoaded || rememberedDevicesFailed; + return ( + rememberedDevicesReady && + trainerConnected && + (!heartRatePaired || heartRateConnected) && + clickConnectedCount >= clickPairedCount + ); +} + +export function useAutoDismissSessionRecoveryNotice( + visible: boolean, + connectionsReady: boolean, + onDismiss: () => void +) { + useEffect(() => { + if (visible && connectionsReady) { + onDismiss(); + } + }, [connectionsReady, onDismiss, visible]); +} + +export function SessionRecoveryNotice({ onDismiss }: { onDismiss: () => void }) { + return ( +
+

+ Your ride data is safe and has been restored. Bluetooth connections may need to be + re-established after a reload, so please wait for your devices to reconnect before + continuing. +

+ +
+ ); +} diff --git a/src/components/session-save-dialog.tsx b/src/components/session-save-dialog.tsx index d0d2a86..412d0ac 100644 --- a/src/components/session-save-dialog.tsx +++ b/src/components/session-save-dialog.tsx @@ -2,9 +2,9 @@ import { useForm, useSelector } from '@tanstack/react-form'; import { useEffect } from 'react'; import { unreachable } from '../lib/errors'; import { formatSessionTime, SESSION_FEELING_OPTIONS } from '../lib/saved-sessions'; +import { MAXIMUM_SESSION_DESCRIPTION_LENGTH } from '../lib/session-description'; import { emptySessionSaveFormValues, - MAXIMUM_SESSION_COMMENTS_LENGTH, sessionMetadataFromFormValues, sessionSaveFormSchema, } from '../lib/session-save-form'; @@ -149,16 +149,25 @@ export function SessionSaveDialog({ {(field) => (