From 2938ecbddca210fc81f446f182628261a2b81483 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 16:36:35 -0700 Subject: [PATCH 01/10] Restore side trays and simplify dialog headers Remember the open devices, workouts, or history tray across reloads and clear it when closed. Remove decorative modal eyebrow labels, add focused overlay coverage, and document the direct-header design rule. --- AGENTS.md | 1 + src/app.tsx | 35 +++++++++++----- src/components/session-save-dialog.tsx | 5 +-- src/components/welcome-dialog.tsx | 11 ++--- src/lib/app-overlay.ts | 47 +++++++++++++++++++++ tests/app-overlay.test.ts | 58 ++++++++++++++++++++++++++ tests/components.test.tsx | 5 ++- 7 files changed, 137 insertions(+), 25 deletions(-) create mode 100644 tests/app-overlay.test.ts diff --git a/AGENTS.md b/AGENTS.md index e2cfc79..9185d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - 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. +- 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/src/app.tsx b/src/app.tsx index 7201c85..2d593d8 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -24,7 +24,12 @@ 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'; @@ -66,9 +71,13 @@ 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 clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); @@ -229,29 +238,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; diff --git a/src/components/session-save-dialog.tsx b/src/components/session-save-dialog.tsx index 350b0b8..57b6cd4 100644 --- a/src/components/session-save-dialog.tsx +++ b/src/components/session-save-dialog.tsx @@ -74,10 +74,7 @@ export function SessionSaveDialog({ >
-

- SESSION ENDED -

-

+

Save this session?

