diff --git a/AGENTS.md b/AGENTS.md index e2cfc79..8f6febe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,8 @@ - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. - Keep the codebase as DRY as practical: before adding constants, calculations, formatting, parsing, labels, or state-derivation logic, search for an existing domain utility and reuse or extend it. Consolidate meaningful duplication into clearly named domain modules, but do not introduce generic abstractions that hide simple behavior or couple unrelated concepts solely because their implementations look similar. - Make dashboard numbers as large, high-contrast, and easy to read at a glance as the available layout practically allows. Numeric ride status must be visually dominant over its label; do not use caption-sized values when space exists for prominent dashboard typography. +- Treat motion that communicates live application state or progress as functional, not decorative. Do not disable essential dashboard, connection, loading, route-position, or workout animations solely because Chromium reports `prefers-reduced-motion`; instead keep the motion calm and legible, and verify time-based state changes in the tested browser because its automation or accessibility defaults may enable reduced motion unexpectedly. +- Keep modal and dialog headers direct: use one clear title and do not add decorative eyebrow, kicker, category, or status text above it (especially small uppercase labels) unless the user explicitly requests that treatment. - Do not scatter raw string literals for shared domain modes through comparisons or state construction. Define or reuse a named `as const` domain object, derive its union type from those values, centralize validation of persisted or external strings, and reference the named members in application code. Prefer an exhaustive `switch` when every variant has distinct behavior. Raw strings remain appropriate for display text, object property names, browser or protocol values, and boundary-focused tests. - Treat TypeScript types as authoritative after data crosses a validated boundary. Do not add scattered inline runtime `typeof` checks for typed domain data; use ordinary presence checks for optional typed fields. Validate JSON, storage, browser, protocol, and other untyped inputs once through named reusable guards in `src/lib/type-guards.ts` or a focused domain parser. Runtime `typeof` checks belong inside those guards; type-level queries such as `typeof CONSTANT` remain appropriate. - Never include AI assistant, product, vendor, or model names (such as `codex` or `openai`) in branch names, commit messages, pull request titles or descriptions, issue titles or descriptions, tags, release notes, or any other repository-visible metadata or content. diff --git a/README.md b/README.md index f468a22..59d8dfa 100644 --- a/README.md +++ b/README.md @@ -7,19 +7,20 @@ 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. -- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, one animated `Connecting...` label in device details and reconnect buttons, 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; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, remembers its identity, continuously retries saved Click connections after a refresh or controller sleep, and keeps the sleeping-controller display stable between retry attempts; stalled attempts can be retried immediately, and Click presses made while this panel is open stay in setup and do not shift the ride. +- 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. +- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices tray that slides smoothly into and out of view, with prominent pulsing status dots, one animated `Connecting...` label in device details and reconnect buttons, 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; keeps the `+` controller above the `−` controller, automatically identifies each physical side, connects both controllers concurrently, routes mirrored Bluetooth notifications only to that side, glows only its row as it is pressed, and remembers its identity. Saved Click controllers connect together when a ride session starts or resumes, continuously retry after controller sleep, and disconnect as soon as the session ends to preserve their batteries; the paired-devices tray keeps them visibly saved while they are intentionally offline. The `−` controller's event session is proactively refreshed before its V2 timeout, detects either explicit session-expired response for immediate recovery, and serializes its intentional disconnect with the replacement connection so stale cleanup cannot tear down the new event stream. Its operational state is reflected in the connected-device count during that refresh, and a manual reconnect restarts a stale event session instead of trusting a lingering Bluetooth link. Click presses made while this 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. -- 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 both Click controllers from one browser permission snapshot after a reload. Every remembered device begins reconnecting immediately and independently. Trainers and Click controllers 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. +- 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 both Click controllers from one browser permission snapshot after a reload. The trainer and heart-rate monitor begin reconnecting immediately and independently; remembered Click controllers join those parallel attempts only during an active ride session. Trainers and active Click controllers 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. - 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 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 long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. 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 on aligned, smoothly curved top-down and elevation views with a clearly labelled ridden-this-lap or ridden-this-trip 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 percentage, current grade, and effective trainer resistance directly on the map, 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. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; 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. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior. -- Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, exact workout distance, and loop or out-and-back course type. Valid GPX tracks or routes from other tools can be imported into a custom library saved only on the current device; closed paths become loops automatically, while open paths become out-and-back workouts with a generated return leg. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor. -- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired, including during terrain workouts. Shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows use an evenly spaced proportional gear curve with matching visual feedback; gear 12 preserves the terrain load, gear 24 is roughly twice as hard, and gear 1 is roughly half as hard. Holding a shift control continues shifting, terrain changes remain smoothly automated underneath the selected gear, and sessions record and graph the gear together with workout elevation and progress. +- 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 long, gradual rollers centered around 20% resistance and ranging from roughly 15–25%. 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. Restored or currently open sessions always resolve bundled workout IDs to the latest course definition, preventing older embedded map geometry from lingering after a course update. A workout can be selected before riding or planned while viewing a completed session; 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. Workout terrain remains the base load when Zwift Click is paired, allowing virtual gears to scale that resistance without losing the grade-driven course behavior. +- Downloads terrain workouts as standard GPX 1.1 files containing geographic track points and elevation data, with names and descriptions readable by ordinary GPX tools and Ride Control extensions for stable ids, difficulty, baseline resistance, exact workout distance, saved starting location, and point-to-point, loop, or out-and-back course type. Valid GPX tracks or routes from other tools can be imported through the file picker or by dropping a GPX anywhere in the workout tray, then saved into a custom library on the current device; each newly imported workout appears immediately at the top and keeps that position after reloading. The tray also links to BikeGPX's collection of thousands of downloadable routes. An imported route's linked “Starts in” description opens an in-app map dialog beside the workout tray, fits its complete path with start and finish markers over an attributed OpenStreetMap base map, and continuously animates a standard bicycle marker along the route. Routes whose start and finish are genuinely near each other become loops automatically; other GPX routes remain one-way, start-to-finish courses without a generated return leg. The workout library filters immediately by course name or displayed difficulty, and every card keeps an up/down-arrow drag handle in the top-right corner; dragging moves the complete workout card strictly vertically, accepts a drop anywhere over another workout, and animates the surrounding cards out of the way to open a full-card-sized insertion space before saving the preferred order across browser reloads. The terrain tray remembers both that it was open and its workout-list scroll position across page reloads. Import, rename, and reorder feedback stays in a fixed bottom status area beside the selected-workout clearing action, preventing the workout list from shifting. Imported workout names can be clicked and renamed without changing the route's stable duplicate-detection identifier. Their top-down geometry is derived from latitude and longitude, terrain resistance is derived from elevation, and a route without its own description uses its first coordinate to label the starting city through a cached OpenStreetMap lookup, then saves that location directly with the workout so restoring or re-importing a Ride Control GPX does not repeat the request. Missing Ride Control metadata receives safe defaults. Stable workout ids—or a route fingerprint for third-party GPX—prevent built-in or previously imported workouts from being uploaded again. Imported entries can be removed, large tracks are sampled for efficient riding, and the geographic source model is ready for a future workout editor. +- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired, including during terrain workouts, and restores the standard resistance control as soon as both controllers are forgotten. Shifting becomes available once the trainer and both controllers are connected, and the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows 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. This gives gear 24 the full 53/12 top end for pushing on descents while retaining 39/24 as gear 1 for climbing. 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. -- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with large, high-visibility live metric cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop. +- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, resistance, and virtual gear, with large, high-visibility numbers in space-efficient live metric and ride-summary cards, oversized numeric ride totals with subdued unit labels, and focused or combined chart views with subtle separator bands between stacked metrics. Workout grade and elevation are graphed in their own distinct colors, resistance remains visible alongside gear during virtual shifting, and the gear graph stays hidden outside gear mode unless the session contains recorded gear data. The larger chart tabs distribute across the complete chart width. Workout elevation is recorded across the entire ride, so the course profile repeats in the graph for every completed loop. - Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. -- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. +- Opens saved rides from the dashboard's Sessions button and organizes them by local date and time in a slide-out Sessions tray with a compact inline session count, clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. The tray restores both the selected session and the session-list scroll position after a page reload, falling back to the newest available session when a remembered ride no longer exists. - Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, terrain workout metadata and elevation samples, calories, ride feeling, comments, and a unique Ride Control session identifier for reliable duplicate detection. - Imports an individual TCX file or every TCX file inside nested folders in a ZIP directly into local session history, preserves compatible ride data and Ride Control session identifiers, detects duplicates by identifier or stable ride data for legacy exports, and continues past individual invalid files in a batch; imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes. - Downloads every locally saved ride at once as a compressed ZIP containing a folder of individual TCX files, with collision-safe filenames when sessions share the same start time. @@ -27,7 +28,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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. -- Keeps all ride data local to the current browser profile; no account or remote service is required. +- Keeps ride and session data local to the current browser profile; no account is required. When an imported GPX route has no description, only its first coordinate is sent to OpenStreetMap's Nominatim service to find a starting-city label, and that result is cached in the browser and stored with the workout. The resulting location sentence links to OpenStreetMap in a new tab, frames the complete route area, and marks the starting coordinate. ## Run @@ -62,11 +63,11 @@ course keeps its editable metadata, map geometry, elevation, and resistance base individual JSON definition under `src/workouts`; shared factories derive the normalized runtime course geometry and terrain behavior. Course geometry produces grade, elevation, current and completed course counts, map position, and a bounded resistance -target. Virtual gearing applies one shared proportional ratio curve to that target, so terrain -changes ramp smoothly while button-driven gear changes remain immediate. Recorded elevation appears -alongside the other session graphs for the full ride and repeats the course profile on every loop or -out-and-back trip, while route progress and selected gear stay portable in saved sessions and TCX -files. +target. Virtual gearing applies the ordered ratios of the modeled 2×12 drivetrain to that target, so terrain +changes ramp smoothly while button-driven gear changes remain immediate. 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. Shared domain utilities own unit conversions, numeric bounds, storage keys, metric presentation, and repeated dialog and keyboard behavior so those rules stay consistent across views and exports. diff --git a/bun.lock b/bun.lock index 06f20f9..09f5768 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,14 @@ "": { "name": "ridecontrol", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "10.0.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", "fflate": "^0.8.3", + "leaflet": "^1.9.4", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -17,6 +21,7 @@ "@biomejs/biome": "^2.5.4", "@tailwindcss/language-server": "^0.16.0", "@types/bun": "^1.3.14", + "@types/leaflet": "^1.9.21", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/web-bluetooth": "^0.0.21", @@ -50,6 +55,16 @@ "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -170,8 +185,12 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="], + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -340,6 +359,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], diff --git a/package.json b/package.json index c881015..8090ba9 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,14 @@ "preview": "vite preview --host localhost --port 4200" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "10.0.0", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", "fflate": "^0.8.3", + "leaflet": "^1.9.4", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3" @@ -26,6 +30,7 @@ "@biomejs/biome": "^2.5.4", "@tailwindcss/language-server": "^0.16.0", "@types/bun": "^1.3.14", + "@types/leaflet": "^1.9.21", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/web-bluetooth": "^0.0.21", diff --git a/src/app.tsx b/src/app.tsx index 7201c85..58fa2fa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -24,12 +24,21 @@ import { useTrainer } from './hooks/use-trainer'; import { useWorkoutResistance } from './hooks/use-workout'; import { useWorkoutLibrary } from './hooks/use-workout-library'; import { useZwiftClick } from './hooks/use-zwift-click'; -import { APP_OVERLAY, type AppOverlay } from './lib/app-overlay'; +import { + APP_OVERLAY, + type AppOverlay, + loadOpenSideTray, + persistOpenSideTray, +} from './lib/app-overlay'; import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; import { resistanceForVirtualGear } from './lib/gears'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; -import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; +import { + loadStoredSession, + requestUnloadConfirmation, + sessionNeedsUnloadWarning, +} from './lib/session'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; import { workoutDashboardPreview, @@ -66,10 +75,15 @@ function controlModeForClick(paired: boolean): ControlMode { export function App() { const rememberedDevices = useRememberedBluetoothDevices(); const trainer = useTrainer(rememberedDevices); - const [activeOverlay, setActiveOverlay] = useState(() => - shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined + const [activeOverlay, setActiveOverlayState] = useState( + () => loadOpenSideTray() ?? (shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined) ); + const setActiveOverlay = useCallback((overlay: AppOverlay | undefined) => { + persistOpenSideTray(overlay); + setActiveOverlayState(overlay); + }, []); const devicesOpen = activeOverlay === APP_OVERLAY.DEVICES; + const [initialClickConnectionActive] = useState(() => !loadStoredSession().ended); const clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); const heartRate = useHeartRateMonitor(rememberedDevices, trainer.setNotice); @@ -77,7 +91,8 @@ export function App() { handleClickShift, trainer.setNotice, devicesOpen, - rememberedDevices + rememberedDevices, + initialClickConnectionActive ); const liveMetrics = metricsWithHeartRate( trainer.metrics, @@ -102,11 +117,12 @@ export function App() { ready: virtualShiftingReady, setNotice: trainer.setNotice, }); + const activeControlMode = controlModeForClick(click.paired); const session = useSession( liveMetrics, { gear: gearControl.gear, - mode: controlModeForClick(click.paired), + mode: activeControlMode, resistance: trainer.resistance, }, trainer.lastPedalingAt, @@ -141,6 +157,9 @@ export function App() { resistance: workoutResistance, }); const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide); + useEffect(() => { + click.setConnectionActive(!session.ended); + }, [click.setConnectionActive, session.ended]); const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen; clickShiftRef.current = shiftHandlerUnlessBlocked( gearControl.shiftGear, @@ -229,29 +248,33 @@ export function App() { handleNewSessionShortcut, session.ended, session.togglePause, + setActiveOverlay, workflow.endSession, workflow.saveDialogOpen, ]); - const closeWelcome = useCallback((dontShowAgain: boolean) => { - if (dontShowAgain) { - rememberWelcomeDismissal(); - } - setActiveOverlay(undefined); - }, []); + const closeWelcome = useCallback( + (dontShowAgain: boolean) => { + if (dontShowAgain) { + rememberWelcomeDismissal(); + } + setActiveOverlay(undefined); + }, + [setActiveOverlay] + ); const continueFromHistory = useCallback( (savedSession: SavedSession) => { setActiveOverlay(undefined); workflow.requestContinuation(savedSession); }, - [workflow.requestContinuation] + [setActiveOverlay, workflow.requestContinuation] ); const selectWorkout = useCallback( (course?: WorkoutCourse) => { session.selectWorkout(course); setActiveOverlay(undefined); }, - [session.selectWorkout] + [session.selectWorkout, setActiveOverlay] ); const selectedWorkoutCourse = session.selectedWorkout?.course; const selectedWorkoutId = selectedWorkoutCourse?.id; @@ -336,7 +359,7 @@ export function App() { ) : null} - {workoutTerrain && !virtualShiftingActive ? null : ( - - )} + setActiveOverlay(APP_OVERLAY.WELCOME)} /> @@ -394,10 +415,11 @@ export function App() { activeCourse={session.selectedWorkout?.course} courses={workoutLibrary.courses} customCourseIds={workoutLibrary.customCourseIds} - ended={session.ended} onClose={() => setActiveOverlay(undefined)} onImportFile={workoutLibrary.importFile} onRemoveCourse={removeWorkout} + onRenameCourse={workoutLibrary.renameCourse} + onReorderCourse={workoutLibrary.reorderCourse} onSelect={selectWorkout} open={activeOverlay === APP_OVERLAY.WORKOUTS} selectionLocked={workoutLocked} diff --git a/src/components/connection-control.tsx b/src/components/connection-control.tsx index 7431e18..b5065ba 100644 --- a/src/components/connection-control.tsx +++ b/src/components/connection-control.tsx @@ -25,7 +25,7 @@ export function ConnectionControl({ className="inline-flex h-10 items-center gap-2 px-1 font-medium text-slate-400 text-sm" role="status" > - + {status}
{SPEED_UNIT_OPTIONS.map((option) => ( diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 89c228e..9ae4eef 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -23,6 +23,7 @@ interface ClickController extends DeviceConnectionView { interface ClickSlot extends DeviceSlot { connectedCount: number; + connectionActive: boolean; controllers: ClickController[]; onForgetController: (deviceId: string) => void | Promise; pairedCount: number; @@ -92,6 +93,30 @@ function clickControllerOrder(controller: ClickController) { return 2; } +function ClickConnectionStatus({ click, waiting }: { click: ClickSlot; waiting: boolean }) { + if (!click.connectionActive && click.pairedCount) { + return <>Connects during an active session; + } + if (waiting) { + return ; + } + return <>{click.status}; +} + +function pairControllerActionLabel(click: ClickSlot) { + if (click.pairing) { + return 'Selecting…'; + } + if (click.pairedCount) { + return 'Pair other controller'; + } + return 'Pair controller'; +} + +function chromeFlagsLabel(copied: boolean) { + return copied ? 'copied, now paste it into a new tab.' : CHROME_BLUETOOTH_FLAGS_URL; +} + function StatusDot({ bluePulse = false, busy, @@ -323,15 +348,8 @@ export function DevicePairingPanel({ const orderedClickControllers = [...click.controllers].sort( (left, right) => clickControllerOrder(left) - clickControllerOrder(right) ); - let pairControllerLabel = 'Pair controller'; - if (click.pairing) { - pairControllerLabel = 'Selecting…'; - } else if (click.pairedCount) { - pairControllerLabel = 'Pair other controller'; - } - const chromeFlagsCopyLabel = flagsUrlCopied - ? 'copied, now paste it into a new tab.' - : CHROME_BLUETOOTH_FLAGS_URL; + const pairControllerLabel = pairControllerActionLabel(click); + const chromeFlagsCopyLabel = chromeFlagsLabel(flagsUrlCopied); return (

- Pair each sensor once. Ride Control reconnects remembered devices when they - wake up. + Pair each sensor once. Ride Control reconnects remembered sensors + automatically; Click controllers connect only for an active ride.

+ +
+ + setName(event.target.value)} + placeholder="Name this workout" + value={name} + /> + {renameError ? ( +

+ {renameError} +

+ ) : null} +
+ + +
+
+ + + ); +} diff --git a/src/components/ride-metrics.tsx b/src/components/ride-metrics.tsx index 1c89051..e1715a2 100644 --- a/src/components/ride-metrics.tsx +++ b/src/components/ride-metrics.tsx @@ -22,7 +22,7 @@ export function RideMetrics({ const rideAverageSpeed = averageSpeed(rideDistance, elapsedSeconds); const { cadence, heartRate, power, speed } = METRIC_PRESENTATION; return ( -
+
sample.gear !== undefined) ? CONTROL_MODE.GEAR : CONTROL_MODE.RESISTANCE); - const modeForAvailableControl = isControlMode(selectedMode) - ? resolvedControlMode - : selectedMode; - const effectiveMode = - modeForAvailableControl === CHART_MODE.ELEVATION && route.length === 0 - ? CHART_MODE.ALL - : modeForAvailableControl; const series = useMemo(() => { const speedValues = history.map((sample) => convertSpeed(sample.speed, speedUnit)); const routeElevations = route.map((point) => convertElevation(point.elevation, speedUnit)); + const gradeValues = history.map((sample) => sample.grade); + const hasRecordedGear = history.some((sample) => sample.gear !== undefined); const standardSeries = STANDARD_METRIC_KEYS.map((key) => { const presentation = METRIC_PRESENTATION[key]; const values = history.map((sample) => sample[key]); @@ -142,28 +140,51 @@ export function SessionChart({ values, }; }); - const controlSeries = - resolvedControlMode === CONTROL_MODE.GEAR - ? { - chartMaximum: MAX_GEAR, - color: '#adf5bd', - decimals: 0, - key: CONTROL_MODE.GEAR, - label: 'Gear', - minimum: MIN_GEAR, - unit: '', - values: history.map((sample) => sample.gear), - } - : { - chartMaximum: MAX_RESISTANCE, - color: '#adf5bd', - decimals: 0, - key: CONTROL_MODE.RESISTANCE, - label: 'Resistance', - minimum: MIN_RESISTANCE, - unit: '%', - values: history.map((sample) => sample.resistance), - }; + const controlSeries = [ + ...(resolvedControlMode === CONTROL_MODE.GEAR || hasRecordedGear + ? [ + { + chartMaximum: MAX_GEAR, + color: GEAR_METRIC_PRESENTATION.chartColor, + decimals: 0, + key: CONTROL_MODE.GEAR, + label: GEAR_METRIC_PRESENTATION.label, + minimum: MIN_GEAR, + unit: '', + values: history.map((sample) => sample.gear), + }, + ] + : []), + { + chartMaximum: MAX_RESISTANCE, + color: RESISTANCE_METRIC_PRESENTATION.chartColor, + decimals: 0, + key: CONTROL_MODE.RESISTANCE, + label: RESISTANCE_METRIC_PRESENTATION.label, + minimum: MIN_RESISTANCE, + unit: RESISTANCE_METRIC_PRESENTATION.unit, + values: history.map((sample) => sample.resistance), + }, + ]; + const maximumAbsoluteGrade = Math.max( + ...gradeValues.map((grade) => Math.abs(grade ?? 0)), + 0 + ); + const gradeMaximum = roundedChartMaximum(maximumAbsoluteGrade, 5, 5); + const gradeSeries = gradeValues.some((grade) => grade !== undefined) + ? [ + { + chartMaximum: gradeMaximum, + color: GRADE_METRIC_PRESENTATION.chartColor, + decimals: 1, + key: CHART_MODE.GRADE, + label: GRADE_METRIC_PRESENTATION.label, + minimum: -gradeMaximum, + unit: GRADE_METRIC_PRESENTATION.unit, + values: gradeValues, + }, + ] + : []; const elevationSeries = route.length ? [ { @@ -198,23 +219,25 @@ export function SessionChart({ values: speedValues, }, ...standardSeries, - controlSeries, + ...controlSeries, + ...gradeSeries, ...elevationSeries, ]; }, [history, resolvedControlMode, route, speedUnit]); + const effectiveMode = + selectedMode === CHART_MODE.ALL || series.some((item) => item.key === selectedMode) + ? selectedMode + : CHART_MODE.ALL; const visibleSeries = effectiveMode === CHART_MODE.ALL ? series : series.filter((item) => item.key === effectiveMode); const availableModes = useMemo( - () => - route.length - ? [ - ...chartModesForControl(resolvedControlMode), - { label: 'Elevation', value: CHART_MODE.ELEVATION }, - ] - : chartModesForControl(resolvedControlMode), - [resolvedControlMode, route.length] + () => [ + { label: 'All', value: CHART_MODE.ALL }, + ...series.map(({ key, label }) => ({ label, value: key })), + ], + [series] ); const historyPositions = history.map((sample) => sample.elapsedSeconds); const historyStart = history[0]?.elapsedSeconds ?? 0; @@ -257,29 +280,27 @@ export function SessionChart({ return (
-
-
- {availableModes.map((mode) => ( - - ))} -
+
+ {availableModes.map((mode) => ( + + ))}
diff --git a/src/components/session-history-list.tsx b/src/components/session-history-list.tsx index 3d572ee..0b8183c 100644 --- a/src/components/session-history-list.tsx +++ b/src/components/session-history-list.tsx @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import { usePersistentScrollPosition } from '../hooks/use-persistent-scroll-position'; import { formatDuration } from '../lib/format'; import { feelingLabel, @@ -6,6 +7,7 @@ import { groupSessionsByDate, isImportedSession, } from '../lib/saved-sessions'; +import { SESSION_HISTORY_SCROLL_POSITION_STORAGE_KEY } from '../lib/session-history-preferences'; import { formatDistance } from '../lib/units'; import type { SavedSessionSummary, SpeedUnit } from '../types'; import { Icon } from './icon'; @@ -15,6 +17,7 @@ export function SessionHistoryList({ highlightedSessionIds, onLoadMore, onSelect, + open, selectedId, speedUnit, summaries, @@ -24,6 +27,7 @@ export function SessionHistoryList({ highlightedSessionIds: string[]; onLoadMore: () => void; onSelect: (id: string) => void; + open: boolean; selectedId?: string; speedUnit: SpeedUnit; summaries: SavedSessionSummary[]; @@ -31,9 +35,19 @@ export function SessionHistoryList({ }) { const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); const highlightedIds = useMemo(() => new Set(highlightedSessionIds), [highlightedSessionIds]); + const sessionListScroll = usePersistentScrollPosition( + SESSION_HISTORY_SCROLL_POSITION_STORAGE_KEY, + open, + summaries.length + ); return ( -
+ + ); +} diff --git a/src/components/workout-panel.tsx b/src/components/workout-panel.tsx index 95cf0c6..d059b15 100644 --- a/src/components/workout-panel.tsx +++ b/src/components/workout-panel.tsx @@ -1,34 +1,125 @@ -import { useRef, useState } from 'react'; +import { + type CollisionDetection, + closestCenter, + DndContext, + type DragEndEvent, + type DraggableAttributes, + type DraggableSyntheticListeners, + PointerSensor, + pointerWithin, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { + type CSSProperties, + Fragment, + type KeyboardEvent, + lazy, + Suspense, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { useFileDrop } from '../hooks/use-file-drop'; +import { usePersistentScrollPosition } from '../hooks/use-persistent-scroll-position'; import { errorMessage } from '../lib/errors'; import { formatDistance, formatElevation } from '../lib/units'; -import { downloadWorkoutFile } from '../lib/workout-file'; -import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW } from '../lib/workout-schema'; -import { workoutDifficultyLabel, workoutMaximumGrade } from '../lib/workouts'; +import { + OPENSTREETMAP_ATTRIBUTION_URL, + WORKOUT_DESCRIPTION_ATTRIBUTION, +} from '../lib/workout-description'; +import { canMoveWorkoutCourse, downloadWorkoutFile } from '../lib/workout-file'; +import { WORKOUT_VIEW, workoutRouteLabel } from '../lib/workout-schema'; +import { workoutDifficultyLabel, workoutMatchesSearch, workoutMaximumGrade } from '../lib/workouts'; import type { SpeedUnit, WorkoutCourse } from '../types'; +import { Icon } from './icon'; +import { RenameWorkoutDialog } from './rename-workout-dialog'; import { SideTray } from './side-tray'; import { WorkoutRouteVisualization } from './workout-route-visualization'; +const REORDER_KEY = { + EARLIER: 'ArrowUp', + LATER: 'ArrowDown', +} as const; +const WORKOUT_SCROLL_POSITION_STORAGE_KEY = 'ride-control-workout-scroll-position'; +const WorkoutMapDialog = lazy(async () => { + const module = await import('./workout-map-dialog'); + return { default: module.WorkoutMapDialog }; +}); + +const workoutCollisionDetection: CollisionDetection = (args) => { + const pointerCollisions = pointerWithin(args); + return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args); +}; + function WorkoutCourseCard({ course, custom, disabled, + dragHandleAttributes, + dragHandleListeners, + dragged, + onMove, onRemove, + onRename, onSelect, + onViewMap, + setDragHandleRef, + setNodeRef, selected, speedUnit, + style, }: { course: WorkoutCourse; custom: boolean; disabled: boolean; + dragHandleAttributes: DraggableAttributes; + dragHandleListeners: DraggableSyntheticListeners; + dragged: boolean; + onMove: (direction: -1 | 1) => void; onRemove: () => void; + onRename: () => void; onSelect: () => void; + onViewMap: () => void; + setDragHandleRef: (node: HTMLElement | null) => void; + setNodeRef: (node: HTMLElement | null) => void; selected: boolean; speedUnit: SpeedUnit; + style?: CSSProperties; }) { + const usesOpenStreetMapAttribution = + course.descriptionAttribution === WORKOUT_DESCRIPTION_ATTRIBUTION.OPENSTREETMAP; + const moveWithKeyboard = (event: KeyboardEvent) => { + if (event.key === REORDER_KEY.EARLIER) { + event.preventDefault(); + onMove(-1); + } else if (event.key === REORDER_KEY.LATER) { + event.preventDefault(); + onMove(1); + } + }; + return (
+
-
-

{course.name}

+
+

+ {custom ? ( + + ) : ( + course.name + )} +

- {course.description} + {usesOpenStreetMapAttribution ? ( + + ) : ( + course.description + )}

-
+
{custom ? ( Imported @@ -67,30 +183,44 @@ function WorkoutCourseCard({
{formatDistance(course.distance, speedUnit, 1)}{' '} - {course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK - ? 'out & back' - : 'loop'} + {workoutRouteLabel(course.routeType)} {formatElevation(course.elevationGain, speedUnit)} climbing Up to +{workoutMaximumGrade(course).toFixed(1)}% -
- - {custom ? ( - ) : null} + {custom ? ( + + ) : null} +
+ +
+ +
+
+ + setSearchQuery(event.currentTarget.value)} + placeholder="Search by name or difficulty" + type="search" + value={searchQuery} + /> + {searchQuery ? ( + + ) : null} +
-
- { - const file = event.currentTarget.files?.[0]; - event.currentTarget.value = ''; - if (file) { - importWorkout(file); - } - }} - ref={importInput} - type="file" - /> - -
+ +
+
- × - -
- -
-

{notice}

- {importStatus ? ( -

- {importStatus} -

- ) : null} - {importError ? ( -

- {importError} -

- ) : null} -
-
- {courses.map((course) => ( - onRemoveCourse(course.id)} - onSelect={() => onSelect(course)} - selected={activeCourse?.id === course.id} - speedUnit={speedUnit} - /> - ))} + {importError || libraryStatus} +
+ {activeCourse && !selectionLocked ? ( + + ) : null} +
- {activeCourse && !selectionLocked ? ( -
- -
- ) : null} -
- + Loading map… +
+ } + > + setMappedCourse(undefined)} + /> + + ) : null} + {renamingCourse ? ( + setRenamingCourse(undefined)} + onRename={(courseId, name) => { + const renamed = onRenameCourse(courseId, name); + setLibraryStatus(`${renamed.name} renamed and saved on this device.`); + }} + /> + ) : null} + ); } diff --git a/src/components/workout-progress.tsx b/src/components/workout-progress.tsx index 0fdb8a7..378d4f8 100644 --- a/src/components/workout-progress.tsx +++ b/src/components/workout-progress.tsx @@ -1,14 +1,39 @@ import { formatGrade } from '../lib/format'; +import { + GRADE_METRIC_PRESENTATION, + RESISTANCE_METRIC_PRESENTATION, +} from '../lib/metric-presentation'; import { formatDistanceProgress, formatElevation } from '../lib/units'; -import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW } from '../lib/workout-schema'; +import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW, type WorkoutRouteType } from '../lib/workout-schema'; import type { ElevationTotals, SessionWorkout, SpeedUnit, WorkoutTerrain } from '../types'; import { WorkoutRouteVisualization } from './workout-route-visualization'; interface WorkoutStat { + color?: string; label: string; value: string; } +const WORKOUT_MAP_PANEL_CLASS = 'bg-[#12171d] px-4 pt-4 pb-2 sm:px-5 sm:pt-5'; +const WORKOUT_MAP_VISUALIZATION_CLASS = 'mt-1 h-36'; + +function workoutCompletionLabels(routeType: WorkoutRouteType): { + completed: string; + ridden: string; + unit: string; +} { + switch (routeType) { + case WORKOUT_ROUTE_TYPE.LOOP: + return { completed: 'Laps completed', ridden: 'Ridden this lap', unit: 'lap' }; + case WORKOUT_ROUTE_TYPE.OUT_AND_BACK: + return { completed: 'Trips completed', ridden: 'Ridden this trip', unit: 'trip' }; + case WORKOUT_ROUTE_TYPE.POINT_TO_POINT: + return { completed: 'Route completed', ridden: 'Ridden this route', unit: 'route' }; + default: + return { completed: '', ridden: '', unit: '' }; + } +} + function WorkoutStats({ highlighted = false, stats, @@ -18,6 +43,7 @@ function WorkoutStats({ }) { const labelSize = highlighted ? 'text-[10px]' : 'text-[9px]'; const valueSize = highlighted ? 'text-3xl sm:text-4xl' : 'text-xl sm:text-2xl'; + const defaultValueColor = highlighted ? 'text-mint' : 'text-white'; return (
{stats.map((stat) => ( @@ -28,7 +54,8 @@ function WorkoutStats({ {stat.label}

{stat.value}

@@ -54,8 +81,7 @@ export function WorkoutProgress({ workout: SessionWorkout; }) { const { course } = workout; - const outAndBack = course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK; - const completionUnit = outAndBack ? 'trip' : 'lap'; + const completion = workoutCompletionLabels(course.routeType); const elevationStats = [ { label: 'Course climb', value: formatElevation(course.elevationGain, speedUnit) }, { label: 'Climbed', value: formatElevation(elevationTotals.ascent, speedUnit) }, @@ -64,10 +90,12 @@ export function WorkoutProgress({ const mapStats = [ { label: 'Progress', value: `${Math.round(terrain.progress * 100)}%` }, { + color: GRADE_METRIC_PRESENTATION.chartColor, label: 'Grade', value: formatGrade(terrain.grade), }, { + color: RESISTANCE_METRIC_PRESENTATION.chartColor, label: 'Resistance', value: `${Math.round(targetResistance ?? terrain.resistance)}%`, }, @@ -79,15 +107,15 @@ export function WorkoutProgress({

{course.name}

- Ridden this {completionUnit} + {completion.ridden}

- {outAndBack ? 'Trips completed' : 'Laps completed'} + {completion.completed}

{terrain.completedLaps} @@ -95,7 +123,7 @@ export function WorkoutProgress({
-
+

@@ -112,14 +140,14 @@ export function WorkoutProgress({

-
+

Elevation profile @@ -127,7 +155,7 @@ export function WorkoutProgress({

+