diff --git a/src/components/welcome-dialog.tsx b/src/components/welcome-dialog.tsx index 72ae600..0ecbd25 100644 --- a/src/components/welcome-dialog.tsx +++ b/src/components/welcome-dialog.tsx @@ -40,14 +40,9 @@ export function WelcomeDialog({ role="dialog" >

-
-

- WELCOME TO -

-

- RideControl.xyz -

-
+

+ RideControl.xyz +

'); @@ -661,7 +661,7 @@ describe('view components', () => { expect(render( undefined} open={false} />)).toBe(''); const html = render( undefined} open />); expect(html).toContain('aria-modal="true"'); - expect(html).toContain('WELCOME TO'); + expect(html).not.toContain('WELCOME TO'); expect(html).toContain('RideControl.xyz'); expect(html).toContain('show again'); expect(html).toContain('Get started'); @@ -856,6 +856,7 @@ describe('view components', () => { /> ); expect(html).toContain('Save this session?'); + expect(html).not.toContain('SESSION ENDED'); expect(html).toContain('How did it feel?'); expect(html).toContain('Continue without saving'); expect(html).toContain('Save & continue'); From ce464377899e9a1017e50e4fc2b6067bc56a3923 Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 16:36:48 -0700 Subject: [PATCH 02/10] Expand imported workout management and GPX routing Add rename, persistent drag ordering, name and difficulty search, whole-tray GPX drop import, and BikeGPX discovery. Label undescribed routes from a cached starting-city lookup with attribution. Preserve open GPX tracks as point-to-point courses, infer loops from nearby endpoints, migrate legacy generated return legs, and keep route progress fixed at the finish. Update workout UI, exports, documentation, validation, and coverage. --- README.md | 9 +- src/app.tsx | 3 +- src/components/rename-workout-dialog.tsx | 94 +++++ src/components/workout-panel.tsx | 460 +++++++++++++++++------ src/components/workout-progress.tsx | 28 +- src/hooks/use-file-drop.ts | 74 ++++ src/hooks/use-workout-library.ts | 32 +- src/lib/reverse-geocode.ts | 166 ++++++++ src/lib/workout-description.ts | 16 + src/lib/workout-file.ts | 168 ++++++++- src/lib/workout-schema.ts | 14 + src/lib/workouts.ts | 160 +++++--- src/types.ts | 2 + tests/components.test.tsx | 50 ++- tests/reverse-geocode.test.ts | 37 ++ tests/workout-file.test.ts | 196 +++++++++- tests/workouts.test.ts | 43 +++ 17 files changed, 1340 insertions(+), 212 deletions(-) create mode 100644 src/components/rename-workout-dialog.tsx create mode 100644 src/hooks/use-file-drop.ts create mode 100644 src/lib/reverse-geocode.ts create mode 100644 src/lib/workout-description.ts create mode 100644 tests/reverse-geocode.test.ts diff --git a/README.md b/README.md index f468a22..7a7b8f2 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,15 @@ 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. +- 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. - 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. - 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. - 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. +- 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 on aligned, smoothly curved 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 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 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; the tray also links to BikeGPX's collection of thousands of downloadable routes. 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, workouts can be dragged into a preferred order that persists across browser reloads, and 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, falling back safely when no locality is available. 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. - 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. @@ -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. ## Run @@ -65,7 +66,7 @@ produces grade, elevation, current and completed course counts, map position, an 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 +out-and-back trip while point-to-point routes stop at their finish; route progress and selected gear 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/src/app.tsx b/src/app.tsx index 2d593d8..bcf7c33 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -407,10 +407,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/rename-workout-dialog.tsx b/src/components/rename-workout-dialog.tsx new file mode 100644 index 0000000..9932caa --- /dev/null +++ b/src/components/rename-workout-dialog.tsx @@ -0,0 +1,94 @@ +import { type FormEvent, useState } from 'react'; +import { useCloseOnEscape } from '../hooks/use-dialog-behavior'; +import { errorMessage } from '../lib/errors'; +import { MAX_WORKOUT_NAME_LENGTH } from '../lib/workout-file'; +import type { WorkoutCourse } from '../types'; + +export function RenameWorkoutDialog({ + course, + onClose, + onRename, +}: { + course: WorkoutCourse; + onClose: () => void; + onRename: (courseId: string, name: string) => void; +}) { + const [name, setName] = useState(course.name); + const [renameError, setRenameError] = useState(''); + useCloseOnEscape(true, onClose); + + const submitRename = (event: FormEvent) => { + event.preventDefault(); + setRenameError(''); + try { + onRename(course.id, name); + onClose(); + } catch (error) { + setRenameError(errorMessage(error)); + } + }; + + return ( +
+
+
+

+ Rename workout +

+ +
+

+ The route and its duplicate-detection identifier will stay the same. +

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

+ {renameError} +

+ ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/workout-panel.tsx b/src/components/workout-panel.tsx index 95cf0c6..97b3f3a 100644 --- a/src/components/workout-panel.tsx +++ b/src/components/workout-panel.tsx @@ -1,18 +1,39 @@ -import { useRef, useState } from 'react'; +import { type DragEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'; +import { useFileDrop } from '../hooks/use-file-drop'; import { errorMessage } from '../lib/errors'; import { formatDistance, formatElevation } from '../lib/units'; +import { + OPENSTREETMAP_ATTRIBUTION_URL, + WORKOUT_DESCRIPTION_ATTRIBUTION, +} from '../lib/workout-description'; import { downloadWorkoutFile } from '../lib/workout-file'; -import { WORKOUT_ROUTE_TYPE, WORKOUT_VIEW } from '../lib/workout-schema'; -import { workoutDifficultyLabel, workoutMaximumGrade } from '../lib/workouts'; +import { WORKOUT_VIEW, workoutRouteLabel } from '../lib/workout-schema'; +import { workoutDifficultyLabel, workoutMatchesSearch, workoutMaximumGrade } from '../lib/workouts'; import type { SpeedUnit, WorkoutCourse } from '../types'; +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 REORDER_GRIP_DOTS = ['one', 'two', 'three', 'four', 'five', 'six'] as const; + function WorkoutCourseCard({ course, custom, disabled, + dragged, + dropEnabled, + dropTarget, + onDragEnd, + onDragOver, + onDragStart, + onDrop, + onMove, onRemove, + onRename, onSelect, selected, speedUnit, @@ -20,15 +41,44 @@ function WorkoutCourseCard({ course: WorkoutCourse; custom: boolean; disabled: boolean; + dragged: boolean; + dropEnabled: boolean; + dropTarget: boolean; + onDragEnd: () => void; + onDragOver: (event: DragEvent) => void; + onDragStart: (event: DragEvent) => void; + onDrop: (event: DragEvent) => void; + onMove: (direction: -1 | 1) => void; onRemove: () => void; + onRename: () => void; onSelect: () => void; selected: boolean; speedUnit: SpeedUnit; }) { + 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 (
+ {dropEnabled ? ( + + ) : ( + course.name + )} +

{course.description}

+ {course.descriptionAttribution === + WORKOUT_DESCRIPTION_ATTRIBUTION.OPENSTREETMAP ? ( + + City lookup © OpenStreetMap contributors + + ) : null}
-
+
+ {custom ? ( Imported @@ -67,9 +160,7 @@ 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)}% @@ -110,10 +201,11 @@ export function WorkoutPanel({ activeCourse, courses, customCourseIds, - ended, onClose, onImportFile, onRemoveCourse, + onRenameCourse, + onReorderCourse, onSelect, open, selectionLocked, @@ -122,10 +214,11 @@ export function WorkoutPanel({ activeCourse?: WorkoutCourse; courses: WorkoutCourse[]; customCourseIds: ReadonlySet; - ended: boolean; onClose: () => void; onImportFile: (file: File) => Promise; onRemoveCourse: (courseId: string) => void; + onRenameCourse: (courseId: string, name: string) => WorkoutCourse; + onReorderCourse: (movedCourseId: string, targetCourseId: string) => void; onSelect: (course?: WorkoutCourse) => void; open: boolean; selectionLocked: boolean; @@ -133,120 +226,253 @@ export function WorkoutPanel({ }) { const importInput = useRef(null); const [importing, setImporting] = useState(false); - const [importStatus, setImportStatus] = useState(''); + const [libraryStatus, setLibraryStatus] = useState(''); const [importError, setImportError] = useState(''); - let notice = - 'Choose a course before you start riding. The route repeats until the session ends.'; - if (ended) { - notice = 'Choose a workout for your next session, then start it when you are ready.'; - } else if (selectionLocked) { - notice = 'End the current session before changing the workout.'; - } + const [renamingCourse, setRenamingCourse] = useState(); + const [draggedCourseId, setDraggedCourseId] = useState(''); + const [dropTargetCourseId, setDropTargetCourseId] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const filteredCourses = useMemo( + () => courses.filter((course) => workoutMatchesSearch(course, searchQuery)), + [courses, searchQuery] + ); + + const importWorkout = useCallback( + async (file: File) => { + setImporting(true); + setLibraryStatus(''); + setImportError(''); + try { + const course = await onImportFile(file); + setSearchQuery(''); + setLibraryStatus(`${course.name} imported and saved on this device.`); + } catch (error) { + setImportError(errorMessage(error)); + } finally { + setImporting(false); + } + }, + [onImportFile] + ); + const { active: fileDropActive, targetRef: fileDropTarget } = useFileDrop( + open && !importing, + importWorkout + ); - const importWorkout = async (file: File) => { - setImporting(true); - setImportStatus(''); - setImportError(''); - try { - const course = await onImportFile(file); - setImportStatus(`${course.name} imported and saved on this device.`); - } catch (error) { - setImportError(errorMessage(error)); - } finally { - setImporting(false); + const closePanel = () => { + setRenamingCourse(undefined); + setDraggedCourseId(''); + setDropTargetCourseId(''); + setSearchQuery(''); + onClose(); + }; + const finishDragging = () => { + setDraggedCourseId(''); + setDropTargetCourseId(''); + }; + const reorderCourse = (movedCourseId: string, targetCourseId: string) => { + if (movedCourseId === targetCourseId) { + return; + } + onReorderCourse(movedCourseId, targetCourseId); + const movedCourse = courses.find((course) => course.id === movedCourseId); + if (movedCourse) { + setLibraryStatus(`${movedCourse.name} moved and its position was saved.`); } }; return ( - -
-
-
-

- Terrain workouts -

-

- Resistance follows the climbs and descents while your position moves - along the route. -

-
-
- { - const file = event.currentTarget.files?.[0]; - event.currentTarget.value = ''; - if (file) { - importWorkout(file); - } - }} - ref={importInput} - type="file" - /> - - -
-
-
-

{notice}

- {importStatus ? ( -

- {importStatus} -

+
+

+ Drop GPX to import +

+

+ The workout will be saved on this device. +

+
+
) : null} - {importError ? ( -

- {importError} -

+
+
+

+ Terrain workouts +

+

+ Resistance follows the climbs and descents while your position moves + along the route. +

+

+ + BikeGPX has thousands of GPX files + {' '} + you can upload here. +

+
+
+ { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ''; + if (file) { + importWorkout(file); + } + }} + ref={importInput} + type="file" + /> + + +
+
+
+
+ + setSearchQuery(event.currentTarget.value)} + placeholder="Search by name or difficulty" + type="search" + value={searchQuery} + /> + {searchQuery ? ( + + ) : null} +
+ {libraryStatus ? ( +

+ {libraryStatus} +

+ ) : null} + {importError ? ( +

+ {importError} +

+ ) : null} +
+
+ {filteredCourses.map((course, index) => ( + { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (draggedCourseId !== course.id) { + setDropTargetCourseId(course.id); + } + }} + onDragStart={(event) => { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', course.id); + setDraggedCourseId(course.id); + }} + onDrop={(event) => { + event.preventDefault(); + const movedCourseId = + draggedCourseId || event.dataTransfer.getData('text/plain'); + reorderCourse(movedCourseId, course.id); + finishDragging(); + }} + onMove={(direction) => { + const target = filteredCourses[index + direction]; + if (target) { + reorderCourse(course.id, target.id); + } + }} + onRemove={() => onRemoveCourse(course.id)} + onRename={() => setRenamingCourse(course)} + onSelect={() => onSelect(course)} + selected={activeCourse?.id === course.id} + speedUnit={speedUnit} + /> + ))} + {filteredCourses.length === 0 ? ( +

+ No workouts match “{searchQuery.trim()}”. +

+ ) : null} +
+ {activeCourse && !selectionLocked ? ( +
+ +
) : null}
-
- {courses.map((course) => ( - onRemoveCourse(course.id)} - onSelect={() => onSelect(course)} - selected={activeCourse?.id === course.id} - speedUnit={speedUnit} - /> - ))} -
- {activeCourse && !selectionLocked ? ( -
- -
- ) : 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..ebcfb92 100644 --- a/src/components/workout-progress.tsx +++ b/src/components/workout-progress.tsx @@ -1,6 +1,6 @@ import { formatGrade } from '../lib/format'; 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'; @@ -9,6 +9,23 @@ interface WorkoutStat { value: string; } +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, @@ -54,8 +71,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) }, @@ -79,15 +95,15 @@ export function WorkoutProgress({

{course.name}

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

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

{terrain.completedLaps} diff --git a/src/hooks/use-file-drop.ts b/src/hooks/use-file-drop.ts new file mode 100644 index 0000000..0d65d6a --- /dev/null +++ b/src/hooks/use-file-drop.ts @@ -0,0 +1,74 @@ +import { type RefObject, useEffect, useRef, useState } from 'react'; + +const FILE_DRAG_TYPE = 'Files'; + +function containsFiles(dataTransfer: DataTransfer): boolean { + return Array.from(dataTransfer.types).includes(FILE_DRAG_TYPE); +} + +export function useFileDrop( + enabled: boolean, + onDropFile: (file: File) => Promise +): { active: boolean; targetRef: RefObject } { + const targetRef = useRef(null); + const dragDepth = useRef(0); + const [active, setActive] = useState(false); + + useEffect(() => { + const target = targetRef.current; + if (!(enabled && target)) { + return; + } + const finish = () => { + dragDepth.current = 0; + setActive(false); + }; + const enter = (event: DragEvent) => { + if (!(event.dataTransfer && containsFiles(event.dataTransfer))) { + return; + } + event.preventDefault(); + dragDepth.current += 1; + setActive(true); + }; + const leave = (event: DragEvent) => { + event.preventDefault(); + dragDepth.current = Math.max(0, dragDepth.current - 1); + if (dragDepth.current === 0) { + setActive(false); + } + }; + const over = (event: DragEvent) => { + if (!(event.dataTransfer && containsFiles(event.dataTransfer))) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + }; + const drop = (event: DragEvent) => { + if (!(event.dataTransfer && containsFiles(event.dataTransfer))) { + return; + } + event.preventDefault(); + const [file] = Array.from(event.dataTransfer.files); + finish(); + if (file) { + onDropFile(file).catch(() => undefined); + } + }; + + target.addEventListener('dragenter', enter); + target.addEventListener('dragleave', leave); + target.addEventListener('dragover', over); + target.addEventListener('drop', drop); + return () => { + finish(); + target.removeEventListener('dragenter', enter); + target.removeEventListener('dragleave', leave); + target.removeEventListener('dragover', over); + target.removeEventListener('drop', drop); + }; + }, [enabled, onDropFile]); + + return { active, targetRef }; +} diff --git a/src/hooks/use-workout-library.ts b/src/hooks/use-workout-library.ts index 6e8119f..df2c5a0 100644 --- a/src/hooks/use-workout-library.ts +++ b/src/hooks/use-workout-library.ts @@ -2,8 +2,13 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { addCustomWorkout, loadCustomWorkouts, + loadWorkoutOrder, + moveWorkoutCourse, + orderWorkoutCourses, readWorkoutFile, + renameCustomWorkout, saveCustomWorkouts, + saveWorkoutOrder, withoutCustomWorkout, } from '../lib/workout-file'; import { WORKOUT_COURSES } from '../lib/workouts'; @@ -11,8 +16,14 @@ import type { WorkoutCourse } from '../types'; export function useWorkoutLibrary() { const [customCourses, setCustomCourses] = useState(loadCustomWorkouts); + const [courseOrder, setCourseOrder] = useState(loadWorkoutOrder); const customCoursesRef = useRef(customCourses); - const courses = useMemo(() => [...WORKOUT_COURSES, ...customCourses], [customCourses]); + const courses = useMemo( + () => orderWorkoutCourses([...WORKOUT_COURSES, ...customCourses], courseOrder), + [courseOrder, customCourses] + ); + const coursesRef = useRef(courses); + coursesRef.current = courses; const customCourseIds = useMemo( () => new Set(customCourses.map((course) => course.id)), [customCourses] @@ -40,11 +51,30 @@ export function useWorkoutLibrary() { }, [replaceCustomCourses] ); + const renameCourse = useCallback( + (courseId: string, name: string) => { + const result = renameCustomWorkout(customCoursesRef.current, courseId, name); + replaceCustomCourses(result.courses); + return result.course; + }, + [replaceCustomCourses] + ); + const reorderCourse = useCallback((movedCourseId: string, targetCourseId: string) => { + const reordered = moveWorkoutCourse(coursesRef.current, movedCourseId, targetCourseId); + if (reordered === coursesRef.current) { + return; + } + const nextOrder = reordered.map((course) => course.id); + saveWorkoutOrder(nextOrder); + setCourseOrder(nextOrder); + }, []); return { courses, customCourseIds, importFile, removeCourse, + renameCourse, + reorderCourse, }; } diff --git a/src/lib/reverse-geocode.ts b/src/lib/reverse-geocode.ts new file mode 100644 index 0000000..27e40dd --- /dev/null +++ b/src/lib/reverse-geocode.ts @@ -0,0 +1,166 @@ +import type { GeographicRoutePoint } from '../types'; +import { isRecord, isString } from './type-guards'; + +const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse'; +const CITY_CACHE_STORAGE_KEY = 'ride-control-reverse-geocode-cache'; +const CACHE_COORDINATE_PRECISION = 5; +const MAX_CACHED_CITIES = 100; +const MINIMUM_REQUEST_INTERVAL_MS = 1100; +const REQUEST_TIMEOUT_MS = 5000; + +interface ReverseGeocodeOptions { + fetcher?: typeof fetch; + language?: string; + storage?: Pick; +} + +interface CityCacheEntry { + city: string; + coordinates: string; +} + +let requestQueue = Promise.resolve(); +let lastRequestStartedAt = 0; + +function coordinateKey(point: Pick): string { + return `${point.latitude.toFixed(CACHE_COORDINATE_PRECISION)},${point.longitude.toFixed(CACHE_COORDINATE_PRECISION)}`; +} + +function cacheEntries(value: unknown): CityCacheEntry[] { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (!(isRecord(entry) && isString(entry.city) && isString(entry.coordinates))) { + return []; + } + const city = entry.city.trim(); + const coordinates = entry.coordinates.trim(); + return city && coordinates ? [{ city, coordinates }] : []; + }); +} + +function readCityCache(storage: Pick | undefined): CityCacheEntry[] { + if (!storage) { + return []; + } + try { + const saved = storage.getItem(CITY_CACHE_STORAGE_KEY); + return saved ? cacheEntries(JSON.parse(saved)) : []; + } catch { + return []; + } +} + +function saveCity( + storage: Pick | undefined, + entry: CityCacheEntry +): void { + if (!storage) { + return; + } + try { + const entries = readCityCache(storage).filter( + (cached) => cached.coordinates !== entry.coordinates + ); + storage.setItem( + CITY_CACHE_STORAGE_KEY, + JSON.stringify([entry, ...entries].slice(0, MAX_CACHED_CITIES)) + ); + } catch { + // A city label is optional, so unavailable browser storage must not block import. + } +} + +function geocodedCity(value: unknown): string | undefined { + if (!(isRecord(value) && Array.isArray(value.features))) { + return; + } + const [feature] = value.features; + if (!(isRecord(feature) && isRecord(feature.properties))) { + return; + } + const { geocoding } = feature.properties; + if (!isRecord(geocoding)) { + return; + } + for (const candidate of [geocoding.city, geocoding.locality, geocoding.name]) { + if (isString(candidate) && candidate.trim()) { + return candidate.trim(); + } + } +} + +function wait(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +function queuedRequest(request: () => Promise): Promise { + const result = requestQueue.then(async () => { + const delay = Math.max(0, lastRequestStartedAt + MINIMUM_REQUEST_INTERVAL_MS - Date.now()); + if (delay > 0) { + await wait(delay); + } + lastRequestStartedAt = Date.now(); + return request(); + }); + requestQueue = result.then( + () => undefined, + () => undefined + ); + return result; +} + +function browserStorage(): Pick | undefined { + try { + return globalThis.localStorage; + } catch { + // Browser privacy modes can make local storage unavailable. + } +} + +export async function reverseGeocodeStartingCity( + point: Pick, + options: ReverseGeocodeOptions = {} +): Promise { + const coordinates = coordinateKey(point); + const storage = options.storage ?? browserStorage(); + const cached = readCityCache(storage).find((entry) => entry.coordinates === coordinates); + if (cached) { + return cached.city; + } + + const url = new URL(NOMINATIM_REVERSE_URL); + url.searchParams.set('addressdetails', '1'); + url.searchParams.set('format', 'geocodejson'); + url.searchParams.set('lat', String(point.latitude)); + url.searchParams.set('layer', 'address'); + url.searchParams.set('lon', String(point.longitude)); + url.searchParams.set('zoom', '10'); + const language = options.language ?? globalThis.navigator?.language; + if (language) { + url.searchParams.set('accept-language', language); + } + + try { + const city = await queuedRequest(async () => { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await (options.fetcher ?? fetch)(url, { + headers: { Accept: 'application/json' }, + signal: abortController.signal, + }); + return response.ok ? geocodedCity(await response.json()) : undefined; + } finally { + clearTimeout(timeout); + } + }); + if (city) { + saveCity(storage, { city, coordinates }); + } + return city; + } catch { + // City lookup is optional and must never prevent a valid GPX import. + } +} diff --git a/src/lib/workout-description.ts b/src/lib/workout-description.ts new file mode 100644 index 0000000..5eb80aa --- /dev/null +++ b/src/lib/workout-description.ts @@ -0,0 +1,16 @@ +export const WORKOUT_DESCRIPTION_ATTRIBUTION = { + OPENSTREETMAP: 'openstreetmap', +} as const; + +export type WorkoutDescriptionAttribution = + (typeof WORKOUT_DESCRIPTION_ATTRIBUTION)[keyof typeof WORKOUT_DESCRIPTION_ATTRIBUTION]; + +export const OPENSTREETMAP_ATTRIBUTION_URL = 'https://www.openstreetmap.org/copyright'; + +export function isWorkoutDescriptionAttribution( + value: unknown +): value is WorkoutDescriptionAttribution { + return Object.values(WORKOUT_DESCRIPTION_ATTRIBUTION).some( + (attribution) => attribution === value + ); +} diff --git a/src/lib/workout-file.ts b/src/lib/workout-file.ts index de4bd3f..674cfa6 100644 --- a/src/lib/workout-file.ts +++ b/src/lib/workout-file.ts @@ -2,7 +2,12 @@ import type { GeographicRoutePoint, WorkoutCourse } from '../types'; import { evenlySample } from './arrays'; import { downloadBrowserFile } from './download'; import { parseGpxDocument } from './gpx'; -import { isRecord } from './type-guards'; +import { reverseGeocodeStartingCity } from './reverse-geocode'; +import { isRecord, isString } from './type-guards'; +import { + isWorkoutDescriptionAttribution, + WORKOUT_DESCRIPTION_ATTRIBUTION, +} from './workout-description'; import { isWorkoutDifficulty, isWorkoutRouteType, @@ -20,20 +25,24 @@ import { import { xmlDescendant, xmlEscape, xmlNumber, xmlText } from './xml'; export const CUSTOM_WORKOUTS_STORAGE_KEY = 'ride-control-custom-workouts'; +export const WORKOUT_ORDER_STORAGE_KEY = 'ride-control-workout-order'; export const WORKOUT_GPX_EXTENSION_NAMESPACE = 'https://github.com/lookfirst/RideControl/xmlschemas/WorkoutExtension/v1'; export const WORKOUT_GPX_FORMAT_VERSION = 2; +export const MAX_WORKOUT_NAME_LENGTH = 100; const WORKOUT_LIBRARY_FORMAT = 'ride-control-workout-library'; const WORKOUT_LIBRARY_VERSION = 1; const WORKOUT_MIME_TYPE = 'application/gpx+xml'; const MAX_CUSTOM_WORKOUTS = 50; const MAX_WORKOUT_FILE_POINTS = 200; -const MAX_LOOP_SOURCE_POINTS = MAX_WORKOUT_FILE_POINTS - 1; +const MAX_DIRECT_SOURCE_POINTS = MAX_WORKOUT_FILE_POINTS - 1; const MAX_OUT_AND_BACK_SOURCE_POINTS = Math.floor((MAX_WORKOUT_FILE_POINTS - 1) / 2); const NON_FILENAME_CHARACTERS = /[^a-z0-9]+/g; const EDGE_HYPHENS = /^-+|-+$/g; const GPX_FILE_EXTENSION = /(?:\.workout)?\.gpx$/i; +const GPX_WORKOUT_ID_PREFIX = 'gpx-'; +const IMPORTED_GPX_DESCRIPTION = 'Imported from a GPX route with elevation data.'; interface WorkoutLibraryData { courses: WorkoutCourse[]; @@ -59,6 +68,7 @@ function restoredCourses(value: unknown): WorkoutCourse[] { return value.courses .slice(0, MAX_CUSTOM_WORKOUTS) .map(restoreWorkoutCourse) + .map((course) => (course ? migrateLegacyImportedOutAndBack(course) : undefined)) .filter((course): course is WorkoutCourse => { if (!course || uniqueIds.has(course.id) || workoutIsBuiltIn(course.id)) { return false; @@ -68,6 +78,41 @@ function restoredCourses(value: unknown): WorkoutCourse[] { }); } +function migrateLegacyImportedOutAndBack(course: WorkoutCourse): WorkoutCourse { + if ( + !( + course.id.startsWith(GPX_WORKOUT_ID_PREFIX) && + course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK + ) + ) { + return course; + } + const distance = course.distance / 2; + const points = course.points.filter((point) => point.distance <= distance); + return ( + restoreWorkoutCourse({ + ...course, + distance, + points, + routeType: WORKOUT_ROUTE_TYPE.POINT_TO_POINT, + }) ?? course + ); +} + +function restoredWorkoutOrder(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const uniqueIds = new Set(); + return value.filter((id): id is string => { + if (!(isString(id) && id && !uniqueIds.has(id))) { + return false; + } + uniqueIds.add(id); + return true; + }); +} + function gpxPointXml(point: GeographicRoutePoint): string { return ` @@ -90,7 +135,7 @@ function routeFingerprint(points: GeographicRoutePoint[]): string { hash ^= source.charCodeAt(index); hash = Math.imul(hash, 16_777_619); } - return `gpx-${(hash >>> 0).toString(16).padStart(8, '0')}`; + return `${GPX_WORKOUT_ID_PREFIX}${(hash >>> 0).toString(16).padStart(8, '0')}`; } function workoutMetadata( @@ -98,14 +143,19 @@ function workoutMetadata( points: GeographicRoutePoint[] ): { baseResistance?: number; + descriptionAttribution?: WorkoutCourse['descriptionAttribution']; difficulty: WorkoutDifficulty; id: string; routeType?: WorkoutRouteType; } { const difficultyValue = xmlText(xmlDescendant(container, 'Difficulty')); + const descriptionAttributionValue = xmlText(xmlDescendant(container, 'DescriptionAttribution')); const routeTypeValue = xmlText(xmlDescendant(container, 'CourseType')); return { baseResistance: xmlNumber(xmlDescendant(container, 'BaseResistance')), + descriptionAttribution: isWorkoutDescriptionAttribution(descriptionAttributionValue) + ? descriptionAttributionValue + : undefined, difficulty: isWorkoutDifficulty(difficultyValue) ? difficultyValue : WORKOUT_DIFFICULTY.MODERATE, @@ -160,6 +210,7 @@ export function workoutFileContents(course: WorkoutCourse): string { ${course.difficulty} ${course.baseResistance.toFixed(1)} ${course.routeType} + ${course.descriptionAttribution ? `${course.descriptionAttribution}` : ''} ${points} @@ -193,17 +244,18 @@ export function parseWorkoutFile( const sourceCloses = workoutRouteCloses(sourcePoints); const routeType = metadata.routeType ?? - (sourceCloses ? WORKOUT_ROUTE_TYPE.LOOP : WORKOUT_ROUTE_TYPE.OUT_AND_BACK); + (sourceCloses ? WORKOUT_ROUTE_TYPE.LOOP : WORKOUT_ROUTE_TYPE.POINT_TO_POINT); let points: GeographicRoutePoint[]; if (routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK) { points = outAndBackPoints(sourcePoints, sourceCloses); } else { - points = evenlySample(sourcePoints, MAX_LOOP_SOURCE_POINTS); + points = evenlySample(sourcePoints, MAX_DIRECT_SOURCE_POINTS); } const distance = points.at(-1)?.distance ?? 0; const course = restoreWorkoutCourse({ baseResistance: metadata.baseResistance, - description: parsed.description || 'Imported from a GPX route with elevation data.', + description: parsed.description || IMPORTED_GPX_DESCRIPTION, + descriptionAttribution: metadata.descriptionAttribution, difficulty: metadata.difficulty, distance, id: metadata.id, @@ -213,14 +265,33 @@ export function parseWorkoutFile( }); if (!course) { throw new Error( - 'The GPX route must describe a valid loop or out-and-back course with increasing distance and elevation data.' + 'The GPX route must describe a valid point-to-point, loop, or out-and-back course with increasing distance and elevation data.' ); } return course; } -export async function readWorkoutFile(file: Pick): Promise { - return parseWorkoutFile(await file.text(), new DOMParser(), workoutNameFromFile(file.name)); +export async function readWorkoutFile( + file: Pick, + resolveStartingCity: typeof reverseGeocodeStartingCity = reverseGeocodeStartingCity +): Promise { + const course = parseWorkoutFile( + await file.text(), + new DOMParser(), + workoutNameFromFile(file.name) + ); + if (course.description !== IMPORTED_GPX_DESCRIPTION) { + return course; + } + const [firstPoint] = course.points; + const city = firstPoint ? await resolveStartingCity(firstPoint) : undefined; + return city + ? { + ...course, + description: `Starts in ${city}.`, + descriptionAttribution: WORKOUT_DESCRIPTION_ATTRIBUTION.OPENSTREETMAP, + } + : course; } export function loadCustomWorkouts( @@ -259,6 +330,60 @@ export function saveCustomWorkouts( storage.setItem(CUSTOM_WORKOUTS_STORAGE_KEY, JSON.stringify(library)); } +export function loadWorkoutOrder(storage: Pick = localStorage): string[] { + try { + const saved = storage.getItem(WORKOUT_ORDER_STORAGE_KEY); + return saved ? restoredWorkoutOrder(JSON.parse(saved)) : []; + } catch { + return []; + } +} + +export function saveWorkoutOrder( + courseIds: string[], + storage: Pick = localStorage +): void { + storage.setItem(WORKOUT_ORDER_STORAGE_KEY, JSON.stringify(restoredWorkoutOrder(courseIds))); +} + +export function orderWorkoutCourses( + courses: WorkoutCourse[], + courseIds: string[] +): WorkoutCourse[] { + const coursesById = new Map(courses.map((course) => [course.id, course])); + const ordered = courseIds.flatMap((id) => { + const course = coursesById.get(id); + if (!course) { + return []; + } + coursesById.delete(id); + return [course]; + }); + return [...ordered, ...coursesById.values()]; +} + +export function moveWorkoutCourse( + courses: WorkoutCourse[], + movedCourseId: string, + targetCourseId: string +): WorkoutCourse[] { + if (movedCourseId === targetCourseId) { + return courses; + } + const movedIndex = courses.findIndex((course) => course.id === movedCourseId); + const targetIndex = courses.findIndex((course) => course.id === targetCourseId); + if (movedIndex < 0 || targetIndex < 0) { + return courses; + } + const reordered = [...courses]; + const [movedCourse] = reordered.splice(movedIndex, 1); + if (!movedCourse) { + return courses; + } + reordered.splice(targetIndex, 0, movedCourse); + return reordered; +} + export function addCustomWorkout( courses: WorkoutCourse[], course: WorkoutCourse @@ -276,6 +401,31 @@ export function addCustomWorkout( }; } +export function renameCustomWorkout( + courses: WorkoutCourse[], + workoutId: string, + name: string +): { course: WorkoutCourse; courses: WorkoutCourse[] } { + const nextName = name.trim(); + if (!nextName) { + throw new Error('Enter a workout name.'); + } + if (nextName.length > MAX_WORKOUT_NAME_LENGTH) { + throw new Error(`Workout names can be at most ${MAX_WORKOUT_NAME_LENGTH} characters.`); + } + const existing = courses.find((candidate) => candidate.id === workoutId); + if (!existing) { + throw new Error('This imported workout is no longer available.'); + } + const renamedCourse = { ...existing, name: nextName }; + return { + course: renamedCourse, + courses: courses.map((candidate) => + candidate.id === workoutId ? renamedCourse : candidate + ), + }; +} + export function withoutCustomWorkout(courses: WorkoutCourse[], workoutId: string): WorkoutCourse[] { return courses.filter((course) => course.id !== workoutId); } diff --git a/src/lib/workout-schema.ts b/src/lib/workout-schema.ts index 5b53655..c0ce015 100644 --- a/src/lib/workout-schema.ts +++ b/src/lib/workout-schema.ts @@ -15,6 +15,7 @@ export function isWorkoutDifficulty(value: unknown): value is WorkoutDifficulty export const WORKOUT_ROUTE_TYPE = { LOOP: 'loop', OUT_AND_BACK: 'out-and-back', + POINT_TO_POINT: 'point-to-point', } as const; export type WorkoutRouteType = (typeof WORKOUT_ROUTE_TYPE)[keyof typeof WORKOUT_ROUTE_TYPE]; @@ -25,6 +26,19 @@ export function isWorkoutRouteType(value: unknown): value is WorkoutRouteType { return WORKOUT_ROUTE_TYPES.has(value); } +export function workoutRouteLabel(routeType: WorkoutRouteType): string { + switch (routeType) { + case WORKOUT_ROUTE_TYPE.LOOP: + return 'loop'; + case WORKOUT_ROUTE_TYPE.OUT_AND_BACK: + return 'out & back'; + case WORKOUT_ROUTE_TYPE.POINT_TO_POINT: + return 'point to point'; + default: + return routeType; + } +} + export const WORKOUT_VIEW = { MAP: 'map', PROFILE: 'profile', diff --git a/src/lib/workouts.ts b/src/lib/workouts.ts index 1ce7069..60b2a25 100644 --- a/src/lib/workouts.ts +++ b/src/lib/workouts.ts @@ -19,6 +19,10 @@ import { distanceBetween } from './gpx'; import { clamp, nonNegativeNumber } from './numbers'; import { clampResistance } from './resistance'; import { isFiniteNumber, isRecord, isString } from './type-guards'; +import { + isWorkoutDescriptionAttribution, + type WorkoutDescriptionAttribution, +} from './workout-description'; import { isWorkoutDifficulty, isWorkoutRouteType, @@ -49,6 +53,7 @@ const ROUTE_VALUE_EPSILON = 0.000_001; const LOW_CLIMB_ELEVATION_GAIN_METERS = 50; const MODERATE_CLIMB_ELEVATION_GAIN_METERS = 150; const PROFILE_REFERENCE_ELEVATION_SPAN_METERS = 200; +const SEARCH_WHITESPACE = /\s+/; export const WORKOUT_SHORT_FLAT_START_DISTANCE = 0.4; export const WORKOUT_MODERATE_FLAT_START_DISTANCE = 0.8; export const WORKOUT_FLAT_START_DISTANCE = 1.5; @@ -158,7 +163,7 @@ function withFlatCourseStart( routeType: WorkoutRouteType, rolloutDistance: number ): GeographicRoutePoint[] { - if (routeType === WORKOUT_ROUTE_TYPE.LOOP) { + if (routeType !== WORKOUT_ROUTE_TYPE.OUT_AND_BACK) { return flatRouteStart(points, rolloutDistance); } const outbound = points.filter((point) => point.distance <= distance / 2 + ROUTE_VALUE_EPSILON); @@ -170,11 +175,16 @@ function withFlatCourseStart( export function workoutRouteCloses(points: GeographicRoutePoint[]): boolean { const [first] = points; const last = points.at(-1); + const routeDistanceMeters = (last?.distance ?? 0) * 1000; + const closureThreshold = Math.min( + COURSE_CLOSURE_METERS, + Math.max(20, routeDistanceMeters * 0.02) + ); return Boolean( first && last && distanceBetween(first.latitude, first.longitude, last.latitude, last.longitude) <= - COURSE_CLOSURE_METERS + closureThreshold ); } @@ -250,32 +260,31 @@ function createGeographicCourse( distance: number, points: GeographicRoutePoint[], baseResistance = DEFAULT_TERRAIN_RESISTANCE, - routeType: WorkoutRouteType = WORKOUT_ROUTE_TYPE.LOOP + routeType: WorkoutRouteType = WORKOUT_ROUTE_TYPE.LOOP, + descriptionAttribution?: WorkoutDescriptionAttribution ): WorkoutCourse { const [first] = points; const rolloutDistance = flatStartDistanceForElevationGain(elevationGain(points)); + const sourcePoints = + first && routeType !== WORKOUT_ROUTE_TYPE.POINT_TO_POINT + ? points.map((point, index) => + index === points.length - 1 + ? { + ...point, + elevation: first.elevation, + latitude: first.latitude, + longitude: first.longitude, + } + : point + ) + : points; const terrainPoints = mapCoordinates( - withFlatCourseStart( - first - ? points.map((point, index) => - index === points.length - 1 - ? { - ...point, - elevation: first.elevation, - latitude: first.latitude, - longitude: first.longitude, - } - : point - ) - : points, - distance, - routeType, - rolloutDistance - ) + withFlatCourseStart(sourcePoints, distance, routeType, rolloutDistance) ); return { baseResistance, description, + descriptionAttribution, difficulty, distance, elevationGain: elevationGain(terrainPoints), @@ -325,8 +334,14 @@ function loopDistance(courseDistance: number, totalDistance: number): number { return nonNegativeNumber(totalDistance) % courseDistance; } +function coursePosition(course: WorkoutCourse, totalDistance: number): number { + return course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT + ? clamp(nonNegativeNumber(totalDistance), 0, course.distance) + : loopDistance(course.distance, totalDistance); +} + function segmentAtDistance(course: WorkoutCourse, distance: number) { - const position = loopDistance(course.distance, distance); + const position = coursePosition(course, distance); const nextIndex = course.points.findIndex((point) => point.distance >= position); const rightIndex = Math.max(1, nextIndex < 0 ? course.points.length - 1 : nextIndex); const left = course.points[rightIndex - 1] ?? course.points[0]; @@ -369,9 +384,7 @@ function coursePointAtDistance(course: WorkoutCourse, distance: number): Workout } export function workoutProgress(course: WorkoutCourse, totalDistance: number): number { - return course.distance <= 0 - ? 0 - : loopDistance(course.distance, totalDistance) / course.distance; + return course.distance <= 0 ? 0 : coursePosition(course, totalDistance) / course.distance; } export function workoutSelectionLocked({ @@ -387,27 +400,36 @@ export function workoutSelectionLocked({ } export function workoutLap(course: WorkoutCourse, totalDistance: number): number { - return workoutCompletedLaps(course, totalDistance) + 1; + return course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT + ? 1 + : workoutCompletedLaps(course, totalDistance) + 1; } export function workoutCompletedLaps(course: WorkoutCourse, totalDistance: number): number { - return course.distance <= 0 - ? 0 - : Math.floor(nonNegativeNumber(totalDistance) / course.distance); + if (course.distance <= 0) { + return 0; + } + if (course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT) { + return nonNegativeNumber(totalDistance) >= course.distance ? 1 : 0; + } + return Math.floor(nonNegativeNumber(totalDistance) / course.distance); } export function workoutElevationTotalsAtDistance( course: WorkoutCourse, totalDistance: number ): ElevationTotals { - const completedLaps = workoutCompletedLaps(course, totalDistance); - const fullLap = elevationTotalsForSamples(course.points); - const position = loopDistance(course.distance, totalDistance); + const position = coursePosition(course, totalDistance); const current = coursePointAtDistance(course, position); const partialLap = elevationTotalsForSamples([ ...course.points.filter((point) => point.distance < position), current, ]); + if (course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT) { + return partialLap; + } + const completedLaps = workoutCompletedLaps(course, totalDistance); + const fullLap = elevationTotalsForSamples(course.points); return { ascent: fullLap.ascent * completedLaps + partialLap.ascent, descent: fullLap.descent * completedLaps + partialLap.descent, @@ -432,17 +454,17 @@ export function workoutTerrainAtDistance( course: WorkoutCourse, totalDistance: number ): WorkoutTerrain { - const distance = loopDistance(course.distance, totalDistance); + const distance = coursePosition(course, totalDistance); const point = coursePointAtDistance(course, distance); const lookAheadDistance = Math.min(0.15, course.distance / 20); - const ahead = coursePointAtDistance(course, distance + lookAheadDistance); + const gradeDistance = + course.routeType === WORKOUT_ROUTE_TYPE.POINT_TO_POINT + ? Math.min(lookAheadDistance, course.distance - distance) + : lookAheadDistance; + const ahead = coursePointAtDistance(course, distance + gradeDistance); const grade = - lookAheadDistance > 0 - ? clamp( - ((ahead.elevation - point.elevation) / (lookAheadDistance * 1000)) * 100, - -15, - 15 - ) + gradeDistance > 0 + ? clamp(((ahead.elevation - point.elevation) / (gradeDistance * 1000)) * 100, -15, 15) : 0; const resistance = clampResistance( Math.round( @@ -604,7 +626,7 @@ function mapCoordinateTangents( } function workoutMapCourse(course: WorkoutCourse): WorkoutCourse { - if (course.routeType === WORKOUT_ROUTE_TYPE.LOOP) { + if (course.routeType !== WORKOUT_ROUTE_TYPE.OUT_AND_BACK) { return course; } const turnaroundDistance = course.distance / 2; @@ -618,7 +640,7 @@ function workoutMapCourse(course: WorkoutCourse): WorkoutCourse { } function workoutMapDistance(course: WorkoutCourse, distance: number): number { - const position = loopDistance(course.distance, distance); + const position = coursePosition(course, distance); return course.routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK && position > course.distance / 2 ? course.distance - position : position; @@ -882,22 +904,53 @@ function isValidOutAndBack(points: GeographicRoutePoint[], distance: number): bo }); } +function isValidPointToPoint(points: GeographicRoutePoint[], distance: number): boolean { + const [first] = points; + const last = points.at(-1); + return Boolean( + first && + last && + points.every((point, index) => { + const previous = points[index - 1]; + return !previous || point.distance > previous.distance; + }) && + approximatelyEqual(first.distance, 0) && + approximatelyEqual(last.distance, distance) + ); +} + function isValidCourse( points: GeographicRoutePoint[], distance: number, routeType: WorkoutRouteType ): boolean { - return routeType === WORKOUT_ROUTE_TYPE.OUT_AND_BACK - ? isValidOutAndBack(points, distance) - : isValidClosedCourse(points, distance); + switch (routeType) { + case WORKOUT_ROUTE_TYPE.LOOP: + return isValidClosedCourse(points, distance); + case WORKOUT_ROUTE_TYPE.OUT_AND_BACK: + return isValidOutAndBack(points, distance); + case WORKOUT_ROUTE_TYPE.POINT_TO_POINT: + return isValidPointToPoint(points, distance); + default: + return false; + } } export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined { if (!isRecord(value)) { return; } - const { baseResistance, description, difficulty, distance, id, name, points, routeType } = - value; + const { + baseResistance, + description, + descriptionAttribution, + difficulty, + distance, + id, + name, + points, + routeType, + } = value; let restoredRouteType: WorkoutRouteType | undefined; if (routeType === undefined) { restoredRouteType = WORKOUT_ROUTE_TYPE.LOOP; @@ -907,6 +960,8 @@ export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined if ( !( isString(description) && + (descriptionAttribution === undefined || + isWorkoutDescriptionAttribution(descriptionAttribution)) && isWorkoutDifficulty(difficulty) && isFiniteNumber(distance) && isString(id) && @@ -952,7 +1007,8 @@ export function restoreWorkoutCourse(value: unknown): WorkoutCourse | undefined distance, restoredPoints, restoredBaseResistance, - restoredRouteType + restoredRouteType, + descriptionAttribution ); } @@ -981,3 +1037,13 @@ export function workoutDifficultyLabel(difficulty: WorkoutDifficulty): string { return difficulty; } } + +export function workoutMatchesSearch(course: WorkoutCourse, query: string): boolean { + const terms = query.trim().toLocaleLowerCase().split(SEARCH_WHITESPACE).filter(Boolean); + if (terms.length === 0) { + return true; + } + const searchable = + `${course.name} ${workoutDifficultyLabel(course.difficulty)}`.toLocaleLowerCase(); + return terms.every((term) => searchable.includes(term)); +} diff --git a/src/types.ts b/src/types.ts index fff7258..4206b3d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { ControlMode } from './lib/control-mode'; +import type { WorkoutDescriptionAttribution } from './lib/workout-description'; import type { WorkoutDifficulty, WorkoutRouteType } from './lib/workout-schema'; export type { ChartMode } from './lib/chart-mode'; @@ -31,6 +32,7 @@ export interface WorkoutRoutePoint extends GeographicRoutePoint { export interface WorkoutCourse { baseResistance: number; description: string; + descriptionAttribution?: WorkoutDescriptionAttribution; difficulty: WorkoutDifficulty; distance: number; elevationGain: number; diff --git a/tests/components.test.tsx b/tests/components.test.tsx index e5711be..5785170 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -8,6 +8,7 @@ import { Icon } from '../src/components/icon'; import { KeyboardShortcutsDialog } from '../src/components/keyboard-shortcuts-dialog'; import { Metric, SessionMetric, SmallMetric } from '../src/components/metrics'; import { Notification } from '../src/components/notification'; +import { RenameWorkoutDialog } from '../src/components/rename-workout-dialog'; import { ResistanceControl } from '../src/components/resistance-control'; import { SessionChart } from '../src/components/session-chart'; import { SessionControls } from '../src/components/session-controls'; @@ -460,10 +461,11 @@ describe('view components', () => { undefined} onImportFile={() => Promise.reject(new Error('Not used in this render test'))} onRemoveCourse={() => undefined} + onRenameCourse={() => course} + onReorderCourse={() => undefined} onSelect={() => undefined} open selectionLocked={false} @@ -480,6 +482,13 @@ describe('view components', () => { expect(panel).toContain('Harbor Ring course map'); expect(panel).toContain('Harbor Ring elevation profile'); expect(panel).toContain('Import GPX'); + expect(panel).toContain('href="https://bikegpx.com/bike_routes/"'); + expect(panel).toContain('BikeGPX has thousands of GPX files'); + expect(panel).toContain('data-gpx-drop-target="true"'); + expect(panel).toContain('placeholder="Search by name or difficulty"'); + expect(panel).not.toContain( + 'Choose a workout for your next session, then start it when you are ready.' + ); expect(panel.match(/Download GPX/g)).toHaveLength(6); expect(panel).toContain('10.0 mi out & back'); expect(panel).toContain('15.0 mi loop'); @@ -489,21 +498,25 @@ describe('view components', () => { expect(panel).toContain('stroke="#64748b"'); expect(panel).toContain('bg-slate-800/70'); expect(panel).not.toContain('bg-lime text-ink'); + expect(panel).not.toContain('aria-label="Rename Harbor Ring"'); + expect(panel).toContain('aria-label="Drag Harbor Ring to reorder"'); + expect(panel.match(/draggable="true"/g)).toHaveLength(6); expect(panel).toContain('data-side-tray="true"'); const importedCourse = { ...course, id: 'imported-course', name: 'Imported course', - routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK, + routeType: WORKOUT_ROUTE_TYPE.POINT_TO_POINT, }; const customPanel = render( undefined} onImportFile={() => Promise.reject(new Error('Not used in this render test'))} onRemoveCourse={() => undefined} + onRenameCourse={() => importedCourse} + onReorderCourse={() => undefined} onSelect={() => undefined} open selectionLocked={false} @@ -511,26 +524,43 @@ describe('view components', () => { /> ); expect(customPanel).toContain('Imported course'); + expect(customPanel).toContain('aria-label="Rename Imported course"'); + expect(customPanel).toContain('title="Rename imported workout"'); expect(customPanel).toContain('Imported'); - expect(customPanel).toContain('out & back'); + expect(customPanel).toContain('point to point'); expect(customPanel).toContain('Remove'); expect(customPanel.match(/Download GPX/g)).toHaveLength(7); + const renameDialog = render( + undefined} + onRename={() => undefined} + /> + ); + expect(renameDialog).toContain('Rename workout'); + expect(renameDialog).not.toContain('IMPORTED GPX'); + expect(renameDialog).toContain( + 'The route and its duplicate-detection identifier will stay the same.' + ); + expect(renameDialog).toContain('value="Imported course"'); + expect(renameDialog).toContain('Save name'); const lockedPanel = render( undefined} onImportFile={() => Promise.reject(new Error('Not used in this render test'))} onRemoveCourse={() => undefined} + onRenameCourse={() => course} + onReorderCourse={() => undefined} onSelect={() => undefined} open selectionLocked speedUnit="mph" /> ); - expect(lockedPanel).toContain('End the current session before changing the workout.'); + expect(lockedPanel).toContain('placeholder="Search by name or difficulty"'); expect(lockedPanel.match(/disabled=""/g)).toHaveLength(6); const terrain = workoutTerrainAtDistance(course, course.distance * 2 + 2); @@ -588,7 +618,7 @@ describe('view components', () => { expect(metricProgress).toContain('30 m'); expect(metricProgress).toContain('12 m'); expect(metricProgress).not.toContain('animate-pulse'); - const outAndBackProgress = render( + const pointToPointProgress = render( { workout={{ course: importedCourse }} /> ); - expect(outAndBackProgress).toContain('Trips completed'); - expect(outAndBackProgress).toContain('Ridden this trip'); - expect(outAndBackProgress).toContain('aria-label="2 trips completed"'); + expect(pointToPointProgress).toContain('Route completed'); + expect(pointToPointProgress).toContain('Ridden this route'); + expect(pointToPointProgress).toContain('aria-label="2 routes completed"'); }); test('hides empty notifications and expands setup guidance', () => { diff --git a/tests/reverse-geocode.test.ts b/tests/reverse-geocode.test.ts new file mode 100644 index 0000000..03e9d16 --- /dev/null +++ b/tests/reverse-geocode.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { reverseGeocodeStartingCity } from '../src/lib/reverse-geocode'; + +describe('starting-city reverse geocoding', () => { + test('requests the starting coordinate and caches the resolved city', async () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }; + let requestCount = 0; + const fetcher = ((input: string | URL | Request) => { + requestCount += 1; + const url = new URL(String(input)); + expect(url.origin).toBe('https://nominatim.openstreetmap.org'); + expect(url.searchParams.get('lat')).toBe('36.9741'); + expect(url.searchParams.get('lon')).toBe('-122.0308'); + expect(url.searchParams.get('zoom')).toBe('10'); + return Promise.resolve( + Response.json({ + features: [{ properties: { geocoding: { city: 'Santa Cruz' } } }], + }) + ); + }) as typeof fetch; + const point = { latitude: 36.9741, longitude: -122.0308 }; + + expect( + await reverseGeocodeStartingCity(point, { + fetcher, + language: 'en-US', + storage, + }) + ).toBe('Santa Cruz'); + expect(await reverseGeocodeStartingCity(point, { fetcher, storage })).toBe('Santa Cruz'); + expect(requestCount).toBe(1); + }); +}); diff --git a/tests/workout-file.test.ts b/tests/workout-file.test.ts index 2f1dbc5..3cc9233 100644 --- a/tests/workout-file.test.ts +++ b/tests/workout-file.test.ts @@ -1,19 +1,27 @@ import { describe, expect, test } from 'bun:test'; import { DOMParser } from '@xmldom/xmldom'; +import { WORKOUT_DESCRIPTION_ATTRIBUTION } from '../src/lib/workout-description'; import { addCustomWorkout, CUSTOM_WORKOUTS_STORAGE_KEY, loadCustomWorkouts, + loadWorkoutOrder, + MAX_WORKOUT_NAME_LENGTH, + moveWorkoutCourse, + orderWorkoutCourses, parseWorkoutFile, readWorkoutFile, + renameCustomWorkout, saveCustomWorkouts, + saveWorkoutOrder, WORKOUT_GPX_EXTENSION_NAMESPACE, + WORKOUT_ORDER_STORAGE_KEY, withoutCustomWorkout, workoutFileContents, workoutFilename, } from '../src/lib/workout-file'; import { WORKOUT_ROUTE_TYPE } from '../src/lib/workout-schema'; -import { WORKOUT_COURSES } from '../src/lib/workouts'; +import { outAndBackRoutePoints, restoreWorkoutCourse, WORKOUT_COURSES } from '../src/lib/workouts'; import type { WorkoutCourse } from '../src/types'; Object.defineProperty(globalThis, 'DOMParser', { configurable: true, value: DOMParser }); @@ -62,6 +70,10 @@ function openThirdPartyGpx(): string { `; } +function gpxWithoutDescription(): string { + return thirdPartyGpx().replace('\n\t\tA real GPX loop', ''); +} + describe('workout GPX files', () => { test('round trips geographic workout source data through standard GPX', async () => { const workout = customWorkout(); @@ -115,34 +127,75 @@ describe('workout GPX files', () => { expect(second.id).toBe(first.id); }); - test('imports open GPX tracks as complete out-and-back courses', () => { + test('labels a GPX without a description from its starting city', async () => { + let resolvedPoint: { latitude: number; longitude: number } | undefined; + const workout = await readWorkoutFile( + { name: 'city-loop.gpx', text: async () => gpxWithoutDescription() }, + (point) => { + resolvedPoint = point; + return Promise.resolve('Santa Cruz'); + } + ); + expect(resolvedPoint).toMatchObject({ latitude: 37, longitude: -122 }); + expect(workout).toMatchObject({ + description: 'Starts in Santa Cruz.', + descriptionAttribution: WORKOUT_DESCRIPTION_ATTRIBUTION.OPENSTREETMAP, + }); + + const roundTripped = parseWorkoutFile( + workoutFileContents(workout), + new DOMParser() as unknown as globalThis.DOMParser + ); + expect(roundTripped.descriptionAttribution).toBe( + WORKOUT_DESCRIPTION_ATTRIBUTION.OPENSTREETMAP + ); + }); + + test('keeps the generic description when the starting city is unavailable', async () => { + const workout = await readWorkoutFile( + { name: 'remote-loop.gpx', text: async () => gpxWithoutDescription() }, + async () => undefined + ); + expect(workout.description).toBe('Imported from a GPX route with elevation data.'); + expect(workout.descriptionAttribution).toBeUndefined(); + }); + + test('does not look up a city when the GPX already has a description', async () => { + let resolverCalled = false; + const workout = await readWorkoutFile( + { name: 'described-loop.gpx', text: async () => thirdPartyGpx() }, + () => { + resolverCalled = true; + return Promise.resolve('Santa Cruz'); + } + ); + expect(resolverCalled).toBeFalse(); + expect(workout.description).toBe('A real GPX loop'); + }); + + test('imports open GPX tracks as one-way point-to-point courses', () => { const parser = new DOMParser() as unknown as globalThis.DOMParser; const workout = parseWorkoutFile(openThirdPartyGpx(), parser); - const turnaroundIndex = (workout.points.length - 1) / 2; - const turnaround = workout.points[turnaroundIndex]; expect(workout).toMatchObject({ name: 'Ridgeline turnaround', - routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK, + routeType: WORKOUT_ROUTE_TYPE.POINT_TO_POINT, }); - expect(workout.points).toHaveLength(9); - expect(turnaround?.distance).toBeCloseTo(workout.distance / 2); + expect(workout.points).toHaveLength(5); expect(workout.points.at(-1)).toMatchObject({ - elevation: workout.points[0]?.elevation, - latitude: workout.points[0]?.latitude, - longitude: workout.points[0]?.longitude, + elevation: 12, + latitude: 37.04, + longitude: -122.04, }); - expect(workout.points.map((point) => point.elevation)).toEqual([ - 12, 12, 22, 18, 12, 18, 22, 12, 12, - ]); + expect(workout.points.map((point) => point.elevation)).toEqual([12, 12, 22, 18, 12]); const exported = workoutFileContents(workout); - expect(exported).toContain('out-and-back'); + expect(exported).toContain('point-to-point'); const roundTripped = parseWorkoutFile(exported, parser); expect(roundTripped).toMatchObject({ description: workout.description, id: workout.id, name: workout.name, - routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK, + routeType: WORKOUT_ROUTE_TYPE.POINT_TO_POINT, }); expect(roundTripped.distance).toBeCloseTo(workout.distance, 5); expect(roundTripped.points).toHaveLength(workout.points.length); @@ -152,6 +205,61 @@ describe('workout GPX files', () => { } }); + test('migrates previously generated GPX return legs to point-to-point routes', () => { + const parser = new DOMParser() as unknown as globalThis.DOMParser; + const pointToPoint = parseWorkoutFile(openThirdPartyGpx(), parser); + const outbound = pointToPoint.points.map(({ x: _x, y: _y, ...point }) => point); + const points = outAndBackRoutePoints(outbound); + const legacy = restoreWorkoutCourse({ + ...pointToPoint, + distance: points.at(-1)?.distance, + points, + routeType: WORKOUT_ROUTE_TYPE.OUT_AND_BACK, + }); + if (!legacy) { + throw new Error('Expected a valid legacy GPX workout'); + } + let saved = ''; + const storage = { + getItem: () => saved, + setItem: (_key: string, value: string) => { + saved = value; + }, + }; + saveCustomWorkouts([legacy], storage); + const [migrated] = loadCustomWorkouts(storage); + expect(migrated).toMatchObject({ + distance: pointToPoint.distance, + id: pointToPoint.id, + routeType: WORKOUT_ROUTE_TYPE.POINT_TO_POINT, + }); + expect(migrated.points.at(-1)).toMatchObject(pointToPoint.points.at(-1) ?? {}); + }); + + test('recognizes a route as a loop only when its endpoints are near for its length', () => { + const parser = new DOMParser() as unknown as globalThis.DOMParser; + const longRouteWithNearbyEndpoints = thirdPartyGpx() + .replaceAll('37.001000', '37.010000') + .replace( + '12\n\t\t', + '12\n\t\t' + ); + const shortRouteWithSeparatedEndpoints = ` + + Short path + 10 + 12 + 11 + +`; + expect(parseWorkoutFile(longRouteWithNearbyEndpoints, parser).routeType).toBe( + WORKOUT_ROUTE_TYPE.LOOP + ); + expect(parseWorkoutFile(shortRouteWithSeparatedEndpoints, parser).routeType).toBe( + WORKOUT_ROUTE_TYPE.POINT_TO_POINT + ); + }); + test('rejects malformed and built-in workout imports', async () => { await expect( readWorkoutFile({ name: 'broken.gpx', text: async () => ' { saveCustomWorkouts(imported.courses, storage); expect(loadCustomWorkouts(storage)).toEqual([workout]); - const renamed = { ...workout, name: 'Renamed route' }; - expect(() => addCustomWorkout(imported.courses, renamed)).toThrow( + const duplicateMetadata = { ...workout, name: 'Renamed route' }; + expect(() => addCustomWorkout(imported.courses, duplicateMetadata)).toThrow( `${workout.name} has already been imported.` ); + + const renamed = renameCustomWorkout(imported.courses, workout.id, ' Morning hills '); + expect(renamed.course).toEqual({ ...workout, name: 'Morning hills' }); + expect(renamed.courses).toEqual([renamed.course]); + expect(imported.course.name).toBe('Ridge & River / Test'); + saveCustomWorkouts(renamed.courses, storage); + expect(loadCustomWorkouts(storage)).toEqual(renamed.courses); + expect(() => renameCustomWorkout(renamed.courses, workout.id, ' ')).toThrow( + 'Enter a workout name.' + ); + expect(() => + renameCustomWorkout( + renamed.courses, + workout.id, + 'x'.repeat(MAX_WORKOUT_NAME_LENGTH + 1) + ) + ).toThrow(`Workout names can be at most ${MAX_WORKOUT_NAME_LENGTH} characters.`); + expect(() => renameCustomWorkout(renamed.courses, 'missing-workout', 'Name')).toThrow( + 'This imported workout is no longer available.' + ); expect(withoutCustomWorkout(imported.courses, workout.id)).toEqual([]); }); + + test('reorders workouts by stable id and persists their positions', () => { + const [first, second, third] = WORKOUT_COURSES; + if (!(first && second && third)) { + throw new Error('Expected at least three built-in workout courses'); + } + const courses = [first, second, third]; + expect(moveWorkoutCourse(courses, first.id, third.id)).toEqual([second, third, first]); + expect(moveWorkoutCourse(courses, third.id, first.id)).toEqual([third, first, second]); + expect(moveWorkoutCourse(courses, first.id, first.id)).toBe(courses); + expect(moveWorkoutCourse(courses, 'missing', third.id)).toBe(courses); + expect(orderWorkoutCourses(courses, [third.id, 'removed-workout', first.id])).toEqual([ + third, + first, + second, + ]); + + let savedOrder = ''; + const storage = { + getItem: (key: string) => (key === WORKOUT_ORDER_STORAGE_KEY ? savedOrder : null), + setItem: (key: string, value: string) => { + if (key === WORKOUT_ORDER_STORAGE_KEY) { + savedOrder = value; + } + }, + }; + const expectedOrder = [third.id, first.id, second.id]; + saveWorkoutOrder(expectedOrder, storage); + expect(loadWorkoutOrder(storage)).toEqual(expectedOrder); + savedOrder = JSON.stringify([third.id, third.id, '', 12, first.id]); + expect(loadWorkoutOrder(storage)).toEqual([third.id, first.id]); + savedOrder = 'not json'; + expect(loadWorkoutOrder(storage)).toEqual([]); + }); }); diff --git a/tests/workouts.test.ts b/tests/workouts.test.ts index c7aeded..cb902bb 100644 --- a/tests/workouts.test.ts +++ b/tests/workouts.test.ts @@ -15,6 +15,7 @@ import { workoutLap, workoutMapPath, workoutMapProgressPath, + workoutMatchesSearch, workoutMaximumGrade, workoutProfilePath, workoutProfilePosition, @@ -75,6 +76,19 @@ describe('terrain workouts', () => { } }); + test('filters workouts by name and displayed difficulty', () => { + const prairie = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll'); + const granite = WORKOUT_COURSES.find((workout) => workout.id === 'granite-switchbacks'); + if (!(prairie && granite)) { + throw new Error('Expected Prairie Roll and Granite Switchbacks'); + } + expect(workoutMatchesSearch(prairie, 'prairie')).toBeTrue(); + expect(workoutMatchesSearch(prairie, 'GENTLE')).toBeTrue(); + expect(workoutMatchesSearch(granite, 'granite challenging')).toBeTrue(); + expect(workoutMatchesSearch(granite, 'gentle')).toBeFalse(); + expect(workoutMatchesSearch(granite, ' ')).toBeTrue(); + }); + test('offers a ten-mile time trial with a mirrored five-mile hillclimb', () => { const timeTrial = WORKOUT_COURSES.find((workout) => workout.id === 'ridgeline-time-trial'); if (!timeTrial) { @@ -158,6 +172,35 @@ describe('terrain workouts', () => { expect(workoutMapPath(outAndBack)).toStartWith('M '); }); + test('finishes point-to-point courses without wrapping back to the start', () => { + if (!course) { + throw new Error('Expected a built-in workout course'); + } + const sourcePoints = course.points.slice(0, 6).map(({ x: _x, y: _y, ...point }) => point); + const distance = sourcePoints.at(-1)?.distance ?? 0; + const pointToPoint = restoreWorkoutCourse({ + ...course, + distance, + id: 'ridge-point-to-point', + points: sourcePoints, + routeType: WORKOUT_ROUTE_TYPE.POINT_TO_POINT, + }); + if (!pointToPoint) { + throw new Error('Expected a valid point-to-point workout course'); + } + const finish = workoutTerrainAtDistance(pointToPoint, distance); + const beyondFinish = workoutTerrainAtDistance(pointToPoint, distance * 2); + expect(finish).toMatchObject({ completedLaps: 1, distance, grade: 0, lap: 1, progress: 1 }); + expect(beyondFinish).toEqual(finish); + expect(workoutLap(pointToPoint, distance * 2)).toBe(1); + expect(workoutCompletedLaps(pointToPoint, distance * 2)).toBe(1); + expect(workoutProgress(pointToPoint, distance * 2)).toBe(1); + expect(workoutElevationTotalsAtDistance(pointToPoint, distance * 2)).toEqual( + workoutElevationTotalsAtDistance(pointToPoint, distance) + ); + expect(workoutMapProgressPath(pointToPoint, finish)).toBe(workoutMapPath(pointToPoint)); + }); + test('offers a fifteen-mile course with long rollers centered on 20% resistance', () => { const rollingCourse = WORKOUT_COURSES.find((workout) => workout.id === 'prairie-roll'); if (!rollingCourse) { From f18a568a8482a924b8a4b574deaa2c8a40fc969b Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 17:02:00 -0700 Subject: [PATCH 03/10] feat: refine workout library experience - add precise insertion-line reordering with an up/down drag handle - persist tray scroll position and keep feedback in a stable footer - save geocoded starting locations and link route bounds on OpenStreetMap - streamline workout renaming and expand persistence and rendering coverage --- README.md | 4 +- src/components/icon.tsx | 1 + src/components/rename-workout-dialog.tsx | 4 - src/components/workout-panel.tsx | 281 +++++++++++++------- src/hooks/use-persistent-scroll-position.ts | 21 ++ src/hooks/use-workout-library.ts | 4 +- src/lib/openstreetmap.ts | 41 +++ src/lib/scroll-position.ts | 25 ++ src/lib/workout-file.ts | 27 +- src/lib/workouts.ts | 12 +- src/types.ts | 1 + tests/components.test.tsx | 40 ++- tests/openstreetmap.test.ts | 26 ++ tests/scroll-position.test.ts | 32 +++ tests/workout-file.test.ts | 35 ++- 15 files changed, 429 insertions(+), 125 deletions(-) create mode 100644 src/hooks/use-persistent-scroll-position.ts create mode 100644 src/lib/openstreetmap.ts create mode 100644 src/lib/scroll-position.ts create mode 100644 tests/openstreetmap.test.ts create mode 100644 tests/scroll-position.test.ts diff --git a/README.md b/README.md index 7a7b8f2..a992a74 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 - 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, 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 on aligned, smoothly curved 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 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 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; the tray also links to BikeGPX's collection of thousands of downloadable routes. 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, workouts can be dragged into a preferred order that persists across browser reloads, and 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, falling back safely when no locality is available. 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. +- 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; the tray also links to BikeGPX's collection of thousands of downloadable routes. 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 reveals insertion lines above, between, and below workouts so a precise preferred order can be saved across browser reloads without highlighting another card. 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. 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. - 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. @@ -28,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 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. +- 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 diff --git a/src/components/icon.tsx b/src/components/icon.tsx index eaa07bf..4fdd603 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -8,6 +8,7 @@ const paths: Record = { imported: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8m-6-6 6 6m-6-6v6h6M8 15l2 2 5-5', minus: 'M5 12h14', + 'move-vertical': 'M8 7l4-4 4 4M12 3v18M8 17l4 4 4-4', pause: 'M9 5v14m6-14v14', play: 'm9 5 10 7-10 7V5Z', plus: 'M12 5v14M5 12h14', diff --git a/src/components/rename-workout-dialog.tsx b/src/components/rename-workout-dialog.tsx index 9932caa..34a90be 100644 --- a/src/components/rename-workout-dialog.tsx +++ b/src/components/rename-workout-dialog.tsx @@ -31,7 +31,6 @@ export function RenameWorkoutDialog({ return (
-

- The route and its duplicate-detection identifier will stay the same. -

-
setDropTargetIndex(targetBoundaryForDrag(event))} + onDragStart={(event) => { + const activeCourseId = String(event.active.id); + const movedCourse = filteredCourses.find( + (course) => course.id === activeCourseId + ); + setDraggedCourseId(movedCourse?.id ?? ''); + setDropTargetIndex(undefined); + }} + sensors={sensors} > - {filteredCourses.map((course, index) => ( - - dragOverBoundary(event, index)} - onDrop={(event) => dropAtBoundary(event, index)} - /> - { - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', course.id); - const card = event.currentTarget.closest('article'); - if (card) { - const bounds = card.getBoundingClientRect(); - event.dataTransfer.setDragImage( - card, - Math.max(0, event.clientX - bounds.left), - Math.max(0, event.clientY - bounds.top) - ); - } - setDraggedCourseId(course.id); - setDropTargetIndex(undefined); - }} - onMove={(direction) => { - const target = filteredCourses[index + direction]; - if (target) { - const targetIndex = courses.findIndex( - (candidate) => candidate.id === target.id - ); - reorderCourse( - course.id, - direction < 0 ? targetIndex : targetIndex + 1 - ); - } - }} - onRemove={() => onRemoveCourse(course.id)} - onRename={() => setRenamingCourse(course)} - onSelect={() => onSelect(course)} - selected={activeCourse?.id === course.id} - speedUnit={speedUnit} - /> - - ))} - {filteredCourses.length > 0 ? ( - - dragOverBoundary(event, filteredCourses.length) - } - onDrop={(event) => dropAtBoundary(event, filteredCourses.length)} - /> - ) : null} - {filteredCourses.length === 0 ? ( -

- No workouts match “{searchQuery.trim()}”. -

- ) : null} -
+
+ + {filteredCourses.map((course, index) => ( + + + { + const target = filteredCourses[index + direction]; + if (target) { + const targetIndex = courses.findIndex( + (candidate) => candidate.id === target.id + ); + reorderCourse( + course.id, + direction < 0 + ? targetIndex + : targetIndex + 1 + ); + } + }} + onRemove={() => onRemoveCourse(course.id)} + onRename={() => setRenamingCourse(course)} + onSelect={() => onSelect(course)} + onViewMap={() => setMappedCourse(course)} + selected={activeCourse?.id === course.id} + speedUnit={speedUnit} + /> + + ))} + {filteredCourses.length > 0 ? ( + + ) : null} + + {filteredCourses.length === 0 ? ( +

+ No workouts match “{searchQuery.trim()}”. +

+ ) : null} +
+
+ {mappedCourse ? ( + + Loading map… +
+ } + > + setMappedCourse(undefined)} + /> + + ) : null} {renamingCourse ? ( point.distance >= targetDistance); + if (nextIndex <= 0) { + return { latitude: start.latitude, longitude: start.longitude }; + } + const next = points[nextIndex] ?? finish; + const previous = points[nextIndex - 1] ?? start; + const segmentDistance = next.distance - previous.distance; + const segmentProgress = + segmentDistance > 0 ? (targetDistance - previous.distance) / segmentDistance : 0; + return { + latitude: previous.latitude + (next.latitude - previous.latitude) * segmentProgress, + longitude: previous.longitude + (next.longitude - previous.longitude) * segmentProgress, + }; +} diff --git a/src/style.css b/src/style.css index 14309d0..616b172 100644 --- a/src/style.css +++ b/src/style.css @@ -35,6 +35,25 @@ button:disabled { cursor: not-allowed; } + .ride-control-map-tiles { + filter: brightness(0.72) saturate(0.72) contrast(1.08); + } + .ride-control-bike-marker { + background: transparent; + border: 0; + } + .ride-control-bike-marker__body { + display: grid; + place-items: center; + width: 34px; + height: 34px; + font-size: 21px; + line-height: 1; + background: rgb(16 21 26 / 92%); + border: 2px solid var(--color-mint); + border-radius: 9999px; + box-shadow: 0 3px 14px rgb(0 0 0 / 55%); + } } @property --ramp-progress { diff --git a/tests/components.test.tsx b/tests/components.test.tsx index 08d4fa4..ab70d0b 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -508,10 +508,12 @@ describe('view components', () => { expect(panel).toContain('aria-label="Drag Harbor Ring to reorder"'); expect(panel).toContain('Move workout up or down'); expect(panel).toContain('absolute top-3 right-3'); - expect(panel.match(/draggable="true"/g)).toHaveLength(6); + expect(panel).not.toContain('draggable="true"'); + expect(panel.match(/aria-label="Drag [^"]+ to reorder"/g)).toHaveLength(6); expect(panel.match(/data-workout-drop-index=/g)).toHaveLength(7); expect(panel).not.toContain('Move dragged workout to'); expect(panel).not.toContain('ring-2 ring-cyan-400/70'); + expect(panel).not.toContain('View map'); expect(panel).toContain('data-side-tray="true"'); const importedCourse = { ...course, @@ -539,14 +541,18 @@ describe('view components', () => { ); expect(customPanel).toContain('Imported course'); expect(customPanel).toContain('Starts in Ålands Countryside.'); - expect(customPanel).toContain('View the route area and starting point on OpenStreetMap'); - expect(customPanel).toContain('https://www.openstreetmap.org/?minlon='); + expect(customPanel).toContain('title="View the route map"'); expect(customPanel).toContain('target="_blank"'); expect(customPanel).toContain('© OpenStreetMap contributors'); + expect(customPanel.indexOf('© OpenStreetMap contributors')).toBeGreaterThan( + customPanel.indexOf('point to point') + ); expect(customPanel).toContain('aria-label="Rename Imported course"'); expect(customPanel).toContain('title="Rename imported workout"'); expect(customPanel).toContain('Imported'); expect(customPanel).toContain('point to point'); + expect(customPanel).not.toContain('?workout-map='); + expect(customPanel).not.toContain('View map'); expect(customPanel).toContain('Remove'); expect(customPanel.match(/Download GPX/g)).toHaveLength(7); const renameDialog = render( @@ -986,6 +992,8 @@ describe('view components', () => { /> ); expect(html).toContain('Sessions'); + expect(html).toContain('0 sessions'); + expect(html).not.toContain('Saved on this device'); expect(html).toContain('data-side-tray="true"'); expect(html).toContain('No saved sessions yet'); expect(html).toContain('Import TCX'); diff --git a/tests/workout-map.test.ts b/tests/workout-map.test.ts new file mode 100644 index 0000000..af42bbb --- /dev/null +++ b/tests/workout-map.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; +import { workoutRouteCoordinateAtProgress } from '../src/lib/workout-map'; + +describe('workout route maps', () => { + test('interpolates an animated bike position through route distances', () => { + const points = [ + { distance: 0, elevation: 0, latitude: 10, longitude: 20 }, + { distance: 2, elevation: 5, latitude: 12, longitude: 24 }, + { distance: 6, elevation: 3, latitude: 16, longitude: 28 }, + ]; + expect(workoutRouteCoordinateAtProgress(points, -1)).toEqual({ + latitude: 10, + longitude: 20, + }); + expect(workoutRouteCoordinateAtProgress(points, 0.5)).toEqual({ + latitude: 13, + longitude: 25, + }); + expect(workoutRouteCoordinateAtProgress(points, 2)).toEqual({ + latitude: 16, + longitude: 28, + }); + expect(workoutRouteCoordinateAtProgress([], 0.5)).toBeUndefined(); + }); +}); From 1ee84da9882f6c4c23d1159809379a64787b604c Mon Sep 17 00:00:00 2001 From: Public Profile Date: Mon, 20 Jul 2026 22:30:28 -0700 Subject: [PATCH 08/10] feat: harden session-scoped virtual shifting --- src/app.tsx | 58 ++++--- src/components/device-pairing.tsx | 52 +++++-- src/hooks/use-zwift-click-input.ts | 7 +- src/hooks/use-zwift-click.ts | 242 +++++++++++++++++++++++------ src/lib/device-connection.ts | 4 + src/lib/gears.ts | 32 +++- src/lib/tcx.ts | 32 +++- src/lib/zwift-click-device.ts | 17 +- src/lib/zwift-click.ts | 67 +++++++- src/stores/session-store.ts | 8 +- src/stores/zwift-click-store.ts | 27 +++- tests/device-connection.test.ts | 3 + tests/devices.test.ts | 85 ++++++++++ tests/gears.test.ts | 62 ++++++-- tests/session-store.test.ts | 10 +- tests/tcx.test.ts | 9 +- 16 files changed, 571 insertions(+), 144 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index bcf7c33..58fa2fa 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -34,7 +34,11 @@ 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, @@ -79,6 +83,7 @@ export function App() { 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); @@ -86,7 +91,8 @@ export function App() { handleClickShift, trainer.setNotice, devicesOpen, - rememberedDevices + rememberedDevices, + initialClickConnectionActive ); const liveMetrics = metricsWithHeartRate( trainer.metrics, @@ -111,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, @@ -150,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, @@ -349,7 +359,7 @@ export function App() { ) : null} - {workoutTerrain && !virtualShiftingActive ? null : ( - - )} + setActiveOverlay(APP_OVERLAY.WELCOME)} /> 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.

- ))} - +
+ {availableModes.map((mode) => ( + + ))}
diff --git a/src/components/session-overview.tsx b/src/components/session-overview.tsx index 2f07547..5c12a51 100644 --- a/src/components/session-overview.tsx +++ b/src/components/session-overview.tsx @@ -23,7 +23,7 @@ export function SessionOverview({ workout?: SessionWorkout; }) { return ( -
+
- ); +function WorkoutDropBoundary({ index }: { index: number }) { + return