diff --git a/AGENTS.md b/AGENTS.md index e603145..1786071 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,13 @@ # Project instructions - After making any changes, always run `bun run ci` before handing work back. -- `bun run ci` must run the Ultracite-configured Biome checks automatically, followed by the TypeScript check and production build. +- `bun run ci` must run the Ultracite-configured Biome checks and Tailwind CSS language-server diagnostics automatically, followed by the unit tests, TypeScript check, and production build. - Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception. -- Do not reference the AI name used (codex, openai, etc...) in any commits, pr's, issue titles or anywhere else. +- 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. +- 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. +- Name branches only after the work being performed, such as `refactor/tanstack-store`; do not apply a tool's default branch prefix when that prefix would violate the naming rule above. - When the user says "add, commit", group all existing changes into logical sets, stage and commit each group, and repeat until every change is committed and the working tree is clean. - At the start of every new work request, check the current Git branch before changing files. If the current branch is `main`, create and switch to a descriptively named task branch first. - Never push work directly to `main`. If `main` already contains uncommitted changes or commits that have not been pushed, create the task branch from its current state so all of that work moves forward on the new branch, then continue the normal workflow there. diff --git a/README.md b/README.md index 0c5bfd2..23462d7 100644 --- a/README.md +++ b/README.md @@ -7,18 +7,20 @@ Bike trainer control web app using Web Bluetooth. Tested with Wahoo KICKR Core 2 ## Features - Welcomes first-time visitors with a concise introduction, open-source and local-data privacy details, a direct source-code link, and an optional “Don't show again” preference stored in the browser; the welcome screen remains available from the Ride Control footer link. -- Manages the smart trainer, heart rate monitor, and both Zwift Click V2 controllers independently from one paired-devices panel, with blue pulsing dots for controllers awaiting connection 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 reconnect-saving guidance alongside the pairing controls only in Chrome. +- 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 blue pulsing dots and a consistent reconnecting status for controllers awaiting connection 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 automatically reconnects when possible. - 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. -- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; the Click minus/plus buttons, on-screen controls, and keyboard down/up arrows make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. +- Replaces direct resistance controls with a focused 1–24 virtual shifting interface whenever Zwift Click V2 is paired; 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 then make quick, three-point resistance changes with matching visual feedback, holding a shift control continues shifting, and sessions record and graph the selected gear instead of resistance. - Automatically records while pedaling, auto-pauses during inactivity, supports manual pause and resume, and allows a session to end at any time—even before trainer data arrives. -- Tracks complete time-series data plus averages and maximums for power, cadence, heart rate, speed, and either resistance or virtual gear, with focused and combined chart views. -- Saves completed sessions to browser-managed IndexedDB storage, including optional comments and how the ride felt, and requests persistent browser storage when supported. -- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation, and permanent deletion. -- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, and comments for upload to Strava and other cycling services. +- 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. +- Lets riders explicitly save a completed session or end it without saving, while keeping start-new and continue-session choices to two clear, context-aware actions; saved sessions use browser-managed IndexedDB storage with optional comments and ride feeling, and persistent browser storage is requested when supported. +- Organizes saved sessions by local date and time in a slide-out history tray, with clear date ranges for rides that span midnight, paginated loading, detailed metrics and charts, keyboard navigation with grouped shortcut help, and permanent deletion. +- Downloads saved rides as Strava-compatible TCX files, including timestamps, distance, speed, power, cadence, heart rate, resistance or virtual gear, calories, ride feeling, comments, and a unique Ride Control session identifier for reliable duplicate detection. +- Imports an individual TCX file or every TCX file inside nested folders in a ZIP directly into local session history, preserves compatible ride data and Ride Control session identifiers, detects duplicates by identifier or stable ride data for legacy exports, and continues past individual invalid files in a batch; imported rides permanently retain their import timestamp and a subtle import icon, while only the latest batch remains highlighted until the history tray closes. +- Downloads every locally saved ride at once as a compressed ZIP containing a folder of individual TCX files, with collision-safe filenames when sessions share the same start time. - Continues any saved session in a new unsaved copy while preserving its recorded time, distance, calories, samples, averages, maximums, and original start time. - 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. @@ -34,6 +36,21 @@ bun run dev Open in current Chrome. +## Architecture + +Ride session data is held in a per-app TanStack Store and changed through atomic domain actions. +The `useSession` adapter owns recording timers and durable browser persistence while exposing the +existing session controller API to the interface. Each Bluetooth hook exposes one explicit +connection phase instead of independently managed status flags. Shared reconnect scheduling and +notification subscriptions live in plain controllers, while device-specific adapters own GATT +setup for heart-rate monitors, trainers, and Click controllers. Bluetooth objects and timers stay +outside shared application state. The application component coordinates focused dashboard regions, +uses an explicit overlay state for mutually exclusive trays, renders every side tray through one +animated and accessible shell, and delegates save/discard/start/continue transitions to a +store-backed session workflow. Temporary form inputs remain local React state. +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. + ## Deployment Pull requests and pushes to `main` run the complete `bun run ci` suite in GitHub Actions. After @@ -51,6 +68,8 @@ Persistent Web Bluetooth permissions are disabled by default in current Chromium 2. Enable **Use the new permissions backend for Web Bluetooth**. 3. Relaunch Chrome and pair the device once more from the app. +The paired-devices panel detects Chrome's persistent reconnect capability and replaces these setup steps with a configured confirmation when it is available. + ## License Copyright (C) 2026 Public Profile. diff --git a/bun.lock b/bun.lock index 14eb2cc..06f20f9 100644 --- a/bun.lock +++ b/bun.lock @@ -6,17 +6,21 @@ "name": "ridecontrol", "dependencies": { "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", + "fflate": "^0.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", }, "devDependencies": { "@biomejs/biome": "^2.5.4", + "@tailwindcss/language-server": "^0.16.0", "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/web-bluetooth": "^0.0.21", + "@xmldom/xmldom": "^0.9.10", "typescript": "^7.0.2", "ultracite": "^7.9.4", "vite": "^8.1.5", @@ -122,6 +126,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@tailwindcss/language-server": ["@tailwindcss/language-server@0.16.0", "", { "bin": { "css-language-server": "bin/css-language-server", "tailwindcss-language-server": "bin/tailwindcss-language-server" } }, "sha512-ko2xr4nlFIkqwJFnnw7hfdpjbsnK0DOk+QEEI51tvKOCF97OG0RU59iGfadoaOoY6I9xkSMbTW+AAwgnGnLH3w=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], @@ -152,6 +158,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + "@tanstack/react-store": ["@tanstack/react-store@0.11.0", "", { "dependencies": { "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w=="], + + "@tanstack/store": ["@tanstack/store@0.11.0", "", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -226,6 +236,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -288,6 +300,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -434,6 +448,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/package.json b/package.json index 0987463..c881015 100644 --- a/package.json +++ b/package.json @@ -9,22 +9,27 @@ "check": "biome check .", "fix": "biome check --write .", "test": "bun test", - "ci": "biome check . && bun test && tsc --noEmit && vite build", + "tailwind:check": "bun scripts/check-tailwind.ts", + "ci": "biome check . && bun run tailwind:check && bun test && tsc --noEmit && vite build", "preview": "vite preview --host localhost --port 4200" }, "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-store": "^0.11.0", "@vitejs/plugin-react": "^6.0.3", + "fflate": "^0.8.3", "react": "^19.2.7", "react-dom": "^19.2.7", - "tailwindcss": "^4.3.3", - "@tailwindcss/vite": "^4.3.3" + "tailwindcss": "^4.3.3" }, "devDependencies": { "@biomejs/biome": "^2.5.4", + "@tailwindcss/language-server": "^0.16.0", "@types/bun": "^1.3.14", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@types/web-bluetooth": "^0.0.21", + "@xmldom/xmldom": "^0.9.10", "typescript": "^7.0.2", "ultracite": "^7.9.4", "vite": "^8.1.5" diff --git a/scripts/check-tailwind.ts b/scripts/check-tailwind.ts new file mode 100644 index 0000000..3962946 --- /dev/null +++ b/scripts/check-tailwind.ts @@ -0,0 +1,354 @@ +import { spawn } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import { extname, join, relative, resolve as resolvePath } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const DIAGNOSTIC_TIMEOUT_MS = 30_000; +const CONTENT_LENGTH_PATTERN = /Content-Length: (\d+)/i; +const LANGUAGE_IDS = new Map([ + ['.css', 'css'], + ['.html', 'html'], + ['.js', 'javascript'], + ['.jsx', 'javascriptreact'], + ['.ts', 'typescript'], + ['.tsx', 'typescriptreact'], +]); + +const defaultSettings = { + editor: { tabSize: 4 }, + tailwindCSS: { + classAttributes: ['class', 'className', 'ngClass', 'class:list'], + classFunctions: [], + codeActions: true, + codeLens: true, + colorDecorators: true, + emmetCompletions: false, + experimental: { classRegex: [], configFile: null }, + files: { + exclude: ['**/.git/**', '**/dist/**', '**/node_modules/**'], + }, + hovers: true, + includeLanguages: {}, + inspectPort: null, + lint: { + cssConflict: 'warning', + deprecatedAtRule: 'warning', + invalidApply: 'error', + invalidConfigPath: 'error', + invalidScreen: 'error', + invalidSourceDirective: 'error', + invalidTailwindDirective: 'error', + invalidVariant: 'error', + recommendedVariantOrder: 'warning', + suggestCanonicalClasses: 'warning', + usedBlocklistedClass: 'warning', + }, + rootFontSize: 16, + showPixelEquivalents: true, + suggestions: true, + validate: true, + }, +}; + +interface Diagnostic { + code?: number | string; + message: string; + range: { + start: { character: number; line: number }; + }; + severity?: number; +} + +interface JsonRpcMessage { + error?: { code: number; message: string }; + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; +} + +interface PublishedDiagnostics { + diagnostics: Diagnostic[]; + uri: string; +} + +interface Deferred { + promise: Promise; + reject: (reason: Error) => void; + resolve: (value: T) => void; +} + +function deferred(): Deferred { + let reject = (_reason: Error) => undefined; + let resolve = (_value: T) => undefined; + const promise = new Promise((resolvePromise, rejectPromise) => { + reject = rejectPromise; + resolve = resolvePromise; + }); + return { promise, reject, resolve }; +} + +function withTimeout(promise: Promise, description: string): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error(`Timed out waiting for ${description}.`)), + DIAGNOSTIC_TIMEOUT_MS + ); + promise.then( + (value) => { + clearTimeout(timer); + resolvePromise(value); + }, + (error: unknown) => { + clearTimeout(timer); + rejectPromise(error); + } + ); + }); +} + +async function sourceFiles(directory: string): Promise { + const files: string[] = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await sourceFiles(path))); + } else if (LANGUAGE_IDS.has(extname(entry.name))) { + files.push(path); + } + } + return files; +} + +function settingForSection(section: string | undefined) { + if (!section) { + return defaultSettings; + } + return defaultSettings[section as keyof typeof defaultSettings] ?? {}; +} + +function diagnosticLevel(severity: number | undefined): string { + if (severity === 1) { + return 'error'; + } + if (severity === 2) { + return 'warning'; + } + return 'notice'; +} + +async function main() { + const workspace = process.cwd(); + const workspaceUri = pathToFileURL(workspace).href; + const server = spawn( + resolvePath('node_modules/.bin/tailwindcss-language-server'), + ['--stdio'], + { + cwd: workspace, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + const pendingRequests = new Map>(); + const diagnostics = new Map(); + const diagnosticWaiters = new Map>(); + const serverReady = deferred(); + let nextRequestId = 1; + let output = Buffer.alloc(0); + let serverErrors = ''; + + server.stderr.setEncoding('utf8'); + server.stderr.on('data', (chunk: string) => { + serverErrors += chunk; + }); + + const send = (message: JsonRpcMessage) => { + const body = JSON.stringify({ jsonrpc: '2.0', ...message }); + server.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`); + }; + + const respond = (id: number | string, result: unknown) => send({ id, result }); + + const handleMessage = (message: JsonRpcMessage) => { + if (message.id !== undefined && !message.method) { + const request = pendingRequests.get(Number(message.id)); + if (!request) { + return; + } + pendingRequests.delete(Number(message.id)); + if (message.error) { + request.reject(new Error(message.error.message)); + } else { + request.resolve(message.result); + } + return; + } + + if (message.method === 'textDocument/publishDiagnostics') { + const published = message.params as PublishedDiagnostics; + diagnostics.set(published.uri, published.diagnostics); + diagnosticWaiters.get(published.uri)?.resolve(published.diagnostics); + return; + } + + if (message.method === '@/tailwindCSS/serverReady') { + serverReady.resolve(); + return; + } + + if (message.id === undefined) { + return; + } + + if (message.method === 'workspace/configuration') { + const params = message.params as { items: Array<{ section?: string }> }; + respond( + message.id, + params.items.map((item) => settingForSection(item.section)) + ); + return; + } + + if (message.method === 'workspace/workspaceFolders') { + respond(message.id, [{ name: 'RideControl', uri: workspaceUri }]); + return; + } + + if (message.method === 'workspace/applyEdit') { + respond(message.id, { applied: false }); + return; + } + + respond(message.id, null); + }; + + server.stdout.on('data', (chunk: Buffer) => { + output = Buffer.concat([output, chunk]); + while (output.length > 0) { + const headerEnd = output.indexOf('\r\n\r\n'); + if (headerEnd < 0) { + break; + } + const header = output.subarray(0, headerEnd).toString('ascii'); + const contentLength = CONTENT_LENGTH_PATTERN.exec(header)?.[1]; + if (!contentLength) { + throw new Error('Tailwind language server sent an invalid response header.'); + } + const bodyStart = headerEnd + 4; + const bodyEnd = bodyStart + Number(contentLength); + if (output.length < bodyEnd) { + break; + } + const body = output.subarray(bodyStart, bodyEnd).toString('utf8'); + output = output.subarray(bodyEnd); + handleMessage(JSON.parse(body) as JsonRpcMessage); + } + }); + + const request = (method: string, params?: unknown) => { + const id = nextRequestId; + nextRequestId += 1; + const response = deferred(); + pendingRequests.set(id, response); + send({ id, method, params }); + return response.promise; + }; + + try { + await withTimeout( + request('initialize', { + capabilities: { + experimental: { tailwind: { projectDetails: true } }, + textDocument: { + codeAction: { dynamicRegistration: true }, + completion: { dynamicRegistration: true }, + publishDiagnostics: { relatedInformation: true }, + synchronization: { didSave: true, dynamicRegistration: true }, + }, + workspace: { + configuration: true, + didChangeConfiguration: { dynamicRegistration: true }, + didChangeWatchedFiles: { dynamicRegistration: true }, + workspaceFolders: true, + }, + }, + initializationOptions: { testMode: true }, + processId: process.pid, + rootUri: workspaceUri, + trace: 'off', + workspaceFolders: [{ name: 'RideControl', uri: workspaceUri }], + }), + 'Tailwind language server initialization' + ); + send({ method: 'initialized', params: {} }); + await withTimeout(serverReady.promise, 'Tailwind language server readiness'); + + const files = [ + ...(await sourceFiles(resolvePath('src'))), + resolvePath('index.html'), + ].sort(); + await Promise.all( + files.map(async (file) => { + const uri = pathToFileURL(file).href; + const waiter = deferred(); + diagnosticWaiters.set(uri, waiter); + send({ + method: 'textDocument/didOpen', + params: { + textDocument: { + languageId: LANGUAGE_IDS.get(extname(file)) ?? 'plaintext', + text: await readFile(file, 'utf8'), + uri, + version: 1, + }, + }, + }); + await withTimeout( + waiter.promise, + `Tailwind diagnostics for ${relative(workspace, file)}` + ); + }) + ); + + const failures = [...diagnostics.entries()] + .flatMap(([uri, fileDiagnostics]) => + fileDiagnostics + .filter( + (diagnostic) => + diagnostic.severity === undefined || diagnostic.severity <= 2 + ) + .map((diagnostic) => ({ diagnostic, file: fileURLToPath(uri) })) + ) + .sort( + (left, right) => + left.file.localeCompare(right.file) || + left.diagnostic.range.start.line - right.diagnostic.range.start.line || + left.diagnostic.range.start.character - right.diagnostic.range.start.character + ); + + if (failures.length > 0) { + for (const { diagnostic, file } of failures) { + const location = `${relative(workspace, file)}:${diagnostic.range.start.line + 1}:${diagnostic.range.start.character + 1}`; + const code = diagnostic.code === undefined ? '' : ` [${diagnostic.code}]`; + console.error( + `${location} ${diagnosticLevel(diagnostic.severity)}${code} ${diagnostic.message}` + ); + } + throw new Error(`Tailwind CSS reported ${failures.length} diagnostic(s).`); + } + + console.log(`Tailwind CSS diagnostics passed for ${files.length} source files.`); + } finally { + try { + await request('shutdown'); + send({ method: 'exit' }); + } catch { + // Preserve the original failure when shutdown cannot complete. + } + server.kill(); + if (serverErrors.trim()) { + console.error(serverErrors.trim()); + } + } +} + +await main(); diff --git a/src/app.tsx b/src/app.tsx index 1394a07..e01aea0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,49 +1,40 @@ +import { useSelector } from '@tanstack/react-store'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { DevicePairingButton, DevicePairingPanel } from './components/device-pairing'; -import { GearControl } from './components/gear-control'; -import { Icon } from './components/icon'; +import { AppFooter } from './components/app-footer'; +import { Dashboard, DashboardToolbar, DashboardWorkspace } from './components/dashboard-layout'; +import { DashboardTools } from './components/dashboard-tools'; +import { DevicePairingPanel } from './components/device-pairing'; import { KeyboardShortcutsDialog } from './components/keyboard-shortcuts-dialog'; -import { Metric, SmallMetric } from './components/metrics'; import { Notification } from './components/notification'; -import { ResistanceControl } from './components/resistance-control'; -import { SessionChart } from './components/session-chart'; +import { RideMetrics } from './components/ride-metrics'; +import { SessionControls } from './components/session-controls'; import { SessionHistory } from './components/session-history'; +import { SessionOverview } from './components/session-overview'; import { SessionSaveDialog } from './components/session-save-dialog'; +import { TrainingControl } from './components/training-control'; import { WelcomeDialog } from './components/welcome-dialog'; import { useGearControl } from './hooks/use-gear-control'; import { useHeartRateMonitor } from './hooks/use-heart-rate-monitor'; import { useSession } from './hooks/use-session'; +import { useSessionWorkflow } from './hooks/use-session-workflow'; import { useTrainer } from './hooks/use-trainer'; import { useZwiftClick } from './hooks/use-zwift-click'; -import { BUILD_PR_URL, BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from './lib/build-info'; -import { formatAggregateAverage, formatDuration } from './lib/format'; +import { CONTROL_MODE, type ControlMode } from './lib/control-mode'; +import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom'; import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard'; -import { - createSavedSession, - requestPersistentSessionStorage, - saveSession, -} from './lib/saved-sessions'; import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session'; import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome'; -import type { - ControlMode, - Metrics, - RoutePoint, - SavedSession, - SessionMetadata, - SpeedUnit, -} from './types'; +import { MAX_CLICK_CONTROLLERS } from './lib/zwift-click'; +import { preferencesStore } from './stores/preferences-store'; +import type { Metrics, SavedSession } from './types'; -const EMPTY_ROUTE: RoutePoint[] = []; +type AppOverlay = 'devices' | 'history' | 'shortcuts' | 'welcome'; function shouldIgnoreShortcut(event: KeyboardEvent) { - const target = event.target as HTMLElement | null; return ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - target?.matches("button, a, input, textarea, select, [contenteditable='true']") + keyboardEventHasModifiers(event) || + eventTargetsInteractiveControl(event) ); } @@ -59,12 +50,15 @@ function shiftHandlerUnlessBlocked(handler: (change: number) => void, blocked: b } function controlModeForClick(paired: boolean): ControlMode { - return paired ? 'gear' : 'resistance'; + return paired ? CONTROL_MODE.GEAR : CONTROL_MODE.RESISTANCE; } export function App() { const trainer = useTrainer(); - const [devicesOpen, setDevicesOpen] = useState(false); + const [activeOverlay, setActiveOverlay] = useState(() => + shouldShowWelcome() ? 'welcome' : undefined + ); + const devicesOpen = activeOverlay === 'devices'; const clickShiftRef = useRef<(change: number) => void>(() => undefined); const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []); const click = useZwiftClick(handleClickShift, trainer.setNotice, devicesOpen); @@ -75,22 +69,16 @@ export function App() { heartRate.heartRate ); const { connected } = trainer; - const [speedUnit, setSpeedUnit] = useState(() => - localStorage.getItem('speed-unit') === 'kmh' ? 'kmh' : 'mph' - ); - const [historyOpen, setHistoryOpen] = useState(false); - const [shortcutsOpen, setShortcutsOpen] = useState(false); - const [welcomeOpen, setWelcomeOpen] = useState(shouldShowWelcome); - const dashboardKeyboardEnabled = !(devicesOpen || historyOpen || shortcutsOpen || welcomeOpen); + const speedUnit = useSelector(preferencesStore, (preferences) => preferences.speedUnit); + const virtualShiftingReady = + trainer.connected && click.connectedCount === MAX_CLICK_CONTROLLERS; const gearControl = useGearControl({ active: click.paired, - connected: trainer.connected, - keyboardEnabled: dashboardKeyboardEnabled, onResistanceChange: trainer.shiftResistanceBy, + ready: virtualShiftingReady, resistance: trainer.resistance, setNotice: trainer.setNotice, }); - clickShiftRef.current = shiftHandlerUnlessBlocked(gearControl.shiftGear, devicesOpen); const session = useSession( liveMetrics, { @@ -101,76 +89,26 @@ export function App() { trainer.lastPedalingAt, trainer.trainerReportsDistance ); - const { isRiding, manuallyPaused } = session; - const sessionIsSaved = Boolean(session.savedSessionId); - const [saveDialogOpen, setSaveDialogOpen] = useState(() => session.ended && !sessionIsSaved); - const [saving, setSaving] = useState(false); - const [startAfterSave, setStartAfterSave] = useState(false); - const [continuationAfterSave, setContinuationAfterSave] = useState(); - const endSession = useCallback(() => { - session.endSession(); - setStartAfterSave(false); - setContinuationAfterSave(undefined); - setSaveDialogOpen(true); - }, [session.endSession]); - const startNewSession = useCallback(() => { - session.startNew(); - setSaveDialogOpen(false); - setStartAfterSave(false); - setContinuationAfterSave(undefined); - trainer.setNotice('New session ready.'); - }, [session.startNew, trainer.setNotice]); - const continueSession = useCallback( - (savedSession: SavedSession) => { - session.continueFrom(savedSession); - setHistoryOpen(false); - setSaveDialogOpen(false); - setStartAfterSave(false); - setContinuationAfterSave(undefined); - trainer.setNotice('Session continued.'); - }, - [session.continueFrom, trainer.setNotice] + const workflow = useSessionWorkflow(session, trainer.setNotice); + const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen; + clickShiftRef.current = shiftHandlerUnlessBlocked( + gearControl.shiftGear, + !dashboardKeyboardEnabled ); - const requestNewSession = useCallback(() => { - if (session.ended) { - if (sessionIsSaved) { - startNewSession(); - } else { - setStartAfterSave(true); - setContinuationAfterSave(undefined); - setSaveDialogOpen(true); - } - return; - } - if (session.elapsedSeconds > 0) { - session.endSession(); - setStartAfterSave(true); - setContinuationAfterSave(undefined); - setSaveDialogOpen(true); - return; - } - startNewSession(); - }, [ - session.elapsedSeconds, - session.endSession, - session.ended, - sessionIsSaved, - startNewSession, - ]); const handleNewSessionShortcut = useCallback( (event: KeyboardEvent) => { if (!session.ended) { return; } event.preventDefault(); - requestNewSession(); + workflow.requestNewSession(); }, - [requestNewSession, session.ended] + [session.ended, workflow.requestNewSession] ); useEffect(() => { - requestPersistentSessionStorage().catch(() => false); - }, []); + workflow.requestPersistentStorage(); + }, [workflow.requestPersistentStorage]); const warnBeforeUnload = sessionNeedsUnloadWarning(session.ended, session.elapsedSeconds); useEffect(() => { @@ -187,9 +125,11 @@ export function App() { useEffect(() => { trainer.setKeyboardControlsEnabled(dashboardKeyboardEnabled); trainer.setGearControlsEnabled(click.paired); + gearControl.setKeyboardControlsEnabled(dashboardKeyboardEnabled); }, [ click.paired, dashboardKeyboardEnabled, + gearControl.setKeyboardControlsEnabled, trainer.setGearControlsEnabled, trainer.setKeyboardControlsEnabled, ]); @@ -197,46 +137,32 @@ export function App() { useEffect(() => { const shortcutHandlers: Record void> = { endSession: (event) => { - if (saveDialogOpen || shortcutsOpen || session.ended) { + if (session.ended) { return; } event.preventDefault(); - endSession(); + workflow.endSession(); }, history: (event) => { - if (saveDialogOpen) { - return; - } event.preventDefault(); - setShortcutsOpen(false); - setHistoryOpen(true); - }, - newSession: (event) => { - if (!(saveDialogOpen || shortcutsOpen)) { - handleNewSessionShortcut(event); - } + setActiveOverlay('history'); }, + newSession: handleNewSessionShortcut, pause: (event) => { - if (saveDialogOpen || shortcutsOpen) { - return; - } event.preventDefault(); session.togglePause(); }, shortcuts: (event) => { - if (saveDialogOpen) { - return; - } event.preventDefault(); - setHistoryOpen(false); - setShortcutsOpen(true); + setActiveOverlay('shortcuts'); }, }; const handleShortcut = (event: KeyboardEvent) => { - if (devicesOpen || welcomeOpen || shouldIgnoreShortcut(event)) { - return; - } - if (historyOpen) { + if ( + activeOverlay !== undefined || + workflow.saveDialogOpen || + shouldIgnoreShortcut(event) + ) { return; } const shortcut = appShortcutForKey(event); @@ -247,369 +173,122 @@ export function App() { window.addEventListener('keydown', handleShortcut); return () => window.removeEventListener('keydown', handleShortcut); }, [ - devicesOpen, - endSession, + activeOverlay, handleNewSessionShortcut, - historyOpen, - saveDialogOpen, session.ended, session.togglePause, - shortcutsOpen, - welcomeOpen, + workflow.endSession, + workflow.saveDialogOpen, ]); - function selectSpeedUnit(unit: SpeedUnit) { - setSpeedUnit(unit); - localStorage.setItem('speed-unit', unit); - } - - function closeSaveDialog() { - setSaveDialogOpen(false); - setStartAfterSave(false); - setContinuationAfterSave(undefined); - } - - async function saveCurrentSession(metadata: SessionMetadata) { - setSaving(true); - try { - const saved = createSavedSession(session.snapshot, metadata); - await saveSession(saved); - session.markSaved(saved.id); - if (startAfterSave) { - if (continuationAfterSave) { - continueSession(continuationAfterSave); - trainer.setNotice('Session saved. Selected session continued.'); - } else { - startNewSession(); - trainer.setNotice('Session saved. New session ready.'); - } - } else { - setSaveDialogOpen(false); - trainer.setNotice('Session saved.'); - } - } catch (error) { - trainer.setNotice( - `Session could not be saved: ${error instanceof Error ? error.message : String(error)}` - ); - } finally { - setSaving(false); - } - } - - const closeHistory = useCallback(() => setHistoryOpen(false), []); - const closeShortcuts = useCallback(() => setShortcutsOpen(false), []); const closeWelcome = useCallback((dontShowAgain: boolean) => { if (dontShowAgain) { rememberWelcomeDismissal(); } - setWelcomeOpen(false); + setActiveOverlay(undefined); }, []); - const startNewSessionFromHistory = useCallback( + const continueFromHistory = useCallback( (savedSession: SavedSession) => { - setHistoryOpen(false); - const currentNeedsSave = - (session.ended && !sessionIsSaved) || - (!session.ended && session.elapsedSeconds > 0); - if (currentNeedsSave) { - if (!session.ended) { - session.endSession(); - } - setContinuationAfterSave(savedSession); - setStartAfterSave(true); - setSaveDialogOpen(true); - return; - } - continueSession(savedSession); + setActiveOverlay(undefined); + workflow.requestContinuation(savedSession); }, - [continueSession, session.elapsedSeconds, session.endSession, session.ended, sessionIsSaved] + [workflow.requestContinuation] ); - const proceedWithoutSaving = useCallback(() => { - if (continuationAfterSave) { - continueSession(continuationAfterSave); - } else { - startNewSession(); - } - }, [continuationAfterSave, continueSession, startNewSession]); - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; - const displayedSpeed = liveMetrics.speed * unitFactor; - const displayedDistance = session.rideDistance * unitFactor; - const displayedMaximumSpeed = session.maximums.speed * unitFactor; - const averageSpeed = - session.elapsedSeconds > 0 ? session.rideDistance / (session.elapsedSeconds / 3600) : 0; - const displayedAverageSpeed = averageSpeed * unitFactor; const connectedDeviceCount = Number(trainer.connected) + Number(heartRate.connected) + click.connectedCount; const pairedDeviceCount = Number(trainer.paired) + Number(heartRate.paired) + click.pairedCount; const devicesConnecting = [ trainer.connectionBusy, heartRate.busy, - click.reconnecting, + click.busy, click.pairing, ].some(Boolean); - let sessionControlLabel = 'Auto paused'; - let sessionControlIcon = 'stop'; - if (isRiding) { - sessionControlLabel = 'Pause'; - sessionControlIcon = 'pause'; - } - if (manuallyPaused) { - sessionControlLabel = 'Resume'; - sessionControlIcon = 'play'; - } return (
-
-
-
- {session.ended ? ( - <> - {sessionIsSaved ? null : ( - - )} - - - ) : ( - <> - - - - )} -
-
- -
- - -
- - setDevicesOpen(true)} - pairedCount={pairedDeviceCount} - /> -
-
- -
- + + - setActiveOverlay('devices')} + onOpenHistory={() => setActiveOverlay('history')} + onOpenShortcuts={() => setActiveOverlay('shortcuts')} + onSelectSpeedUnit={preferencesStore.actions.selectSpeedUnit} + pairedDeviceCount={pairedDeviceCount} + speedUnit={speedUnit} /> - + + + - -
- -
-
-
- - - -
- -
-
-
-

- {click.paired ? 'Virtual shifting' : 'Resistance control'} -

-
- - {click.paired ? gearControl.gear : trainer.resistance} - - {click.paired ? 'of 24' : '%'} - - -
-
- {click.paired ? ( - - ) : ( - - )} -
-
-
- + + + setActiveOverlay('welcome')} /> trainer.setNotice('')} /> setActiveOverlay(undefined)} + onStartNew={continueFromHistory} + open={activeOverlay === 'history'} speedUnit={speedUnit} /> setDevicesOpen(false)} + onClose={() => setActiveOverlay(undefined)} open={devicesOpen} trainer={{ busy: trainer.connectionBusy, @@ -639,15 +318,17 @@ export function App() { onPair: trainer.connect, onReconnect: trainer.reconnect, paired: trainer.paired, + phase: trainer.phase, + reconnecting: trainer.reconnecting, status: trainer.status, }} /> setActiveOverlay(undefined)} + open={activeOverlay === 'shortcuts'} shortcuts={click.paired ? gearingKeyboardShortcuts : undefined} /> - +
); } diff --git a/src/components/app-footer.tsx b/src/components/app-footer.tsx new file mode 100644 index 0000000..8abf41d --- /dev/null +++ b/src/components/app-footer.tsx @@ -0,0 +1,45 @@ +import { BUILD_PR_URL, BUILD_TIMESTAMP_UTC, formatBuildTimestamp } from '../lib/build-info'; + +export function AppFooter({ onOpenWelcome }: { onOpenWelcome: () => void }) { + return ( + + ); +} diff --git a/src/components/dashboard-layout.tsx b/src/components/dashboard-layout.tsx new file mode 100644 index 0000000..357614b --- /dev/null +++ b/src/components/dashboard-layout.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from 'react'; + +export function Dashboard({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function DashboardToolbar({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function DashboardWorkspace({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/src/components/dashboard-tools.tsx b/src/components/dashboard-tools.tsx new file mode 100644 index 0000000..48a19f3 --- /dev/null +++ b/src/components/dashboard-tools.tsx @@ -0,0 +1,61 @@ +import { SPEED_UNIT_OPTIONS } from '../lib/units'; +import type { SpeedUnit } from '../types'; +import { DevicePairingButton } from './device-pairing'; + +export function DashboardTools({ + connectedDeviceCount, + devicesConnecting, + onOpenDevices, + onOpenHistory, + onOpenShortcuts, + onSelectSpeedUnit, + pairedDeviceCount, + speedUnit, +}: { + connectedDeviceCount: number; + devicesConnecting: boolean; + onOpenDevices: () => void; + onOpenHistory: () => void; + onOpenShortcuts: () => void; + onSelectSpeedUnit: (unit: SpeedUnit) => void; + pairedDeviceCount: number; + speedUnit: SpeedUnit; +}) { + return ( +
+ +
+ {SPEED_UNIT_OPTIONS.map((option) => ( + + ))} +
+ + +
+ ); +} diff --git a/src/components/device-pairing.tsx b/src/components/device-pairing.tsx index 5239697..6626bff 100644 --- a/src/components/device-pairing.tsx +++ b/src/components/device-pairing.tsx @@ -1,25 +1,22 @@ -import { useEffect } from 'react'; -import { AUTOMATIC_RECONNECT_README_URL } from '../constants'; -import { bluetoothBrowserNotice } from '../lib/browser'; +import { useState } from 'react'; +import { CHROME_BLUETOOTH_FLAGS_URL } from '../constants'; +import { automaticBluetoothReconnectConfigured, bluetoothBrowserNotice } from '../lib/browser'; +import type { DeviceConnectionView } from '../lib/device-connection'; +import { MAX_CLICK_CONTROLLERS } from '../lib/zwift-click'; import { Icon } from './icon'; +import { SideTray } from './side-tray'; -interface DeviceSlot { +interface DeviceSlot extends DeviceConnectionView { battery?: number; - busy: boolean; - connected: boolean; name?: string; onDisconnect: () => void; onForget: () => void | Promise; onPair: () => void | Promise; onReconnect: () => void | Promise; - paired: boolean; - status: string; } -interface ClickController { +interface ClickController extends DeviceConnectionView { active: boolean; - connected: boolean; - connecting: boolean; id: string; label: string; } @@ -30,7 +27,7 @@ interface ClickSlot extends DeviceSlot { onForgetController: (deviceId: string) => void | Promise; pairedCount: number; pairing: boolean; - reconnecting?: boolean; + reconnecting: boolean; } function clickControllerOrder(controller: ClickController) { @@ -66,30 +63,31 @@ function StatusDot({ } function DeviceActions({ slot }: { slot: DeviceSlot }) { + const actionBusy = slot.busy; if (!slot.paired) { return ( ); } let connectionAction = 'Reconnect'; - if (slot.busy) { + if (actionBusy) { connectionAction = 'Connecting…'; } else if (slot.connected) { connectionAction = 'Disconnect'; } - const disconnecting = slot.connected && !slot.busy; + const disconnecting = slot.connected && !actionBusy; return (
+ +
  • + Enable Use the new permissions backend for Web Bluetooth. +
  • +
  • Relaunch Chrome, then pair each device once more.
  • + + + ); +} + function DeviceCard({ description, icon, @@ -189,6 +239,7 @@ export function DevicePairingButton({ } export function DevicePairingPanel({ + automaticReconnectConfigured = automaticBluetoothReconnectConfigured(), browserNotice = bluetoothBrowserNotice(), click, heartRate, @@ -196,6 +247,7 @@ export function DevicePairingPanel({ open, trainer, }: { + automaticReconnectConfigured?: boolean; browserNotice?: string; click: ClickSlot; heartRate: DeviceSlot; @@ -203,36 +255,14 @@ export function DevicePairingPanel({ open: boolean; trainer: DeviceSlot; }) { - useEffect(() => { - if (!open) { - return; - } - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); - } - }; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - window.addEventListener('keydown', closeOnEscape); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', closeOnEscape); - }; - }, [onClose, open]); - - if (!open) { - return null; - } - - const clickSlot: DeviceSlot = { - ...click, - busy: false, - connected: click.pairedCount > 0 && click.connectedCount === click.pairedCount, - paired: click.pairedCount > 0, + const [flagsUrlCopied, setFlagsUrlCopied] = useState(false); + const copyChromeFlagsUrl = async () => { + await navigator.clipboard.writeText(CHROME_BLUETOOTH_FLAGS_URL); + setFlagsUrlCopied(true); }; - const waitingForControllers = Boolean(click.reconnecting); + + const clickSlot: DeviceSlot = click; + const waitingForControllers = click.reconnecting || click.phase === 'connecting'; const orderedClickControllers = [...click.controllers].sort( (left, right) => clickControllerOrder(left) - clickControllerOrder(right) ); @@ -242,161 +272,144 @@ export function DevicePairingPanel({ } else if (click.pairedCount) { pairControllerLabel = 'Pair other controller'; } + const chromeFlagsCopyLabel = flagsUrlCopied + ? 'copied, now paste it into a new tab.' + : CHROME_BLUETOOTH_FLAGS_URL; return ( -
    - + +
    +
    +

    + Paired devices +

    +

    + Pair each sensor once. Ride Control reconnects remembered devices when they + wake up. +

    + +
    - {browserNotice ? ( -
    -

    {browserNotice}

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

    {browserNotice}

    +
    + ) : null} - {browserNotice ? null : ( -
    - - + {browserNotice ? null : ( +
    + + -
    -
    -
    - +
    +
    +
    + +
    +
    +
    + 0} + /> +

    Zwift Click V2

    -
    -
    +

    + {click.pairedCount + ? `${click.connectedCount} of ${click.pairedCount} controllers connected` + : 'Pair each controller separately'} +

    +

    + {waitingForControllers ? 'Reconnecting…' : click.status} +

    +
    +
    + + {orderedClickControllers.length ? ( +
    + {orderedClickControllers.map((controller) => ( +
    0} + bluePulse + busy={controller.busy} + connected={controller.connected} /> -

    - Zwift Click V2 -

    +
    +

    + {controller.label} +

    +
    +
    -

    - {click.pairedCount - ? `${click.connectedCount} of ${click.pairedCount} controllers connected` - : 'Pair each controller separately'} -

    -

    - {waitingForControllers - ? 'Waiting for controllers…' - : click.status} -

    -
    + ))}
    + ) : null} - {orderedClickControllers.length ? ( -
    - {orderedClickControllers.map((controller) => ( -
    - -
    -

    - {controller.label} -

    -
    - -
    - ))} -
    +
    + {click.pairedCount > 0 ? : null} + {click.pairedCount < MAX_CLICK_CONTROLLERS ? ( + ) : null} - -
    - {click.pairedCount > 0 ? : null} - {click.pairedCount < 2 ? ( - - ) : null} -
    -

    - Wake each controller before pairing. The + and − sides are - identified automatically and reconnect in the background. -

    -
    -

    - Having reconnect trouble?{' '} - - Enable reconnect saving in Chrome - +

    +

    + Wake each controller before pairing. The + and − sides are identified + automatically and reconnect in the background.

    -
    - )} - -
    + + +
    + )} +
    ); } diff --git a/src/components/gear-control.tsx b/src/components/gear-control.tsx index c921ab9..5ee0682 100644 --- a/src/components/gear-control.tsx +++ b/src/components/gear-control.tsx @@ -125,10 +125,12 @@ export function GearControl({
    -

    - Use Zwift Click or {' '} - to shift -

    + {disabled ? null : ( +

    + Use Zwift Click or {' '} + to shift +

    + )}
    ); } diff --git a/src/components/gear-training-control.tsx b/src/components/gear-training-control.tsx new file mode 100644 index 0000000..1c39f67 --- /dev/null +++ b/src/components/gear-training-control.tsx @@ -0,0 +1,27 @@ +import { MAX_GEAR } from '../lib/gears'; +import type { ResistanceAdjustmentDirection } from '../types'; +import { GearControl } from './gear-control'; +import { TrainingControlPanel } from './training-control-panel'; + +export function GearTrainingControl({ + connected, + gear, + onShift, + shiftFlash, +}: { + connected: boolean; + gear: number; + onShift: (change: number) => void; + shiftFlash?: ResistanceAdjustmentDirection; +}) { + return ( + + + + ); +} diff --git a/src/components/icon.tsx b/src/components/icon.tsx index 5c8a2f6..eaa07bf 100644 --- a/src/components/icon.tsx +++ b/src/components/icon.tsx @@ -5,6 +5,8 @@ const paths: Record = { cadence: 'M20 11a8 8 0 0 0-15.5-2M4 4v5h5M4 13a8 8 0 0 0 15.5 2M20 20v-5h-5', controls: 'M7 4v6m0 4v6m10-16v2m0 4v10M4 10h6m4-4h6M4 14h6m4-4h6', heart: 'M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6a5.5 5.5 0 0 0 1-8.8Z', + 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', pause: 'M9 5v14m6-14v14', play: 'm9 5 10 7-10 7V5Z', @@ -16,11 +18,19 @@ const paths: Record = { upload: 'M12 16V4m0 0 4 4m-4-4L8 8M5 20h14', }; -export function Icon({ name, className = '' }: { name: string; className?: string }) { +export function Icon({ + name, + className = 'h-5 w-5', + title = name, +}: { + name: string; + className?: string; + title?: string; +}) { return ( ); diff --git a/src/components/keyboard-shortcuts-dialog.tsx b/src/components/keyboard-shortcuts-dialog.tsx index a19e81d..dff28d1 100644 --- a/src/components/keyboard-shortcuts-dialog.tsx +++ b/src/components/keyboard-shortcuts-dialog.tsx @@ -1,4 +1,5 @@ -import { Fragment, useEffect } from 'react'; +import { Fragment } from 'react'; +import { useCloseOnEscape } from '../hooks/use-dialog-behavior'; import { dashboardKeyboardShortcuts, type KeyboardShortcutDescription } from '../lib/keyboard'; export function KeyboardShortcutsDialog({ @@ -14,19 +15,7 @@ export function KeyboardShortcutsDialog({ shortcuts?: KeyboardShortcutDescription[]; title?: string; }) { - useEffect(() => { - if (!(handleEscape && open)) { - return; - } - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); - } - }; - window.addEventListener('keydown', closeOnEscape); - return () => window.removeEventListener('keydown', closeOnEscape); - }, [handleEscape, onClose, open]); + useCloseOnEscape(handleEscape && open, onClose); if (!open) { return null; @@ -57,11 +46,11 @@ export function KeyboardShortcutsDialog({ {shortcuts.map((shortcut, index) => ( {shortcut.group && shortcut.group !== shortcuts[index - 1]?.group ? ( -
    +
    {shortcut.group.toUpperCase()}
    ) : null} -
    +
    {shortcut.label} {shortcut.keys.map((key) => ( diff --git a/src/components/metrics.tsx b/src/components/metrics.tsx index cd1491b..e173eb8 100644 --- a/src/components/metrics.tsx +++ b/src/components/metrics.tsx @@ -1,35 +1,23 @@ +import { metricAccentClass, metricIconClass } from '../lib/metric-presentation'; import { Icon } from './icon'; -export function metricAccentClass(accent: string): string { - if (accent === 'sky') { - return 'bg-sky-400'; - } - if (accent === 'yellow') { - return 'bg-yellow-400'; - } - if (accent === 'violet') { - return 'bg-violet-400'; - } - if (accent === 'rose') { - return 'bg-rose-400'; - } - return 'bg-mint'; -} - -export function metricIconClass(accent: string): string { - if (accent === 'mint') { - return 'text-mint'; - } - if (accent === 'yellow') { - return 'text-yellow-400'; - } - if (accent === 'violet') { - return 'text-violet-400'; - } - if (accent === 'rose') { - return 'text-rose-400'; - } - return 'text-sky-400'; +function MetricSummary({ + label, + rightAligned = false, + value, +}: { + label: string; + rightAligned?: boolean; + value: string; +}) { + return ( +
    +

    {label}

    +

    + {value} +

    +
    + ); } export function Metric({ @@ -53,38 +41,49 @@ export function Metric({
    {label} - {icon ? : null} + {icon ? ( + + ) : null}
    - {value} + {value} {unit}
    -
    -

    AVG

    -

    - {average} - {unit} -

    -
    -
    -

    MAX

    -

    - {maximum} - {unit} -

    -
    + +
    ); } -export function SmallMetric({ label, value }: { label: string; value: string }) { +export function SmallMetric({ + large = false, + label, + unit, + value, +}: { + large?: boolean; + label: string; + unit?: string; + value: string; +}) { return (

    {label}

    -

    {value}

    +

    + {value} + {unit ? ( + + {unit} + + ) : null} +

    ); } @@ -121,7 +120,7 @@ export function SessionMetric({ MAX - {maximum} {unit} + {maximum} )}
    diff --git a/src/components/resistance-control.tsx b/src/components/resistance-control.tsx index 611f0cb..3a4d355 100644 --- a/src/components/resistance-control.tsx +++ b/src/components/resistance-control.tsx @@ -1,4 +1,6 @@ import { type CSSProperties, useEffect, useRef, useState } from 'react'; +import { CONTROL_FLASH_MS } from '../constants'; +import { clamp } from '../lib/numbers'; import { resistanceAdjustmentDirection } from '../lib/resistance'; import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; import { Icon } from './icon'; @@ -26,10 +28,9 @@ export function ResistanceControl({ const sliderDragging = useRef(false); const sliderFlashTimer = useRef(undefined); const sliderValue = useRef(value); - const rampProgress = ramp.phase === 'settled' ? 1 : Math.max(0, Math.min(1, ramp.progress)); + const rampProgress = ramp.phase === 'settled' ? 1 : clamp(ramp.progress, 0, 1); const rampProgressPercent = Math.round(rampProgress * 100); - const sliderPosition = - max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) * 100 : 0; + const sliderPosition = max > min ? clamp((value - min) / (max - min), 0, 1) * 100 : 0; const sliderStyle = { '--ramp-progress': `${rampProgress * 360}deg`, '--resistance-position': `${sliderPosition}%`, @@ -54,7 +55,10 @@ export function ResistanceControl({ const clearSliderFlash = () => { sliderDragging.current = false; window.clearTimeout(sliderFlashTimer.current); - sliderFlashTimer.current = window.setTimeout(() => setSliderFlash(undefined), 180); + sliderFlashTimer.current = window.setTimeout( + () => setSliderFlash(undefined), + CONTROL_FLASH_MS + ); }; const handleSliderChange = (next: number) => { @@ -64,7 +68,10 @@ export function ResistanceControl({ window.clearTimeout(sliderFlashTimer.current); setSliderFlash(direction); if (!sliderDragging.current) { - sliderFlashTimer.current = window.setTimeout(() => setSliderFlash(undefined), 180); + sliderFlashTimer.current = window.setTimeout( + () => setSliderFlash(undefined), + CONTROL_FLASH_MS + ); } } onChange(next); diff --git a/src/components/resistance-training-control.tsx b/src/components/resistance-training-control.tsx new file mode 100644 index 0000000..642f251 --- /dev/null +++ b/src/components/resistance-training-control.tsx @@ -0,0 +1,33 @@ +import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; +import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; +import { ResistanceControl } from './resistance-control'; +import { TrainingControlPanel } from './training-control-panel'; + +export function ResistanceTrainingControl({ + connected, + keyboardFlash, + onChange, + ramp, + resistance, +}: { + connected: boolean; + keyboardFlash?: ResistanceAdjustmentDirection; + onChange: (resistance: number) => void; + ramp: ResistanceRamp; + resistance: number; +}) { + return ( + + + + ); +} diff --git a/src/components/ride-metrics.tsx b/src/components/ride-metrics.tsx new file mode 100644 index 0000000..1c89051 --- /dev/null +++ b/src/components/ride-metrics.tsx @@ -0,0 +1,64 @@ +import { formatAggregateAverage, formatWholeNumber } from '../lib/format'; +import { METRIC_PRESENTATION } from '../lib/metric-presentation'; +import { averageSpeed, formatSpeed, speedUnitLabel } from '../lib/units'; +import type { Metrics, SessionAggregates, SpeedUnit } from '../types'; +import { Metric } from './metrics'; + +export function RideMetrics({ + aggregates, + elapsedSeconds, + liveMetrics, + maximums, + rideDistance, + speedUnit, +}: { + aggregates: SessionAggregates; + elapsedSeconds: number; + liveMetrics: Metrics; + maximums: Metrics; + rideDistance: number; + speedUnit: SpeedUnit; +}) { + const rideAverageSpeed = averageSpeed(rideDistance, elapsedSeconds); + const { cadence, heartRate, power, speed } = METRIC_PRESENTATION; + return ( +
    + + + + +
    + ); +} diff --git a/src/components/session-chart.tsx b/src/components/session-chart.tsx index be1374f..f3f20a6 100644 --- a/src/components/session-chart.tsx +++ b/src/components/session-chart.tsx @@ -1,11 +1,14 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - chartModesForControl, - chartPath, - roundedChartMaximum, - storedChartMode, -} from '../lib/chart'; +import { useSelector } from '@tanstack/react-store'; +import { Fragment, useCallback, useEffect, useMemo } from 'react'; +import { chartModesForControl, chartPath, roundedChartMaximum } from '../lib/chart'; +import { CONTROL_MODE, isControlMode } from '../lib/control-mode'; +import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; import { formatChartSeconds } from '../lib/format'; +import { MAX_GEAR, MIN_GEAR } from '../lib/gears'; +import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; +import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; +import { convertSpeed, minimumSpeedChartMaximum, speedUnitLabel } from '../lib/units'; +import { preferencesStore } from '../stores/preferences-store'; import type { ChartMode, ControlMode, MetricSample, RoutePoint, SpeedUnit } from '../types'; interface PlotProps { @@ -91,108 +94,80 @@ export function SessionChart({ controlMode?: ControlMode; history: MetricSample[]; keyboardEnabled?: boolean; - route: RoutePoint[]; + route: readonly RoutePoint[]; speedUnit: SpeedUnit; }) { - const [selectedMode, setSelectedMode] = useState(storedChartMode); + const selectedMode = useSelector(preferencesStore, (preferences) => preferences.chartMode); const resolvedControlMode = controlMode ?? - (history.some((sample) => sample.gear !== undefined) ? 'gear' : 'resistance'); - const modeForAvailableControl = - selectedMode === 'gear' || selectedMode === 'resistance' - ? resolvedControlMode - : selectedMode; + (history.some((sample) => sample.gear !== undefined) + ? CONTROL_MODE.GEAR + : CONTROL_MODE.RESISTANCE); + const modeForAvailableControl = isControlMode(selectedMode) + ? resolvedControlMode + : selectedMode; const effectiveMode = modeForAvailableControl === 'elevation' && route.length === 0 ? 'all' : modeForAvailableControl; const series = useMemo(() => { + const speedValues = history.map((sample) => convertSpeed(sample.speed, speedUnit)); + const standardSeries = STANDARD_METRIC_KEYS.map((key) => { + const presentation = METRIC_PRESENTATION[key]; + const values = history.map((sample) => sample[key]); + return { + chartMaximum: roundedChartMaximum( + Math.max(...values, 0), + presentation.chartMinimumMaximum, + presentation.chartStep + ), + color: presentation.chartColor, + decimals: 0, + key, + label: presentation.label, + minimum: 0, + unit: presentation.unit, + values, + }; + }); const controlSeries = - resolvedControlMode === 'gear' + resolvedControlMode === CONTROL_MODE.GEAR ? { - chartMaximum: 24, + chartMaximum: MAX_GEAR, color: '#adf5bd', decimals: 0, - key: 'gear' as const, + key: CONTROL_MODE.GEAR, label: 'Gear', - minimum: 1, + minimum: MIN_GEAR, unit: '', values: history.map((sample) => sample.gear), } : { - chartMaximum: 100, + chartMaximum: MAX_RESISTANCE, color: '#adf5bd', decimals: 0, - key: 'resistance' as const, + key: CONTROL_MODE.RESISTANCE, label: 'Resistance', - minimum: 0, + minimum: MIN_RESISTANCE, unit: '%', values: history.map((sample) => sample.resistance), }; return [ { chartMaximum: roundedChartMaximum( - Math.max( - ...history.map( - (sample) => sample.speed * (speedUnit === 'mph' ? 0.621_371 : 1) - ), - 0 - ), - speedUnit === 'mph' ? 20 : 30, + Math.max(...speedValues, 0), + minimumSpeedChartMaximum(speedUnit), 5 ), - color: '#38bdf8', + color: METRIC_PRESENTATION.speed.chartColor, decimals: 1, key: 'speed' as const, - label: 'Speed', + label: METRIC_PRESENTATION.speed.label, minimum: 0, - unit: speedUnit === 'mph' ? 'mph' : 'km/h', - values: history.map((sample) => - speedUnit === 'mph' ? sample.speed * 0.621_371 : sample.speed - ), - }, - { - chartMaximum: roundedChartMaximum( - Math.max(...history.map((sample) => sample.power), 0), - 100, - 50 - ), - color: '#facc15', - decimals: 0, - key: 'power' as const, - label: 'Power', - minimum: 0, - unit: 'W', - values: history.map((sample) => sample.power), - }, - { - chartMaximum: roundedChartMaximum( - Math.max(...history.map((sample) => sample.cadence), 0), - 80, - 10 - ), - color: '#a78bfa', - decimals: 0, - key: 'cadence' as const, - label: 'Cadence', - minimum: 0, - unit: 'rpm', - values: history.map((sample) => sample.cadence), - }, - { - chartMaximum: roundedChartMaximum( - Math.max(...history.map((sample) => sample.heartRate), 0), - 180, - 10 - ), - color: '#fb7185', - decimals: 0, - key: 'heartRate' as const, - label: 'Heart rate', - minimum: 0, - unit: 'bpm', - values: history.map((sample) => sample.heartRate), + unit: speedUnitLabel(speedUnit), + values: speedValues, }, + ...standardSeries, controlSeries, ]; }, [history, resolvedControlMode, speedUnit]); @@ -216,23 +191,20 @@ export function SessionChart({ const historySeconds = history.length > 1 ? (history.at(-1)?.elapsedSeconds ?? 0) - historyStart : 0; - const selectMode = useCallback((mode: ChartMode) => { - setSelectedMode(mode); - localStorage.setItem('trainer-chart-mode', mode); - }, []); + const selectMode = useCallback( + (mode: ChartMode) => preferencesStore.actions.selectChartMode(mode), + [] + ); useEffect(() => { if (!keyboardEnabled) { return; } const handleKeys = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; if ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - target?.matches("input, textarea, select, [contenteditable='true']") || + keyboardEventHasModifiers(event) || + eventTargetsEditableControl(event) || !['ArrowLeft', 'ArrowRight'].includes(event.key) ) { return; @@ -299,19 +271,27 @@ export function SessionChart({ values={elevationValues} /> ) : ( - visibleSeries.map((item) => ( - + visibleSeries.map((item, index) => ( + + + {effectiveMode === 'all' && index < visibleSeries.length - 1 ? ( + diff --git a/src/components/session-controls.tsx b/src/components/session-controls.tsx new file mode 100644 index 0000000..53770a5 --- /dev/null +++ b/src/components/session-controls.tsx @@ -0,0 +1,74 @@ +import { Icon } from './icon'; + +export function SessionControls({ + ended, + isRiding, + manuallyPaused, + onEnd, + onRequestNew, + onSave, + onTogglePause, + saveResolved, +}: { + ended: boolean; + isRiding: boolean; + manuallyPaused: boolean; + onEnd: () => void; + onRequestNew: () => void; + onSave: () => void; + onTogglePause: () => void; + saveResolved: boolean; +}) { + if (ended) { + return ( +
    + {saveResolved ? null : ( + + )} + +
    + ); + } + + let controlLabel = 'Auto paused'; + let controlIcon = 'stop'; + if (isRiding) { + controlLabel = 'Pause'; + controlIcon = 'pause'; + } + if (manuallyPaused) { + controlLabel = 'Resume'; + controlIcon = 'play'; + } + return ( +
    + + +
    + ); +} diff --git a/src/components/session-detail.tsx b/src/components/session-detail.tsx new file mode 100644 index 0000000..51dd684 --- /dev/null +++ b/src/components/session-detail.tsx @@ -0,0 +1,231 @@ +import { useEffect, useRef } from 'react'; +import { EMPTY_ROUTE } from '../constants'; +import { CONTROL_MODE } from '../lib/control-mode'; +import { aggregateMaximum, formatAggregateAverage, formatWholeNumber } from '../lib/format'; +import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from '../lib/metric-presentation'; +import { + feelingLabel, + formatSessionDateRange, + formatSessionImportLabel, + formatSessionTimeRange, + isImportedSession, +} from '../lib/saved-sessions'; +import { downloadSessionTcx } from '../lib/tcx'; +import type { SavedSession, SpeedUnit } from '../types'; +import { SessionMetric } from './metrics'; +import { SessionChart } from './session-chart'; +import { SessionSummary } from './session-summary'; + +export function DeleteSessionDialog({ + deleting, + onCancel, + onConfirm, + open, +}: { + deleting: boolean; + onCancel: () => void; + onConfirm: () => void; + open: boolean; +}) { + const confirmButton = useRef(null); + + useEffect(() => { + if (open) { + confirmButton.current?.focus(); + } + }, [open]); + + if (!open) { + return null; + } + + return ( +
    +

    + Delete this session? +

    +

    + This cannot be undone. +

    +
    + + +
    +
    + ); +} + +export function SessionDetail({ + chartKeyboardEnabled = true, + deleteConfirmationOpen = false, + deleting = false, + onCancelDelete, + onConfirmDelete, + onDelete, + onStartNew, + session, + speedUnit, +}: { + chartKeyboardEnabled?: boolean; + deleteConfirmationOpen?: boolean; + deleting?: boolean; + onCancelDelete?: () => void; + onConfirmDelete?: () => void; + onDelete?: () => void; + onStartNew?: () => void; + session: SavedSession; + speedUnit: SpeedUnit; +}) { + const usesGear = session.controlMode === CONTROL_MODE.GEAR; + const imported = isImportedSession(session); + const controlMetric = usesGear + ? { + accent: 'mint', + average: formatAggregateAverage(session.aggregates.gear, 0), + icon: 'controls', + label: 'GEAR', + maximum: formatWholeNumber(aggregateMaximum(session.aggregates.gear)), + unit: '', + } + : { + accent: 'mint', + average: formatAggregateAverage(session.aggregates.resistance, 0), + icon: 'resistance', + label: 'RESISTANCE', + maximum: formatWholeNumber(aggregateMaximum(session.aggregates.resistance)), + unit: '%', + }; + const standardMetrics = STANDARD_METRIC_KEYS.map((key) => { + const presentation = METRIC_PRESENTATION[key]; + return { + accent: presentation.accent, + average: formatAggregateAverage(session.aggregates[key], 0), + icon: presentation.icon, + label: presentation.label.toUpperCase(), + maximum: formatWholeNumber(session.maximums[key]), + unit: presentation.unit, + }; + }); + + return ( +
    +
    +
    +
    +

    + {formatSessionDateRange(session)} +

    + {imported ? ( + + Imported + + ) : null} +
    +

    {formatSessionTimeRange(session)}

    +
    +
    + + {onStartNew ? ( + + ) : null} + {onDelete ? ( + + ) : null} +
    + {onCancelDelete && onConfirmDelete ? ( + + ) : null} +
    +
    + +
    +
    + {[...standardMetrics, controlMetric].map((metric) => ( + + ))} +
    +
    +
    +

    FELT

    +

    + {feelingLabel(session.feeling)} +

    +
    +
    +

    + COMMENTS +

    +

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

    +
    +
    + +
    + ); +} diff --git a/src/components/session-history-list.tsx b/src/components/session-history-list.tsx new file mode 100644 index 0000000..6b9a457 --- /dev/null +++ b/src/components/session-history-list.tsx @@ -0,0 +1,108 @@ +import { useMemo } from 'react'; +import { formatDuration } from '../lib/format'; +import { + feelingLabel, + formatSessionListTime, + groupSessionsByDate, + isImportedSession, +} from '../lib/saved-sessions'; +import { formatDistance } from '../lib/units'; +import type { SavedSessionSummary, SpeedUnit } from '../types'; +import { Icon } from './icon'; + +export function SessionHistoryList({ + error, + highlightedSessionIds, + onLoadMore, + onSelect, + selectedId, + speedUnit, + summaries, + total, +}: { + error: string; + highlightedSessionIds: string[]; + onLoadMore: () => void; + onSelect: (id: string) => void; + selectedId?: string; + speedUnit: SpeedUnit; + summaries: SavedSessionSummary[]; + total: number; +}) { + const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); + const highlightedIds = useMemo(() => new Set(highlightedSessionIds), [highlightedSessionIds]); + + return ( + + ); +} diff --git a/src/components/session-history.tsx b/src/components/session-history.tsx index ea225a4..a81b65f 100644 --- a/src/components/session-history.tsx +++ b/src/components/session-history.tsx @@ -1,258 +1,28 @@ import type { ReactNode } from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { formatAggregateAverage, formatDuration } from '../lib/format'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSessionHistory } from '../hooks/use-session-history'; +import { + eventTargetsEditableControl, + eventTargetsInteractiveControl, + keyboardEventHasModifiers, +} from '../lib/dom'; import { type HistoryShortcut, historyKeyboardShortcuts, historyShortcutForKey, } from '../lib/keyboard'; -import { - adjacentSession, - countSavedSessions, - deleteSavedSession, - feelingLabel, - formatSessionDateRange, - formatSessionListTime, - formatSessionTimeRange, - getSavedSession, - groupSessionsByDate, - listSavedSessions, - sessionListAfterDelete, -} from '../lib/saved-sessions'; -import { downloadSessionTcx } from '../lib/tcx'; -import type { SavedSession, SavedSessionSummary, SpeedUnit } from '../types'; +import { adjacentSession } from '../lib/saved-sessions'; +import type { SavedSession, SpeedUnit } from '../types'; import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'; -import { SessionMetric, SmallMetric } from './metrics'; -import { SessionChart } from './session-chart'; - -const PAGE_SIZE = 30; -const EMPTY_ROUTE: [] = []; +import { SessionDetail } from './session-detail'; +import { SessionHistoryList } from './session-history-list'; +import { SideTray } from './side-tray'; function shouldIgnoreHistoryAction(event: KeyboardEvent) { - const target = event.target as HTMLElement | null; return ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - target?.matches("input, textarea, select, [contenteditable='true']") - ); -} - -export function DeleteSessionDialog({ - deleting, - onCancel, - onConfirm, - open, -}: { - deleting: boolean; - onCancel: () => void; - onConfirm: () => void; - open: boolean; -}) { - const confirmButton = useRef(null); - - useEffect(() => { - if (open) { - confirmButton.current?.focus(); - } - }, [open]); - - if (!open) { - return null; - } - - return ( -
    -

    - Delete this session? -

    -

    - This cannot be undone. -

    -
    - - -
    -
    - ); -} - -export function SessionDetail({ - chartKeyboardEnabled = true, - deleteConfirmationOpen = false, - deleting = false, - onCancelDelete, - onConfirmDelete, - onDelete, - onStartNew, - session, - speedUnit, -}: { - chartKeyboardEnabled?: boolean; - deleteConfirmationOpen?: boolean; - deleting?: boolean; - onCancelDelete?: () => void; - onConfirmDelete?: () => void; - onDelete?: () => void; - onStartNew?: () => void; - session: SavedSession; - speedUnit: SpeedUnit; -}) { - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; - const usesGear = session.controlMode === 'gear'; - const controlMetric = usesGear - ? { - accent: 'mint', - average: formatAggregateAverage(session.aggregates.gear, 0), - icon: 'controls', - label: 'GEAR', - unit: '', - } - : { - accent: 'mint', - average: formatAggregateAverage(session.aggregates.resistance, 0), - icon: 'resistance', - label: 'RESISTANCE', - unit: '%', - }; - - return ( -
    -
    -
    -

    - {formatSessionDateRange(session)} -

    -

    {formatSessionTimeRange(session)}

    -
    -
    - - {onStartNew ? ( - - ) : null} - {onDelete ? ( - - ) : null} -
    - {onCancelDelete && onConfirmDelete ? ( - - ) : null} -
    -
    - - - -
    -
    - {[ - { - accent: 'yellow', - average: formatAggregateAverage(session.aggregates.power, 0), - icon: 'bolt', - label: 'POWER', - maximum: String(Math.round(session.maximums.power)), - unit: 'W', - }, - { - accent: 'violet', - average: formatAggregateAverage(session.aggregates.cadence, 0), - icon: 'cadence', - label: 'CADENCE', - maximum: String(Math.round(session.maximums.cadence)), - unit: 'rpm', - }, - { - accent: 'rose', - average: formatAggregateAverage(session.aggregates.heartRate, 0), - icon: 'heart', - label: 'HEART RATE', - maximum: String(Math.round(session.maximums.heartRate)), - unit: 'bpm', - }, - controlMetric, - ].map((metric) => ( - - ))} -
    -
    -
    -

    FELT

    -

    - {feelingLabel(session.feeling)} -

    -
    -
    -

    - COMMENTS -

    -

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

    -
    -
    - -
    + keyboardEventHasModifiers(event) || + eventTargetsEditableControl(event) ); } @@ -267,120 +37,50 @@ export function SessionHistory({ open: boolean; speedUnit: SpeedUnit; }) { - const [summaries, setSummaries] = useState([]); - const [total, setTotal] = useState(0); - const [selected, setSelected] = useState(); - const [selectedId, setSelectedId] = useState(); - const [loading, setLoading] = useState(false); - const [deleting, setDeleting] = useState(false); + const { + deleteSelectedSession: deleteHistorySession, + deleting, + downloadAllTcx, + error, + exporting, + historyStatus, + highlightedSessionIds, + importTcxFile, + importing, + loading, + loadMore, + selected, + selectedId, + selectSession: selectHistorySession, + summaries, + total, + } = useSessionHistory(open); const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false); const [historyHelpOpen, setHistoryHelpOpen] = useState(false); - const [rendered, setRendered] = useState(open); - const [trayVisible, setTrayVisible] = useState(open); - const [error, setError] = useState(''); - const deleteInProgress = useRef(false); - const groups = useMemo(() => groupSessionsByDate(summaries), [summaries]); - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; + const importInput = useRef(null); + const transferring = exporting || importing; useEffect(() => { - let frame: number | undefined; - let timeout: number | undefined; - if (open) { - setRendered(true); - frame = window.requestAnimationFrame(() => setTrayVisible(true)); - } else { - setTrayVisible(false); + if (!open) { setDeleteConfirmationOpen(false); setHistoryHelpOpen(false); - timeout = window.setTimeout(() => setRendered(false), 200); } - return () => { - if (frame !== undefined) { - window.cancelAnimationFrame(frame); - } - if (timeout !== undefined) { - window.clearTimeout(timeout); - } - }; }, [open]); - const selectSession = useCallback(async (id: string) => { - setDeleteConfirmationOpen(false); - setHistoryHelpOpen(false); - setSelectedId(id); - setLoading(true); - try { - setSelected(await getSavedSession(id)); - setError(''); - } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : String(loadError)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (!open) { - return; - } - let cancelled = false; - async function loadHistory() { + const selectSession = useCallback( + (id: string) => { setDeleteConfirmationOpen(false); setHistoryHelpOpen(false); - const [sessions, count] = await Promise.all([ - listSavedSessions(PAGE_SIZE), - countSavedSessions(), - ]); - if (cancelled) { - return; - } - setSummaries(sessions); - setTotal(count); - setError(''); - if (sessions[0]) { - await selectSession(sessions[0].id); - } else { - setSelected(undefined); - setSelectedId(undefined); - } - } - loadHistory().catch((loadError: unknown) => { - if (!cancelled) { - setError(loadError instanceof Error ? loadError.message : String(loadError)); - } - }); - return () => { - cancelled = true; - }; - }, [open, selectSession]); + selectHistorySession(id); + }, + [selectHistorySession] + ); const deleteSelectedSession = useCallback(async () => { - if (!selected || deleteInProgress.current) { - return; - } - deleteInProgress.current = true; - setDeleting(true); - try { - await deleteSavedSession(selected.id); + if (await deleteHistorySession()) { setDeleteConfirmationOpen(false); - const updated = sessionListAfterDelete(summaries, selected.id); - setSummaries(updated.sessions); - setTotal((current) => Math.max(0, current - 1)); - setError(''); - if (updated.next) { - await selectSession(updated.next.id); - } else { - setSelected(undefined); - setSelectedId(undefined); - } - } catch (deleteError) { - setError(deleteError instanceof Error ? deleteError.message : String(deleteError)); - } finally { - deleteInProgress.current = false; - setDeleting(false); } - }, [selected, selectSession, summaries]); + }, [deleteHistorySession]); useEffect(() => { if (!open) { @@ -408,11 +108,7 @@ export function SessionHistory({ } }, confirmDelete: (event) => { - const target = event.target as HTMLElement | null; - if ( - !deleteConfirmationOpen || - target?.matches("button, a, input, textarea, select, [contenteditable='true']") - ) { + if (!deleteConfirmationOpen || eventTargetsInteractiveControl(event)) { return; } event.preventDefault(); @@ -456,19 +152,6 @@ export function SessionHistory({ summaries, ]); - if (!rendered) { - return null; - } - - async function loadMore() { - const last = summaries.at(-1); - if (!last) { - return; - } - const more = await listSavedSessions(PAGE_SIZE, last.endedAt); - setSummaries((current) => [...current, ...more]); - } - let detail: ReactNode = null; if (loading) { detail = ( @@ -500,31 +183,61 @@ export function SessionHistory({ } return ( -
    - + - ))} -
    -
    - ))} - {summaries.length < total ? ( - - ) : null} - + {detail}
    - + setHistoryHelpOpen(false)} @@ -610,6 +280,6 @@ export function SessionHistory({ shortcuts={historyKeyboardShortcuts} title="History keyboard controls" /> -
    + ); } diff --git a/src/components/session-overview.tsx b/src/components/session-overview.tsx new file mode 100644 index 0000000..152ffa4 --- /dev/null +++ b/src/components/session-overview.tsx @@ -0,0 +1,43 @@ +import { EMPTY_ROUTE } from '../constants'; +import type { ControlMode, MetricSample, SpeedUnit } from '../types'; +import { SessionChart } from './session-chart'; +import { SessionSummary } from './session-summary'; + +export function SessionOverview({ + controlMode, + elapsedSeconds, + history, + keyboardEnabled, + rideCalories, + rideDistance, + speedUnit, +}: { + controlMode: ControlMode; + elapsedSeconds: number; + history: MetricSample[]; + keyboardEnabled: boolean; + rideCalories: number; + rideDistance: number; + speedUnit: SpeedUnit; +}) { + return ( +
    +
    + +
    + +
    + ); +} diff --git a/src/components/session-save-dialog.tsx b/src/components/session-save-dialog.tsx index 1c601da..8ffa172 100644 --- a/src/components/session-save-dialog.tsx +++ b/src/components/session-save-dialog.tsx @@ -1,19 +1,37 @@ import { useEffect, useState } from 'react'; -import { formatDuration } from '../lib/format'; -import { formatSessionTime } from '../lib/saved-sessions'; +import { unreachable } from '../lib/errors'; +import { formatSessionTime, SESSION_FEELING_OPTIONS } from '../lib/saved-sessions'; +import { SESSION_WORKFLOW_INTENT, type SessionWorkflowIntent } from '../lib/session-workflow'; import type { SessionFeeling, SessionMetadata, SessionSnapshot, SpeedUnit } from '../types'; -import { SmallMetric } from './metrics'; +import { SessionSummary } from './session-summary'; -const feelings: { label: string; value: SessionFeeling }[] = [ - { label: 'Great', value: 'great' }, - { label: 'Good', value: 'good' }, - { label: 'Okay', value: 'okay' }, - { label: 'Tough', value: 'tough' }, - { label: 'Exhausted', value: 'exhausted' }, -]; +function actionLabels(intent: SessionWorkflowIntent['kind']) { + switch (intent) { + case SESSION_WORKFLOW_INTENT.CONTINUE: + return { + primary: 'Save & continue', + secondary: 'Continue without saving', + secondaryClass: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200', + }; + case SESSION_WORKFLOW_INTENT.NEW: + return { + primary: 'Save & start new', + secondary: 'Start new without saving', + secondaryClass: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200', + }; + case SESSION_WORKFLOW_INTENT.END: + return { + primary: 'Save session', + secondary: 'End without saving', + secondaryClass: 'text-rose-300 hover:bg-rose-400/5 hover:text-rose-200', + }; + default: + return unreachable(intent); + } +} export function SessionSaveDialog({ - continuing = false, + intent, open, onClose, onSave, @@ -22,7 +40,7 @@ export function SessionSaveDialog({ session, speedUnit, }: { - continuing?: boolean; + intent: SessionWorkflowIntent['kind']; open: boolean; onClose: () => void; onSave: (metadata: SessionMetadata) => Promise; @@ -33,6 +51,7 @@ export function SessionSaveDialog({ }) { const [comments, setComments] = useState(''); const [feeling, setFeeling] = useState(); + const labels = actionLabels(intent); useEffect(() => { if (open) { @@ -45,9 +64,6 @@ export function SessionSaveDialog({ return null; } - const unitFactor = speedUnit === 'mph' ? 0.621_371 : 1; - const distanceUnit = speedUnit === 'mph' ? 'mi' : 'km'; - return (
    - - -
    How did it feel?
    - {feelings.map((option) => ( + {SESSION_FEELING_OPTIONS.map((option) => (
    diff --git a/src/components/session-summary.tsx b/src/components/session-summary.tsx new file mode 100644 index 0000000..ba5b38c --- /dev/null +++ b/src/components/session-summary.tsx @@ -0,0 +1,38 @@ +import { formatDuration, formatWholeNumber } from '../lib/format'; +import { distanceUnitLabel, formatDistanceValue } from '../lib/units'; +import type { SpeedUnit } from '../types'; +import { SmallMetric } from './metrics'; + +export function SessionSummary({ + calories, + distance, + elapsedSeconds, + large = false, + speedUnit, + timeLabel = 'TIME', +}: { + calories: number; + distance: number; + elapsedSeconds: number; + large?: boolean; + speedUnit: SpeedUnit; + timeLabel?: string; +}) { + return ( + <> + + + + + ); +} diff --git a/src/components/side-tray.tsx b/src/components/side-tray.tsx new file mode 100644 index 0000000..19a12c9 --- /dev/null +++ b/src/components/side-tray.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { useAnimatedTray, useBodyScrollLock, useCloseOnEscape } from '../hooks/use-dialog-behavior'; + +export function SideTray({ + children, + closeLabel, + closeOnEscape = true, + labelledBy, + onClose, + open, + panelClassName = '', +}: { + children: ReactNode; + closeLabel: string; + closeOnEscape?: boolean; + labelledBy: string; + onClose: () => void; + open: boolean; + panelClassName?: string; +}) { + const { rendered, visible } = useAnimatedTray(open); + useCloseOnEscape(closeOnEscape && open, onClose); + useBodyScrollLock(rendered); + + if (!rendered) { + return null; + } + + return ( +
    +
    + ); +} diff --git a/src/components/training-control-panel.tsx b/src/components/training-control-panel.tsx new file mode 100644 index 0000000..2c00487 --- /dev/null +++ b/src/components/training-control-panel.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; + +export function TrainingControlPanel({ + children, + title, + unit, + value, +}: { + children: ReactNode; + title: string; + unit: string; + value: number; +}) { + return ( +
    +
    +

    {title}

    +
    + + {value} + {unit} + +
    +
    + {children} +
    + ); +} diff --git a/src/components/training-control.tsx b/src/components/training-control.tsx new file mode 100644 index 0000000..24dd856 --- /dev/null +++ b/src/components/training-control.tsx @@ -0,0 +1,52 @@ +import { CONTROL_MODE } from '../lib/control-mode'; +import { unreachable } from '../lib/errors'; +import type { ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; +import { GearTrainingControl } from './gear-training-control'; +import { ResistanceTrainingControl } from './resistance-training-control'; + +type TrainingControlModel = + | { + gear: number; + mode: typeof CONTROL_MODE.GEAR; + onShift: (change: number) => void; + shiftFlash?: ResistanceAdjustmentDirection; + } + | { + keyboardFlash?: ResistanceAdjustmentDirection; + mode: typeof CONTROL_MODE.RESISTANCE; + onChange: (resistance: number) => void; + ramp: ResistanceRamp; + resistance: number; + }; + +export function TrainingControl({ + connected, + control, +}: { + connected: boolean; + control: TrainingControlModel; +}) { + switch (control.mode) { + case CONTROL_MODE.GEAR: + return ( + + ); + case CONTROL_MODE.RESISTANCE: + return ( + + ); + default: + return unreachable(control); + } +} diff --git a/src/components/welcome-dialog.tsx b/src/components/welcome-dialog.tsx index c191223..72ae600 100644 --- a/src/components/welcome-dialog.tsx +++ b/src/components/welcome-dialog.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCloseOnEscape } from '../hooks/use-dialog-behavior'; export function WelcomeDialog({ onClose, @@ -9,6 +10,8 @@ export function WelcomeDialog({ }) { const [dontShowAgain, setDontShowAgain] = useState(false); const dontShowAgainRef = useRef(false); + const closeFromEscape = useCallback(() => onClose(dontShowAgainRef.current), [onClose]); + useCloseOnEscape(open, closeFromEscape); useEffect(() => { if (!open) { @@ -16,15 +19,7 @@ export function WelcomeDialog({ } dontShowAgainRef.current = false; setDontShowAgain(false); - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(dontShowAgainRef.current); - } - }; - window.addEventListener('keydown', closeOnEscape); - return () => window.removeEventListener('keydown', closeOnEscape); - }, [onClose, open]); + }, [open]); function updateDontShowAgain(checked: boolean) { dontShowAgainRef.current = checked; diff --git a/src/constants.ts b/src/constants.ts index f4b45b3..5c02c7a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,4 @@ +import { CONTROL_MODE } from './lib/control-mode'; import type { Metrics, StoredSession } from './types'; export const FITNESS_MACHINE = 0x18_26; @@ -13,10 +14,13 @@ export const CYCLING_POWER_MEASUREMENT = 0x2a_63; export const CSC_MEASUREMENT = 0x2a_5b; export const CHROME_BLUETOOTH_FLAGS_URL = 'chrome://flags/#enable-web-bluetooth-new-permissions-backend'; -export const AUTOMATIC_RECONNECT_README_URL = - 'https://github.com/lookfirst/RideControl#automatic-reconnect'; export const CHROME_BLUETOOTH_PERMISSION_MESSAGE = 'Chrome returned no site-authorized.'; +export const WEB_BLUETOOTH_UNAVAILABLE_MESSAGE = + 'Web Bluetooth requires current Chrome or Edge on localhost or HTTPS.'; export const RECORDING_PAUSE_DELAY_MS = 10_000; +export const CONTROL_FLASH_MS = 180; +export const MAX_SESSION_HISTORY_SAMPLES = 3600; +export const EMPTY_ROUTE = [] as const; export const optionalServices: BluetoothServiceUUID[] = [ FITNESS_MACHINE, @@ -44,7 +48,8 @@ export const emptySession: StoredSession = { resistance: { count: 0, sum: 0 }, }, calories: 0, - controlMode: 'resistance', + controlMode: CONTROL_MODE.RESISTANCE, + discarded: false, distance: 0, elapsedSeconds: 0, ended: false, diff --git a/src/hooks/use-dialog-behavior.ts b/src/hooks/use-dialog-behavior.ts new file mode 100644 index 0000000..52c5289 --- /dev/null +++ b/src/hooks/use-dialog-behavior.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; + +const TRAY_TRANSITION_MS = 200; + +export function useAnimatedTray(open: boolean): { + rendered: boolean; + visible: boolean; +} { + const [rendered, setRendered] = useState(open); + const [visible, setVisible] = useState(open); + + useEffect(() => { + let frame: number | undefined; + let timeout: number | undefined; + if (open) { + setRendered(true); + frame = window.requestAnimationFrame(() => setVisible(true)); + } else { + setVisible(false); + timeout = window.setTimeout(() => setRendered(false), TRAY_TRANSITION_MS); + } + return () => { + if (frame !== undefined) { + window.cancelAnimationFrame(frame); + } + if (timeout !== undefined) { + window.clearTimeout(timeout); + } + }; + }, [open]); + + return { rendered, visible }; +} + +export function useCloseOnEscape(enabled: boolean, onClose: () => void): void { + useEffect(() => { + if (!enabled) { + return; + } + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [enabled, onClose]); +} + +export function useBodyScrollLock(locked: boolean): void { + useEffect(() => { + if (!locked) { + return; + } + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [locked]); +} diff --git a/src/hooks/use-gear-control.ts b/src/hooks/use-gear-control.ts index 4041d11..9151690 100644 --- a/src/hooks/use-gear-control.ts +++ b/src/hooks/use-gear-control.ts @@ -1,32 +1,40 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { gearForResistance, resistanceChangeForGears, shiftedGear, storedGear } from '../lib/gears'; +import { CONTROL_FLASH_MS } from '../constants'; +import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; +import { + GEAR_STORAGE_KEY, + gearForResistance, + resistanceChangeForGears, + SHIFTING_CONNECTION_MESSAGE, + shiftedGear, + storedGear, +} from '../lib/gears'; import { resistanceDirectionForKey } from '../lib/resistance'; import type { ResistanceAdjustmentDirection } from '../types'; export function useGearControl({ active, - connected, - keyboardEnabled, onResistanceChange, + ready, resistance, setNotice, }: { active: boolean; - connected: boolean; - keyboardEnabled: boolean; onResistanceChange: (change: number) => void; + ready: boolean; resistance: number; setNotice: (notice: string) => void; }) { const [gear, setGear] = useState(() => storedGear(localStorage, gearForResistance(resistance))); const [shiftFlash, setShiftFlash] = useState(); const gearRef = useRef(gear); + const keyboardControlsEnabled = useRef(true); const shiftFlashTimer = useRef(undefined); const shiftGear = useCallback( (change: number) => { - if (!connected) { - setNotice('Connect the trainer before shifting gears.'); + if (!ready) { + setNotice(SHIFTING_CONNECTION_MESSAGE); return; } const previous = gearRef.current; @@ -36,13 +44,16 @@ export function useGearControl({ } setShiftFlash(change > 0 ? 'increase' : 'decrease'); window.clearTimeout(shiftFlashTimer.current); - shiftFlashTimer.current = window.setTimeout(() => setShiftFlash(undefined), 180); + shiftFlashTimer.current = window.setTimeout( + () => setShiftFlash(undefined), + CONTROL_FLASH_MS + ); gearRef.current = next; setGear(next); - localStorage.setItem('trainer-virtual-gear', String(next)); + localStorage.setItem(GEAR_STORAGE_KEY, String(next)); onResistanceChange(resistanceChangeForGears(previous, next)); }, - [connected, onResistanceChange, setNotice] + [onResistanceChange, ready, setNotice] ); useEffect(() => { @@ -50,16 +61,14 @@ export function useGearControl({ return; } const handleGearKey = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isGearControl = target?.matches('[data-gear-control="true"]'); + const isGearControl = + event.target instanceof HTMLElement && + event.target.matches('[data-gear-control="true"]'); if ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - !keyboardEnabled || - (!isGearControl && - target?.matches("input, textarea, select, [contenteditable='true']")) + keyboardEventHasModifiers(event) || + !keyboardControlsEnabled.current || + (!isGearControl && eventTargetsEditableControl(event)) ) { return; } @@ -74,7 +83,7 @@ export function useGearControl({ return () => { window.removeEventListener('keydown', handleGearKey); }; - }, [active, keyboardEnabled, shiftGear]); + }, [active, shiftGear]); useEffect( () => () => { @@ -83,5 +92,9 @@ export function useGearControl({ [] ); - return { gear, shiftFlash, shiftGear }; + const setKeyboardControlsEnabled = useCallback((enabled: boolean) => { + keyboardControlsEnabled.current = enabled; + }, []); + + return { gear, setKeyboardControlsEnabled, shiftFlash, shiftGear }; } diff --git a/src/hooks/use-heart-rate-monitor.ts b/src/hooks/use-heart-rate-monitor.ts index 0f6feec..6bcd7a3 100644 --- a/src/hooks/use-heart-rate-monitor.ts +++ b/src/hooks/use-heart-rate-monitor.ts @@ -1,92 +1,87 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { BATTERY, HEART_RATE } from '../constants'; -import { parseHeartRateMeasurement } from '../lib/heart-rate'; +import { BATTERY, HEART_RATE, WEB_BLUETOOTH_UNAVAILABLE_MESSAGE } from '../constants'; +import { isBluetoothChooserCancellation } from '../lib/bluetooth'; +import { type DeviceConnectionPhase, deviceConnectionView } from '../lib/device-connection'; +import { errorMessage } from '../lib/errors'; +import { connectHeartRateDevice } from '../lib/heart-rate-device'; +import { createReconnectController } from '../lib/reconnect-controller'; -const HEART_RATE_MEASUREMENT = 0x2a_37; -const BATTERY_LEVEL = 0x2a_19; const STORAGE_KEY = 'heart-rate-device-id'; export function useHeartRateMonitor(setNotice: (notice: string) => void) { const [device, setDevice] = useState(); - const [connected, setConnected] = useState(false); - const [busy, setBusy] = useState(false); - const [status, setStatus] = useState('Not paired'); + const [phase, setPhase] = useState('unpaired'); const [heartRate, setHeartRate] = useState(0); const [battery, setBattery] = useState(); const autoReconnect = useRef(true); const connecting = useRef(false); const forgotten = useRef(false); - const connectDeviceRef = useRef<((selected: BluetoothDevice) => Promise) | undefined>( - undefined + const connectionCleanup = useRef<() => void>(() => undefined); + const connectDeviceRef = useRef< + ((selected: BluetoothDevice, reconnecting?: boolean) => Promise) | undefined + >(undefined); + const reconnectController = useRef( + createReconnectController({ + attempt: (selected) => + connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), + canRetry: () => autoReconnect.current && !forgotten.current, + delayForAttempt: (attempt) => Math.min(5000, 900 * attempt), + onWaiting: () => setPhase('reconnecting'), + }) + ); + const handleDisconnect = useCallback((selected: BluetoothDevice) => { + connectionCleanup.current(); + setHeartRate(0); + if (autoReconnect.current && !forgotten.current) { + reconnectController.current.start(selected.id, selected, 900); + } else { + setPhase('offline'); + } + }, []); + const handleConnectionFailure = useCallback( + (selected: BluetoothDevice, error: unknown, reconnecting: boolean) => { + selected.gatt?.disconnect(); + setHeartRate(0); + setPhase(reconnecting ? 'reconnecting' : 'offline'); + if (reconnecting) { + return; + } + setNotice(`Heart rate monitor connection failed: ${errorMessage(error)}`); + if (autoReconnect.current && !forgotten.current) { + reconnectController.current.start(selected.id, selected); + } + }, + [setNotice] ); const connectDevice = useCallback( - async (selected: BluetoothDevice): Promise => { + async (selected: BluetoothDevice, reconnecting = false): Promise => { if (forgotten.current || connecting.current) { return false; } connecting.current = true; - setBusy(true); - setStatus('Connecting…'); + setPhase(reconnecting ? 'reconnecting' : 'connecting'); + connectionCleanup.current(); try { - const server = await selected.gatt?.connect(); - if (!server) { - throw new Error('This heart rate monitor does not expose Bluetooth services.'); - } - const service = await server.getPrimaryService(HEART_RATE); - const measurement = await service.getCharacteristic(HEART_RATE_MEASUREMENT); - measurement.addEventListener('characteristicvaluechanged', (event) => { - const { value } = event.target as BluetoothRemoteGATTCharacteristic; - if (!value) { - return; - } - const next = parseHeartRateMeasurement(value); - if (next !== undefined) { - setHeartRate(next); - } + const connection = await connectHeartRateDevice(selected, { + onDisconnect: () => handleDisconnect(selected), + onHeartRate: setHeartRate, }); - await measurement.startNotifications(); - try { - const batteryValue = await ( - await ( - await server.getPrimaryService(BATTERY) - ).getCharacteristic(BATTERY_LEVEL) - ).readValue(); - setBattery(batteryValue.getUint8(0)); - } catch { - setBattery(undefined); - } - selected.addEventListener( - 'gattserverdisconnected', - () => { - setConnected(false); - setHeartRate(0); - setStatus('Paired · offline'); - if (autoReconnect.current && !forgotten.current) { - window.setTimeout(() => connectDeviceRef.current?.(selected), 900); - } - }, - { once: true } - ); + connectionCleanup.current = connection.cleanup; + setBattery(connection.battery); setDevice(selected); - setConnected(true); - setStatus('Connected'); + setPhase('connected'); + reconnectController.current.reset(selected.id); localStorage.setItem(STORAGE_KEY, selected.id); return true; } catch (error) { - selected.gatt?.disconnect(); - setConnected(false); - setStatus('Paired · offline'); - setNotice( - `Heart rate monitor connection failed: ${error instanceof Error ? error.message : String(error)}` - ); + handleConnectionFailure(selected, error, reconnecting); return false; } finally { connecting.current = false; - setBusy(false); } }, - [setNotice] + [handleConnectionFailure, handleDisconnect] ); useEffect(() => { @@ -95,9 +90,10 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { const pair = useCallback(async () => { if (!navigator.bluetooth) { - setNotice('Web Bluetooth requires current Chrome or Edge on localhost or HTTPS.'); + setNotice(WEB_BLUETOOTH_UNAVAILABLE_MESSAGE); return; } + setPhase('pairing'); try { const selected = await navigator.bluetooth.requestDevice({ filters: [{ services: [HEART_RATE] }], @@ -109,11 +105,12 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { localStorage.setItem(STORAGE_KEY, selected.id); await connectDevice(selected); } catch (error) { - if (!(error instanceof DOMException && error.name === 'NotFoundError')) { - setNotice(error instanceof Error ? error.message : String(error)); + setPhase(device ? 'offline' : 'unpaired'); + if (!isBluetoothChooserCancellation(error)) { + setNotice(errorMessage(error)); } } - }, [connectDevice, setNotice]); + }, [connectDevice, device, setNotice]); const reconnect = useCallback(async () => { if (!device) { @@ -121,30 +118,39 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { } forgotten.current = false; autoReconnect.current = true; - await connectDevice(device); + reconnectController.current.reset(device.id); + if (!(await connectDevice(device, true))) { + reconnectController.current.start(device.id, device); + } }, [connectDevice, device]); const disconnect = useCallback(() => { autoReconnect.current = false; + if (device) { + reconnectController.current.cancel(device.id, true); + } + connectionCleanup.current(); device?.gatt?.disconnect(); - setConnected(false); setHeartRate(0); - setStatus(device ? 'Paired · offline' : 'Not paired'); + setPhase(device ? 'offline' : 'unpaired'); }, [device]); const forget = useCallback(async () => { autoReconnect.current = false; forgotten.current = true; + if (device) { + reconnectController.current.cancel(device.id, true); + } + connectionCleanup.current(); device?.gatt?.disconnect(); try { await device?.forget(); } finally { localStorage.removeItem(STORAGE_KEY); setDevice(undefined); - setConnected(false); setHeartRate(0); setBattery(undefined); - setStatus('Not paired'); + setPhase('unpaired'); } }, [device]); @@ -165,29 +171,28 @@ export function useHeartRateMonitor(setNotice: (notice: string) => void) { return; } setDevice(remembered); - setStatus('Paired · offline'); + setPhase('reconnecting'); forgotten.current = false; autoReconnect.current = true; - await connectDevice(remembered); + reconnectController.current.start(remembered.id, remembered, 1); } - restore().catch(() => setStatus('Paired · offline')); + restore().catch(() => setPhase('offline')); return () => { cancelled = true; autoReconnect.current = false; + reconnectController.current.cancelAll(); + connectionCleanup.current(); }; - }, [connectDevice]); + }, []); return { battery, - busy, - connected, + ...deviceConnectionView(phase), disconnect, forget, heartRate, name: device?.name, pair, - paired: Boolean(device), reconnect, - status, }; } diff --git a/src/hooks/use-session-history.ts b/src/hooks/use-session-history.ts new file mode 100644 index 0000000..319769d --- /dev/null +++ b/src/hooks/use-session-history.ts @@ -0,0 +1,185 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { errorMessage } from '../lib/errors'; +import { + countSavedSessions, + deleteSavedSession, + getSavedSession, + listAllSavedSessions, + listSavedSessions, + sessionListAfterDelete, +} from '../lib/saved-sessions'; +import { downloadSessionTcxArchive } from '../lib/tcx-archive'; +import { importTcxUpload, tcxImportResultMessage } from '../lib/tcx-import'; +import type { SavedSession, SavedSessionSummary } from '../types'; + +const PAGE_SIZE = 30; + +export function useSessionHistory(open: boolean) { + const [summaries, setSummaries] = useState([]); + const [total, setTotal] = useState(0); + const [selected, setSelected] = useState(); + const [selectedId, setSelectedId] = useState(); + const [loading, setLoading] = useState(false); + const [deleting, setDeleting] = useState(false); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [historyStatus, setHistoryStatus] = useState(''); + const [highlightedSessionIds, setHighlightedSessionIds] = useState([]); + const [error, setError] = useState(''); + const deleteInProgress = useRef(false); + const historyLoadGeneration = useRef(0); + + const selectSession = useCallback(async (id: string) => { + setSelectedId(id); + setLoading(true); + try { + setSelected(await getSavedSession(id)); + setError(''); + } catch (loadError) { + setError(errorMessage(loadError)); + } finally { + setLoading(false); + } + }, []); + + const loadHistory = useCallback( + async (preferredSessionId?: string, includeAll = false) => { + const generation = historyLoadGeneration.current + 1; + historyLoadGeneration.current = generation; + const [sessions, count] = await Promise.all([ + listSavedSessions(includeAll ? Number.MAX_SAFE_INTEGER : PAGE_SIZE), + countSavedSessions(), + ]); + if (generation !== historyLoadGeneration.current) { + return; + } + setSummaries(sessions); + setTotal(count); + setError(''); + const nextSessionId = preferredSessionId ?? sessions[0]?.id; + if (nextSessionId) { + await selectSession(nextSessionId); + } else { + setSelected(undefined); + setSelectedId(undefined); + } + }, + [selectSession] + ); + + useEffect(() => { + if (!open) { + historyLoadGeneration.current += 1; + setHistoryStatus(''); + setHighlightedSessionIds([]); + return; + } + loadHistory().catch((loadError: unknown) => setError(errorMessage(loadError))); + }, [loadHistory, open]); + + const importTcxFile = useCallback( + async (file: File) => { + setImporting(true); + setHistoryStatus(''); + setHighlightedSessionIds([]); + try { + const result = await importTcxUpload(file); + setHistoryStatus(tcxImportResultMessage(result)); + setHighlightedSessionIds(result.importedSessions.map((session) => session.id)); + const newestImported = result.importedSessions.reduce( + (newest, session) => + !newest || session.endedAt > newest.endedAt ? session : newest, + undefined + ); + if (newestImported) { + await loadHistory(newestImported.id, true); + } + setError(''); + } catch (importError) { + setError(errorMessage(importError)); + } finally { + setImporting(false); + } + }, + [loadHistory] + ); + + const downloadAllTcx = useCallback(async () => { + setExporting(true); + setHistoryStatus(''); + try { + const sessions = await listAllSavedSessions(); + await downloadSessionTcxArchive(sessions); + setHistoryStatus( + `Downloaded ${sessions.length} TCX ${sessions.length === 1 ? 'file' : 'files'} in one ZIP` + ); + setError(''); + } catch (downloadError) { + setError(errorMessage(downloadError)); + } finally { + setExporting(false); + } + }, []); + + const deleteSelectedSession = useCallback(async () => { + if (!selected || deleteInProgress.current) { + return false; + } + deleteInProgress.current = true; + setDeleting(true); + try { + await deleteSavedSession(selected.id); + setHighlightedSessionIds((current) => current.filter((id) => id !== selected.id)); + const updated = sessionListAfterDelete(summaries, selected.id); + setSummaries(updated.sessions); + setTotal((current) => Math.max(0, current - 1)); + setError(''); + if (updated.next) { + await selectSession(updated.next.id); + } else { + setSelected(undefined); + setSelectedId(undefined); + } + return true; + } catch (deleteError) { + setError(errorMessage(deleteError)); + return false; + } finally { + deleteInProgress.current = false; + setDeleting(false); + } + }, [selected, selectSession, summaries]); + + const loadMore = useCallback(async () => { + const last = summaries.at(-1); + if (!last) { + return; + } + try { + const more = await listSavedSessions(PAGE_SIZE, last.endedAt); + setSummaries((current) => [...current, ...more]); + setError(''); + } catch (loadError) { + setError(errorMessage(loadError)); + } + }, [summaries]); + + return { + deleteSelectedSession, + deleting, + downloadAllTcx, + error, + exporting, + highlightedSessionIds, + historyStatus, + importing, + importTcxFile, + loading, + loadMore, + selected, + selectedId, + selectSession, + summaries, + total, + }; +} diff --git a/src/hooks/use-session-workflow.ts b/src/hooks/use-session-workflow.ts new file mode 100644 index 0000000..50b4d80 --- /dev/null +++ b/src/hooks/use-session-workflow.ts @@ -0,0 +1,177 @@ +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useRef } from 'react'; +import { errorMessage, unreachable } from '../lib/errors'; +import { + createSavedSession, + requestPersistentSessionStorage, + saveSession, +} from '../lib/saved-sessions'; +import { + SESSION_WORKFLOW_INTENT, + SESSION_WORKFLOW_PHASE, + type SessionWorkflowController, + type SessionWorkflowIntent, +} from '../lib/session-workflow'; +import { createSessionWorkflowStore } from '../stores/session-workflow-store'; +import type { SavedSession, SessionMetadata } from '../types'; + +export function useSessionWorkflow( + session: SessionWorkflowController, + setNotice: (notice: string) => void +) { + const sessionIsResolved = Boolean(session.savedSessionId) || session.discarded; + const storeRef = useRef | undefined>(undefined); + storeRef.current ??= createSessionWorkflowStore(session.ended && !sessionIsResolved); + const store = storeRef.current; + const state = useSelector(store); + + const startNewSession = useCallback(() => { + session.startNew(); + store.actions.close(); + setNotice('New session ready.'); + }, [session.startNew, setNotice, store]); + + const continueSession = useCallback( + (savedSession: SavedSession) => { + session.continueFrom(savedSession); + store.actions.close(); + setNotice('Session continued.'); + }, + [session.continueFrom, setNotice, store] + ); + + const completeIntent = useCallback( + (intent: SessionWorkflowIntent, saved: boolean) => { + switch (intent.kind) { + case SESSION_WORKFLOW_INTENT.CONTINUE: + session.continueFrom(intent.session); + setNotice( + saved ? 'Session saved. Selected session continued.' : 'Session continued.' + ); + break; + case SESSION_WORKFLOW_INTENT.NEW: + session.startNew(); + setNotice(saved ? 'Session saved. New session ready.' : 'New session ready.'); + break; + case SESSION_WORKFLOW_INTENT.END: + if (saved) { + setNotice('Session saved.'); + } else { + session.markDiscarded(); + setNotice('Session ended without saving.'); + } + break; + default: + unreachable(intent); + } + store.actions.close(); + }, + [session.continueFrom, session.markDiscarded, session.startNew, setNotice, store] + ); + + const endSession = useCallback(() => { + session.endSession(); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END }); + }, [session.endSession, store]); + + const requestNewSession = useCallback(() => { + if (session.ended) { + if (sessionIsResolved) { + startNewSession(); + } else { + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); + } + return; + } + if (session.elapsedSeconds > 0) { + session.endSession(); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); + return; + } + startNewSession(); + }, [ + session.elapsedSeconds, + session.endSession, + session.ended, + sessionIsResolved, + startNewSession, + store, + ]); + + const requestContinuation = useCallback( + (savedSession: SavedSession) => { + const currentNeedsSave = + (session.ended && !sessionIsResolved) || + (!session.ended && session.elapsedSeconds > 0); + if (!currentNeedsSave) { + continueSession(savedSession); + return; + } + if (!session.ended) { + session.endSession(); + } + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.CONTINUE, session: savedSession }); + }, + [ + continueSession, + session.elapsedSeconds, + session.endSession, + session.ended, + sessionIsResolved, + store, + ] + ); + + const saveCurrentSession = useCallback( + async (metadata: SessionMetadata) => { + if (state.phase === SESSION_WORKFLOW_PHASE.CLOSED) { + return; + } + const { intent } = state; + store.actions.startSaving(); + try { + const savedSession = createSavedSession(session.snapshot, metadata); + await saveSession(savedSession); + session.markSaved(savedSession.id); + completeIntent(intent, true); + } catch (error) { + store.actions.saveFailed(); + setNotice(`Session could not be saved: ${errorMessage(error)}`); + } + }, + [completeIntent, session.markSaved, session.snapshot, setNotice, state, store] + ); + + const proceedWithoutSaving = useCallback(() => { + if (state.phase !== SESSION_WORKFLOW_PHASE.CLOSED) { + completeIntent(state.intent, false); + } + }, [completeIntent, state]); + const closeSaveDialog = useCallback(() => store.actions.close(), [store]); + const openSaveDialog = useCallback( + () => store.actions.open({ kind: SESSION_WORKFLOW_INTENT.END }), + [store] + ); + const requestPersistentStorage = useCallback( + () => requestPersistentSessionStorage().catch(() => false), + [] + ); + + return { + closeSaveDialog, + endSession, + openSaveDialog, + proceedWithoutSaving, + requestContinuation, + requestNewSession, + requestPersistentStorage, + saveCurrentSession, + saveDialogIntent: + state.phase === SESSION_WORKFLOW_PHASE.CLOSED + ? SESSION_WORKFLOW_INTENT.END + : state.intent.kind, + saveDialogOpen: state.phase !== SESSION_WORKFLOW_PHASE.CLOSED, + saving: state.phase === SESSION_WORKFLOW_PHASE.SAVING, + sessionIsResolved, + }; +} diff --git a/src/hooks/use-session.ts b/src/hooks/use-session.ts index 7f772a9..28eb900 100644 --- a/src/hooks/use-session.ts +++ b/src/hooks/use-session.ts @@ -1,13 +1,19 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { emptyMetrics, emptySession, RECORDING_PAUSE_DELAY_MS } from '../constants'; -import { addMetricAggregates, loadStoredSession, sessionContinuation } from '../lib/session'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { RECORDING_PAUSE_DELAY_MS } from '../constants'; +import { loadStoredSession, SESSION_STORAGE_KEY } from '../lib/session'; +import { MILLISECONDS_PER_SECOND, secondsForMilliseconds } from '../lib/units'; +import { + createSessionStore, + persistSessionState, + sessionSnapshotFromState, +} from '../stores/session-store'; import type { ControlMode, MetricSample, Metrics, SessionAggregates, SessionSnapshot, - StoredSession, } from '../types'; interface ActivityRef { @@ -28,12 +34,14 @@ interface SessionController { aggregates: SessionAggregates; continueFrom: (sourceSession: SessionSnapshot) => void; controlMode: ControlMode; + discarded: boolean; elapsedSeconds: number; ended: boolean; endSession: () => void; history: MetricSample[]; isRiding: boolean; manuallyPaused: boolean; + markDiscarded: () => void; markSaved: (id: string) => void; maximums: Metrics; rideCalories: number; @@ -51,92 +59,40 @@ export function useSession( lastPedalingAt: ActivityRef, trainerReportsDistance: FlagRef ): SessionController { - const restored = useMemo(loadStoredSession, []); - const initialStartedAt = useMemo(() => restored.startedAt || Date.now(), [restored.startedAt]); - const [isRiding, setIsRiding] = useState(false); - const [manuallyPaused, setManuallyPaused] = useState(false); - const [ended, setEnded] = useState(restored.ended); - const [endedAt, setEndedAt] = useState(restored.endedAt); - const [elapsedSeconds, setElapsedSeconds] = useState(restored.elapsedSeconds); - const [rideDistance, setRideDistance] = useState(restored.distance); - const [rideCalories, setRideCalories] = useState(restored.calories); - const [controlMode, setControlMode] = useState(restored.controlMode); - const [history, setHistory] = useState(restored.history); - const [maximums, setMaximums] = useState(restored.maximums); - const [aggregates, setAggregates] = useState(restored.aggregates); - const [savedSessionId, setSavedSessionId] = useState( - restored.savedSessionId - ); - const [startedAt, setStartedAt] = useState(initialStartedAt); + const store = useMemo(() => createSessionStore(loadStoredSession()), []); + const state = useSelector(store); const latestMetrics = useRef(metrics); const latestControl = useRef(control); - const elapsedRef = useRef(restored.elapsedSeconds); const lastTrainerDistance = useRef(undefined); useEffect(() => { latestMetrics.current = metrics; - if (ended) { - return; - } - setMaximums((current) => ({ - ...current, - cadence: Math.max(current.cadence, metrics.cadence), - heartRate: Math.max(current.heartRate, metrics.heartRate), - power: Math.max(current.power, metrics.power), - speed: Math.max(current.speed, metrics.speed), - })); - }, [ended, metrics]); + store.actions.observeMetrics(metrics); + }, [metrics, store]); useEffect(() => { latestControl.current = control; - if (elapsedRef.current === 0) { - setControlMode(control.mode); - } - }, [control]); + store.actions.observeControlMode(control.mode); + }, [control, store]); useEffect(() => { - localStorage.setItem( - 'trainer-session', - JSON.stringify({ - aggregates, - calories: rideCalories, - controlMode, - distance: rideDistance, - elapsedSeconds, - ended, - endedAt, - history: history.slice(-3600), - maximums, - savedSessionId, - startedAt, - } satisfies StoredSession) - ); - }, [ - aggregates, - controlMode, - elapsedSeconds, - ended, - endedAt, - history, - maximums, - rideCalories, - rideDistance, - savedSessionId, - startedAt, - ]); + persistSessionState(store.get()); + const subscription = store.subscribe((next) => persistSessionState(next)); + return () => subscription.unsubscribe(); + }, [store]); useEffect(() => { const interval = window.setInterval(() => { const recentlyPedaling = lastPedalingAt.current > 0 && performance.now() - lastPedalingAt.current <= RECORDING_PAUSE_DELAY_MS; - setIsRiding(!(ended || manuallyPaused) && recentlyPedaling); + store.actions.syncRiding(recentlyPedaling); }, 500); return () => window.clearInterval(interval); - }, [ended, lastPedalingAt, manuallyPaused]); + }, [lastPedalingAt, store]); useEffect(() => { - if (!isRiding) { + if (!state.isRiding) { return; } let lastTick = performance.now(); @@ -145,159 +101,86 @@ export function useSession( } const interval = window.setInterval(() => { const now = performance.now(); - const seconds = (now - lastTick) / 1000; + const seconds = secondsForMilliseconds(now - lastTick); lastTick = now; const live = latestMetrics.current; const liveControl = latestControl.current; - const controlSample = - liveControl.mode === 'gear' - ? { gear: liveControl.gear } - : { resistance: liveControl.resistance }; - elapsedRef.current += seconds; - setElapsedSeconds(elapsedRef.current); - setControlMode(liveControl.mode); + let distanceDelta: number | undefined; if (trainerReportsDistance.current) { const previous = lastTrainerDistance.current; const delta = previous === undefined ? 0 : live.distance - previous; - if (delta >= 0 && delta < 0.25) { - setRideDistance((value) => value + delta); - } + distanceDelta = delta >= 0 && delta < 0.25 ? delta : 0; lastTrainerDistance.current = live.distance; - } else { - setRideDistance((value) => value + (live.speed * seconds) / 3600); - } - setHistory((samples) => [ - ...samples, - { - cadence: live.cadence, - elapsedSeconds: elapsedRef.current, - heartRate: live.heartRate, - power: live.power, - speed: live.speed, - ...controlSample, - }, - ]); - setAggregates((current) => addMetricAggregates(current, { ...live, ...controlSample })); - if (live.power > 0) { - setRideCalories((value) => value + (live.power * seconds) / (4184 * 0.24)); } - }, 1000); + store.actions.recordTick({ + control: liveControl, + distanceDelta, + metrics: live, + seconds, + }); + }, MILLISECONDS_PER_SECOND); return () => window.clearInterval(interval); - }, [isRiding, trainerReportsDistance]); + }, [state.isRiding, store, trainerReportsDistance]); const togglePause = useCallback(() => { - if (ended) { - return; - } - if (manuallyPaused) { - setManuallyPaused(false); - setIsRiding( - lastPedalingAt.current > 0 && - performance.now() - lastPedalingAt.current <= RECORDING_PAUSE_DELAY_MS - ); - } else { - setManuallyPaused(true); - setIsRiding(false); - } - }, [ended, lastPedalingAt, manuallyPaused]); + const recentlyPedaling = + lastPedalingAt.current > 0 && + performance.now() - lastPedalingAt.current <= RECORDING_PAUSE_DELAY_MS; + store.actions.togglePause(recentlyPedaling); + }, [lastPedalingAt, store]); const endSession = useCallback(() => { - setEnded(true); - setEndedAt(Date.now()); - setIsRiding(false); - setManuallyPaused(false); - }, []); + store.actions.endSession(Date.now()); + }, [store]); - const markSaved = useCallback((id: string) => { - setSavedSessionId(id); - }, []); + const markSaved = useCallback( + (id: string) => { + store.actions.markSaved(id); + }, + [store] + ); + const markDiscarded = useCallback(() => { + store.actions.markDiscarded(); + }, [store]); const startNew = useCallback(() => { - elapsedRef.current = 0; lastTrainerDistance.current = latestMetrics.current.distance; lastPedalingAt.current = 0; - setEnded(false); - setEndedAt(0); - setElapsedSeconds(0); - setRideDistance(0); - setRideCalories(0); - setControlMode(latestControl.current.mode); - setHistory([]); - setIsRiding(false); - setManuallyPaused(false); - setMaximums(emptyMetrics); - setAggregates(emptySession.aggregates); - setSavedSessionId(undefined); - setStartedAt(Date.now()); - localStorage.removeItem('trainer-session'); - }, [lastPedalingAt]); + localStorage.removeItem(SESSION_STORAGE_KEY); + store.actions.reset(latestControl.current.mode, Date.now()); + }, [lastPedalingAt, store]); const continueFrom = useCallback( (sourceSession: SessionSnapshot) => { - const continued = sessionContinuation(sourceSession); - elapsedRef.current = continued.elapsedSeconds; lastTrainerDistance.current = latestMetrics.current.distance; lastPedalingAt.current = 0; - setEnded(false); - setEndedAt(0); - setElapsedSeconds(continued.elapsedSeconds); - setRideDistance(continued.distance); - setRideCalories(continued.calories); - setControlMode(continued.controlMode); - setHistory(continued.history); - setIsRiding(false); - setManuallyPaused(false); - setMaximums(continued.maximums); - setAggregates(continued.aggregates); - setSavedSessionId(undefined); - setStartedAt(continued.startedAt); - localStorage.removeItem('trainer-session'); + localStorage.removeItem(SESSION_STORAGE_KEY); + store.actions.continueFrom(sourceSession); }, - [lastPedalingAt] + [lastPedalingAt, store] ); - const snapshot = useMemo( - () => ({ - aggregates, - calories: rideCalories, - controlMode, - distance: rideDistance, - elapsedSeconds, - endedAt, - history, - maximums, - startedAt, - }), - [ - aggregates, - controlMode, - elapsedSeconds, - endedAt, - history, - maximums, - rideCalories, - rideDistance, - startedAt, - ] - ); + const snapshot = useMemo(() => sessionSnapshotFromState(state), [state]); return { - aggregates, + aggregates: state.aggregates, continueFrom, - controlMode, - elapsedSeconds, - ended, + controlMode: state.controlMode, + discarded: state.discarded, + elapsedSeconds: state.elapsedSeconds, + ended: state.ended, endSession, - history, - isRiding, - manuallyPaused, + history: state.history, + isRiding: state.isRiding, + manuallyPaused: state.manuallyPaused, + markDiscarded, markSaved, - maximums, - rideCalories, - rideDistance, - savedSessionId, + maximums: state.maximums, + rideCalories: state.calories, + rideDistance: state.distance, + savedSessionId: state.savedSessionId, snapshot, - startedAt, + startedAt: state.startedAt, startNew, togglePause, }; diff --git a/src/hooks/use-trainer-connection.ts b/src/hooks/use-trainer-connection.ts new file mode 100644 index 0000000..b19f483 --- /dev/null +++ b/src/hooks/use-trainer-connection.ts @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + CHROME_BLUETOOTH_PERMISSION_MESSAGE, + emptyMetrics, + optionalServices, + WEB_BLUETOOTH_UNAVAILABLE_MESSAGE, +} from '../constants'; +import { + findRememberedKickr, + isBluetoothChooserCancellation, + recordMetricActivity, + resistanceCommand, + TRAINER_DEVICE_STORAGE_KEY, +} from '../lib/bluetooth'; +import { errorMessage } from '../lib/errors'; +import { createReconnectController } from '../lib/reconnect-controller'; +import { storedResistance } from '../lib/session'; +import { connectTrainerDevice } from '../lib/trainer-device'; +import type { TrainerStore } from '../stores/trainer-store'; + +interface NumberRef { + current: number; +} + +function pairingWasCancelled(error: unknown, connectionCancelled: boolean) { + return connectionCancelled || isBluetoothChooserCancellation(error); +} + +export function useTrainerConnection( + store: TrainerStore, + appliedResistance: NumberRef, + resistanceTarget: NumberRef +) { + const { setConnectionPhase, setMetrics, setNotice, setResistance, setResistanceRamp } = + store.actions; + const device = useRef(undefined); + const pairedDevice = useRef(undefined); + const commandQueue = useRef(Promise.resolve()); + const connecting = useRef(false); + const connectionCancelled = useRef(false); + const disconnectRequested = useRef(false); + const autoReconnect = useRef(true); + const pendingDevice = useRef(undefined); + const connectionCleanup = useRef<() => void>(() => undefined); + const connectDeviceRef = useRef< + ((selected: BluetoothDevice, rediscover?: boolean) => Promise) | undefined + >(undefined); + const unloading = useRef(false); + const lastPedalingAt = useRef(0); + const trainerReportsDistance = useRef(false); + const controlPoint = useRef(undefined); + const resistanceRange = useRef(store.get().resistanceRange); + const reconnectController = useRef( + createReconnectController({ + attempt: (selected) => + connectDeviceRef.current?.(selected, true) ?? Promise.resolve(false), + canRetry: () => + autoReconnect.current && !unloading.current && !connectionCancelled.current, + delayForAttempt: (attempt) => Math.min(5000, 700 * attempt), + onWaiting: () => setConnectionPhase('reconnecting'), + }) + ); + + const writeControl = useCallback( + async (characteristic: BluetoothRemoteGATTCharacteristic | undefined, bytes: number[]) => { + if (!characteristic) { + setNotice('Connect the trainer before changing its settings.'); + return; + } + const action = async () => { + try { + await characteristic.writeValueWithResponse(new Uint8Array(bytes)); + } catch (error) { + setNotice(`Trainer command failed: ${errorMessage(error)}`); + } + }; + commandQueue.current = commandQueue.current.then(action, action); + await commandQueue.current; + }, + [setNotice] + ); + + function connectionStopped(rediscover: boolean) { + return connectionCancelled.current || (rediscover && !autoReconnect.current); + } + + function handleConnectionError(error: unknown, rediscover: boolean) { + if (rediscover && autoReconnect.current && !connectionCancelled.current) { + setConnectionPhase('reconnecting'); + } else if (connectionCancelled.current) { + setConnectionPhase('offline'); + } else { + setConnectionPhase('offline'); + setNotice(errorMessage(error)); + } + } + + function handleTrainerDisconnected(selected: BluetoothDevice) { + const shouldReconnect = + !(disconnectRequested.current || unloading.current) && autoReconnect.current; + disconnectRequested.current = false; + device.current = undefined; + store.actions.setDeviceName(undefined); + controlPoint.current = undefined; + setMetrics(emptyMetrics); + lastPedalingAt.current = 0; + trainerReportsDistance.current = false; + if (shouldReconnect) { + pendingDevice.current = selected; + setConnectionPhase('reconnecting'); + setNotice('Trainer disconnected. Reconnecting automatically…'); + reconnectController.current.start(selected.id, selected, 700); + } else if (connectionCancelled.current) { + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + setNotice('Connection attempt stopped.'); + } else { + setConnectionPhase('offline'); + setNotice('Trainer disconnected.'); + } + } + + async function connectDevice(selected: BluetoothDevice, rediscover = false): Promise { + if (connecting.current) { + return false; + } + connecting.current = true; + setConnectionPhase(rediscover ? 'reconnecting' : 'connecting'); + connectionCleanup.current(); + try { + pairedDevice.current = selected; + store.actions.setPairedDeviceName(selected.name); + const nextConnection = await connectTrainerDevice( + selected, + rediscover, + resistanceRange.current, + { + onControlRejected: () => setNotice('Trainer did not accept that command.'), + onDisconnect: () => { + connectionCleanup.current(); + handleTrainerDisconnected(selected); + }, + onMetrics: (nextMetrics, reportsDistance) => { + if (reportsDistance) { + trainerReportsDistance.current = true; + } + recordMetricActivity(lastPedalingAt, nextMetrics); + store.actions.mergeMetrics(nextMetrics); + }, + } + ); + if (connectionStopped(rediscover)) { + nextConnection.cleanup(); + selected.gatt?.disconnect(); + return false; + } + connectionCleanup.current = nextConnection.cleanup; + controlPoint.current = nextConnection.controlPoint; + resistanceRange.current = nextConnection.resistanceRange; + store.actions.setResistanceRange(nextConnection.resistanceRange); + const restored = storedResistance(); + setResistance(restored); + appliedResistance.current = restored; + resistanceTarget.current = restored; + setResistanceRamp({ + current: restored, + from: restored, + phase: 'holding', + progress: 0, + to: restored, + }); + await writeControl(controlPoint.current, [0]); + await new Promise((resolve) => window.setTimeout(resolve, 150)); + await writeControl( + controlPoint.current, + resistanceCommand(restored, resistanceRange.current) + ); + if (connectionStopped(rediscover)) { + selected.gatt?.disconnect(); + return false; + } + localStorage.setItem(TRAINER_DEVICE_STORAGE_KEY, selected.id); + device.current = selected; + store.actions.setDeviceName(selected.name); + setConnectionPhase('connected'); + reconnectController.current.reset(selected.id); + setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`); + return true; + } catch (error) { + if (selected.gatt?.connected) { + selected.gatt.disconnect(); + } + handleConnectionError(error, rediscover); + return false; + } finally { + connecting.current = false; + } + } + + useEffect(() => { + connectDeviceRef.current = connectDevice; + }); + + async function connect() { + if (!navigator.bluetooth) { + setNotice(WEB_BLUETOOTH_UNAVAILABLE_MESSAGE); + return; + } + connectionCancelled.current = false; + disconnectRequested.current = false; + setConnectionPhase('pairing'); + try { + const selected = await navigator.bluetooth.requestDevice({ + filters: [{ namePrefix: 'KICKR' }], + optionalServices, + }); + pendingDevice.current = selected; + pairedDevice.current = selected; + store.actions.setPairedDeviceName(selected.name); + autoReconnect.current = true; + if (!(await connectDevice(selected))) { + reconnectController.current.start(selected.id, selected); + } + } catch (error) { + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + if (!pairingWasCancelled(error, connectionCancelled.current)) { + setNotice(errorMessage(error)); + } + } finally { + pendingDevice.current = undefined; + } + } + + const cancelConnection = useCallback(() => { + connectionCancelled.current = true; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + pendingDevice.current?.gatt?.disconnect(); + pendingDevice.current = undefined; + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + setNotice('Connection attempt stopped.'); + }, [setConnectionPhase, setNotice]); + + const disconnect = useCallback(() => { + connectionCancelled.current = false; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + device.current = undefined; + store.actions.setDeviceName(undefined); + controlPoint.current = undefined; + setMetrics(emptyMetrics); + setConnectionPhase(pairedDevice.current ? 'offline' : 'unpaired'); + }, [setConnectionPhase, setMetrics, store]); + + async function reconnect() { + if (!pairedDevice.current) { + return; + } + const selected = pairedDevice.current; + connectionCancelled.current = false; + disconnectRequested.current = false; + autoReconnect.current = true; + reconnectController.current.reset(selected.id); + if (!(await connectDevice(selected, true))) { + reconnectController.current.start(selected.id, selected); + } + } + + const forget = useCallback(async () => { + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + try { + await pairedDevice.current?.forget(); + } finally { + localStorage.removeItem(TRAINER_DEVICE_STORAGE_KEY); + device.current = undefined; + pairedDevice.current = undefined; + store.actions.setDeviceName(undefined); + store.actions.setPairedDeviceName(undefined); + controlPoint.current = undefined; + setMetrics(emptyMetrics); + setConnectionPhase('unpaired'); + setNotice('Trainer removed from paired devices.'); + } + }, [setConnectionPhase, setMetrics, setNotice, store]); + + const sendResistance = useCallback( + async (percent: number) => { + await writeControl( + controlPoint.current, + resistanceCommand(percent, resistanceRange.current) + ); + }, + [writeControl] + ); + + useEffect(() => { + const handlePageHide = () => { + unloading.current = true; + autoReconnect.current = false; + disconnectRequested.current = true; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + device.current?.gatt?.disconnect(); + }; + window.addEventListener('pagehide', handlePageHide); + return () => window.removeEventListener('pagehide', handlePageHide); + }, []); + + useEffect(() => { + let cancelled = false; + async function restore() { + autoReconnect.current = true; + connectionCancelled.current = false; + disconnectRequested.current = false; + if (!navigator.bluetooth?.getDevices) { + setConnectionPhase('unpaired'); + setNotice(CHROME_BLUETOOTH_PERMISSION_MESSAGE); + return; + } + const remembered = await findRememberedKickr(); + if (cancelled) { + return; + } + if (!remembered) { + setConnectionPhase('unpaired'); + return; + } + pairedDevice.current = remembered; + store.actions.setPairedDeviceName(remembered.name); + setConnectionPhase('reconnecting'); + reconnectController.current.start(remembered.id, remembered, 1); + } + restore().catch((error: unknown) => setNotice(errorMessage(error))); + return () => { + cancelled = true; + autoReconnect.current = false; + reconnectController.current.cancelAll(); + connectionCleanup.current(); + }; + }, [setConnectionPhase, setNotice, store.actions.setPairedDeviceName]); + + return { + cancelConnection, + connect, + disconnect, + forget, + lastPedalingAt, + reconnect, + sendResistance, + trainerReportsDistance, + }; +} diff --git a/src/hooks/use-trainer.ts b/src/hooks/use-trainer.ts index 6d6294c..6faade7 100644 --- a/src/hooks/use-trainer.ts +++ b/src/hooks/use-trainer.ts @@ -1,87 +1,39 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { - CHROME_BLUETOOTH_PERMISSION_MESSAGE, - CONTROL_POINT, - CSC_MEASUREMENT, - CYCLING_POWER, - CYCLING_POWER_MEASUREMENT, - CYCLING_SPEED_AND_CADENCE, - emptyMetrics, - FITNESS_MACHINE, - FITNESS_MACHINE_STATUS, - INDOOR_BIKE_DATA, - optionalServices, - SUPPORTED_RESISTANCE_LEVEL_RANGE, -} from '../constants'; -import { - characteristicValue, - connectGatt, - findRememberedKickr, - parseCrankCadence, - parseIndoorBikeData, - recordMetricActivity, - recordPedaling, - resistanceCommand, -} from '../lib/bluetooth'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { CONTROL_FLASH_MS } from '../constants'; +import { deviceConnectionView } from '../lib/device-connection'; +import { eventTargetsEditableControl, keyboardEventHasModifiers } from '../lib/dom'; +import { errorMessage } from '../lib/errors'; import { scheduleNoticeDismissal } from '../lib/notification'; +import { clamp } from '../lib/numbers'; import { + clampResistance, resistanceDirectionForKey, resistanceRampDuration, smoothedResistance, } from '../lib/resistance'; -import { storedResistance } from '../lib/session'; -import type { Metrics, Range, ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; +import { RESISTANCE_STORAGE_KEY } from '../lib/session'; +import { createTrainerStore } from '../stores/trainer-store'; +import { useTrainerConnection } from './use-trainer-connection'; export function useTrainer() { - const [device, setDevice] = useState(); - const [pairedDevice, setPairedDevice] = useState(); - const [controlPoint, setControlPoint] = useState(); - const [metrics, setMetrics] = useState(emptyMetrics); - const [resistance, setResistance] = useState(storedResistance); - const [resistanceKeyFlash, setResistanceKeyFlash] = useState< - ResistanceAdjustmentDirection | undefined - >(); - const [resistanceRamp, setResistanceRamp] = useState(() => { - const current = storedResistance(); - return { current, from: current, phase: 'holding', progress: 0, to: current }; - }); - const [status, setStatus] = useState('Ready to connect'); - const [notice, setNotice] = useState(''); - const [connectionBusy, setConnectionBusy] = useState(false); - const [resistanceRange, setResistanceRange] = useState({ - max: 100, - min: 0, - }); - const commandQueue = useRef(Promise.resolve()); + const store = useMemo(() => createTrainerStore(), []); + const state = useSelector(store); + const { setNotice, setResistance, setResistanceKeyFlash, setResistanceRamp } = store.actions; const resistanceTimer = useRef(undefined); const resistanceRampTimer = useRef(undefined); const resistanceKeyFlashTimer = useRef(undefined); - const appliedResistance = useRef(storedResistance()); - const resistanceTarget = useRef(storedResistance()); - const connecting = useRef(false); - const connectionCancelled = useRef(false); - const disconnectRequested = useRef(false); - const autoReconnect = useRef(true); - const pendingDevice = useRef(undefined); + const appliedResistance = useRef(store.get().resistance); + const resistanceTarget = useRef(store.get().resistance); const keyboardControlsEnabled = useRef(true); const gearControlsEnabled = useRef(false); - const unloading = useRef(false); - const lastCrank = useRef<{ revolutions: number; time: number } | undefined>(undefined); - const lastPedalingAt = useRef(0); - const trainerReportsDistance = useRef(false); - const controlPointRef = useRef(controlPoint); - const rangeRef = useRef(resistanceRange); - const connected = Boolean(device?.gatt?.connected); - - useEffect(() => { - controlPointRef.current = controlPoint; - }, [controlPoint]); - - useEffect(() => { - rangeRef.current = resistanceRange; - }, [resistanceRange]); + const trainerConnection = useTrainerConnection(store, appliedResistance, resistanceTarget); + const connection = deviceConnectionView(state.connectionPhase); - useEffect(() => scheduleNoticeDismissal(notice, () => setNotice('')), [notice]); + useEffect( + () => scheduleNoticeDismissal(state.notice, () => setNotice('')), + [setNotice, state.notice] + ); useEffect( () => () => { @@ -92,330 +44,6 @@ export function useTrainer() { [] ); - const writeControl = useCallback( - async (characteristic: BluetoothRemoteGATTCharacteristic | undefined, bytes: number[]) => { - if (!characteristic) { - setNotice('Connect the trainer before changing its settings.'); - return; - } - const action = async () => { - try { - await characteristic.writeValueWithResponse(new Uint8Array(bytes)); - } catch (error) { - setNotice( - `Trainer command failed: ${error instanceof Error ? error.message : String(error)}` - ); - } - }; - commandQueue.current = commandQueue.current.then(action, action); - await commandQueue.current; - }, - [] - ); - - async function subscribeToPowerAndCadence(server: BluetoothRemoteGATTServer) { - try { - const measurement = await ( - await server.getPrimaryService(CYCLING_POWER) - ).getCharacteristic(CYCLING_POWER_MEASUREMENT); - measurement.addEventListener('characteristicvaluechanged', (event) => { - const value = characteristicValue(event); - if (!value) { - return; - } - const power = value.getInt16(2, true); - recordPedaling(lastPedalingAt, power > 5); - setMetrics((current) => ({ ...current, power })); - }); - await measurement.startNotifications(); - } catch { - // Indoor Bike Data is the normal path. - } - try { - const measurement = await ( - await server.getPrimaryService(CYCLING_SPEED_AND_CADENCE) - ).getCharacteristic(CSC_MEASUREMENT); - measurement.addEventListener('characteristicvaluechanged', (event) => { - const value = characteristicValue(event); - if (!value) { - return; - } - const parsed = parseCrankCadence(value, lastCrank.current); - if (parsed.current) { - lastCrank.current = parsed.current; - } - if (parsed.cadence !== undefined) { - recordPedaling(lastPedalingAt, parsed.cadence > 0); - setMetrics((current) => ({ - ...current, - cadence: parsed.cadence ?? current.cadence, - })); - } - }); - await measurement.startNotifications(); - } catch { - // CSC is optional. - } - } - - function connectionStopped(rediscover: boolean) { - return connectionCancelled.current || (rediscover && !autoReconnect.current); - } - - function handleConnectionError(error: unknown, rediscover: boolean) { - if (rediscover && autoReconnect.current && !connectionCancelled.current) { - setStatus('Reconnecting…'); - } else if (connectionCancelled.current) { - setStatus('Ready to connect'); - } else { - setStatus('Connection failed'); - setNotice(error instanceof Error ? error.message : String(error)); - } - } - - function handleTrainerDisconnected(selected: BluetoothDevice) { - const shouldReconnect = - !(disconnectRequested.current || unloading.current) && autoReconnect.current; - disconnectRequested.current = false; - setDevice(undefined); - setControlPoint(undefined); - setMetrics(emptyMetrics); - lastPedalingAt.current = 0; - trainerReportsDistance.current = false; - if (shouldReconnect) { - pendingDevice.current = selected; - setConnectionBusy(true); - setStatus('Reconnecting…'); - setNotice('Trainer disconnected. Reconnecting automatically…'); - window.setTimeout(() => reconnectDevice(selected), 700); - } else if (connectionCancelled.current) { - setStatus('Ready to connect'); - setNotice('Connection attempt stopped.'); - } else { - setStatus('Disconnected'); - setNotice('Trainer disconnected.'); - } - } - - async function connectDevice(selected: BluetoothDevice, rediscover = false): Promise { - if (connecting.current) { - return false; - } - connecting.current = true; - try { - setPairedDevice(selected); - const server = await connectGatt(selected, rediscover, setStatus); - if (connectionStopped(rediscover)) { - selected.gatt?.disconnect(); - return false; - } - const service = await server.getPrimaryService(FITNESS_MACHINE); - const bikeData = await service.getCharacteristic(INDOOR_BIKE_DATA); - bikeData.addEventListener('characteristicvaluechanged', (event) => { - const value = characteristicValue(event); - if (!value) { - return; - } - const parsed = parseIndoorBikeData(value); - if (parsed.reportsDistance) { - trainerReportsDistance.current = true; - } - recordMetricActivity(lastPedalingAt, parsed.metrics); - setMetrics((current) => ({ ...current, ...parsed.metrics })); - }); - await bikeData.startNotifications(); - const point = await service.getCharacteristic(CONTROL_POINT); - point.addEventListener('characteristicvaluechanged', (event) => { - const value = characteristicValue(event); - if (value?.getUint8(0) === 0x80 && value.getUint8(2) !== 0x01) { - setNotice('Trainer did not accept that command.'); - } - }); - await point.startNotifications(); - setControlPoint(point); - try { - await ( - await service.getCharacteristic(FITNESS_MACHINE_STATUS) - ).startNotifications(); - } catch { - // Optional characteristic. - } - let activeRange = resistanceRange; - try { - const rangeValue = await ( - await service.getCharacteristic(SUPPORTED_RESISTANCE_LEVEL_RANGE) - ).readValue(); - activeRange = { - max: rangeValue.getInt16(2, true) / 10, - min: rangeValue.getInt16(0, true) / 10, - }; - setResistanceRange(activeRange); - } catch { - // Use the generic range. - } - const restored = storedResistance(); - setResistance(restored); - appliedResistance.current = restored; - resistanceTarget.current = restored; - setResistanceRamp({ - current: restored, - from: restored, - phase: 'holding', - progress: 0, - to: restored, - }); - await writeControl(point, [0]); - await new Promise((resolve) => window.setTimeout(resolve, 150)); - await writeControl(point, resistanceCommand(restored, activeRange)); - if (connectionStopped(rediscover)) { - selected.gatt?.disconnect(); - return false; - } - selected.addEventListener( - 'gattserverdisconnected', - () => handleTrainerDisconnected(selected), - { once: true } - ); - localStorage.setItem('trainer-device-id', selected.id); - setDevice(selected); - setStatus('Connected'); - setNotice(`${selected.name ?? 'Trainer'} is connected and ready.`); - subscribeToPowerAndCadence(server).catch((error: unknown) => - setNotice(error instanceof Error ? error.message : String(error)) - ); - return true; - } catch (error) { - if (selected.gatt?.connected) { - selected.gatt.disconnect(); - } - handleConnectionError(error, rediscover); - return false; - } finally { - connecting.current = false; - } - } - - async function reconnectDevice(selected: BluetoothDevice) { - if (!autoReconnect.current || unloading.current || connectionCancelled.current) { - return; - } - pendingDevice.current = selected; - setConnectionBusy(true); - let attempt = 0; - try { - while (autoReconnect.current && !unloading.current && !connectionCancelled.current) { - if (await connectDevice(selected, true)) { - return; - } - if (!autoReconnect.current || unloading.current || connectionCancelled.current) { - return; - } - attempt += 1; - setStatus('Reconnecting…'); - await new Promise((resolve) => - window.setTimeout(resolve, Math.min(5000, 700 * attempt)) - ); - } - } finally { - pendingDevice.current = undefined; - setConnectionBusy(false); - } - } - - async function connect() { - if (!navigator.bluetooth) { - setNotice('Web Bluetooth requires current Chrome or Edge on localhost or HTTPS.'); - return; - } - connectionCancelled.current = false; - disconnectRequested.current = false; - setConnectionBusy(true); - try { - setStatus('Choose a trainer…'); - const selected = await navigator.bluetooth.requestDevice({ - filters: [{ namePrefix: 'KICKR' }], - optionalServices, - }); - pendingDevice.current = selected; - setPairedDevice(selected); - autoReconnect.current = true; - await connectDevice(selected); - } catch (error) { - if ( - connectionCancelled.current || - (error instanceof DOMException && error.name === 'NotFoundError') - ) { - setStatus('Ready to connect'); - } else { - setStatus('Connection failed'); - setNotice(error instanceof Error ? error.message : String(error)); - } - } finally { - pendingDevice.current = undefined; - setConnectionBusy(false); - } - } - - const cancelConnection = useCallback(() => { - connectionCancelled.current = true; - autoReconnect.current = false; - disconnectRequested.current = true; - pendingDevice.current?.gatt?.disconnect(); - pendingDevice.current = undefined; - setConnectionBusy(false); - setStatus('Ready to connect'); - setNotice('Connection attempt stopped.'); - }, []); - - const disconnect = useCallback(() => { - connectionCancelled.current = false; - autoReconnect.current = false; - disconnectRequested.current = true; - device?.gatt?.disconnect(); - }, [device]); - - async function reconnect() { - if (!pairedDevice) { - return; - } - connectionCancelled.current = false; - disconnectRequested.current = false; - autoReconnect.current = true; - setConnectionBusy(true); - try { - await connectDevice(pairedDevice, true); - } finally { - setConnectionBusy(false); - } - } - - const forget = useCallback(async () => { - autoReconnect.current = false; - disconnectRequested.current = true; - device?.gatt?.disconnect(); - try { - await pairedDevice?.forget(); - } finally { - localStorage.removeItem('trainer-device-id'); - setDevice(undefined); - setPairedDevice(undefined); - setControlPoint(undefined); - setMetrics(emptyMetrics); - setStatus('Ready to pair'); - setNotice('Trainer removed from paired devices.'); - } - }, [device, pairedDevice]); - - const sendResistance = useCallback( - async (percent: number) => { - await writeControl( - controlPointRef.current, - resistanceCommand(percent, rangeRef.current) - ); - }, - [writeControl] - ); - const rampResistance = useCallback( (target: number) => { window.clearTimeout(resistanceRampTimer.current); @@ -440,7 +68,7 @@ export function useTrainer() { to: target, }); const advance = () => { - const progress = Math.min(1, (performance.now() - startedAt) / duration); + const progress = clamp((performance.now() - startedAt) / duration, 0, 1); const current = smoothedResistance(start, target, progress); appliedResistance.current = current; setResistanceRamp({ @@ -450,24 +78,24 @@ export function useTrainer() { progress, to: target, }); - sendResistance(current).catch((error: unknown) => - setNotice(error instanceof Error ? error.message : String(error)) - ); + trainerConnection + .sendResistance(current) + .catch((error: unknown) => setNotice(errorMessage(error))); if (progress < 1) { resistanceRampTimer.current = window.setTimeout(advance, 200); } }; advance(); }, - [sendResistance] + [setNotice, setResistanceRamp, trainerConnection.sendResistance] ); const updateResistance = useCallback( (value: number) => { - const next = Math.max(0, Math.min(100, value)); + const next = clampResistance(value); resistanceTarget.current = next; setResistance(next); - localStorage.setItem('trainer-resistance-percent', String(next)); + localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); window.clearTimeout(resistanceTimer.current); window.clearTimeout(resistanceRampTimer.current); const { current } = appliedResistance; @@ -482,12 +110,12 @@ export function useTrainer() { rampResistance(next); }, 180); }, - [rampResistance] + [rampResistance, setResistance, setResistanceRamp] ); const shiftResistanceBy = useCallback( (change: number) => { - const next = Math.max(0, Math.min(100, resistanceTarget.current + change)); + const next = clampResistance(resistanceTarget.current + change); window.clearTimeout(resistanceTimer.current); window.clearTimeout(resistanceRampTimer.current); resistanceTarget.current = next; @@ -500,78 +128,28 @@ export function useTrainer() { progress: 1, to: next, }); - localStorage.setItem('trainer-resistance-percent', String(next)); - sendResistance(next).catch((error: unknown) => - setNotice(error instanceof Error ? error.message : String(error)) - ); + localStorage.setItem(RESISTANCE_STORAGE_KEY, String(next)); + trainerConnection + .sendResistance(next) + .catch((error: unknown) => setNotice(errorMessage(error))); }, - [sendResistance] + [setNotice, setResistance, setResistanceRamp, trainerConnection.sendResistance] ); - useEffect(() => { - const handlePageHide = () => { - unloading.current = true; - autoReconnect.current = false; - disconnectRequested.current = true; - device?.gatt?.disconnect(); - }; - window.addEventListener('pagehide', handlePageHide); - return () => window.removeEventListener('pagehide', handlePageHide); - }, [device]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: Permission restoration is a one-time mount operation. - useEffect(() => { - let cancelled = false; - async function restore() { - autoReconnect.current = true; - connectionCancelled.current = false; - disconnectRequested.current = false; - if (!navigator.bluetooth?.getDevices) { - setStatus('Browser setup required'); - setNotice(CHROME_BLUETOOTH_PERMISSION_MESSAGE); - return; - } - const remembered = await findRememberedKickr(); - if (cancelled) { - return; - } - if (!remembered) { - setStatus('Ready to connect'); - return; - } - setPairedDevice(remembered); - setStatus('Reconnecting…'); - await reconnectDevice(remembered); - } - restore().catch((error: unknown) => - setNotice(error instanceof Error ? error.message : String(error)) - ); - return () => { - cancelled = true; - autoReconnect.current = false; - }; - }, []); - useEffect(() => { const handleKeys = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - const isResistanceControl = target?.matches('[data-resistance-control="true"]'); + const isResistanceControl = + event.target instanceof HTMLElement && + event.target.matches('[data-resistance-control="true"]'); if ( event.defaultPrevented || - event.altKey || - event.ctrlKey || - event.metaKey || - (!isResistanceControl && - target?.matches("input, textarea, select, [contenteditable='true']")) + keyboardEventHasModifiers(event) || + (!isResistanceControl && eventTargetsEditableControl(event)) || + !keyboardControlsEnabled.current || + gearControlsEnabled.current ) { return; } - if (!keyboardControlsEnabled.current) { - return; - } - if (gearControlsEnabled.current) { - return; - } const direction = resistanceDirectionForKey(event.key); if (!direction) { return; @@ -579,11 +157,7 @@ export function useTrainer() { event.preventDefault(); setResistanceKeyFlash(direction); window.clearTimeout(resistanceKeyFlashTimer.current); - if (direction === 'increase') { - updateResistance(resistanceTarget.current + 1); - } else { - updateResistance(resistanceTarget.current - 1); - } + updateResistance(resistanceTarget.current + (direction === 'increase' ? 1 : -1)); }; const handleKeyUp = (event: KeyboardEvent) => { if (!resistanceDirectionForKey(event.key)) { @@ -592,7 +166,7 @@ export function useTrainer() { window.clearTimeout(resistanceKeyFlashTimer.current); resistanceKeyFlashTimer.current = window.setTimeout( () => setResistanceKeyFlash(undefined), - 180 + CONTROL_FLASH_MS ); }; const handleBlur = () => { @@ -607,7 +181,7 @@ export function useTrainer() { window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('blur', handleBlur); }; - }, [updateResistance]); + }, [setResistanceKeyFlash, updateResistance]); const setKeyboardControlsEnabled = useCallback((enabled: boolean) => { keyboardControlsEnabled.current = enabled; @@ -618,28 +192,26 @@ export function useTrainer() { }, []); return { - cancelConnection, - connect, - connected, - connectionBusy, - deviceName: device?.name, - disconnect, - forget, - lastPedalingAt, - metrics, - notice, - paired: Boolean(pairedDevice), - pairedDeviceName: pairedDevice?.name, - reconnect, - resistance, - resistanceKeyFlash, - resistanceRamp, + ...connection, + cancelConnection: trainerConnection.cancelConnection, + connect: trainerConnection.connect, + connectionBusy: connection.busy, + deviceName: state.deviceName, + disconnect: trainerConnection.disconnect, + forget: trainerConnection.forget, + lastPedalingAt: trainerConnection.lastPedalingAt, + metrics: state.metrics, + notice: state.notice, + pairedDeviceName: state.pairedDeviceName, + reconnect: trainerConnection.reconnect, + resistance: state.resistance, + resistanceKeyFlash: state.resistanceKeyFlash, + resistanceRamp: state.resistanceRamp, setGearControlsEnabled, setKeyboardControlsEnabled, setNotice, shiftResistanceBy, - status, - trainerReportsDistance, + trainerReportsDistance: trainerConnection.trainerReportsDistance, updateResistance, }; } diff --git a/src/hooks/use-zwift-click-input.ts b/src/hooks/use-zwift-click-input.ts new file mode 100644 index 0000000..82bb6be --- /dev/null +++ b/src/hooks/use-zwift-click-input.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + CLICK_CONTROLLER_ROLES_STORAGE_KEY, + type ClickControllerRoles, + type ClickShift, + filterAcceptedClickShifts, + filterClickShiftsForController, + parseClickV2Shift, + registerClickControllerRole, +} from '../lib/zwift-click'; +import type { ZwiftClickStore } from '../stores/zwift-click-store'; + +interface ClickRepeatTimer { + delay: number; + interval?: number; +} + +const CLICK_HOLD_DELAY_MS = 600; +const CLICK_HOLD_REPEAT_MS = 220; +const CLICK_CONTROLLER_FLASH_MS = 350; +const CLICK_SHIFTS: ClickShift[] = ['down', 'up']; + +function saveControllerRoles(roles: ClickControllerRoles) { + localStorage.setItem(CLICK_CONTROLLER_ROLES_STORAGE_KEY, JSON.stringify(roles)); +} + +export function useZwiftClickInput({ + identifyControllers, + onOperational, + onShift, + store, +}: { + identifyControllers: boolean; + onOperational: (deviceId: string) => void; + onShift: (change: number) => void; + store: ZwiftClickStore; +}) { + const controllerFlashTimers = useRef(new Map()); + const controllerRoles = useRef({}); + const heldShiftsByDevice = useRef(new Map()); + const identifyControllersRef = useRef(identifyControllers); + const previousButtonMaps = useRef(new Map()); + const lastShiftTimes = useRef(new Map()); + const repeatTimers = useRef(new Map()); + const onShiftRef = useRef(onShift); + + useEffect(() => { + onShiftRef.current = onShift; + }, [onShift]); + + useEffect(() => { + identifyControllersRef.current = identifyControllers; + if (!identifyControllers) { + for (const timer of controllerFlashTimers.current.values()) { + window.clearTimeout(timer); + } + controllerFlashTimers.current.clear(); + store.actions.clearActiveControllers(); + } + }, [identifyControllers, store]); + + const registerControllerRole = useCallback( + (deviceId: string, shifts: ClickShift[]) => { + const next = registerClickControllerRole(controllerRoles.current, deviceId, shifts); + if (next === controllerRoles.current) { + return; + } + controllerRoles.current = next; + store.actions.setControllerRoles(next); + saveControllerRoles(next); + }, + [store] + ); + + const forgetControllerRole = useCallback( + (deviceId: string) => { + if (!controllerRoles.current[deviceId]) { + return; + } + const next = { ...controllerRoles.current }; + delete next[deviceId]; + controllerRoles.current = next; + store.actions.setControllerRoles(next); + saveControllerRoles(next); + }, + [store] + ); + + const stopRepeat = useCallback((shift: ClickShift) => { + const timer = repeatTimers.current.get(shift); + if (!timer) { + return; + } + window.clearTimeout(timer.delay); + window.clearInterval(timer.interval); + repeatTimers.current.delete(shift); + }, []); + + const syncRepeats = useCallback(() => { + const heldShifts = new Set(); + for (const deviceShifts of heldShiftsByDevice.current.values()) { + for (const shift of deviceShifts) { + heldShifts.add(shift); + } + } + for (const shift of CLICK_SHIFTS) { + if (!heldShifts.has(shift)) { + stopRepeat(shift); + continue; + } + if (repeatTimers.current.has(shift)) { + continue; + } + const change = shift === 'down' ? -1 : 1; + const timer: ClickRepeatTimer = { delay: 0 }; + timer.delay = window.setTimeout(() => { + onShiftRef.current(change); + timer.interval = window.setInterval( + () => onShiftRef.current(change), + CLICK_HOLD_REPEAT_MS + ); + }, CLICK_HOLD_DELAY_MS); + repeatTimers.current.set(shift, timer); + } + }, [stopRepeat]); + + const setDeviceHeldShifts = useCallback( + (deviceId: string, heldShifts: ClickShift[]) => { + if (heldShifts.length) { + heldShiftsByDevice.current.set(deviceId, heldShifts); + } else { + heldShiftsByDevice.current.delete(deviceId); + } + syncRepeats(); + }, + [syncRepeats] + ); + + const clearDeviceHeldShifts = useCallback( + (deviceId: string) => { + heldShiftsByDevice.current.delete(deviceId); + syncRepeats(); + }, + [syncRepeats] + ); + + const flashController = useCallback( + (deviceId: string) => { + if (!identifyControllersRef.current) { + return; + } + window.clearTimeout(controllerFlashTimers.current.get(deviceId)); + store.actions.activateController(deviceId); + const timer = window.setTimeout(() => { + controllerFlashTimers.current.delete(deviceId); + store.actions.deactivateController(deviceId); + }, CLICK_CONTROLLER_FLASH_MS); + controllerFlashTimers.current.set(deviceId, timer); + }, + [store] + ); + + const handleControllerMessage = useCallback( + (deviceId: string, event: Event) => { + const { value } = event.target as BluetoothRemoteGATTCharacteristic; + if (!value) { + return; + } + const parsed = parseClickV2Shift(value, previousButtonMaps.current.get(deviceId)); + if (!parsed) { + return; + } + onOperational(deviceId); + previousButtonMaps.current.set(deviceId, parsed.buttonMap); + const controllerRole = controllerRoles.current[deviceId]; + const heldShifts = filterClickShiftsForController(parsed.heldShifts, controllerRole); + const controllerShifts = filterClickShiftsForController(parsed.shifts, controllerRole); + setDeviceHeldShifts(deviceId, heldShifts); + const acceptedShifts = filterAcceptedClickShifts( + controllerShifts, + performance.now(), + lastShiftTimes.current + ); + if (acceptedShifts.length) { + flashController(deviceId); + } + if (identifyControllersRef.current) { + registerControllerRole(deviceId, acceptedShifts); + } + for (const shift of acceptedShifts) { + onShiftRef.current(shift === 'down' ? -1 : 1); + } + }, + [flashController, onOperational, registerControllerRole, setDeviceHeldShifts] + ); + + const resetControllerInput = useCallback( + (deviceId: string) => { + previousButtonMaps.current.delete(deviceId); + clearDeviceHeldShifts(deviceId); + }, + [clearDeviceHeldShifts] + ); + + const restoreControllerRoles = useCallback( + (roles: ClickControllerRoles) => { + controllerRoles.current = roles; + store.actions.setControllerRoles(roles); + }, + [store] + ); + + useEffect( + () => () => { + for (const timer of controllerFlashTimers.current.values()) { + window.clearTimeout(timer); + } + for (const shift of repeatTimers.current.keys()) { + stopRepeat(shift); + } + }, + [stopRepeat] + ); + + return { + clearDeviceHeldShifts, + forgetControllerRole, + handleControllerMessage, + registerControllerRole, + resetControllerInput, + restoreControllerRoles, + }; +} diff --git a/src/hooks/use-zwift-click.ts b/src/hooks/use-zwift-click.ts index bce75c8..491c3af 100644 --- a/src/hooks/use-zwift-click.ts +++ b/src/hooks/use-zwift-click.ts @@ -1,57 +1,39 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { BATTERY } from '../constants'; +import { useSelector } from '@tanstack/react-store'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { BATTERY, WEB_BLUETOOTH_UNAVAILABLE_MESSAGE } from '../constants'; +import { isBluetoothChooserCancellation } from '../lib/bluetooth'; +import { aggregateConnectionPhase, deviceConnectionView } from '../lib/device-connection'; +import { errorMessage } from '../lib/errors'; +import { createReconnectController } from '../lib/reconnect-controller'; import { + CLICK_DEVICE_IDS_STORAGE_KEY, type ClickControllerRoles, type ClickShift, - clickV2StartCommand, - connectClickGatt, - filterAcceptedClickShifts, - filterClickShiftsForController, - parseClickV2Shift, - registerClickControllerRole, + MAX_CLICK_CONTROLLERS, storedClickControllerRoles, storedClickDeviceIds, - waitForUsableClickNotification, - withClickConnectionTimeout, - ZWIFT_ASYNC_CHARACTERISTIC, ZWIFT_CLICK_NAME, ZWIFT_CLICK_SERVICE, ZWIFT_LEGACY_SERVICE, ZWIFT_MANUFACTURER_ID, - ZWIFT_SYNC_RX_CHARACTERISTIC, - ZWIFT_SYNC_TX_CHARACTERISTIC, } from '../lib/zwift-click'; - -interface ClickRepeatTimer { - delay: number; - interval?: number; -} +import { connectClickDevice, SupersededClickConnectionError } from '../lib/zwift-click-device'; +import { createZwiftClickStore } from '../stores/zwift-click-store'; +import { useZwiftClickInput } from './use-zwift-click-input'; interface ClickConnectionOptions { force?: boolean; rediscover?: boolean; + scheduleRetry?: boolean; } -const STORAGE_KEY = 'zwift-click-v2-device-ids'; -const CONTROLLER_ROLES_STORAGE_KEY = 'zwift-click-v2-controller-roles'; -const CLICK_HOLD_DELAY_MS = 600; -const CLICK_HOLD_REPEAT_MS = 220; -const CLICK_RECONNECT_BASE_DELAY_MS = 400; -const CLICK_RECONNECT_MAX_DELAY_MS = 1000; -const CLICK_CONTROLLER_FLASH_MS = 350; -const CLICK_SETUP_STEP_TIMEOUT_MS = 3000; -const CLICK_SHIFTS: ClickShift[] = ['down', 'up']; - type ClickConnectionCleanup = () => void; -class SupersededClickConnectionError extends Error {} - function saveDeviceIds(devices: BluetoothDevice[]) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(devices.map(({ id }) => id).slice(0, 2))); -} - -function saveControllerRoles(roles: ClickControllerRoles) { - localStorage.setItem(CONTROLLER_ROLES_STORAGE_KEY, JSON.stringify(roles)); + localStorage.setItem( + CLICK_DEVICE_IDS_STORAGE_KEY, + JSON.stringify(devices.map(({ id }) => id).slice(0, MAX_CLICK_CONTROLLERS)) + ); } function controllerLabel(role: ClickShift | undefined) { @@ -72,364 +54,96 @@ function shouldAutoReconnect( return autoReconnect && !forgottenIds.has(deviceId); } -function ensureCurrentConnection(isCurrent: () => boolean) { - if (!isCurrent()) { - throw new SupersededClickConnectionError(); - } -} - -async function clickService(server: BluetoothRemoteGATTServer) { - try { - return await server.getPrimaryService(ZWIFT_CLICK_SERVICE); - } catch { - return server.getPrimaryService(ZWIFT_LEGACY_SERVICE); - } -} - export function useZwiftClick( onShift: (change: number) => void, setNotice: (notice: string) => void, identifyControllers = false ) { - const [devices, setDevices] = useState([]); - const [connectedIds, setConnectedIds] = useState([]); - const [connectingControllerIds, setConnectingControllerIds] = useState([]); - const [activeControllerIds, setActiveControllerIds] = useState([]); - const [controllerRoles, setControllerRoles] = useState({}); - const [busy, setBusy] = useState(false); - const [pairing, setPairing] = useState(false); - const [status, setStatus] = useState('Not paired'); + const store = useMemo(() => createZwiftClickStore(), []); + const state = useSelector(store); + const { setControllerPhase } = store.actions; const autoReconnect = useRef(true); const connectingIds = useRef(new Set()); const connectionAttempts = useRef(new Map()); const connectionCleanups = useRef(new Map()); - const controllerFlashTimers = useRef(new Map()); - const controllerRolesRef = useRef({}); const devicesRef = useRef([]); const forgottenIds = useRef(new Set()); - const heldShiftsByDevice = useRef(new Map()); - const identifyControllersRef = useRef(identifyControllers); - const previousButtonMaps = useRef(new Map()); - const reconnectAttempts = useRef(new Map()); - const reconnectTimers = useRef(new Map()); const reportedConnectionFailures = useRef(new Set()); - const lastShiftTimes = useRef(new Map()); - const repeatTimers = useRef(new Map()); const connectDeviceRef = useRef< | ((selected: BluetoothDevice, options?: ClickConnectionOptions) => Promise) | undefined >(undefined); - const onShiftRef = useRef(onShift); const operationalIds = useRef(new Set()); - - useEffect(() => { - onShiftRef.current = onShift; - }, [onShift]); - - useEffect(() => { - identifyControllersRef.current = identifyControllers; - if (!identifyControllers) { - for (const timer of controllerFlashTimers.current.values()) { - window.clearTimeout(timer); - } - controllerFlashTimers.current.clear(); - setActiveControllerIds((current) => (current.length ? [] : current)); - } - }, [identifyControllers]); - - useEffect( - () => () => { - for (const timer of controllerFlashTimers.current.values()) { - window.clearTimeout(timer); - } - }, - [] + const reconnectController = useRef( + createReconnectController({ + attempt: (selected) => + connectDeviceRef.current?.(selected, { + rediscover: true, + scheduleRetry: false, + }) ?? Promise.resolve(false), + canRetry: (selected) => autoReconnect.current && !forgottenIds.current.has(selected.id), + delayForAttempt: (attempt) => Math.min(1000, 400 * 2 ** (attempt - 1)), + onWaiting: (selected) => store.actions.setControllerPhase(selected.id, 'reconnecting'), + }) ); - const registerControllerRole = useCallback((deviceId: string, shifts: ClickShift[]) => { - const next = registerClickControllerRole(controllerRolesRef.current, deviceId, shifts); - if (next === controllerRolesRef.current) { - return; - } - controllerRolesRef.current = next; - setControllerRoles(next); - saveControllerRoles(next); - }, []); - - const forgetControllerRole = useCallback((deviceId: string) => { - if (!controllerRolesRef.current[deviceId]) { - return; - } - const next = { ...controllerRolesRef.current }; - delete next[deviceId]; - controllerRolesRef.current = next; - setControllerRoles(next); - saveControllerRoles(next); - }, []); - - const stopRepeat = useCallback((shift: ClickShift) => { - const timer = repeatTimers.current.get(shift); - if (!timer) { - return; - } - window.clearTimeout(timer.delay); - window.clearInterval(timer.interval); - repeatTimers.current.delete(shift); - }, []); - - const syncRepeats = useCallback(() => { - const heldShifts = new Set(); - for (const deviceShifts of heldShiftsByDevice.current.values()) { - for (const shift of deviceShifts) { - heldShifts.add(shift); - } - } - for (const shift of CLICK_SHIFTS) { - if (!heldShifts.has(shift)) { - stopRepeat(shift); - continue; - } - if (repeatTimers.current.has(shift)) { - continue; - } - const change = shift === 'down' ? -1 : 1; - const timer: ClickRepeatTimer = { delay: 0 }; - timer.delay = window.setTimeout(() => { - onShiftRef.current(change); - timer.interval = window.setInterval( - () => onShiftRef.current(change), - CLICK_HOLD_REPEAT_MS - ); - }, CLICK_HOLD_DELAY_MS); - repeatTimers.current.set(shift, timer); - } - }, [stopRepeat]); - - const setDeviceHeldShifts = useCallback( - (deviceId: string, heldShifts: ClickShift[]) => { - if (heldShifts.length) { - heldShiftsByDevice.current.set(deviceId, heldShifts); - } else { - heldShiftsByDevice.current.delete(deviceId); - } - syncRepeats(); - }, - [syncRepeats] - ); - - const clearDeviceHeldShifts = useCallback( + const markControllerOperational = useCallback( (deviceId: string) => { - heldShiftsByDevice.current.delete(deviceId); - syncRepeats(); - }, - [syncRepeats] - ); - - const cancelReconnect = useCallback((deviceId: string, resetAttempts = false) => { - const timer = reconnectTimers.current.get(deviceId); - if (timer !== undefined) { - window.clearTimeout(timer); - reconnectTimers.current.delete(deviceId); - } - if (resetAttempts) { - reconnectAttempts.current.delete(deviceId); - } - }, []); - - const scheduleReconnect = useCallback((selected: BluetoothDevice) => { - if ( - !autoReconnect.current || - forgottenIds.current.has(selected.id) || - reconnectTimers.current.has(selected.id) - ) { - return; - } - const attempt = (reconnectAttempts.current.get(selected.id) ?? 0) + 1; - reconnectAttempts.current.set(selected.id, attempt); - const delay = Math.min( - CLICK_RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1), - CLICK_RECONNECT_MAX_DELAY_MS - ); - const timer = window.setTimeout(() => { - reconnectTimers.current.delete(selected.id); - connectDeviceRef.current?.(selected, { rediscover: true }); - }, delay); - reconnectTimers.current.set(selected.id, timer); - }, []); - - useEffect( - () => () => { - for (const shift of repeatTimers.current.keys()) { - stopRepeat(shift); - } + operationalIds.current.add(deviceId); + reconnectController.current.reset(deviceId); + reportedConnectionFailures.current.delete(deviceId); + setControllerPhase(deviceId, 'connected'); }, - [stopRepeat] + [setControllerPhase] ); + const clickInput = useZwiftClickInput({ + identifyControllers, + onOperational: markControllerOperational, + onShift, + store, + }); const cleanupConnection = useCallback((deviceId: string) => { connectionCleanups.current.get(deviceId)?.(); connectionCleanups.current.delete(deviceId); }, []); - const flashController = useCallback((deviceId: string) => { - if (!identifyControllersRef.current) { - return; - } - window.clearTimeout(controllerFlashTimers.current.get(deviceId)); - setActiveControllerIds((current) => - current.includes(deviceId) ? current : [...current, deviceId] - ); - const timer = window.setTimeout(() => { - controllerFlashTimers.current.delete(deviceId); - setActiveControllerIds((current) => current.filter((id) => id !== deviceId)); - }, CLICK_CONTROLLER_FLASH_MS); - controllerFlashTimers.current.set(deviceId, timer); - }, []); - - const handleControllerMessage = useCallback( - (deviceId: string, event: Event) => { - const { value } = event.target as BluetoothRemoteGATTCharacteristic; - if (!value) { - return; - } - const parsed = parseClickV2Shift(value, previousButtonMaps.current.get(deviceId)); - if (!parsed) { - return; - } - operationalIds.current.add(deviceId); - cancelReconnect(deviceId, true); - reportedConnectionFailures.current.delete(deviceId); - setConnectedIds((current) => { - const next = current.includes(deviceId) ? current : [...current, deviceId]; - setStatus( - next.length >= devicesRef.current.length ? 'Connected' : 'Paired · reconnecting' - ); - return next; - }); - previousButtonMaps.current.set(deviceId, parsed.buttonMap); - const controllerRole = controllerRolesRef.current[deviceId]; - const heldShifts = filterClickShiftsForController(parsed.heldShifts, controllerRole); - const controllerShifts = filterClickShiftsForController(parsed.shifts, controllerRole); - setDeviceHeldShifts(deviceId, heldShifts); - const acceptedShifts = filterAcceptedClickShifts( - controllerShifts, - performance.now(), - lastShiftTimes.current - ); - if (acceptedShifts.length) { - flashController(deviceId); - } - if (identifyControllersRef.current) { - registerControllerRole(deviceId, acceptedShifts); - } - for (const shift of acceptedShifts) { - onShiftRef.current(shift === 'down' ? -1 : 1); - } - }, - [cancelReconnect, flashController, registerControllerRole, setDeviceHeldShifts] - ); - const handleControllerDisconnect = useCallback( (selected: BluetoothDevice) => { cleanupConnection(selected.id); operationalIds.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); - previousButtonMaps.current.delete(selected.id); - setConnectedIds((current) => current.filter((id) => id !== selected.id)); + clickInput.resetControllerInput(selected.id); if (shouldAutoReconnect(autoReconnect.current, forgottenIds.current, selected.id)) { - setStatus('Paired · reconnecting'); - scheduleReconnect(selected); + setControllerPhase(selected.id, 'reconnecting'); + reconnectController.current.start(selected.id, selected); } else { - setStatus('Paired · offline'); + setControllerPhase(selected.id, 'offline'); } }, - [cleanupConnection, clearDeviceHeldShifts, scheduleReconnect] + [cleanupConnection, clickInput.resetControllerInput, setControllerPhase] ); const establishControllerConnection = useCallback( async (selected: BluetoothDevice, isCurrentAttempt: () => boolean, rediscover: boolean) => { - const server = await connectClickGatt(selected, rediscover, setStatus, (role) => - registerControllerRole(selected.id, [role]) - ); - ensureCurrentConnection(isCurrentAttempt); - let removeMessageListeners = () => undefined; const handleDisconnect = () => handleControllerDisconnect(selected); - const cleanup = () => { - removeMessageListeners(); - selected.removeEventListener('gattserverdisconnected', handleDisconnect); - }; + const cleanup = await connectClickDevice(selected, rediscover, { + isCurrent: isCurrentAttempt, + isOperational: () => operationalIds.current.has(selected.id), + onControllerRole: (role) => clickInput.registerControllerRole(selected.id, [role]), + onDisconnect: handleDisconnect, + onMessage: (event) => clickInput.handleControllerMessage(selected.id, event), + }); connectionCleanups.current.set(selected.id, cleanup); - selected.addEventListener('gattserverdisconnected', handleDisconnect, { once: true }); - try { - const service = await withClickConnectionTimeout( - clickService(server), - CLICK_SETUP_STEP_TIMEOUT_MS - ); - ensureCurrentConnection(isCurrentAttempt); - const [asyncCharacteristic, syncTxCharacteristic, syncRxCharacteristic] = - await Promise.all([ - withClickConnectionTimeout( - service.getCharacteristic(ZWIFT_ASYNC_CHARACTERISTIC), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - withClickConnectionTimeout( - service.getCharacteristic(ZWIFT_SYNC_TX_CHARACTERISTIC), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - withClickConnectionTimeout( - service.getCharacteristic(ZWIFT_SYNC_RX_CHARACTERISTIC), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - ]); - ensureCurrentConnection(isCurrentAttempt); - const handleMessage = (event: Event) => handleControllerMessage(selected.id, event); - removeMessageListeners = () => { - asyncCharacteristic.removeEventListener( - 'characteristicvaluechanged', - handleMessage - ); - syncTxCharacteristic.removeEventListener( - 'characteristicvaluechanged', - handleMessage - ); - }; - asyncCharacteristic.addEventListener('characteristicvaluechanged', handleMessage); - syncTxCharacteristic.addEventListener('characteristicvaluechanged', handleMessage); - await waitForUsableClickNotification( - [ - withClickConnectionTimeout( - asyncCharacteristic.startNotifications(), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - withClickConnectionTimeout( - syncTxCharacteristic.startNotifications(), - CLICK_SETUP_STEP_TIMEOUT_MS - ), - ], - () => operationalIds.current.has(selected.id) - ); - ensureCurrentConnection(isCurrentAttempt); - try { - await withClickConnectionTimeout( - syncRxCharacteristic.writeValueWithoutResponse(clickV2StartCommand()), - CLICK_SETUP_STEP_TIMEOUT_MS - ); - } catch (error) { - if (!operationalIds.current.has(selected.id)) { - throw error; - } - } - ensureCurrentConnection(isCurrentAttempt); - } catch (error) { - removeMessageListeners(); - throw error; - } }, - [handleControllerDisconnect, handleControllerMessage, registerControllerRole] + [ + clickInput.handleControllerMessage, + clickInput.registerControllerRole, + handleControllerDisconnect, + ] ); const beginControllerConnectionAttempt = useCallback( - (selected: BluetoothDevice, force: boolean) => { + (selected: BluetoothDevice, force: boolean, rediscover: boolean) => { if (forgottenIds.current.has(selected.id)) { return; } @@ -446,64 +160,54 @@ export function useZwiftClick( cleanupConnection(selected.id); selected.gatt?.disconnect(); } - cancelReconnect(selected.id); + reconnectController.current.cancel(selected.id); const attempt = (connectionAttempts.current.get(selected.id) ?? 0) + 1; connectionAttempts.current.set(selected.id, attempt); connectingIds.current.add(selected.id); - setConnectingControllerIds((current) => - current.includes(selected.id) ? current : [...current, selected.id] - ); - previousButtonMaps.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); - setBusy(true); - setStatus('Connecting controllers…'); + setControllerPhase(selected.id, force || rediscover ? 'reconnecting' : 'connecting'); + clickInput.resetControllerInput(selected.id); return attempt; }, - [cancelReconnect, cleanupConnection, clearDeviceHeldShifts] + [cleanupConnection, clickInput.resetControllerInput, setControllerPhase] ); const handleConnectionFailure = useCallback( - (selected: BluetoothDevice, error: unknown) => { + (selected: BluetoothDevice, error: unknown, scheduleRetry: boolean) => { cleanupConnection(selected.id); operationalIds.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); + clickInput.clearDeviceHeldShifts(selected.id); selected.gatt?.disconnect(); - setConnectedIds((current) => current.filter((id) => id !== selected.id)); const shouldReconnect = shouldAutoReconnect( autoReconnect.current, forgottenIds.current, selected.id ); - setStatus(shouldReconnect ? 'Paired · reconnecting' : 'Paired · offline'); + setControllerPhase(selected.id, shouldReconnect ? 'reconnecting' : 'offline'); if (!(shouldReconnect || reportedConnectionFailures.current.has(selected.id))) { reportedConnectionFailures.current.add(selected.id); - setNotice( - `Zwift Click connection failed: ${error instanceof Error ? error.message : String(error)}` - ); + setNotice(`Zwift Click connection failed: ${errorMessage(error)}`); } - if (shouldReconnect) { - scheduleReconnect(selected); + if (shouldReconnect && scheduleRetry) { + reconnectController.current.start(selected.id, selected); } }, - [cleanupConnection, clearDeviceHeldShifts, scheduleReconnect, setNotice] + [cleanupConnection, clickInput.clearDeviceHeldShifts, setControllerPhase, setNotice] ); useEffect( () => () => { autoReconnect.current = false; - for (const deviceId of reconnectTimers.current.keys()) { - cancelReconnect(deviceId, true); - } + reconnectController.current.cancelAll(); }, - [cancelReconnect] + [] ); const connectDevice = useCallback( async ( selected: BluetoothDevice, - { force = false, rediscover = false }: ClickConnectionOptions = {} + { force = false, rediscover = false, scheduleRetry = true }: ClickConnectionOptions = {} ): Promise => { - const connectionAttempt = beginControllerConnectionAttempt(selected, force); + const connectionAttempt = beginControllerConnectionAttempt(selected, force, rediscover); if (connectionAttempt === undefined) { return false; } @@ -512,37 +216,28 @@ export function useZwiftClick( try { await establishControllerConnection(selected, isCurrentAttempt, rediscover); operationalIds.current.add(selected.id); - setConnectedIds((current) => { - const next = current.includes(selected.id) - ? current - : [...current, selected.id]; - setStatus( - next.length >= devicesRef.current.length - ? 'Connected' - : 'Paired · reconnecting' - ); - return next; - }); - reconnectAttempts.current.delete(selected.id); + setControllerPhase(selected.id, 'connected'); + reconnectController.current.reset(selected.id); reportedConnectionFailures.current.delete(selected.id); return true; } catch (error) { if (error instanceof SupersededClickConnectionError || !isCurrentAttempt()) { return false; } - handleConnectionFailure(selected, error); + handleConnectionFailure(selected, error, scheduleRetry); return false; } finally { if (isCurrentAttempt()) { connectingIds.current.delete(selected.id); - setConnectingControllerIds((current) => - current.filter((deviceId) => deviceId !== selected.id) - ); - setBusy(connectingIds.current.size > 0); } } }, - [beginControllerConnectionAttempt, establishControllerConnection, handleConnectionFailure] + [ + beginControllerConnectionAttempt, + establishControllerConnection, + handleConnectionFailure, + setControllerPhase, + ] ); useEffect(() => { @@ -551,10 +246,10 @@ export function useZwiftClick( const pair = useCallback(async () => { if (!navigator.bluetooth) { - setNotice('Web Bluetooth requires current Chrome or Edge on localhost or HTTPS.'); + setNotice(WEB_BLUETOOTH_UNAVAILABLE_MESSAGE); return; } - setPairing(true); + store.actions.setPairing(true); try { const selected = await navigator.bluetooth.requestDevice({ filters: [{ name: ZWIFT_CLICK_NAME }], @@ -563,80 +258,85 @@ export function useZwiftClick( }); autoReconnect.current = true; forgottenIds.current.delete(selected.id); - setDevices((current) => { - const next = current.some(({ id }) => id === selected.id) - ? current - : [...current, selected].slice(0, 2); - devicesRef.current = next; - saveDeviceIds(next); - return next; - }); + const { current } = devicesRef; + const next = current.some(({ id }) => id === selected.id) + ? current + : [...current, selected].slice(0, MAX_CLICK_CONTROLLERS); + devicesRef.current = next; + store.actions.setDeviceIds(next.map(({ id }) => id)); + saveDeviceIds(next); // Do not make selection of the second controller wait for this controller's // complete GATT setup. Its connection continues independently in the background. connectDevice(selected); } catch (error) { - if (!(error instanceof DOMException && error.name === 'NotFoundError')) { - setNotice(error instanceof Error ? error.message : String(error)); + if (!isBluetoothChooserCancellation(error)) { + setNotice(errorMessage(error)); } } finally { - setPairing(false); + store.actions.setPairing(false); } - }, [connectDevice, setNotice]); + }, [connectDevice, setNotice, store]); - const reconnect = useCallback(async () => { + const reconnect = useCallback(() => { autoReconnect.current = true; - await Promise.all( - devices - .filter((selected) => !connectedIds.includes(selected.id)) - .map((selected) => connectDevice(selected, { force: true, rediscover: true })) - ); - }, [connectDevice, connectedIds, devices]); + for (const selected of devicesRef.current) { + if (store.get().controllerPhases[selected.id] === 'connected') { + continue; + } + reconnectController.current.reset(selected.id); + reconnectController.current.start(selected.id, selected, 1); + } + }, [store]); const disconnect = useCallback(() => { autoReconnect.current = false; + const devices = devicesRef.current; for (const selected of devices) { - cancelReconnect(selected.id, true); + reconnectController.current.cancel(selected.id, true); operationalIds.current.delete(selected.id); - clearDeviceHeldShifts(selected.id); + clickInput.clearDeviceHeldShifts(selected.id); cleanupConnection(selected.id); selected.gatt?.disconnect(); } - setConnectedIds([]); - setStatus(devices.length ? 'Paired · offline' : 'Not paired'); - }, [cancelReconnect, cleanupConnection, clearDeviceHeldShifts, devices]); + store.actions.setControllerPhases( + Object.fromEntries(devices.map((selected) => [selected.id, 'offline'])) + ); + }, [cleanupConnection, clickInput.clearDeviceHeldShifts, store]); const forgetDevice = useCallback( async (deviceId: string) => { forgottenIds.current.add(deviceId); operationalIds.current.delete(deviceId); - cancelReconnect(deviceId, true); - clearDeviceHeldShifts(deviceId); - forgetControllerRole(deviceId); - const selected = devices.find(({ id }) => id === deviceId); + reconnectController.current.cancel(deviceId, true); + clickInput.clearDeviceHeldShifts(deviceId); + clickInput.forgetControllerRole(deviceId); + const selected = devicesRef.current.find(({ id }) => id === deviceId); cleanupConnection(deviceId); selected?.gatt?.disconnect(); try { await selected?.forget(); } finally { - setDevices((current) => { - const next = current.filter(({ id }) => id !== deviceId); - devicesRef.current = next; - saveDeviceIds(next); - setStatus(next.length ? 'Paired' : 'Not paired'); - return next; - }); - setConnectedIds((current) => current.filter((id) => id !== deviceId)); + const next = devicesRef.current.filter(({ id }) => id !== deviceId); + devicesRef.current = next; + store.actions.setDeviceIds(next.map(({ id }) => id)); + saveDeviceIds(next); + store.actions.removeControllerPhase(deviceId); } }, - [cancelReconnect, cleanupConnection, clearDeviceHeldShifts, devices, forgetControllerRole] + [ + cleanupConnection, + clickInput.clearDeviceHeldShifts, + clickInput.forgetControllerRole, + store, + ] ); const forget = useCallback(async () => { autoReconnect.current = false; - for (const selected of [...devices]) { + for (const selected of [...devicesRef.current]) { await forgetDevice(selected.id); } - }, [devices, forgetDevice]); + }, [forgetDevice]); useEffect(() => { let cancelled = false; @@ -652,7 +352,7 @@ export function useZwiftClick( const remembered = ids .map((id) => permitted.find((candidate) => candidate.id === id)) .filter((candidate): candidate is BluetoothDevice => Boolean(candidate)) - .slice(0, 2); + .slice(0, MAX_CLICK_CONTROLLERS); if (cancelled) { return; } @@ -662,51 +362,57 @@ export function useZwiftClick( rememberedIds.has(deviceId) ) ) as ClickControllerRoles; - controllerRolesRef.current = rememberedRoles; + clickInput.restoreControllerRoles(rememberedRoles); devicesRef.current = remembered; - setControllerRoles(rememberedRoles); - setDevices(remembered); + store.actions.setDeviceIds(remembered.map(({ id }) => id)); + store.actions.setControllerPhases( + Object.fromEntries(remembered.map((selected) => [selected.id, 'reconnecting'])) + ); for (const selected of remembered) { forgottenIds.current.delete(selected.id); } - setStatus(remembered.length ? 'Paired · offline' : 'Not paired'); autoReconnect.current = true; - await Promise.all( - remembered.map((selected) => connectDevice(selected, { rediscover: true })) - ); + for (const selected of remembered) { + reconnectController.current.start(selected.id, selected, 1); + } } - restore().catch(() => setStatus('Paired · offline')); + restore().catch(() => { + store.actions.setControllerPhases( + Object.fromEntries( + Object.keys(store.get().controllerPhases).map((deviceId) => [ + deviceId, + 'offline', + ]) + ) + ); + }); return () => { cancelled = true; autoReconnect.current = false; + reconnectController.current.cancelAll(); }; - }, [connectDevice]); + }, [clickInput.restoreControllerRoles, store]); + const connectionPhases = state.deviceIds.map( + (deviceId) => state.controllerPhases[deviceId] ?? 'offline' + ); + const connection = deviceConnectionView(aggregateConnectionPhase(connectionPhases)); + const connectedCount = connectionPhases.filter((phase) => phase === 'connected').length; return { - busy, - connected: connectedIds.length > 0, - connectedCount: connectedIds.length, - controllers: devices.map((selected) => ({ - active: activeControllerIds.includes(selected.id), - connected: connectedIds.includes(selected.id), - connecting: connectingControllerIds.includes(selected.id), - id: selected.id, - label: controllerLabel(controllerRoles[selected.id]), + ...connection, + connectedCount, + controllers: state.deviceIds.map((deviceId) => ({ + active: state.activeControllerIds.includes(deviceId), + ...deviceConnectionView(state.controllerPhases[deviceId] ?? 'offline'), + id: deviceId, + label: controllerLabel(state.controllerRoles[deviceId]), })), disconnect, forget, forgetDevice, pair, - paired: devices.length > 0, - pairedCount: devices.length, - pairing, + pairedCount: state.deviceIds.length, + pairing: state.pairing, reconnect, - reconnecting: - devices.length > connectedIds.length && - (busy || - status === 'Connecting controllers…' || - status === 'Finding controllers…' || - status === 'Paired · reconnecting'), - status, }; } diff --git a/src/lib/bluetooth-notifications.ts b/src/lib/bluetooth-notifications.ts new file mode 100644 index 0000000..5fe6998 --- /dev/null +++ b/src/lib/bluetooth-notifications.ts @@ -0,0 +1,43 @@ +export type BluetoothNotificationCleanup = () => void; + +export interface BluetoothNotificationSubscription { + cleanup: BluetoothNotificationCleanup; + start: () => Promise; +} + +export function createBluetoothNotificationSubscription( + characteristic: BluetoothRemoteGATTCharacteristic, + listener: (event: Event) => void +): BluetoothNotificationSubscription { + characteristic.addEventListener('characteristicvaluechanged', listener); + return { + cleanup: () => characteristic.removeEventListener('characteristicvaluechanged', listener), + start: async () => { + await characteristic.startNotifications(); + }, + }; +} + +export async function startBluetoothNotifications( + characteristic: BluetoothRemoteGATTCharacteristic, + listener: (event: Event) => void +): Promise { + const subscription = createBluetoothNotificationSubscription(characteristic, listener); + try { + await subscription.start(); + } catch (error) { + subscription.cleanup(); + throw error; + } + return subscription.cleanup; +} + +export function combineBluetoothCleanups( + ...cleanups: Array +): BluetoothNotificationCleanup { + return () => { + for (const cleanup of cleanups) { + cleanup?.(); + } + }; +} diff --git a/src/lib/bluetooth.ts b/src/lib/bluetooth.ts index e9c2e19..9f9e5d9 100644 --- a/src/lib/bluetooth.ts +++ b/src/lib/bluetooth.ts @@ -1,10 +1,18 @@ import type { Metrics, Range } from '../types'; +import { MAX_RESISTANCE } from './resistance'; +import { kilometersForMeters, SECONDS_PER_MINUTE } from './units'; + +export const TRAINER_DEVICE_STORAGE_KEY = 'trainer-device-id'; export interface CrankReading { revolutions: number; time: number; } +export function isBluetoothChooserCancellation(error: unknown): boolean { + return error instanceof DOMException && error.name === 'NotFoundError'; +} + export function characteristicValue(event: Event): DataView | undefined { return (event.target as BluetoothRemoteGATTCharacteristic).value; } @@ -51,11 +59,11 @@ export function parseIndoorBikeData(value: DataView): { let reportsDistance = false; if (flags & (1 << 4)) { reportsDistance = true; - metrics.distance = - (value.getUint8(offset) | + metrics.distance = kilometersForMeters( + value.getUint8(offset) | (value.getUint8(offset + 1) << 8) | - (value.getUint8(offset + 2) << 16)) / - 1000; + (value.getUint8(offset + 2) << 16) + ); offset += 3; } if (flags & (1 << 5)) { @@ -99,13 +107,13 @@ export function parseCrankCadence( const deltaTime = (current.time - previous.time + 65_536) % 65_536; const deltaRevs = (current.revolutions - previous.revolutions + 65_536) % 65_536; return { - cadence: deltaTime ? (deltaRevs * 60 * 1024) / deltaTime : undefined, + cadence: deltaTime ? (deltaRevs * SECONDS_PER_MINUTE * 1024) / deltaTime : undefined, current, }; } export function resistanceCommand(percent: number, range: Range): number[] { - const trainerLevel = range.min + (percent / 100) * (range.max - range.min); + const trainerLevel = range.min + (percent / MAX_RESISTANCE) * (range.max - range.min); const scaled = Math.round(trainerLevel * 10); return [0x04, scaled & 255, (scaled >> 8) & 255]; } @@ -118,7 +126,7 @@ export async function findRememberedKickr( return; } const permitted = await bluetooth.getDevices(); - const savedDeviceId = storage.getItem('trainer-device-id'); + const savedDeviceId = storage.getItem(TRAINER_DEVICE_STORAGE_KEY); return ( permitted.find((candidate) => candidate.id === savedDeviceId) ?? permitted.find((candidate) => candidate.name?.toUpperCase().startsWith('KICKR')) ?? @@ -131,7 +139,7 @@ export async function waitForFreshAdvertisement( onAdvertisement?: (event: BluetoothAdvertisingEvent) => void, timeoutMs = 3000 ): Promise { - if (typeof device.watchAdvertisements !== 'function') { + if (!device.watchAdvertisements) { return; } const controller = new AbortController(); @@ -163,22 +171,18 @@ export async function waitForFreshAdvertisement( export async function connectGatt( device: BluetoothDevice, - rediscover: boolean, - updateStatus: (status: string) => void + rediscover: boolean ): Promise { if (!device.gatt) { throw new Error('This device does not expose a GATT server.'); } - updateStatus('Connecting…'); try { return await device.gatt.connect(); } catch (directConnectionError) { if (!rediscover) { throw directConnectionError; } - updateStatus('Finding trainer…'); await waitForFreshAdvertisement(device); - updateStatus('Connecting…'); return await device.gatt.connect(); } } diff --git a/src/lib/browser.ts b/src/lib/browser.ts index 75c9531..fbd79c6 100644 --- a/src/lib/browser.ts +++ b/src/lib/browser.ts @@ -1,3 +1,5 @@ +import { isFunction } from './type-guards'; + interface BrowserBrand { brand: string; } @@ -10,6 +12,10 @@ interface BrowserNavigator { }; } +interface BluetoothReconnectCapability { + getDevices?: unknown; +} + const CHROME_USER_AGENT = /\bChrome\//; const CHROMIUM_DERIVATIVE_USER_AGENT = /(?:Brave|Edg|OPR|Vivaldi|YaBrowser)\//; @@ -45,3 +51,9 @@ export function bluetoothBrowserNotice( } return 'Bluetooth is unavailable in this browser. Chrome is currently the only browser tested with Ride Control.'; } + +export function automaticBluetoothReconnectConfigured( + bluetooth: BluetoothReconnectCapability | undefined = globalThis.navigator?.bluetooth +): boolean { + return isFunction(bluetooth?.getDevices); +} diff --git a/src/lib/chart.ts b/src/lib/chart.ts index 7bb7dcf..0704fde 100644 --- a/src/lib/chart.ts +++ b/src/lib/chart.ts @@ -1,27 +1,40 @@ -import type { ChartMode, ControlMode } from '../types'; +import type { ChartMode } from '../types'; +import { CONTROL_MODE, type ControlMode } from './control-mode'; +import { METRIC_PRESENTATION, STANDARD_METRIC_KEYS } from './metric-presentation'; +import { clamp } from './numbers'; +import { isFiniteNumber } from './type-guards'; + +export const CHART_MODE_STORAGE_KEY = 'trainer-chart-mode'; const baseChartModes: { label: string; value: ChartMode }[] = [ { label: 'All', value: 'all' }, - { label: 'Speed', value: 'speed' }, - { label: 'Power', value: 'power' }, - { label: 'Cadence', value: 'cadence' }, - { label: 'Heart rate', value: 'heartRate' }, + { label: METRIC_PRESENTATION.speed.label, value: 'speed' }, + ...STANDARD_METRIC_KEYS.map((key) => ({ + label: METRIC_PRESENTATION[key].label, + value: key, + })), ]; export function chartModesForControl(controlMode: ControlMode) { return [ ...baseChartModes, - controlMode === 'gear' - ? { label: 'Gear', value: 'gear' as const } - : { label: 'Resistance', value: 'resistance' as const }, + controlMode === CONTROL_MODE.GEAR + ? { label: 'Gear', value: CONTROL_MODE.GEAR } + : { label: 'Resistance', value: CONTROL_MODE.RESISTANCE }, ]; } export function storedChartMode(storage: Pick = localStorage): ChartMode { - const saved = storage.getItem('trainer-chart-mode'); - return ['all', 'speed', 'power', 'cadence', 'heartRate', 'gear', 'resistance'].includes( - saved ?? '' - ) + const saved = storage.getItem(CHART_MODE_STORAGE_KEY); + return [ + 'all', + 'speed', + 'power', + 'cadence', + 'heartRate', + CONTROL_MODE.GEAR, + CONTROL_MODE.RESISTANCE, + ].includes(saved ?? '') ? (saved as ChartMode) : 'all'; } @@ -41,7 +54,7 @@ export function chartPath( let drawing = false; return values .map((value, index) => { - if (typeof value !== 'number' || !Number.isFinite(value)) { + if (!isFiniteNumber(value)) { drawing = false; return ''; } @@ -49,7 +62,7 @@ export function chartPath( if (positions && positionSpan > 0) { x = (((positions[index] ?? firstPosition) - firstPosition) / positionSpan) * 100; } - const normalized = Math.max(0, Math.min(1, (value - minimum) / span)); + const normalized = clamp((value - minimum) / span, 0, 1); const y = 90 - normalized * 76; const command = drawing ? 'L' : 'M'; drawing = true; diff --git a/src/lib/control-mode.ts b/src/lib/control-mode.ts new file mode 100644 index 0000000..bb87aca --- /dev/null +++ b/src/lib/control-mode.ts @@ -0,0 +1,12 @@ +export const CONTROL_MODE = { + GEAR: 'gear', + RESISTANCE: 'resistance', +} as const; + +export type ControlMode = (typeof CONTROL_MODE)[keyof typeof CONTROL_MODE]; + +const CONTROL_MODES = new Set(Object.values(CONTROL_MODE)); + +export function isControlMode(value: unknown): value is ControlMode { + return CONTROL_MODES.has(value); +} diff --git a/src/lib/cycling-energy.ts b/src/lib/cycling-energy.ts new file mode 100644 index 0000000..1242729 --- /dev/null +++ b/src/lib/cycling-energy.ts @@ -0,0 +1,8 @@ +export const JOULES_PER_KILOCALORIE = 4184; +export const ESTIMATED_CYCLING_EFFICIENCY = 0.24; + +export function estimatedCyclingCalories(powerWatts: number, seconds: number): number { + return powerWatts > 0 + ? (powerWatts * seconds) / (JOULES_PER_KILOCALORIE * ESTIMATED_CYCLING_EFFICIENCY) + : 0; +} diff --git a/src/lib/device-connection.ts b/src/lib/device-connection.ts new file mode 100644 index 0000000..8a55f20 --- /dev/null +++ b/src/lib/device-connection.ts @@ -0,0 +1,77 @@ +export type DeviceConnectionPhase = + | 'unpaired' + | 'pairing' + | 'connecting' + | 'reconnecting' + | 'connected' + | 'offline'; + +export interface DeviceConnectionView { + busy: boolean; + connected: boolean; + paired: boolean; + phase: DeviceConnectionPhase; + reconnecting: boolean; + status: string; +} + +const STATUS_BY_PHASE: Record = { + connected: 'Connected', + connecting: 'Connecting…', + offline: 'Paired · offline', + pairing: 'Pairing…', + reconnecting: 'Reconnecting…', + unpaired: 'Not paired', +}; + +export function deviceConnectionView(phase: DeviceConnectionPhase): DeviceConnectionView { + return { + busy: phase === 'pairing' || phase === 'connecting' || phase === 'reconnecting', + connected: phase === 'connected', + paired: phase !== 'unpaired' && phase !== 'pairing', + phase, + reconnecting: phase === 'reconnecting', + status: STATUS_BY_PHASE[phase], + }; +} + +export function aggregateConnectionPhase( + phases: readonly DeviceConnectionPhase[] +): DeviceConnectionPhase { + if (!phases.length) { + return 'unpaired'; + } + if (phases.every((phase) => phase === 'connected')) { + return 'connected'; + } + if (phases.some((phase) => phase === 'reconnecting')) { + return 'reconnecting'; + } + if (phases.some((phase) => phase === 'connecting')) { + return 'connecting'; + } + return 'offline'; +} + +export function setConnectionPhase( + phases: Readonly>, + deviceId: string, + phase: DeviceConnectionPhase +): Record { + if (phases[deviceId] === phase) { + return phases; + } + return { ...phases, [deviceId]: phase }; +} + +export function removeConnectionPhase( + phases: Readonly>, + deviceId: string +): Record { + if (!(deviceId in phases)) { + return phases; + } + const next = { ...phases }; + delete next[deviceId]; + return next; +} diff --git a/src/lib/dom.ts b/src/lib/dom.ts new file mode 100644 index 0000000..a599e2d --- /dev/null +++ b/src/lib/dom.ts @@ -0,0 +1,18 @@ +const EDITABLE_TARGET_SELECTOR = "input, textarea, select, [contenteditable='true']"; +const INTERACTIVE_TARGET_SELECTOR = `button, a, ${EDITABLE_TARGET_SELECTOR}`; + +function eventTarget(event: Event): HTMLElement | null { + return event.target instanceof HTMLElement ? event.target : null; +} + +export function eventTargetsEditableControl(event: Event): boolean { + return eventTarget(event)?.matches(EDITABLE_TARGET_SELECTOR) ?? false; +} + +export function eventTargetsInteractiveControl(event: Event): boolean { + return eventTarget(event)?.matches(INTERACTIVE_TARGET_SELECTOR) ?? false; +} + +export function keyboardEventHasModifiers(event: KeyboardEvent): boolean { + return event.altKey || event.ctrlKey || event.metaKey; +} diff --git a/src/lib/download.ts b/src/lib/download.ts new file mode 100644 index 0000000..7dd3946 --- /dev/null +++ b/src/lib/download.ts @@ -0,0 +1,10 @@ +export function downloadBrowserFile(contents: BlobPart, filename: string, mimeType: string): void { + const url = URL.createObjectURL(new Blob([contents], { type: mimeType })); + const anchor = document.createElement('a'); + anchor.download = filename; + anchor.href = url; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..e8e38ba --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,7 @@ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function unreachable(value: never): never { + throw new Error(`Unexpected value: ${String(value)}`); +} diff --git a/src/lib/format.ts b/src/lib/format.ts index bebe6da..7008960 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,19 +1,34 @@ import type { MetricAggregate } from '../types'; +import { nonNegativeNumber } from './numbers'; +import { SECONDS_PER_HOUR, SECONDS_PER_MINUTE } from './units'; export function formatDuration(totalSeconds: number) { const seconds = Math.floor(totalSeconds); - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return [hours, minutes, seconds % 60].map((value) => String(value).padStart(2, '0')).join(':'); + const hours = Math.floor(seconds / SECONDS_PER_HOUR); + const minutes = Math.floor((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); + return [hours, minutes, seconds % SECONDS_PER_MINUTE] + .map((value) => String(value).padStart(2, '0')) + .join(':'); } export function formatChartSeconds(totalSeconds: number) { const seconds = Math.max(0, Math.round(totalSeconds)); - const minutes = Math.floor(seconds / 60); - return `${minutes}:${String(seconds % 60).padStart(2, '0')}`; + const minutes = Math.floor(seconds / SECONDS_PER_MINUTE); + return `${minutes}:${String(seconds % SECONDS_PER_MINUTE).padStart(2, '0')}`; } export function formatAggregateAverage(aggregate: MetricAggregate, decimals: number) { - const average = aggregate.count > 0 ? aggregate.sum / aggregate.count : 0; - return average.toFixed(decimals); + return aggregateAverage(aggregate).toFixed(decimals); +} + +export function aggregateAverage(aggregate: MetricAggregate): number { + return aggregate.count > 0 ? aggregate.sum / aggregate.count : 0; +} + +export function aggregateMaximum(aggregate: MetricAggregate): number { + return nonNegativeNumber(aggregate.maximum); +} + +export function formatWholeNumber(value: number): string { + return String(Math.round(value)); } diff --git a/src/lib/gears.ts b/src/lib/gears.ts index 363469b..590c6bf 100644 --- a/src/lib/gears.ts +++ b/src/lib/gears.ts @@ -1,10 +1,15 @@ +import { clamp } from './numbers'; + export const MIN_GEAR = 1; export const MAX_GEAR = 24; export const DEFAULT_GEAR = 12; export const RESISTANCE_POINTS_PER_GEAR = 3; +export const GEAR_STORAGE_KEY = 'trainer-virtual-gear'; +export const SHIFTING_CONNECTION_MESSAGE = + 'Connect the trainer and controllers before shifting gears.'; export function clampGear(gear: number): number { - return Math.max(MIN_GEAR, Math.min(MAX_GEAR, Math.round(gear))); + return clamp(Math.round(gear), MIN_GEAR, MAX_GEAR); } export function gearForResistance(resistance: number): number { @@ -15,7 +20,7 @@ export function storedGear( storage: Pick = localStorage, fallback = DEFAULT_GEAR ): number { - const saved = Number(storage.getItem('trainer-virtual-gear')); + const saved = Number(storage.getItem(GEAR_STORAGE_KEY)); return Number.isFinite(saved) && saved > 0 ? clampGear(saved) : clampGear(fallback); } diff --git a/src/lib/gpx.ts b/src/lib/gpx.ts index 285ddc1..ba755e7 100644 --- a/src/lib/gpx.ts +++ b/src/lib/gpx.ts @@ -1,4 +1,5 @@ import type { RoutePoint } from '../types'; +import { kilometersForMeters } from './units'; export function distanceBetween(a: number, b: number, c: number, d: number) { const rad = Math.PI / 180; @@ -21,7 +22,7 @@ export function parseGpx(source: string, parser: DOMParser = new DOMParser()): R } previous = { lat, lon }; return { - distance: total / 1000, + distance: kilometersForMeters(total), elevation: Number(point.querySelector('ele')?.textContent ?? 0), }; }); diff --git a/src/lib/heart-rate-device.ts b/src/lib/heart-rate-device.ts new file mode 100644 index 0000000..2a39f68 --- /dev/null +++ b/src/lib/heart-rate-device.ts @@ -0,0 +1,59 @@ +import { BATTERY, HEART_RATE } from '../constants'; +import { startBluetoothNotifications } from './bluetooth-notifications'; +import { parseHeartRateMeasurement } from './heart-rate'; + +const HEART_RATE_MEASUREMENT = 0x2a_37; +const BATTERY_LEVEL = 0x2a_19; + +interface HeartRateDeviceCallbacks { + onDisconnect: () => void; + onHeartRate: (heartRate: number) => void; +} + +export interface HeartRateDeviceConnection { + battery?: number; + cleanup: () => void; +} + +export async function connectHeartRateDevice( + device: BluetoothDevice, + { onDisconnect, onHeartRate }: HeartRateDeviceCallbacks +): Promise { + const server = await device.gatt?.connect(); + if (!server) { + throw new Error('This heart rate monitor does not expose Bluetooth services.'); + } + const service = await server.getPrimaryService(HEART_RATE); + const measurement = await service.getCharacteristic(HEART_RATE_MEASUREMENT); + const handleMeasurement = (event: Event) => { + const { value } = event.target as BluetoothRemoteGATTCharacteristic; + if (!value) { + return; + } + const heartRate = parseHeartRateMeasurement(value); + if (heartRate !== undefined) { + onHeartRate(heartRate); + } + }; + const removeMeasurementListener = await startBluetoothNotifications( + measurement, + handleMeasurement + ); + let battery: number | undefined; + try { + const batteryValue = await ( + await (await server.getPrimaryService(BATTERY)).getCharacteristic(BATTERY_LEVEL) + ).readValue(); + battery = batteryValue.getUint8(0); + } catch { + battery = undefined; + } + device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); + return { + battery, + cleanup: () => { + removeMeasurementListener(); + device.removeEventListener('gattserverdisconnected', onDisconnect); + }, + }; +} diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index d4b18b1..89b878a 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -41,12 +41,12 @@ export const gearingKeyboardShortcuts: KeyboardShortcutDescription[] = ); export const historyKeyboardShortcuts: KeyboardShortcutDescription[] = [ - { keys: ['↑', '↓'], label: 'Select the previous or next session' }, - { keys: ['←', '→'], label: 'Change the session chart view' }, - { keys: ['d'], label: 'Delete the selected session' }, - { keys: ['Enter'], label: 'Confirm session deletion' }, - { keys: ['?'], label: 'Show history keyboard controls' }, - { keys: ['Esc'], label: 'Close help or session history' }, + { group: 'Navigation', keys: ['↑', '↓'], label: 'Select the previous or next session' }, + { group: 'Navigation', keys: ['←', '→'], label: 'Change the session chart view' }, + { group: 'Session', keys: ['d'], label: 'Delete the selected session' }, + { group: 'Session', keys: ['Enter'], label: 'Confirm session deletion' }, + { group: 'General', keys: ['?'], label: 'Show history keyboard controls' }, + { group: 'General', keys: ['Esc'], label: 'Close help or session history' }, ]; export function appShortcutForKey({ code, key }: Pick) { diff --git a/src/lib/metric-presentation.ts b/src/lib/metric-presentation.ts new file mode 100644 index 0000000..033098b --- /dev/null +++ b/src/lib/metric-presentation.ts @@ -0,0 +1,72 @@ +export const METRIC_PRESENTATION = { + cadence: { + accent: 'violet', + chartColor: '#a78bfa', + chartMinimumMaximum: 80, + chartStep: 10, + dashboardUnit: 'rpm', + icon: 'cadence', + label: 'Cadence', + unit: 'rpm', + }, + heartRate: { + accent: 'rose', + chartColor: '#fb7185', + chartMinimumMaximum: 180, + chartStep: 10, + dashboardUnit: 'bpm', + icon: 'heart', + label: 'Heart rate', + unit: 'bpm', + }, + power: { + accent: 'yellow', + chartColor: '#facc15', + chartMinimumMaximum: 100, + chartStep: 50, + dashboardUnit: 'watts', + icon: 'bolt', + label: 'Power', + unit: 'W', + }, + speed: { + accent: 'sky', + chartColor: '#38bdf8', + icon: 'speed', + label: 'Speed', + }, +} as const; + +export const STANDARD_METRIC_KEYS = ['power', 'cadence', 'heartRate'] as const; + +export function metricAccentClass(accent: string): string { + if (accent === 'sky') { + return 'bg-sky-400'; + } + if (accent === 'yellow') { + return 'bg-yellow-400'; + } + if (accent === 'violet') { + return 'bg-violet-400'; + } + if (accent === 'rose') { + return 'bg-rose-400'; + } + return 'bg-mint'; +} + +export function metricIconClass(accent: string): string { + if (accent === 'mint') { + return 'text-mint'; + } + if (accent === 'yellow') { + return 'text-yellow-400'; + } + if (accent === 'violet') { + return 'text-violet-400'; + } + if (accent === 'rose') { + return 'text-rose-400'; + } + return 'text-sky-400'; +} diff --git a/src/lib/notification.ts b/src/lib/notification.ts index 43e7ada..3ae36c5 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,5 +1,7 @@ +import { MILLISECONDS_PER_SECOND } from './units'; + export const NOTICE_AUTO_DISMISS_MS = 15_000; -export const NOTICE_AUTO_DISMISS_SECONDS = NOTICE_AUTO_DISMISS_MS / 1000; +export const NOTICE_AUTO_DISMISS_SECONDS = NOTICE_AUTO_DISMISS_MS / MILLISECONDS_PER_SECOND; interface NoticeTimers { clearTimeout: (timeout: number) => void; @@ -20,5 +22,5 @@ export function scheduleNoticeDismissal( export function noticeSecondsRemaining(elapsedMilliseconds: number): number { const remaining = NOTICE_AUTO_DISMISS_MS - Math.max(0, elapsedMilliseconds); - return Math.max(0, Math.ceil(remaining / 1000)); + return Math.max(0, Math.ceil(remaining / MILLISECONDS_PER_SECOND)); } diff --git a/src/lib/numbers.ts b/src/lib/numbers.ts new file mode 100644 index 0000000..1b32255 --- /dev/null +++ b/src/lib/numbers.ts @@ -0,0 +1,9 @@ +import { isFiniteNumber } from './type-guards'; + +export function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +export function nonNegativeNumber(value: unknown): number { + return isFiniteNumber(value) ? Math.max(0, value) : 0; +} diff --git a/src/lib/reconnect-controller.ts b/src/lib/reconnect-controller.ts new file mode 100644 index 0000000..00db6f1 --- /dev/null +++ b/src/lib/reconnect-controller.ts @@ -0,0 +1,91 @@ +interface ReconnectControllerOptions { + attempt: (target: T) => Promise; + canRetry: (target: T) => boolean; + clearTimer?: typeof clearTimeout; + delayForAttempt: (attempt: number) => number; + onWaiting?: (target: T) => void; + setTimer?: typeof setTimeout; +} + +interface RetryEntry { + attempt: number; + target: T; + timer?: ReturnType; +} + +export interface ReconnectController { + cancel: (key: string, resetAttempts?: boolean) => void; + cancelAll: () => void; + isPending: (key: string) => boolean; + reset: (key: string) => void; + start: (key: string, target: T, initialDelay?: number) => void; +} + +export function createReconnectController({ + attempt, + canRetry, + clearTimer = clearTimeout, + delayForAttempt, + onWaiting, + setTimer = setTimeout, +}: ReconnectControllerOptions): ReconnectController { + const entries = new Map>(); + + const cancel = (key: string, resetAttempts = false) => { + const entry = entries.get(key); + if (entry?.timer) { + clearTimer(entry.timer); + } + if (resetAttempts) { + entries.delete(key); + } else if (entry) { + entry.timer = undefined; + } + }; + + const schedule = (key: string, entry: RetryEntry, delay: number) => { + if (!canRetry(entry.target)) { + entries.delete(key); + return; + } + onWaiting?.(entry.target); + entry.timer = setTimer(async () => { + entry.timer = undefined; + if (entries.get(key) !== entry || !canRetry(entry.target)) { + entries.delete(key); + return; + } + const connected = await attempt(entry.target).catch(() => false); + if (entries.get(key) !== entry) { + return; + } + if (connected) { + entries.delete(key); + return; + } + entry.attempt += 1; + schedule(key, entry, delayForAttempt(entry.attempt)); + }, delay); + }; + + return { + cancel, + cancelAll: () => { + for (const key of entries.keys()) { + cancel(key, true); + } + }, + isPending: (key) => Boolean(entries.get(key)?.timer), + reset: (key) => cancel(key, true), + start: (key, target, initialDelay = 0) => { + const current = entries.get(key); + if (current?.timer) { + return; + } + const entry = current ?? { attempt: 1, target }; + entry.target = target; + entries.set(key, entry); + schedule(key, entry, initialDelay || delayForAttempt(entry.attempt)); + }, + }; +} diff --git a/src/lib/resistance.ts b/src/lib/resistance.ts index f3877c3..7edff05 100644 --- a/src/lib/resistance.ts +++ b/src/lib/resistance.ts @@ -1,4 +1,13 @@ import type { ResistanceAdjustmentDirection } from '../types'; +import { clamp } from './numbers'; + +export const MIN_RESISTANCE = 0; +export const MAX_RESISTANCE = 100; +export const DEFAULT_RESISTANCE = 10; + +export function clampResistance(resistance: number): number { + return clamp(resistance, MIN_RESISTANCE, MAX_RESISTANCE); +} export function resistanceAdjustmentDirection( from: number, @@ -26,7 +35,7 @@ export function resistanceRampDuration(from: number, to: number): number { } export function smoothedResistance(from: number, to: number, progress: number): number { - const boundedProgress = Math.max(0, Math.min(1, progress)); + const boundedProgress = clamp(progress, 0, 1); const eased = boundedProgress * boundedProgress * (3 - 2 * boundedProgress); return from + (to - from) * eased; } diff --git a/src/lib/saved-sessions.ts b/src/lib/saved-sessions.ts index d531768..e08355d 100644 --- a/src/lib/saved-sessions.ts +++ b/src/lib/saved-sessions.ts @@ -5,7 +5,14 @@ import type { SessionMetadata, SessionSnapshot, } from '../types'; -import { aggregateGear, aggregateResistance, controlModeForHistory } from './session'; +import { + aggregateGear, + aggregateResistance, + controlModeForHistory, + restoreAggregate, +} from './session'; +import { IMPORTED_TCX_ID_PREFIX } from './tcx-schema'; +import { isFiniteNumber } from './type-guards'; const DATABASE_NAME = 'ridecontrol-sessions'; const DATABASE_VERSION = 1; @@ -14,6 +21,18 @@ const SUMMARY_STORE = 'session-summaries'; const ENDED_AT_INDEX = 'endedAt'; const MERIDIEM_SUFFIX = /\s*(AM|PM)$/i; const SESSION_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }); +const SESSION_IMPORT_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); + +export const SESSION_FEELING_OPTIONS: { label: string; value: SessionFeeling }[] = [ + { label: 'Great', value: 'great' }, + { label: 'Good', value: 'good' }, + { label: 'Okay', value: 'okay' }, + { label: 'Tough', value: 'tough' }, + { label: 'Exhausted', value: 'exhausted' }, +]; type SessionTiming = Pick; @@ -90,10 +109,25 @@ export function sessionSummary(session: SavedSession): SavedSessionSummary { endedAt: session.endedAt, feeling: session.feeling, id: session.id, + ...(session.importedAt === undefined ? {} : { importedAt: session.importedAt }), startedAt: session.startedAt, }; } +function normalizedImportedAt(importedAt: unknown): number | undefined { + return isFiniteNumber(importedAt) && importedAt >= 0 ? importedAt : undefined; +} + +export function normalizeSavedSessionSummary(session: SavedSessionSummary): SavedSessionSummary { + return { ...session, importedAt: normalizedImportedAt(session.importedAt) }; +} + +export function isImportedSession( + session: Pick +): boolean { + return session.importedAt !== undefined || session.id.startsWith(IMPORTED_TCX_ID_PREFIX); +} + type StoreGetter = (name: string) => T; export function saveSessionRecords( @@ -147,14 +181,17 @@ export function normalizeSavedSession(session: SavedSession): SavedSession { ...session, aggregates: { ...session.aggregates, - gear: - (session.aggregates as Partial).gear ?? - aggregateGear(session.history), - resistance: - (session.aggregates as Partial).resistance ?? - aggregateResistance(session.history), + gear: restoreAggregate( + (session.aggregates as Partial).gear, + aggregateGear(session.history) + ), + resistance: restoreAggregate( + (session.aggregates as Partial).resistance, + aggregateResistance(session.history) + ), }, controlMode: controlModeForHistory(session.history, session.controlMode), + importedAt: normalizedImportedAt(session.importedAt), }; } @@ -187,7 +224,7 @@ export async function listSavedSessions( resolve(); return; } - summaries.push(cursor.value as SavedSessionSummary); + summaries.push(normalizeSavedSessionSummary(cursor.value as SavedSessionSummary)); cursor.continue(); }); }); @@ -195,6 +232,17 @@ export async function listSavedSessions( return summaries; } +export async function listAllSavedSessions(): Promise { + const database = await openDatabase(); + const transaction = database.transaction(SESSION_STORE, 'readonly'); + const completed = transactionComplete(transaction); + const sessions = await requestResult( + transaction.objectStore(SESSION_STORE).getAll() as IDBRequest + ); + await completed; + return sessions.map(normalizeSavedSession).sort((left, right) => right.endedAt - left.endedAt); +} + export interface SessionGroup { date: string; key: string; @@ -299,6 +347,16 @@ export function formatSessionListTime(session: SessionTiming): string { : formatSessionTime(session.startedAt); } +export function formatSessionImportTime(timestamp: number): string { + return SESSION_IMPORT_FORMATTER.format(timestamp); +} + +export function formatSessionImportLabel(session: Pick): string { + return session.importedAt === undefined + ? 'Imported' + : `Imported ${formatSessionImportTime(session.importedAt)}`; +} + export async function requestPersistentSessionStorage(): Promise { if (!navigator.storage?.persist) { return false; diff --git a/src/lib/session-workflow.ts b/src/lib/session-workflow.ts new file mode 100644 index 0000000..fd4e46b --- /dev/null +++ b/src/lib/session-workflow.ts @@ -0,0 +1,38 @@ +import type { SavedSession, SessionSnapshot } from '../types'; + +export interface SessionWorkflowController { + continueFrom: (snapshot: SessionSnapshot) => void; + discarded: boolean; + elapsedSeconds: number; + ended: boolean; + endSession: () => void; + markDiscarded: () => void; + markSaved: (id: string) => void; + savedSessionId?: string; + snapshot: SessionSnapshot; + startNew: () => void; +} + +export const SESSION_WORKFLOW_INTENT = { + CONTINUE: 'continue', + END: 'end', + NEW: 'new', +} as const; + +export const SESSION_WORKFLOW_PHASE = { + CLOSED: 'closed', + PROMPT: 'prompt', + SAVING: 'saving', +} as const; + +export type SessionWorkflowIntent = + | { kind: typeof SESSION_WORKFLOW_INTENT.END } + | { kind: typeof SESSION_WORKFLOW_INTENT.NEW } + | { kind: typeof SESSION_WORKFLOW_INTENT.CONTINUE; session: SavedSession }; + +export type SessionWorkflowState = + | { phase: typeof SESSION_WORKFLOW_PHASE.CLOSED } + | { + intent: SessionWorkflowIntent; + phase: typeof SESSION_WORKFLOW_PHASE.PROMPT | typeof SESSION_WORKFLOW_PHASE.SAVING; + }; diff --git a/src/lib/session.ts b/src/lib/session.ts index 5074786..d1e40bb 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -1,6 +1,5 @@ -import { emptyMetrics, emptySession } from '../constants'; +import { emptyMetrics, emptySession, MAX_SESSION_HISTORY_SAMPLES } from '../constants'; import type { - ControlMode, MetricAggregate, MetricSample, Metrics, @@ -8,18 +7,23 @@ import type { SessionSnapshot, StoredSession, } from '../types'; +import { CONTROL_MODE, type ControlMode, isControlMode } from './control-mode'; +import { clampGear, MAX_GEAR, MIN_GEAR } from './gears'; +import { clamp, nonNegativeNumber } from './numbers'; +import { clampResistance, DEFAULT_RESISTANCE, MAX_RESISTANCE, MIN_RESISTANCE } from './resistance'; +import { isFiniteNumber, isString } from './type-guards'; -type ReadableStorage = Pick; +export const SESSION_STORAGE_KEY = 'trainer-session'; +export const RESISTANCE_STORAGE_KEY = 'trainer-resistance-percent'; -export function nonNegativeNumber(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0; -} +type ReadableStorage = Pick; export function sessionContinuation(snapshot: SessionSnapshot): StoredSession { return { aggregates: snapshot.aggregates, calories: snapshot.calories, controlMode: snapshot.controlMode, + discarded: false, distance: snapshot.distance, elapsedSeconds: snapshot.elapsedSeconds, ended: false, @@ -51,6 +55,7 @@ export function addAggregate( } return { count: aggregate.count + 1, + maximum: Math.max(nonNegativeNumber(aggregate.maximum), Math.max(0, value)), sum: aggregate.sum + Math.max(0, value), }; } @@ -63,29 +68,25 @@ export function addMetricAggregates( return { cadence: addAggregate(aggregates.cadence, metrics.cadence, false), gear: - typeof metrics.gear === 'number' - ? addAggregate(aggregates.gear, metrics.gear, true) - : aggregates.gear, + metrics.gear === undefined + ? aggregates.gear + : addAggregate(aggregates.gear, metrics.gear, true), heartRate: addAggregate(aggregates.heartRate, metrics.heartRate, false), power: addAggregate(aggregates.power, metrics.power, true), resistance: - typeof metrics.resistance === 'number' - ? addAggregate(aggregates.resistance, metrics.resistance, true) - : aggregates.resistance, + metrics.resistance === undefined + ? aggregates.resistance + : addAggregate(aggregates.resistance, metrics.resistance, true), }; } export function aggregateGear(samples: Partial>[]): MetricAggregate { return samples.reduce( (aggregate, sample) => { - if (typeof sample.gear !== 'number' || !Number.isFinite(sample.gear)) { + if (!isFiniteNumber(sample.gear)) { return aggregate; } - return addAggregate( - aggregate, - Math.min(24, Math.max(1, Math.round(sample.gear))), - true - ); + return addAggregate(aggregate, clampGear(sample.gear), true); }, { count: 0, sum: 0 } ); @@ -96,10 +97,10 @@ export function aggregateResistance( ): MetricAggregate { return samples.reduce( (aggregate, sample) => { - if (typeof sample.resistance !== 'number' || !Number.isFinite(sample.resistance)) { + if (!isFiniteNumber(sample.resistance)) { return aggregate; } - return addAggregate(aggregate, Math.min(100, Math.max(0, sample.resistance)), true); + return addAggregate(aggregate, clampResistance(sample.resistance), true); }, { count: 0, sum: 0 } ); @@ -114,29 +115,34 @@ export function restoreAggregate( } return { count: nonNegativeNumber(saved.count), + maximum: isFiniteNumber(saved.maximum) + ? nonNegativeNumber(saved.maximum) + : nonNegativeNumber(fallback.maximum), sum: nonNegativeNumber(saved.sum), }; } function optionalControlValue(value: unknown, minimum: number, maximum: number) { - if (typeof value !== 'number' || !Number.isFinite(value)) { + if (!isFiniteNumber(value)) { return; } - return Math.min(maximum, Math.max(minimum, value)); + return clamp(value, minimum, maximum); } export function controlModeForHistory( history: Partial>[], savedMode?: ControlMode ): ControlMode { - if (savedMode === 'gear' || savedMode === 'resistance') { + if (isControlMode(savedMode)) { return savedMode; } - return history.some((sample) => typeof sample.gear === 'number') ? 'gear' : 'resistance'; + return history.some((sample) => sample.gear !== undefined) + ? CONTROL_MODE.GEAR + : CONTROL_MODE.RESISTANCE; } export function loadStoredSession(storage: ReadableStorage = localStorage): StoredSession { - const saved = storage.getItem('trainer-session'); + const saved = storage.getItem(SESSION_STORAGE_KEY); if (!saved) { return emptySession; } @@ -144,13 +150,17 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor const parsed = JSON.parse(saved) as Partial; const maximums = parsed.maximums ?? emptyMetrics; const history = Array.isArray(parsed.history) - ? parsed.history.slice(-3600).map((sample) => ({ + ? parsed.history.slice(-MAX_SESSION_HISTORY_SAMPLES).map((sample) => ({ cadence: nonNegativeNumber(sample.cadence), elapsedSeconds: nonNegativeNumber(sample.elapsedSeconds), - gear: optionalControlValue(sample.gear, 1, 24), + gear: optionalControlValue(sample.gear, MIN_GEAR, MAX_GEAR), heartRate: nonNegativeNumber(sample.heartRate), power: nonNegativeNumber(sample.power), - resistance: optionalControlValue(sample.resistance, 0, 100), + resistance: optionalControlValue( + sample.resistance, + MIN_RESISTANCE, + MAX_RESISTANCE + ), speed: nonNegativeNumber(sample.speed), })) : []; @@ -171,6 +181,7 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor }, calories: nonNegativeNumber(parsed.calories), controlMode: controlModeForHistory(history, parsed.controlMode), + discarded: parsed.discarded === true, distance: nonNegativeNumber(parsed.distance), elapsedSeconds: nonNegativeNumber(parsed.elapsedSeconds), ended: parsed.ended === true, @@ -184,8 +195,7 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor power: nonNegativeNumber(maximums.power), speed: nonNegativeNumber(maximums.speed), }, - savedSessionId: - typeof parsed.savedSessionId === 'string' ? parsed.savedSessionId : undefined, + savedSessionId: isString(parsed.savedSessionId) ? parsed.savedSessionId : undefined, startedAt: nonNegativeNumber(parsed.startedAt), }; } catch { @@ -194,10 +204,10 @@ export function loadStoredSession(storage: ReadableStorage = localStorage): Stor } export function storedResistance(storage: ReadableStorage = localStorage) { - const saved = storage.getItem('trainer-resistance-percent'); + const saved = storage.getItem(RESISTANCE_STORAGE_KEY); if (saved === null) { - return 10; + return DEFAULT_RESISTANCE; } const value = Number(saved); - return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 10; + return Number.isFinite(value) ? clampResistance(value) : DEFAULT_RESISTANCE; } diff --git a/src/lib/tcx-archive.ts b/src/lib/tcx-archive.ts new file mode 100644 index 0000000..de7d757 --- /dev/null +++ b/src/lib/tcx-archive.ts @@ -0,0 +1,56 @@ +import { strToU8, zip } from 'fflate'; +import type { SavedSession } from '../types'; +import { downloadBrowserFile } from './download'; +import { sessionTcxFilename, sessionToTcx } from './tcx'; + +const TCX_ARCHIVE_FOLDER = 'ride-control-tcx'; +const TCX_EXTENSION_LENGTH = '.tcx'.length; +const TCX_ARCHIVE_MIME_TYPE = 'application/zip'; + +function numberedTcxFilename(filename: string, number: number): string { + if (number === 1) { + return filename; + } + return `${filename.slice(0, -TCX_EXTENSION_LENGTH)}-${number}.tcx`; +} + +export function sessionTcxArchiveEntries(sessions: SavedSession[]): Record { + const filenameCounts = new Map(); + return Object.fromEntries( + sessions.map((session) => { + const filename = sessionTcxFilename(session); + const count = (filenameCounts.get(filename) ?? 0) + 1; + filenameCounts.set(filename, count); + return [ + `${TCX_ARCHIVE_FOLDER}/${numberedTcxFilename(filename, count)}`, + strToU8(sessionToTcx(session)), + ]; + }) + ); +} + +export function createSessionTcxArchive(sessions: SavedSession[]): Promise { + return new Promise((resolve, reject) => { + zip(sessionTcxArchiveEntries(sessions), { level: 6 }, (error, archive) => { + if (error) { + reject(error); + return; + } + resolve(archive); + }); + }); +} + +export function sessionTcxArchiveFilename(timestamp = Date.now()): string { + return `ride-control-tcx-${new Date(timestamp).toISOString().slice(0, 10)}.zip`; +} + +export async function downloadSessionTcxArchive(sessions: SavedSession[]): Promise { + if (sessions.length === 0) { + throw new Error('There are no saved sessions to download.'); + } + const archive = await createSessionTcxArchive(sessions); + const archiveBuffer = new ArrayBuffer(archive.byteLength); + new Uint8Array(archiveBuffer).set(archive); + downloadBrowserFile(archiveBuffer, sessionTcxArchiveFilename(), TCX_ARCHIVE_MIME_TYPE); +} diff --git a/src/lib/tcx-import.ts b/src/lib/tcx-import.ts new file mode 100644 index 0000000..619965b --- /dev/null +++ b/src/lib/tcx-import.ts @@ -0,0 +1,462 @@ +import { strFromU8, unzip } from 'fflate'; +import { emptyMetrics, emptySession, MAX_SESSION_HISTORY_SAMPLES } from '../constants'; +import type { + MetricAggregate, + MetricSample, + SavedSession, + SessionAggregates, + SessionFeeling, +} from '../types'; +import { CONTROL_MODE } from './control-mode'; +import { errorMessage } from './errors'; +import { clampGear } from './gears'; +import { nonNegativeNumber } from './numbers'; +import { clampResistance } from './resistance'; +import { listAllSavedSessions, saveSession } from './saved-sessions'; +import { addMetricAggregates } from './session'; +import { IMPORTED_TCX_ID_PREFIX, RIDECONTROL_TCX_EXTENSION_NAMESPACE } from './tcx-schema'; +import { + KILOMETERS_PER_HOUR_PER_METER_PER_SECOND, + kilometersForMeters, + secondsForMilliseconds, +} from './units'; + +const TCX_FILE_EXTENSION = /\.tcx$/i; +const ZIP_FILE_EXTENSION = /\.zip$/i; +const MAX_TCX_FILES_PER_IMPORT = 500; +const MAX_TCX_FILE_BYTES = 20 * 1024 * 1024; +const MAX_TCX_ARCHIVE_BYTES = 100 * 1024 * 1024; +const ELEMENT_NODE = 1; +const LINE_BREAK = /\r?\n/; +const FEELING_NOTE = /^Feeling:\s*(.+)$/i; +const COMMENTS_NOTE_PREFIX = /^Comments:\s*/i; +const VALID_FEELINGS = new Set(['great', 'good', 'okay', 'tough', 'exhausted']); + +interface NamedTcxFile { + contents: string; + name: string; +} + +interface ImportDependencies { + listSessions: () => Promise; + saveSession: (session: SavedSession) => Promise; +} + +export interface TcxImportFailure { + fileName: string; + message: string; +} + +export interface TcxImportResult { + duplicateCount: number; + failures: TcxImportFailure[]; + importedSessions: SavedSession[]; + tcxFileCount: number; +} + +const DEFAULT_IMPORT_DEPENDENCIES: ImportDependencies = { + listSessions: listAllSavedSessions, + saveSession, +}; + +function elementName(element: Element): string { + return element.localName || element.nodeName.split(':').at(-1) || element.nodeName; +} + +function childElements(element: Element): Element[] { + return Array.from(element.childNodes).filter( + (node): node is Element => node.nodeType === ELEMENT_NODE + ); +} + +function child(element: Element, name: string): Element | undefined { + return childElements(element).find((candidate) => elementName(candidate) === name); +} + +function descendants(element: Element, name: string): Element[] { + return Array.from(element.getElementsByTagName('*')).filter( + (candidate) => elementName(candidate) === name + ); +} + +function descendant(element: Element, name: string): Element | undefined { + return descendants(element, name)[0]; +} + +function text(element: Element | undefined): string { + return element?.textContent?.trim() ?? ''; +} + +function numberValue(element: Element | undefined): number | undefined { + const value = Number(text(element)); + return Number.isFinite(value) ? value : undefined; +} + +function dateValue(value: string): number | undefined { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function positiveSum(values: (number | undefined)[]): number { + return values.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0); +} + +function maximum(values: number[]): number { + return values.reduce((highest, value) => Math.max(highest, value), 0); +} + +function evenlySample(values: T[], limit: number): T[] { + if (values.length <= limit) { + return values; + } + return Array.from({ length: limit }, (_, index) => { + const sourceIndex = Math.round((index * (values.length - 1)) / (limit - 1)); + return values[sourceIndex] as T; + }); +} + +function notesMetadata(activity: Element): Pick { + const notes = text(child(activity, 'Notes')); + if (!notes) { + return { comments: '' }; + } + const lines = notes.split(LINE_BREAK); + let feeling: SessionFeeling | undefined; + const commentLines: string[] = []; + for (const line of lines) { + const feelingMatch = FEELING_NOTE.exec(line); + if (feelingMatch) { + const value = feelingMatch[1]?.trim().toLowerCase() as SessionFeeling; + if (VALID_FEELINGS.has(value)) { + feeling = value; + continue; + } + } + commentLines.push(line.replace(COMMENTS_NOTE_PREFIX, '')); + } + return { + comments: commentLines.join('\n').trim(), + feeling, + }; +} + +function trackpointSample( + trackpoint: Element, + startedAt: number, + previous?: { distanceMeters?: number; elapsedSeconds: number } +): { distanceMeters?: number; sample: MetricSample; timestamp?: number } { + const timestamp = dateValue(text(child(trackpoint, 'Time'))); + const previousElapsedSeconds = previous ? previous.elapsedSeconds : 0; + const elapsedSeconds = + timestamp === undefined + ? previousElapsedSeconds + : Math.max(0, secondsForMilliseconds(timestamp - startedAt)); + const distanceMeters = numberValue(child(trackpoint, 'DistanceMeters')); + const elapsedDelta = previous ? elapsedSeconds - previousElapsedSeconds : 0; + const distanceDelta = + distanceMeters === undefined + ? 0 + : distanceMeters - (previous?.distanceMeters ?? distanceMeters); + const recordedSpeed = numberValue(descendant(trackpoint, 'Speed')); + const calculatedSpeed = + elapsedDelta > 0 && distanceDelta > 0 + ? (distanceDelta / elapsedDelta) * KILOMETERS_PER_HOUR_PER_METER_PER_SECOND + : 0; + const gear = numberValue(descendant(trackpoint, 'Gear')); + const resistance = numberValue(descendant(trackpoint, 'Resistance')); + return { + distanceMeters, + sample: { + cadence: Math.max(0, numberValue(child(trackpoint, 'Cadence')) ?? 0), + elapsedSeconds, + gear: gear === undefined ? undefined : clampGear(gear), + heartRate: Math.max( + 0, + numberValue(descendant(child(trackpoint, 'HeartRateBpm') ?? trackpoint, 'Value')) ?? + 0 + ), + power: Math.max(0, numberValue(descendant(trackpoint, 'Watts')) ?? 0), + resistance: resistance === undefined ? undefined : clampResistance(resistance), + speed: + recordedSpeed === undefined + ? calculatedSpeed + : Math.max(0, recordedSpeed) * KILOMETERS_PER_HOUR_PER_METER_PER_SECOND, + }, + timestamp, + }; +} + +function fallbackAggregate(laps: Element[], names: string[]): MetricAggregate { + const values = laps + .map((lap) => + names + .map((name) => numberValue(descendant(lap, name))) + .find((value) => value !== undefined) + ) + .filter((value): value is number => value !== undefined && value >= 0); + return { count: values.length, maximum: maximum(values), sum: positiveSum(values) }; +} + +function withAggregateFallback( + aggregates: SessionAggregates, + key: keyof SessionAggregates, + fallback: MetricAggregate +): SessionAggregates { + return aggregates[key].count > 0 + ? aggregates + : { + ...aggregates, + [key]: fallback, + }; +} + +function fingerprintValues( + startedAt: number, + elapsedSeconds: number, + distanceKilometers: number, + calories: number, + sampleCount: number +): string { + return [ + Math.round(nonNegativeNumber(startedAt)), + Math.round(nonNegativeNumber(elapsedSeconds) * 1000), + Math.round(nonNegativeNumber(distanceKilometers) * 1_000_000), + Math.round(nonNegativeNumber(calories)), + sampleCount, + ].join(':'); +} + +export function tcxSessionFingerprint( + session: Pick< + SavedSession, + 'calories' | 'distance' | 'elapsedSeconds' | 'history' | 'startedAt' + > +): string { + return fingerprintValues( + session.startedAt, + session.elapsedSeconds, + session.distance, + session.calories, + session.history.length + ); +} + +function parseActivity(activity: Element): SavedSession { + const laps = childElements(activity).filter((element) => elementName(element) === 'Lap'); + const trackpoints = descendants(activity, 'Trackpoint'); + const firstTrackpointTime = trackpoints[0] + ? dateValue(text(child(trackpoints[0], 'Time'))) + : undefined; + const firstLapTime = dateValue(laps[0]?.getAttribute('StartTime') ?? ''); + const startedAt = dateValue(text(child(activity, 'Id'))) ?? firstLapTime ?? firstTrackpointTime; + if (startedAt === undefined) { + throw new Error('The activity has no valid start time.'); + } + + let previous: { distanceMeters?: number; elapsedSeconds: number } | undefined; + let lastTrackpointTime: number | undefined; + const allSamples = trackpoints.map((trackpoint) => { + const parsed = trackpointSample(trackpoint, startedAt, previous); + previous = { + distanceMeters: parsed.distanceMeters, + elapsedSeconds: parsed.sample.elapsedSeconds, + }; + lastTrackpointTime = parsed.timestamp ?? lastTrackpointTime; + return parsed.sample; + }); + const lapElapsedSeconds = positiveSum( + laps.map((lap) => numberValue(child(lap, 'TotalTimeSeconds'))) + ); + const sampleElapsedSeconds = allSamples.at(-1)?.elapsedSeconds ?? 0; + const elapsedSeconds = Math.max(lapElapsedSeconds, sampleElapsedSeconds); + const endedAt = Math.max(startedAt, lastTrackpointTime ?? startedAt + elapsedSeconds * 1000); + const lapDistanceMeters = positiveSum( + laps.map((lap) => numberValue(child(lap, 'DistanceMeters'))) + ); + const trackpointDistanceMeters = Math.max( + 0, + ...trackpoints.map((trackpoint) => numberValue(child(trackpoint, 'DistanceMeters')) ?? 0) + ); + const distanceMeters = lapDistanceMeters || trackpointDistanceMeters; + const calories = positiveSum(laps.map((lap) => numberValue(child(lap, 'Calories')))); + const exportedSessionId = descendants(activity, 'SessionId').find( + (element) => element.namespaceURI === RIDECONTROL_TCX_EXTENSION_NAMESPACE + ); + const hasGear = + allSamples.some((sample) => sample.gear !== undefined) || + descendant(activity, 'AverageGear') !== undefined; + let aggregates = allSamples.reduce(addMetricAggregates, emptySession.aggregates); + aggregates = withAggregateFallback(aggregates, 'cadence', fallbackAggregate(laps, ['Cadence'])); + aggregates = withAggregateFallback( + aggregates, + 'heartRate', + fallbackAggregate(laps, ['AverageHeartRateBpm']) + ); + aggregates = withAggregateFallback(aggregates, 'power', fallbackAggregate(laps, ['AvgWatts'])); + + return { + ...notesMetadata(activity), + aggregates, + calories, + controlMode: hasGear ? CONTROL_MODE.GEAR : CONTROL_MODE.RESISTANCE, + distance: kilometersForMeters(distanceMeters), + elapsedSeconds, + endedAt, + history: evenlySample(allSamples, MAX_SESSION_HISTORY_SAMPLES), + id: + text(exportedSessionId).slice(0, 256) || + `${IMPORTED_TCX_ID_PREFIX}${fingerprintValues( + startedAt, + elapsedSeconds, + kilometersForMeters(distanceMeters), + calories, + allSamples.length + )}`, + maximums: { + ...emptyMetrics, + cadence: maximum(allSamples.map((sample) => sample.cadence)), + heartRate: Math.max( + maximum(allSamples.map((sample) => sample.heartRate)), + maximum(laps.map((lap) => numberValue(descendant(lap, 'MaximumHeartRateBpm')) ?? 0)) + ), + power: Math.max( + maximum(allSamples.map((sample) => sample.power)), + maximum(laps.map((lap) => numberValue(descendant(lap, 'MaxWatts')) ?? 0)) + ), + speed: Math.max( + maximum(allSamples.map((sample) => sample.speed)), + maximum(laps.map((lap) => numberValue(child(lap, 'MaximumSpeed')) ?? 0)) * + KILOMETERS_PER_HOUR_PER_METER_PER_SECOND + ), + }, + startedAt, + }; +} + +export function parseTcxSessions(contents: string): SavedSession[] { + const document = new DOMParser().parseFromString(contents, 'application/xml'); + if (document.getElementsByTagName('parsererror').length > 0) { + throw new Error('The TCX file is not valid XML.'); + } + const root = document.documentElement; + if (!root || elementName(root) !== 'TrainingCenterDatabase') { + throw new Error('The file is not a Training Center XML document.'); + } + const activities = descendants(root, 'Activity'); + if (activities.length === 0) { + throw new Error('The TCX file contains no activities.'); + } + return activities.map(parseActivity); +} + +function unzipArchive(data: Uint8Array): Promise> { + let tcxFileCount = 0; + let totalBytes = 0; + let limitExceeded = false; + return new Promise((resolve, reject) => { + unzip( + data, + { + filter: (file) => { + if (!TCX_FILE_EXTENSION.test(file.name)) { + return false; + } + tcxFileCount += 1; + totalBytes += file.originalSize; + limitExceeded = + tcxFileCount > MAX_TCX_FILES_PER_IMPORT || + file.originalSize > MAX_TCX_FILE_BYTES || + totalBytes > MAX_TCX_ARCHIVE_BYTES; + return !limitExceeded; + }, + }, + (error, files) => { + if (error) { + reject(error); + return; + } + if (limitExceeded) { + reject(new Error('The ZIP contains too many or excessively large TCX files.')); + return; + } + resolve(files); + } + ); + }); +} + +async function uploadedTcxFiles(file: File): Promise { + if (TCX_FILE_EXTENSION.test(file.name)) { + if (file.size > MAX_TCX_FILE_BYTES) { + throw new Error('The TCX file is too large to import.'); + } + return [{ contents: await file.text(), name: file.name }]; + } + if (!ZIP_FILE_EXTENSION.test(file.name)) { + throw new Error('Choose a .tcx file or a .zip containing TCX files.'); + } + const files = await unzipArchive(new Uint8Array(await file.arrayBuffer())); + const entries = Object.entries(files); + if (entries.length === 0) { + throw new Error('The ZIP contains no TCX files.'); + } + return entries.map(([name, contents]) => ({ contents: strFromU8(contents), name })); +} + +export async function importTcxUpload( + file: File, + dependencies: ImportDependencies = DEFAULT_IMPORT_DEPENDENCIES +): Promise { + const tcxFiles = await uploadedTcxFiles(file); + const importedAt = Date.now(); + const savedSessions = await dependencies.listSessions(); + const savedIds = new Set(savedSessions.map((session) => session.id)); + const savedFingerprints = new Set(savedSessions.map(tcxSessionFingerprint)); + const result: TcxImportResult = { + duplicateCount: 0, + failures: [], + importedSessions: [], + tcxFileCount: tcxFiles.length, + }; + for (const tcxFile of tcxFiles) { + try { + const sessions = parseTcxSessions(tcxFile.contents); + for (const session of sessions) { + const fingerprint = tcxSessionFingerprint(session); + if (savedIds.has(session.id) || savedFingerprints.has(fingerprint)) { + result.duplicateCount += 1; + continue; + } + const importedSession = { ...session, importedAt }; + await dependencies.saveSession(importedSession); + savedIds.add(session.id); + savedFingerprints.add(fingerprint); + result.importedSessions.push(importedSession); + } + } catch (error) { + result.failures.push({ fileName: tcxFile.name, message: errorMessage(error) }); + } + } + return result; +} + +export function tcxImportResultMessage(result: TcxImportResult): string { + const messages: string[] = []; + const imported = result.importedSessions.length; + if (imported > 0) { + messages.push(`Imported ${imported} ${imported === 1 ? 'session' : 'sessions'}`); + } else { + messages.push('No new sessions imported'); + } + if (result.duplicateCount > 0) { + messages.push( + `${result.duplicateCount} ${result.duplicateCount === 1 ? 'duplicate' : 'duplicates'} skipped` + ); + } + if (result.failures.length > 0) { + messages.push( + `${result.failures.length} ${result.failures.length === 1 ? 'file' : 'files'} could not be imported` + ); + } + return messages.join(' · '); +} diff --git a/src/lib/tcx-schema.ts b/src/lib/tcx-schema.ts new file mode 100644 index 0000000..3573e67 --- /dev/null +++ b/src/lib/tcx-schema.ts @@ -0,0 +1,6 @@ +export const TCX_NAMESPACE = 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2'; +export const TCX_ACTIVITY_EXTENSION_NAMESPACE = + 'http://www.garmin.com/xmlschemas/ActivityExtension/v2'; +export const RIDECONTROL_TCX_EXTENSION_NAMESPACE = + 'https://github.com/lookfirst/RideControl/xmlschemas/ActivityExtension/v1'; +export const IMPORTED_TCX_ID_PREFIX = 'tcx:'; diff --git a/src/lib/tcx.ts b/src/lib/tcx.ts index 80ab955..d5bbb8b 100644 --- a/src/lib/tcx.ts +++ b/src/lib/tcx.ts @@ -1,9 +1,14 @@ import type { MetricSample, SavedSession } from '../types'; - -const TCX_NAMESPACE = 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2'; -const ACTIVITY_EXTENSION_NAMESPACE = 'http://www.garmin.com/xmlschemas/ActivityExtension/v2'; -const RIDECONTROL_EXTENSION_NAMESPACE = - 'https://github.com/lookfirst/RideControl/xmlschemas/ActivityExtension/v1'; +import { CONTROL_MODE } from './control-mode'; +import { downloadBrowserFile } from './download'; +import { aggregateAverage, aggregateMaximum } from './format'; +import { nonNegativeNumber } from './numbers'; +import { + RIDECONTROL_TCX_EXTENSION_NAMESPACE, + TCX_ACTIVITY_EXTENSION_NAMESPACE, + TCX_NAMESPACE, +} from './tcx-schema'; +import { metersForKilometers, metersPerSecond, millisecondsForSeconds } from './units'; function xmlEscape(value: string): string { return value @@ -14,32 +19,24 @@ function xmlEscape(value: string): string { .replaceAll("'", '''); } -function nonNegative(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0; -} - -function average({ count, sum }: { count: number; sum: number }): number { - return count > 0 ? sum / count : 0; -} - function trackpointDistances(session: SavedSession): number[] { let elapsed = 0; let distance = 0; const integrated = session.history.map((sample) => { - const nextElapsed = nonNegative(sample.elapsedSeconds); + const nextElapsed = nonNegativeNumber(sample.elapsedSeconds); const seconds = Math.max(0, nextElapsed - elapsed); - distance += (nonNegative(sample.speed) * seconds) / 3.6; + distance += metersPerSecond(nonNegativeNumber(sample.speed)) * seconds; elapsed = nextElapsed; return distance; }); - const totalMeters = nonNegative(session.distance) * 1000; + const totalMeters = metersForKilometers(nonNegativeNumber(session.distance)); if (distance > 0 && totalMeters > 0) { return integrated.map((meters) => (meters / distance) * totalMeters); } if (totalMeters > 0 && session.elapsedSeconds > 0) { return session.history.map( (sample) => - (Math.min(nonNegative(sample.elapsedSeconds), session.elapsedSeconds) / + (Math.min(nonNegativeNumber(sample.elapsedSeconds), session.elapsedSeconds) / session.elapsedSeconds) * totalMeters ); @@ -48,11 +45,11 @@ function trackpointDistances(session: SavedSession): number[] { } function trackpointXml(sample: MetricSample, timestamp: number, distanceMeters: number): string { - const heartRate = nonNegative(sample.heartRate); + const heartRate = nonNegativeNumber(sample.heartRate); const controlExtension = - typeof sample.gear === 'number' - ? `${Math.round(nonNegative(sample.gear))}` - : `${nonNegative(sample.resistance).toFixed(1)}`; + sample.gear === undefined + ? `${nonNegativeNumber(sample.resistance).toFixed(1)}` + : `${Math.round(nonNegativeNumber(sample.gear))}`; return ` @@ -62,12 +59,12 @@ function trackpointXml(sample: MetricSample, timestamp: number, distanceMeters: ${Math.round(heartRate)}` : '' } - ${Math.min(255, Math.round(nonNegative(sample.cadence)))} + ${Math.min(255, Math.round(nonNegativeNumber(sample.cadence)))} Present - ${(nonNegative(sample.speed) / 3.6).toFixed(3)} - ${Math.round(nonNegative(sample.power))} + ${metersPerSecond(nonNegativeNumber(sample.speed)).toFixed(3)} + ${Math.round(nonNegativeNumber(sample.power))} ${controlExtension} @@ -81,7 +78,8 @@ export function sessionToTcx(session: SavedSession): string { .map((sample, index) => trackpointXml( sample, - session.startedAt + nonNegative(sample.elapsedSeconds) * 1000, + session.startedAt + + millisecondsForSeconds(nonNegativeNumber(sample.elapsedSeconds)), distances[index] ?? 0 ) ) @@ -92,28 +90,28 @@ export function sessionToTcx(session: SavedSession): string { ] .filter(Boolean) .join('\n'); - const averageHeartRate = average(session.aggregates.heartRate); - const averageCadence = average(session.aggregates.cadence); - const averagePower = average(session.aggregates.power); + const averageHeartRate = aggregateAverage(session.aggregates.heartRate); + const averageCadence = aggregateAverage(session.aggregates.cadence); + const averagePower = aggregateAverage(session.aggregates.power); const controlSummary = - session.controlMode === 'gear' - ? `${average(session.aggregates.gear).toFixed(1)} - ${Math.max(0, ...session.history.map((sample) => nonNegative(sample.gear))).toFixed(0)}` - : `${average(session.aggregates.resistance).toFixed(1)} - ${Math.max(0, ...session.history.map((sample) => nonNegative(sample.resistance))).toFixed(1)}`; - const distanceMeters = nonNegative(session.distance) * 1000; + session.controlMode === CONTROL_MODE.GEAR + ? `${aggregateAverage(session.aggregates.gear).toFixed(1)} + ${Math.max(aggregateMaximum(session.aggregates.gear), ...session.history.map((sample) => nonNegativeNumber(sample.gear))).toFixed(0)}` + : `${aggregateAverage(session.aggregates.resistance).toFixed(1)} + ${Math.max(aggregateMaximum(session.aggregates.resistance), ...session.history.map((sample) => nonNegativeNumber(sample.resistance))).toFixed(1)}`; + const distanceMeters = metersForKilometers(nonNegativeNumber(session.distance)); const averageSpeed = session.elapsedSeconds > 0 ? distanceMeters / session.elapsedSeconds : 0; return ` - + ${startedAt} - ${nonNegative(session.elapsedSeconds).toFixed(3)} + ${nonNegativeNumber(session.elapsedSeconds).toFixed(3)} ${distanceMeters.toFixed(3)} - ${(nonNegative(session.maximums.speed) / 3.6).toFixed(3)} - ${Math.round(nonNegative(session.calories))}${ + ${metersPerSecond(nonNegativeNumber(session.maximums.speed)).toFixed(3)} + ${Math.round(nonNegativeNumber(session.calories))}${ averageHeartRate > 0 ? ` ${Math.round(averageHeartRate)}` @@ -121,7 +119,7 @@ export function sessionToTcx(session: SavedSession): string { }${ session.maximums.heartRate > 0 ? ` - ${Math.round(nonNegative(session.maximums.heartRate))}` + ${Math.round(nonNegativeNumber(session.maximums.heartRate))}` : '' } Active @@ -133,9 +131,10 @@ export function sessionToTcx(session: SavedSession): string { ${averageSpeed.toFixed(3)} ${Math.round(averagePower)} - ${Math.round(nonNegative(session.maximums.power))} + ${Math.round(nonNegativeNumber(session.maximums.power))} + ${xmlEscape(session.id)} ${controlSummary} @@ -156,14 +155,9 @@ export function sessionTcxFilename(session: Pick): st } export function downloadSessionTcx(session: SavedSession): void { - const url = URL.createObjectURL( - new Blob([sessionToTcx(session)], { type: 'application/vnd.garmin.tcx+xml' }) + downloadBrowserFile( + sessionToTcx(session), + sessionTcxFilename(session), + 'application/vnd.garmin.tcx+xml' ); - const anchor = document.createElement('a'); - anchor.download = sessionTcxFilename(session); - anchor.href = url; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); } diff --git a/src/lib/trainer-device.ts b/src/lib/trainer-device.ts new file mode 100644 index 0000000..e437faf --- /dev/null +++ b/src/lib/trainer-device.ts @@ -0,0 +1,141 @@ +import { + CONTROL_POINT, + CSC_MEASUREMENT, + CYCLING_POWER, + CYCLING_POWER_MEASUREMENT, + CYCLING_SPEED_AND_CADENCE, + FITNESS_MACHINE, + FITNESS_MACHINE_STATUS, + INDOOR_BIKE_DATA, + SUPPORTED_RESISTANCE_LEVEL_RANGE, +} from '../constants'; +import type { Metrics, Range } from '../types'; +import { + characteristicValue, + connectGatt, + parseCrankCadence, + parseIndoorBikeData, +} from './bluetooth'; +import { combineBluetoothCleanups, startBluetoothNotifications } from './bluetooth-notifications'; + +interface TrainerDeviceCallbacks { + onControlRejected: () => void; + onDisconnect: () => void; + onMetrics: (metrics: Partial, reportsDistance: boolean) => void; +} + +export interface TrainerDeviceConnection { + cleanup: () => void; + controlPoint: BluetoothRemoteGATTCharacteristic; + resistanceRange: Range; +} + +async function optionalPowerSubscription( + server: BluetoothRemoteGATTServer, + onMetrics: TrainerDeviceCallbacks['onMetrics'] +) { + try { + const measurement = await (await server.getPrimaryService(CYCLING_POWER)).getCharacteristic( + CYCLING_POWER_MEASUREMENT + ); + return await startBluetoothNotifications(measurement, (event) => { + const value = characteristicValue(event); + if (value) { + onMetrics({ power: value.getInt16(2, true) }, false); + } + }); + } catch { + // Indoor Bike Data is the normal power source. + } +} + +async function optionalCadenceSubscription( + server: BluetoothRemoteGATTServer, + onMetrics: TrainerDeviceCallbacks['onMetrics'] +) { + let previousCrank: { revolutions: number; time: number } | undefined; + try { + const measurement = await ( + await server.getPrimaryService(CYCLING_SPEED_AND_CADENCE) + ).getCharacteristic(CSC_MEASUREMENT); + return await startBluetoothNotifications(measurement, (event) => { + const value = characteristicValue(event); + if (!value) { + return; + } + const parsed = parseCrankCadence(value, previousCrank); + previousCrank = parsed.current ?? previousCrank; + if (parsed.cadence !== undefined) { + onMetrics({ cadence: parsed.cadence }, false); + } + }); + } catch { + // CSC cadence is optional. + } +} + +export async function connectTrainerDevice( + device: BluetoothDevice, + rediscover: boolean, + fallbackRange: Range, + { onControlRejected, onDisconnect, onMetrics }: TrainerDeviceCallbacks +): Promise { + const server = await connectGatt(device, rediscover); + const service = await server.getPrimaryService(FITNESS_MACHINE); + const bikeData = await service.getCharacteristic(INDOOR_BIKE_DATA); + const removeBikeData = await startBluetoothNotifications(bikeData, (event) => { + const value = characteristicValue(event); + if (!value) { + return; + } + const parsed = parseIndoorBikeData(value); + onMetrics(parsed.metrics, parsed.reportsDistance); + }); + try { + const controlPoint = await service.getCharacteristic(CONTROL_POINT); + const removeControlPoint = await startBluetoothNotifications(controlPoint, (event) => { + const value = characteristicValue(event); + if (value?.getUint8(0) === 0x80 && value.getUint8(2) !== 0x01) { + onControlRejected(); + } + }); + try { + await (await service.getCharacteristic(FITNESS_MACHINE_STATUS)).startNotifications(); + } catch { + // Optional characteristic. + } + let resistanceRange = fallbackRange; + try { + const rangeValue = await ( + await service.getCharacteristic(SUPPORTED_RESISTANCE_LEVEL_RANGE) + ).readValue(); + resistanceRange = { + max: rangeValue.getInt16(2, true) / 10, + min: rangeValue.getInt16(0, true) / 10, + }; + } catch { + // Use the generic range. + } + const [removePower, removeCadence] = await Promise.all([ + optionalPowerSubscription(server, onMetrics), + optionalCadenceSubscription(server, onMetrics), + ]); + device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); + return { + cleanup: combineBluetoothCleanups( + removeBikeData, + removeControlPoint, + removePower, + removeCadence, + () => device.removeEventListener('gattserverdisconnected', onDisconnect) + ), + controlPoint, + resistanceRange, + }; + } catch (error) { + combineBluetoothCleanups(removeBikeData, () => + device.removeEventListener('gattserverdisconnected', onDisconnect) + )(); + throw error; + } +} diff --git a/src/lib/type-guards.ts b/src/lib/type-guards.ts new file mode 100644 index 0000000..8a3269c --- /dev/null +++ b/src/lib/type-guards.ts @@ -0,0 +1,17 @@ +type UnknownFunction = (...args: never[]) => unknown; + +export function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +export function isFunction(value: unknown): value is UnknownFunction { + return typeof value === 'function'; +} + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} diff --git a/src/lib/units.ts b/src/lib/units.ts new file mode 100644 index 0000000..0a5056d --- /dev/null +++ b/src/lib/units.ts @@ -0,0 +1,78 @@ +import type { SpeedUnit } from '../types'; + +export const MILLISECONDS_PER_SECOND = 1000; +export const SECONDS_PER_MINUTE = 60; +export const SECONDS_PER_HOUR = 3600; +export const METERS_PER_KILOMETER = 1000; +export const KILOMETERS_PER_MILE = 1.609_344; +export const KILOMETERS_PER_HOUR_PER_METER_PER_SECOND = 3.6; +export const SPEED_UNIT_STORAGE_KEY = 'speed-unit'; + +export const SPEED_UNIT_OPTIONS: { label: string; value: SpeedUnit }[] = [ + { label: 'KM/H', value: 'kmh' }, + { label: 'MPH', value: 'mph' }, +]; + +export function storedSpeedUnit(storage: Pick = localStorage): SpeedUnit { + return storage.getItem(SPEED_UNIT_STORAGE_KEY) === 'kmh' ? 'kmh' : 'mph'; +} + +export function distanceUnitLabel(unit: SpeedUnit): 'km' | 'mi' { + return unit === 'mph' ? 'mi' : 'km'; +} + +export function speedUnitLabel(unit: SpeedUnit): 'km/h' | 'mph' { + return unit === 'mph' ? 'mph' : 'km/h'; +} + +export function minimumSpeedChartMaximum(unit: SpeedUnit): number { + return unit === 'mph' ? 20 : 30; +} + +export function convertDistance(kilometers: number, unit: SpeedUnit): number { + return unit === 'mph' ? kilometers / KILOMETERS_PER_MILE : kilometers; +} + +export function convertSpeed(kilometersPerHour: number, unit: SpeedUnit): number { + return unit === 'mph' ? kilometersPerHour / KILOMETERS_PER_MILE : kilometersPerHour; +} + +export function formatDistanceValue(kilometers: number, unit: SpeedUnit, decimals = 2): string { + return convertDistance(kilometers, unit).toFixed(decimals); +} + +export function formatDistance(kilometers: number, unit: SpeedUnit, decimals = 2): string { + return `${formatDistanceValue(kilometers, unit, decimals)} ${distanceUnitLabel(unit)}`; +} + +export function formatSpeed(kilometersPerHour: number, unit: SpeedUnit, decimals = 1): string { + return convertSpeed(kilometersPerHour, unit).toFixed(decimals); +} + +export function averageSpeed(distanceKilometers: number, elapsedSeconds: number): number { + return elapsedSeconds > 0 ? distanceKilometers / (elapsedSeconds / SECONDS_PER_HOUR) : 0; +} + +export function kilometersTraveled(kilometersPerHour: number, seconds: number): number { + return (kilometersPerHour * seconds) / SECONDS_PER_HOUR; +} + +export function metersForKilometers(kilometers: number): number { + return kilometers * METERS_PER_KILOMETER; +} + +export function kilometersForMeters(meters: number): number { + return meters / METERS_PER_KILOMETER; +} + +export function metersPerSecond(kilometersPerHour: number): number { + return kilometersPerHour / KILOMETERS_PER_HOUR_PER_METER_PER_SECOND; +} + +export function millisecondsForSeconds(seconds: number): number { + return seconds * MILLISECONDS_PER_SECOND; +} + +export function secondsForMilliseconds(milliseconds: number): number { + return milliseconds / MILLISECONDS_PER_SECOND; +} diff --git a/src/lib/zwift-click-device.ts b/src/lib/zwift-click-device.ts new file mode 100644 index 0000000..b51f1aa --- /dev/null +++ b/src/lib/zwift-click-device.ts @@ -0,0 +1,107 @@ +import { createBluetoothNotificationSubscription } from './bluetooth-notifications'; +import { + type ClickShift, + clickV2StartCommand, + connectClickGatt, + waitForUsableClickNotification, + withClickConnectionTimeout, + ZWIFT_ASYNC_CHARACTERISTIC, + ZWIFT_CLICK_SERVICE, + ZWIFT_LEGACY_SERVICE, + ZWIFT_SYNC_RX_CHARACTERISTIC, + ZWIFT_SYNC_TX_CHARACTERISTIC, +} from './zwift-click'; + +const CLICK_SETUP_STEP_TIMEOUT_MS = 3000; + +export class SupersededClickConnectionError extends Error {} + +function ensureCurrentConnection(isCurrent: () => boolean) { + if (!isCurrent()) { + throw new SupersededClickConnectionError(); + } +} + +async function clickService(server: BluetoothRemoteGATTServer) { + try { + return await server.getPrimaryService(ZWIFT_CLICK_SERVICE); + } catch { + return server.getPrimaryService(ZWIFT_LEGACY_SERVICE); + } +} + +interface ClickDeviceCallbacks { + isCurrent: () => boolean; + isOperational: () => boolean; + onControllerRole: (role: ClickShift) => void; + onDisconnect: () => void; + onMessage: (event: Event) => void; +} + +export async function connectClickDevice( + device: BluetoothDevice, + rediscover: boolean, + { isCurrent, isOperational, onControllerRole, onDisconnect, onMessage }: ClickDeviceCallbacks +): Promise<() => void> { + const server = await connectClickGatt(device, rediscover, onControllerRole); + ensureCurrentConnection(isCurrent); + const service = await withClickConnectionTimeout( + clickService(server), + CLICK_SETUP_STEP_TIMEOUT_MS + ); + ensureCurrentConnection(isCurrent); + const [asyncCharacteristic, syncTxCharacteristic, syncRxCharacteristic] = await Promise.all([ + withClickConnectionTimeout( + service.getCharacteristic(ZWIFT_ASYNC_CHARACTERISTIC), + CLICK_SETUP_STEP_TIMEOUT_MS + ), + withClickConnectionTimeout( + service.getCharacteristic(ZWIFT_SYNC_TX_CHARACTERISTIC), + CLICK_SETUP_STEP_TIMEOUT_MS + ), + withClickConnectionTimeout( + service.getCharacteristic(ZWIFT_SYNC_RX_CHARACTERISTIC), + CLICK_SETUP_STEP_TIMEOUT_MS + ), + ]); + ensureCurrentConnection(isCurrent); + const asyncNotifications = createBluetoothNotificationSubscription( + asyncCharacteristic, + onMessage + ); + const syncNotifications = createBluetoothNotificationSubscription( + syncTxCharacteristic, + onMessage + ); + const cleanup = () => { + asyncNotifications.cleanup(); + syncNotifications.cleanup(); + device.removeEventListener('gattserverdisconnected', onDisconnect); + }; + device.addEventListener('gattserverdisconnected', onDisconnect, { once: true }); + try { + await waitForUsableClickNotification( + [ + withClickConnectionTimeout(asyncNotifications.start(), CLICK_SETUP_STEP_TIMEOUT_MS), + withClickConnectionTimeout(syncNotifications.start(), CLICK_SETUP_STEP_TIMEOUT_MS), + ], + isOperational + ); + ensureCurrentConnection(isCurrent); + try { + await withClickConnectionTimeout( + syncRxCharacteristic.writeValueWithoutResponse(clickV2StartCommand()), + CLICK_SETUP_STEP_TIMEOUT_MS + ); + } catch (error) { + if (!isOperational()) { + throw error; + } + } + ensureCurrentConnection(isCurrent); + return cleanup; + } catch (error) { + cleanup(); + throw error; + } +} diff --git a/src/lib/zwift-click.ts b/src/lib/zwift-click.ts index faaee2a..5a8265a 100644 --- a/src/lib/zwift-click.ts +++ b/src/lib/zwift-click.ts @@ -1,4 +1,5 @@ import { waitForFreshAdvertisement } from './bluetooth'; +import { isRecord, isString } from './type-guards'; export const ZWIFT_CLICK_NAME = 'Zwift Click'; export const ZWIFT_CLICK_SERVICE = '0000fc82-0000-1000-8000-00805f9b34fb'; @@ -7,6 +8,9 @@ export const ZWIFT_ASYNC_CHARACTERISTIC = '00000002-19ca-4651-86e5-fa29dcdd09d1' export const ZWIFT_SYNC_RX_CHARACTERISTIC = '00000003-19ca-4651-86e5-fa29dcdd09d1'; export const ZWIFT_SYNC_TX_CHARACTERISTIC = '00000004-19ca-4651-86e5-fa29dcdd09d1'; export const ZWIFT_MANUFACTURER_ID = 2378; +export const CLICK_DEVICE_IDS_STORAGE_KEY = 'zwift-click-v2-device-ids'; +export const CLICK_CONTROLLER_ROLES_STORAGE_KEY = 'zwift-click-v2-controller-roles'; +export const MAX_CLICK_CONTROLLERS = 2; const CONTROLLER_NOTIFICATION = 0x23; const MINUS_BUTTON_MASK = 0x01_00; @@ -81,17 +85,13 @@ export async function withClickConnectionTimeout( export async function connectClickGatt( device: BluetoothDevice, rediscover: boolean, - updateStatus: (status: string) => void, onControllerRole?: (role: ClickShift) => void ): Promise { const { gatt } = device; if (!gatt) { throw new Error('This controller does not expose Bluetooth services.'); } - const connect = (timeoutMs: number) => { - updateStatus('Connecting controllers…'); - return withClickConnectionTimeout(gatt.connect(), timeoutMs); - }; + const connect = (timeoutMs: number) => withClickConnectionTimeout(gatt.connect(), timeoutMs); const observeController = () => waitForFreshAdvertisement( device, @@ -112,7 +112,6 @@ export async function connectClickGatt( ); } catch { gatt.disconnect(); - updateStatus('Finding controllers…'); await (advertisement ?? observeController()); return connect(CLICK_REDISCOVERED_CONNECTION_TIMEOUT_MS); } @@ -240,10 +239,8 @@ export function parseClickV2Shift( export function storedClickDeviceIds(storage: Pick = localStorage): string[] { try { - const saved = JSON.parse(storage.getItem('zwift-click-v2-device-ids') ?? '[]'); - return Array.isArray(saved) - ? saved.filter((value): value is string => typeof value === 'string').slice(0, 2) - : []; + const saved = JSON.parse(storage.getItem(CLICK_DEVICE_IDS_STORAGE_KEY) ?? '[]'); + return Array.isArray(saved) ? saved.filter(isString).slice(0, MAX_CLICK_CONTROLLERS) : []; } catch { return []; } @@ -253,8 +250,8 @@ export function storedClickControllerRoles( storage: Pick = localStorage ): ClickControllerRoles { try { - const saved = JSON.parse(storage.getItem('zwift-click-v2-controller-roles') ?? '{}'); - if (!saved || typeof saved !== 'object' || Array.isArray(saved)) { + const saved = JSON.parse(storage.getItem(CLICK_CONTROLLER_ROLES_STORAGE_KEY) ?? '{}'); + if (!isRecord(saved)) { return {}; } const roles: ClickControllerRoles = {}; diff --git a/src/stores/preferences-store.ts b/src/stores/preferences-store.ts new file mode 100644 index 0000000..7f86940 --- /dev/null +++ b/src/stores/preferences-store.ts @@ -0,0 +1,39 @@ +import { createStore } from '@tanstack/react-store'; +import { CHART_MODE_STORAGE_KEY, storedChartMode } from '../lib/chart'; +import { SPEED_UNIT_STORAGE_KEY, storedSpeedUnit } from '../lib/units'; +import type { ChartMode, SpeedUnit } from '../types'; + +interface PreferencesStorage extends Pick {} + +const unavailableStorage: PreferencesStorage = { + getItem: () => null, + setItem: () => undefined, +}; + +const defaultPreferencesStorage = globalThis.localStorage ?? unavailableStorage; + +export interface PreferencesStoreState { + chartMode: ChartMode; + speedUnit: SpeedUnit; +} + +export function createPreferencesStore(storage: PreferencesStorage = defaultPreferencesStorage) { + return createStore( + { + chartMode: storedChartMode(storage), + speedUnit: storedSpeedUnit(storage), + }, + ({ setState }) => ({ + selectChartMode: (chartMode: ChartMode) => { + storage.setItem(CHART_MODE_STORAGE_KEY, chartMode); + setState((current) => ({ ...current, chartMode })); + }, + selectSpeedUnit: (speedUnit: SpeedUnit) => { + storage.setItem(SPEED_UNIT_STORAGE_KEY, speedUnit); + setState((current) => ({ ...current, speedUnit })); + }, + }) + ); +} + +export const preferencesStore = createPreferencesStore(); diff --git a/src/stores/session-store.ts b/src/stores/session-store.ts new file mode 100644 index 0000000..e207d81 --- /dev/null +++ b/src/stores/session-store.ts @@ -0,0 +1,196 @@ +import { createStore } from '@tanstack/react-store'; +import { emptyMetrics, emptySession, MAX_SESSION_HISTORY_SAMPLES } from '../constants'; +import { CONTROL_MODE, type ControlMode } from '../lib/control-mode'; +import { estimatedCyclingCalories } from '../lib/cycling-energy'; +import { addMetricAggregates, SESSION_STORAGE_KEY, sessionContinuation } from '../lib/session'; +import { kilometersTraveled } from '../lib/units'; +import type { Metrics, SessionSnapshot, StoredSession } from '../types'; + +interface RecordSessionTick { + control: { + gear: number; + mode: ControlMode; + resistance: number; + }; + distanceDelta?: number; + metrics: Metrics; + seconds: number; +} + +export interface SessionStoreState extends StoredSession { + isRiding: boolean; + manuallyPaused: boolean; +} + +type SessionStorage = Pick; + +function initialSessionState(restored: StoredSession, now: number): SessionStoreState { + return { + ...restored, + isRiding: false, + manuallyPaused: false, + startedAt: restored.startedAt || now, + }; +} + +export function sessionSnapshotFromState(state: SessionStoreState): SessionSnapshot { + return { + aggregates: state.aggregates, + calories: state.calories, + controlMode: state.controlMode, + distance: state.distance, + elapsedSeconds: state.elapsedSeconds, + endedAt: state.endedAt, + history: state.history, + maximums: state.maximums, + startedAt: state.startedAt, + }; +} + +export function storedSessionFromState(state: SessionStoreState): StoredSession { + return { + aggregates: state.aggregates, + calories: state.calories, + controlMode: state.controlMode, + discarded: state.discarded, + distance: state.distance, + elapsedSeconds: state.elapsedSeconds, + ended: state.ended, + endedAt: state.endedAt, + history: state.history.slice(-MAX_SESSION_HISTORY_SAMPLES), + maximums: state.maximums, + savedSessionId: state.savedSessionId, + startedAt: state.startedAt, + }; +} + +export function persistSessionState( + state: SessionStoreState, + storage: SessionStorage = localStorage +) { + storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(storedSessionFromState(state))); +} + +export function createSessionStore(restored: StoredSession, now = Date.now()) { + return createStore(initialSessionState(restored, now), ({ setState }) => ({ + continueFrom: (sourceSession: SessionSnapshot) => { + const continued = sessionContinuation(sourceSession); + setState(() => ({ + ...continued, + isRiding: false, + manuallyPaused: false, + })); + }, + endSession: (endedAt: number) => { + setState((current) => ({ + ...current, + ended: true, + endedAt, + isRiding: false, + manuallyPaused: false, + })); + }, + markDiscarded: () => { + setState((current) => ({ ...current, discarded: true, savedSessionId: undefined })); + }, + markSaved: (savedSessionId: string) => { + setState((current) => ({ ...current, discarded: false, savedSessionId })); + }, + observeControlMode: (controlMode: ControlMode) => { + setState((current) => + current.elapsedSeconds === 0 && current.controlMode !== controlMode + ? { ...current, controlMode } + : current + ); + }, + observeMetrics: (metrics: Metrics) => { + setState((current) => { + if (current.ended) { + return current; + } + const maximums = { + ...current.maximums, + cadence: Math.max(current.maximums.cadence, metrics.cadence), + heartRate: Math.max(current.maximums.heartRate, metrics.heartRate), + power: Math.max(current.maximums.power, metrics.power), + speed: Math.max(current.maximums.speed, metrics.speed), + }; + if ( + maximums.cadence === current.maximums.cadence && + maximums.heartRate === current.maximums.heartRate && + maximums.power === current.maximums.power && + maximums.speed === current.maximums.speed + ) { + return current; + } + return { ...current, maximums }; + }); + }, + recordTick: ({ control, distanceDelta, metrics, seconds }: RecordSessionTick) => { + setState((current) => { + if (!current.isRiding) { + return current; + } + const elapsedSeconds = current.elapsedSeconds + seconds; + const controlSample = + control.mode === CONTROL_MODE.GEAR + ? { gear: control.gear } + : { resistance: control.resistance }; + const sample = { + cadence: metrics.cadence, + elapsedSeconds, + heartRate: metrics.heartRate, + power: metrics.power, + speed: metrics.speed, + ...controlSample, + }; + return { + ...current, + aggregates: addMetricAggregates(current.aggregates, { + ...metrics, + ...controlSample, + }), + calories: current.calories + estimatedCyclingCalories(metrics.power, seconds), + controlMode: control.mode, + distance: + current.distance + + (distanceDelta ?? kilometersTraveled(metrics.speed, seconds)), + elapsedSeconds, + history: [...current.history, sample], + }; + }); + }, + reset: (controlMode: ControlMode, startedAt: number) => { + setState(() => ({ + ...emptySession, + aggregates: emptySession.aggregates, + controlMode, + isRiding: false, + manuallyPaused: false, + maximums: emptyMetrics, + startedAt, + })); + }, + syncRiding: (recentlyPedaling: boolean) => { + setState((current) => { + const isRiding = !(current.ended || current.manuallyPaused) && recentlyPedaling; + return current.isRiding === isRiding ? current : { ...current, isRiding }; + }); + }, + togglePause: (recentlyPedaling: boolean) => { + setState((current) => { + if (current.ended) { + return current; + } + const manuallyPaused = !current.manuallyPaused; + return { + ...current, + isRiding: manuallyPaused ? false : recentlyPedaling, + manuallyPaused, + }; + }); + }, + })); +} + +export type SessionStore = ReturnType; diff --git a/src/stores/session-workflow-store.ts b/src/stores/session-workflow-store.ts new file mode 100644 index 0000000..4c008b5 --- /dev/null +++ b/src/stores/session-workflow-store.ts @@ -0,0 +1,43 @@ +import { createStore } from '@tanstack/react-store'; +import { + SESSION_WORKFLOW_INTENT, + SESSION_WORKFLOW_PHASE, + type SessionWorkflowIntent, + type SessionWorkflowState, +} from '../lib/session-workflow'; + +export function initialSessionWorkflowState(open: boolean): SessionWorkflowState { + return open + ? { + intent: { kind: SESSION_WORKFLOW_INTENT.END }, + phase: SESSION_WORKFLOW_PHASE.PROMPT, + } + : { phase: SESSION_WORKFLOW_PHASE.CLOSED }; +} + +export function createSessionWorkflowStore(open: boolean) { + return createStore(initialSessionWorkflowState(open), ({ setState }) => ({ + close: () => { + setState(() => ({ phase: SESSION_WORKFLOW_PHASE.CLOSED })); + }, + open: (intent: SessionWorkflowIntent) => { + setState(() => ({ intent, phase: SESSION_WORKFLOW_PHASE.PROMPT })); + }, + saveFailed: () => { + setState((current) => + current.phase === SESSION_WORKFLOW_PHASE.SAVING + ? { ...current, phase: SESSION_WORKFLOW_PHASE.PROMPT } + : current + ); + }, + startSaving: () => { + setState((current) => + current.phase === SESSION_WORKFLOW_PHASE.PROMPT + ? { ...current, phase: SESSION_WORKFLOW_PHASE.SAVING } + : current + ); + }, + })); +} + +export type SessionWorkflowStore = ReturnType; diff --git a/src/stores/trainer-store.ts b/src/stores/trainer-store.ts new file mode 100644 index 0000000..a5464b8 --- /dev/null +++ b/src/stores/trainer-store.ts @@ -0,0 +1,75 @@ +import { createStore } from '@tanstack/react-store'; +import { emptyMetrics } from '../constants'; +import type { DeviceConnectionPhase } from '../lib/device-connection'; +import { MAX_RESISTANCE, MIN_RESISTANCE } from '../lib/resistance'; +import { storedResistance } from '../lib/session'; +import type { Metrics, Range, ResistanceAdjustmentDirection, ResistanceRamp } from '../types'; + +export interface TrainerStoreState { + connectionPhase: DeviceConnectionPhase; + deviceName?: string; + metrics: Metrics; + notice: string; + pairedDeviceName?: string; + resistance: number; + resistanceKeyFlash?: ResistanceAdjustmentDirection; + resistanceRamp: ResistanceRamp; + resistanceRange: Range; +} + +export function createTrainerStore() { + const resistance = storedResistance(); + return createStore( + { + connectionPhase: 'unpaired', + metrics: emptyMetrics, + notice: '', + resistance, + resistanceRamp: { + current: resistance, + from: resistance, + phase: 'holding', + progress: 0, + to: resistance, + }, + resistanceRange: { max: MAX_RESISTANCE, min: MIN_RESISTANCE }, + } as TrainerStoreState, + ({ setState }) => ({ + mergeMetrics: (metrics: Partial) => { + setState((current) => ({ + ...current, + metrics: { ...current.metrics, ...metrics }, + })); + }, + setConnectionPhase: (connectionPhase: DeviceConnectionPhase) => { + setState((current) => ({ ...current, connectionPhase })); + }, + setDeviceName: (deviceName?: string) => { + setState((current) => ({ ...current, deviceName })); + }, + setMetrics: (metrics: Metrics) => { + setState((current) => ({ ...current, metrics })); + }, + setNotice: (notice: string) => { + setState((current) => ({ ...current, notice })); + }, + setPairedDeviceName: (pairedDeviceName?: string) => { + setState((current) => ({ ...current, pairedDeviceName })); + }, + setResistance: (nextResistance: number) => { + setState((current) => ({ ...current, resistance: nextResistance })); + }, + setResistanceKeyFlash: (resistanceKeyFlash?: ResistanceAdjustmentDirection) => { + setState((current) => ({ ...current, resistanceKeyFlash })); + }, + setResistanceRamp: (resistanceRamp: ResistanceRamp) => { + setState((current) => ({ ...current, resistanceRamp })); + }, + setResistanceRange: (resistanceRange: Range) => { + setState((current) => ({ ...current, resistanceRange })); + }, + }) + ); +} + +export type TrainerStore = ReturnType; diff --git a/src/stores/zwift-click-store.ts b/src/stores/zwift-click-store.ts new file mode 100644 index 0000000..8ede74c --- /dev/null +++ b/src/stores/zwift-click-store.ts @@ -0,0 +1,80 @@ +import { createStore } from '@tanstack/react-store'; +import { + type DeviceConnectionPhase, + removeConnectionPhase, + setConnectionPhase, +} from '../lib/device-connection'; +import type { ClickControllerRoles } from '../lib/zwift-click'; + +export interface ZwiftClickStoreState { + activeControllerIds: string[]; + controllerPhases: Record; + controllerRoles: ClickControllerRoles; + deviceIds: string[]; + pairing: boolean; +} + +export function createZwiftClickStore() { + return createStore( + { + activeControllerIds: [], + controllerPhases: {}, + controllerRoles: {}, + deviceIds: [], + pairing: false, + } as ZwiftClickStoreState, + ({ setState }) => ({ + activateController: (deviceId: string) => { + setState((current) => + current.activeControllerIds.includes(deviceId) + ? current + : { + ...current, + activeControllerIds: [...current.activeControllerIds, deviceId], + } + ); + }, + clearActiveControllers: () => { + setState((current) => + current.activeControllerIds.length + ? { ...current, activeControllerIds: [] } + : current + ); + }, + deactivateController: (deviceId: string) => { + setState((current) => ({ + ...current, + activeControllerIds: current.activeControllerIds.filter( + (id) => id !== deviceId + ), + })); + }, + removeControllerPhase: (deviceId: string) => { + setState((current) => ({ + ...current, + controllerPhases: removeConnectionPhase(current.controllerPhases, deviceId), + })); + }, + setControllerPhase: (deviceId: string, phase: DeviceConnectionPhase) => { + setState((current) => ({ + ...current, + controllerPhases: setConnectionPhase(current.controllerPhases, deviceId, phase), + })); + }, + setControllerPhases: (controllerPhases: Record) => { + setState((current) => ({ ...current, controllerPhases })); + }, + setControllerRoles: (controllerRoles: ClickControllerRoles) => { + setState((current) => ({ ...current, controllerRoles })); + }, + setDeviceIds: (deviceIds: string[]) => { + setState((current) => ({ ...current, deviceIds })); + }, + setPairing: (pairing: boolean) => { + setState((current) => ({ ...current, pairing })); + }, + }) + ); +} + +export type ZwiftClickStore = ReturnType; diff --git a/src/types.ts b/src/types.ts index 309f282..cd970c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +import type { ControlMode } from './lib/control-mode'; + +export type { ControlMode } from './lib/control-mode'; + export interface Metrics { cadence: number; calories: number; @@ -40,6 +44,7 @@ export interface MetricSample { export interface MetricAggregate { count: number; + maximum?: number; sum: number; } @@ -51,12 +56,11 @@ export interface SessionAggregates { resistance: MetricAggregate; } -export type ControlMode = 'gear' | 'resistance'; - export interface StoredSession { aggregates: SessionAggregates; calories: number; controlMode: ControlMode; + discarded: boolean; distance: number; elapsedSeconds: number; ended: boolean; @@ -88,6 +92,7 @@ export interface SessionSnapshot { export interface SavedSession extends SessionSnapshot, SessionMetadata { id: string; + importedAt?: number; } export interface SavedSessionSummary { @@ -97,6 +102,7 @@ export interface SavedSessionSummary { endedAt: number; feeling?: SessionFeeling; id: string; + importedAt?: number; startedAt: number; } diff --git a/tests/bluetooth.test.ts b/tests/bluetooth.test.ts index 2b510a4..7dea269 100644 --- a/tests/bluetooth.test.ts +++ b/tests/bluetooth.test.ts @@ -95,14 +95,12 @@ describe('Bluetooth data utilities', () => { expect(await findRememberedKickr({} as Bluetooth, { getItem: () => null })).toBeUndefined(); }); - test('connects GATT and reports status', async () => { + test('connects GATT', async () => { const server = {} as BluetoothRemoteGATTServer; - const statuses: string[] = []; const device = { gatt: { connect: async () => server }, } as unknown as BluetoothDevice; - expect(await connectGatt(device, false, (status) => statuses.push(status))).toBe(server); - expect(statuses).toEqual(['Connecting…']); + expect(await connectGatt(device, false)).toBe(server); }); test('rediscovers and retries a failed GATT connection', async () => { @@ -115,7 +113,6 @@ describe('Bluetooth data utilities', () => { }, }); const server = {} as BluetoothRemoteGATTServer; - const statuses: string[] = []; let attempts = 0; const device = { addEventListener: () => undefined, @@ -132,9 +129,8 @@ describe('Bluetooth data utilities', () => { watchAdvertisements: () => Promise.reject(new Error('advertisement already fresh')), } as unknown as BluetoothDevice; try { - expect(await connectGatt(device, true, (status) => statuses.push(status))).toBe(server); + expect(await connectGatt(device, true)).toBe(server); expect(attempts).toBe(2); - expect(statuses).toEqual(['Connecting…', 'Finding trainer…', 'Connecting…']); } finally { if (originalWindow) { Object.defineProperty(globalThis, 'window', originalWindow); @@ -145,7 +141,7 @@ describe('Bluetooth data utilities', () => { }); test('rejects devices without a GATT server', async () => { - await expect(connectGatt({} as BluetoothDevice, false, () => undefined)).rejects.toThrow( + await expect(connectGatt({} as BluetoothDevice, false)).rejects.toThrow( 'does not expose a GATT server' ); }); diff --git a/tests/browser.test.ts b/tests/browser.test.ts index 0f661c4..500f835 100644 --- a/tests/browser.test.ts +++ b/tests/browser.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; -import { bluetoothBrowserNotice, isTestedChromeBrowser } from '../src/lib/browser'; +import { + automaticBluetoothReconnectConfigured, + bluetoothBrowserNotice, + isTestedChromeBrowser, +} from '../src/lib/browser'; function browser(userAgent: string, brands: string[] = [], extra: Record = {}) { return { @@ -39,4 +43,9 @@ describe('browser compatibility', () => { 'Chrome is currently the only browser tested' ); }); + + test('detects the persistent Bluetooth reconnect capability', () => { + expect(automaticBluetoothReconnectConfigured({ getDevices: async () => [] })).toBeTrue(); + expect(automaticBluetoothReconnectConfigured({})).toBeFalse(); + }); }); diff --git a/tests/components.test.tsx b/tests/components.test.tsx index ba9e5af..d414837 100644 --- a/tests/components.test.tsx +++ b/tests/components.test.tsx @@ -6,25 +6,27 @@ import { DevicePairingButton, DevicePairingPanel } from '../src/components/devic import { GearControl } from '../src/components/gear-control'; import { Icon } from '../src/components/icon'; import { KeyboardShortcutsDialog } from '../src/components/keyboard-shortcuts-dialog'; -import { - Metric, - metricAccentClass, - metricIconClass, - SessionMetric, - SmallMetric, -} from '../src/components/metrics'; +import { Metric, SessionMetric, SmallMetric } from '../src/components/metrics'; import { Notification } from '../src/components/notification'; import { ResistanceControl } from '../src/components/resistance-control'; import { SessionChart } from '../src/components/session-chart'; -import { - DeleteSessionDialog, - SessionDetail, - SessionHistory, -} from '../src/components/session-history'; +import { DeleteSessionDialog, SessionDetail } from '../src/components/session-detail'; +import { SessionHistory } from '../src/components/session-history'; +import { SessionHistoryList } from '../src/components/session-history-list'; import { SessionSaveDialog } from '../src/components/session-save-dialog'; +import { TrainingControl } from '../src/components/training-control'; import { WelcomeDialog } from '../src/components/welcome-dialog'; -import { CHROME_BLUETOOTH_PERMISSION_MESSAGE, emptyMetrics, emptySession } from '../src/constants'; +import { + CHROME_BLUETOOTH_FLAGS_URL, + CHROME_BLUETOOTH_PERMISSION_MESSAGE, + emptyMetrics, + emptySession, +} from '../src/constants'; import { historyKeyboardShortcuts } from '../src/lib/keyboard'; +import { metricAccentClass, metricIconClass } from '../src/lib/metric-presentation'; +import { formatSessionImportTime, sessionSummary } from '../src/lib/saved-sessions'; +import { SESSION_WORKFLOW_INTENT } from '../src/lib/session-workflow'; +import { savedSessionFixture } from './fixtures/saved-session'; const render = (element: React.ReactNode) => renderToStaticMarkup(element); const enabledEndSessionButton = /]*disabled)[^>]*>End session<\/button>/; @@ -53,9 +55,11 @@ describe('view components', () => { expect(html).toContain('POWER'); expect(html).toContain('200'); expect(html).toContain('grid grid-cols-2 gap-3 border-line border-t pt-3'); - expect(html).toContain('font-semibold text-2xl text-white tabular-nums tracking-tight'); - expect(html).toContain('180'); - expect(html).toContain('300'); + expect(html).toContain('font-semibold text-6xl tracking-tight'); + expect(html).toContain('font-semibold text-4xl text-white tabular-nums tracking-tight'); + expect(html).toContain('>180

    '); + expect(html).toContain('>300

    '); + expect(html.match(/watts/g)).toHaveLength(1); expect(metricAccentClass('rose')).toBe('bg-rose-400'); expect(metricAccentClass('other')).toBe('bg-mint'); expect(metricIconClass('violet')).toBe('text-violet-400'); @@ -64,6 +68,13 @@ describe('view components', () => { test('renders a compact session metric', () => { expect(render()).toContain('01:02:03'); + expect(render()).toContain( + 'text-3xl sm:text-5xl' + ); + const distance = render(); + expect(distance).toContain('>10.00'); + expect(distance).toContain('text-base sm:text-xl'); + expect(distance).toContain('>mi'); const html = render( { expect(html).toContain('text-3xl'); expect(html).toContain('bg-yellow-400'); expect(html).toContain('bolt'); + expect(html.match(/>W<\/span>/g)).toHaveLength(1); + expect(html).toContain('MAX300'); const averageOnly = render( { onPair: () => undefined, onReconnect: () => undefined, paired: false, + phase: 'unpaired' as const, + reconnecting: false, status: 'Not paired', }; const panel = render( @@ -259,26 +274,37 @@ describe('view components', () => { browserNotice="" click={{ ...common, + busy: true, connectedCount: 0, controllers: [ { active: false, + busy: true, connected: false, - connecting: true, id: 'minus-click', label: '− Controller', + paired: true, + phase: 'reconnecting', + reconnecting: true, + status: 'Reconnecting…', }, { active: true, + busy: false, connected: false, - connecting: false, id: 'plus-click', label: '+ Controller', + paired: true, + phase: 'offline', + reconnecting: false, + status: 'Paired · offline', }, ], onForgetController: () => undefined, + paired: true, pairedCount: 2, pairing: false, + phase: 'reconnecting', reconnecting: true, }} heartRate={common} @@ -288,22 +314,55 @@ describe('view components', () => { /> ); expect(panel).toContain('Paired devices'); + expect(panel).toContain('data-side-tray="true"'); + expect(panel).toContain('transition-opacity duration-200'); + expect(panel).toContain('transition-transform duration-200'); + expect(panel).toContain('translate-x-0'); expect(panel).toContain('Smart trainer'); expect(panel).toContain('Heart rate'); expect(panel).toContain('Zwift Click V2'); - expect(panel).toContain('Waiting for controllers…'); + expect(panel).toContain('Reconnecting…'); + expect(panel).not.toContain('Waiting for controllers…'); expect(panel).not.toContain('Retry'); - expect(panel).toContain('Reconnect'); - expect(panel.match(/animate-pulse/g)).toHaveLength(3); - expect(panel.match(/animate-pulse bg-sky-400/g)).toHaveLength(2); - expect(panel).toContain('Enable reconnect saving in Chrome'); - expect(panel).toContain('https://github.com/lookfirst/RideControl#automatic-reconnect'); + expect(panel).toContain('Connecting…'); + expect(panel).not.toContain('>Reconnect'); + expect(panel.match(/animate-pulse/g)).toHaveLength(2); + expect(panel.match(/animate-pulse bg-sky-400/g)).toHaveLength(1); + expect(panel).toContain('Automatic reconnect in Chrome'); + expect(panel).toContain('Chrome needs persistent Bluetooth permissions'); + expect(panel).toContain('chrome://flags/#enable-web-bluetooth-new-permissions-backend'); + expect(panel).toContain('Copy Chrome Bluetooth settings address'); + expect(panel).toContain('Use the new permissions backend for Web Bluetooth'); + expect(panel).toContain('Relaunch Chrome, then pair each device once more.'); + expect(panel).not.toContain('github.com/lookfirst/RideControl#automatic-reconnect'); expect(panel).toContain('+ Controller'); expect(panel.indexOf('+ Controller')).toBeLessThan(panel.indexOf('− Controller')); expect(panel).toContain('animate-pulse'); expect(panel).toContain('bg-mint/10'); expect(panel).not.toContain('shadow-[inset_0_0_18px'); expect(panel).not.toContain('divide-y'); + const configuredPanel = render( + undefined, + pairedCount: 0, + pairing: false, + reconnecting: false, + }} + heartRate={common} + onClose={() => undefined} + open + trainer={common} + /> + ); + expect(configuredPanel).toContain('Automatic reconnect is configured correctly'); + expect(configuredPanel).not.toContain(CHROME_BLUETOOTH_FLAGS_URL); + expect(configuredPanel).not.toContain('Use the new permissions backend for Web Bluetooth'); const unsupportedPanel = render( { /> ); expect(unsupportedPanel).toContain('Bluetooth does not work in Brave'); - expect(unsupportedPanel).not.toContain('Enable reconnect saving in Chrome'); + expect(unsupportedPanel).not.toContain('Automatic reconnect in Chrome'); expect(unsupportedPanel).not.toContain( - 'https://github.com/lookfirst/RideControl#automatic-reconnect' + 'chrome://flags/#enable-web-bluetooth-new-permissions-backend' ); expect(unsupportedPanel).not.toContain('Smart trainer'); expect(unsupportedPanel).not.toContain('Heart rate'); @@ -348,6 +407,39 @@ describe('view components', () => { expect(html).toContain('HARDER'); expect(html).toContain('grid h-9 w-9 shrink-0 place-items-center rounded-lg'); expect(html).toContain('scale-105 border-mint bg-mint/15 text-mint'); + expect(html).not.toContain('Connect the trainer and controllers before shifting gears.'); + const disabled = render( undefined} />); + expect(disabled).not.toContain( + 'Connect the trainer and controllers before shifting gears.' + ); + expect(disabled).not.toContain('Use Zwift Click'); + expect(disabled.match(/disabled=""/g)).toHaveLength(2); + }); + + test('renders only the selected training control mode', () => { + const gear = render( + undefined }} + /> + ); + expect(gear).toContain('Virtual shifting'); + expect(gear).toContain('of 24'); + expect(gear).not.toContain('Resistance control'); + + const resistance = render( + undefined, + ramp: { current: 40, from: 40, phase: 'holding', progress: 0, to: 40 }, + resistance: 40, + }} + /> + ); + expect(resistance).toContain('Resistance control'); + expect(resistance).not.toContain('Virtual shifting'); }); test('hides empty notifications and expands setup guidance', () => { @@ -448,6 +540,9 @@ describe('view components', () => { expect(historyHtml).toContain('Change the session chart view'); expect(historyHtml).toContain('Delete the selected session'); expect(historyHtml).toContain('Confirm session deletion'); + expect(historyHtml).toContain('NAVIGATION'); + expect(historyHtml).toContain('SESSION'); + expect(historyHtml).toContain('GENERAL'); expect(historyHtml).not.toContain('Increase or decrease resistance'); expect(historyHtml).not.toContain('Pause or resume'); expect(historyHtml).not.toContain('Start a new session after ending'); @@ -486,6 +581,8 @@ describe('view components', () => { expect(html).toContain('class="block h-full w-full"'); expect(html).toMatch(solidChartBoundaries); expect(html).toMatch(dashedChartGuides); + expect(html.match(/data-chart-separator="true"/g)).toHaveLength(4); + expect(html.match(/-my-3 ml-15 h-6 bg-white\/1\.5/g)).toHaveLength(4); expect(html).not.toContain('absolute top-[11%] bottom-[8%] left-1'); }); @@ -516,6 +613,7 @@ describe('view components', () => { expect( render( undefined} onSave={async () => undefined} onStartWithoutSaving={() => undefined} @@ -528,7 +626,7 @@ describe('view components', () => { ).toBe(''); const html = render( undefined} onSave={async () => undefined} onStartWithoutSaving={() => undefined} @@ -551,6 +649,35 @@ describe('view components', () => { expect(html).toContain('Save this session?'); expect(html).toContain('How did it feel?'); expect(html).toContain('Continue without saving'); + expect(html).toContain('Save & continue'); + const endSession = render( + undefined} + onSave={async () => undefined} + onStartWithoutSaving={() => undefined} + open + saving={false} + session={{ ...emptySession, maximums: emptyMetrics }} + speedUnit="kmh" + /> + ); + expect(endSession).toContain('End without saving'); + expect(endSession).toContain('Save session'); + const newSession = render( + undefined} + onSave={async () => undefined} + onStartWithoutSaving={() => undefined} + open + saving={false} + session={{ ...emptySession, maximums: emptyMetrics }} + speedUnit="kmh" + /> + ); + expect(newSession).toContain('Start new without saving'); + expect(newSession).toContain('Save & start new'); }); test('renders an empty session history', () => { @@ -563,12 +690,64 @@ describe('view components', () => { /> ); expect(html).toContain('Session history'); + expect(html).toContain('data-side-tray="true"'); expect(html).toContain('No saved sessions yet'); + expect(html).toContain('Import TCX'); + expect(html).toContain('Download all'); + expect(html).toContain('.tcx,.zip'); + expect(html).toContain('End a session or import a TCX file to add it here.'); expect(html).toContain('ml-auto'); expect(html).toContain('translate-x-0'); expect(html).toContain('Show history keyboard controls'); }); + test('highlights every session from the latest import in history navigation', () => { + const importedSession = { ...savedSessionFixture, importedAt: Date.UTC(2026, 6, 19) }; + const html = render( + undefined} + onSelect={() => undefined} + selectedId={importedSession.id} + speedUnit="kmh" + summaries={[sessionSummary(importedSession)]} + total={1} + /> + ); + expect(html).toContain('aria-label="Imported from TCX file"'); + expect(html).toContain('Imported from TCX file'); + expect(html).toContain('absolute right-2.5 bottom-3'); + expect(html).toContain('class="h-5 w-5"'); + expect(html).toContain('ring-cyan-400/70'); + expect(html).toContain('shadow-[0_0_14px_rgba(34,211,238,0.16)]'); + }); + + test('labels imported sessions permanently without retaining the fresh highlight', () => { + const importedSession = { ...savedSessionFixture, importedAt: Date.UTC(2026, 6, 19) }; + const list = render( + undefined} + onSelect={() => undefined} + speedUnit="kmh" + summaries={[sessionSummary(importedSession)]} + total={1} + /> + ); + expect(list).toContain('aria-label="Imported from TCX file"'); + expect(list).not.toContain('>Imported<'); + expect(list).not.toContain('ring-cyan-400/70'); + const detail = render(); + expect(detail).toContain('>Imported<'); + expect(detail).not.toContain('Imported TCX'); + expect(detail).toContain('MAX45'); + expect(detail).toContain( + `title="Imported ${formatSessionImportTime(importedSession.importedAt)}` + ); + }); + test('renders session deletion confirmation as a modal', () => { const html = render( { session={{ aggregates: { ...emptySession.aggregates, - gear: { count: 2, sum: 27 }, + gear: { count: 2, maximum: 14, sum: 27 }, }, calories: 0, comments: '', @@ -694,6 +873,7 @@ describe('view components', () => { /> ); expect(html).toContain('GEAR'); + expect(html).toContain('MAX14'); expect(html).not.toContain('RESISTANCE'); }); }); diff --git a/tests/cycling-energy.test.ts b/tests/cycling-energy.test.ts new file mode 100644 index 0000000..5b2ff65 --- /dev/null +++ b/tests/cycling-energy.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; +import { + ESTIMATED_CYCLING_EFFICIENCY, + estimatedCyclingCalories, + JOULES_PER_KILOCALORIE, +} from '../src/lib/cycling-energy'; + +describe('cycling energy estimates', () => { + test('converts mechanical work into estimated dietary calories', () => { + const seconds = 3600; + const powerWatts = 200; + const expected = + (powerWatts * seconds) / (JOULES_PER_KILOCALORIE * ESTIMATED_CYCLING_EFFICIENCY); + + expect(estimatedCyclingCalories(powerWatts, seconds)).toBe(expected); + expect(estimatedCyclingCalories(0, seconds)).toBe(0); + }); +}); diff --git a/tests/device-connection.test.ts b/tests/device-connection.test.ts new file mode 100644 index 0000000..0c5905c --- /dev/null +++ b/tests/device-connection.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test'; +import { startBluetoothNotifications } from '../src/lib/bluetooth-notifications'; +import { + aggregateConnectionPhase, + deviceConnectionView, + removeConnectionPhase, + setConnectionPhase, +} from '../src/lib/device-connection'; +import { createReconnectController } from '../src/lib/reconnect-controller'; + +describe('device connection state', () => { + test('derives every public flag and label from one phase', () => { + expect(deviceConnectionView('unpaired')).toEqual({ + busy: false, + connected: false, + paired: false, + phase: 'unpaired', + reconnecting: false, + status: 'Not paired', + }); + expect(deviceConnectionView('reconnecting')).toEqual({ + busy: true, + connected: false, + paired: true, + phase: 'reconnecting', + reconnecting: true, + status: 'Reconnecting…', + }); + expect(deviceConnectionView('connected').connected).toBeTrue(); + }); + + test('aggregates independent controller phases without impossible states', () => { + expect(aggregateConnectionPhase([])).toBe('unpaired'); + expect(aggregateConnectionPhase(['connected', 'connected'])).toBe('connected'); + expect(aggregateConnectionPhase(['connected', 'reconnecting'])).toBe('reconnecting'); + expect(aggregateConnectionPhase(['connected', 'connecting'])).toBe('connecting'); + expect(aggregateConnectionPhase(['connected', 'offline'])).toBe('offline'); + }); + + test('updates keyed controller phases without publishing no-op changes', () => { + const initial = { plus: 'connected' as const }; + expect(setConnectionPhase(initial, 'plus', 'connected')).toBe(initial); + const withMinus = setConnectionPhase(initial, 'minus', 'reconnecting'); + expect(withMinus).toEqual({ minus: 'reconnecting', plus: 'connected' }); + expect(removeConnectionPhase(withMinus, 'minus')).toEqual({ plus: 'connected' }); + }); +}); + +describe('reconnect controller', () => { + test('retries until connected and then clears its pending work', async () => { + const callbacks: Array<() => void | Promise> = []; + let attempts = 0; + const controller = createReconnectController({ + attempt: () => { + attempts += 1; + return Promise.resolve(attempts === 2); + }, + canRetry: () => true, + clearTimer: () => undefined, + delayForAttempt: (attempt) => attempt * 100, + setTimer: ((callback: () => void) => { + callbacks.push(callback); + return callbacks.length; + }) as typeof setTimeout, + }); + controller.start('device', 'target'); + expect(controller.isPending('device')).toBeTrue(); + await callbacks.shift()?.(); + expect(attempts).toBe(1); + expect(controller.isPending('device')).toBeTrue(); + await callbacks.shift()?.(); + expect(attempts).toBe(2); + expect(controller.isPending('device')).toBeFalse(); + }); + + test('cancels retries and ignores duplicate scheduling', () => { + const callbacks: Array<() => void> = []; + const cleared: unknown[] = []; + const controller = createReconnectController({ + attempt: async () => false, + canRetry: () => true, + clearTimer: (timer) => cleared.push(timer), + delayForAttempt: () => 100, + setTimer: ((callback: () => void) => { + callbacks.push(callback); + return callbacks.length; + }) as typeof setTimeout, + }); + controller.start('device', 'first'); + controller.start('device', 'second'); + expect(callbacks).toHaveLength(1); + controller.cancel('device', true); + expect(cleared).toEqual([1]); + expect(controller.isPending('device')).toBeFalse(); + }); + + test('schedules independent devices concurrently', () => { + const callbacks: Array<() => void> = []; + const controller = createReconnectController({ + attempt: async () => true, + canRetry: () => true, + delayForAttempt: () => 100, + setTimer: ((callback: () => void) => { + callbacks.push(callback); + return callbacks.length; + }) as typeof setTimeout, + }); + controller.start('plus', 'plus'); + controller.start('minus', 'minus'); + expect(callbacks).toHaveLength(2); + expect(controller.isPending('plus')).toBeTrue(); + expect(controller.isPending('minus')).toBeTrue(); + }); +}); + +describe('Bluetooth notifications', () => { + test('owns listener setup and cleanup', async () => { + const listeners = new Set(); + const characteristic = { + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => + listeners.add(listener), + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => + listeners.delete(listener), + startNotifications: async () => characteristic, + } as unknown as BluetoothRemoteGATTCharacteristic; + const cleanup = await startBluetoothNotifications(characteristic, () => undefined); + expect(listeners.size).toBe(1); + cleanup(); + expect(listeners.size).toBe(0); + }); + + test('removes a listener when notification setup fails', async () => { + const listeners = new Set(); + const characteristic = { + addEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => + listeners.add(listener), + removeEventListener: (_type: string, listener: EventListenerOrEventListenerObject) => + listeners.delete(listener), + startNotifications: () => Promise.reject(new Error('unavailable')), + } as unknown as BluetoothRemoteGATTCharacteristic; + await expect(startBluetoothNotifications(characteristic, () => undefined)).rejects.toThrow( + 'unavailable' + ); + expect(listeners.size).toBe(0); + }); +}); diff --git a/tests/devices.test.ts b/tests/devices.test.ts index 15d68aa..fc44db9 100644 --- a/tests/devices.test.ts +++ b/tests/devices.test.ts @@ -97,7 +97,6 @@ describe('paired device protocols', () => { value: { clearTimeout, setTimeout }, }); const server = {} as BluetoothRemoteGATTServer; - const awakeStatuses: string[] = []; let advertisementWatches = 0; const awakeDevice = { addEventListener: () => undefined, @@ -111,14 +110,10 @@ describe('paired device protocols', () => { }, } as unknown as BluetoothDevice; try { - expect( - await connectClickGatt(awakeDevice, true, (status) => awakeStatuses.push(status)) - ).toBe(server); + expect(await connectClickGatt(awakeDevice, true)).toBe(server); expect(advertisementWatches).toBe(0); - expect(awakeStatuses).toEqual(['Connecting controllers…']); let attempts = 0; - const sleepingStatuses: string[] = []; const sleepingDevice = { addEventListener: () => undefined, gatt: { @@ -136,18 +131,9 @@ describe('paired device protocols', () => { return Promise.reject(new Error('advertisement already fresh')); }, } as unknown as BluetoothDevice; - expect( - await connectClickGatt(sleepingDevice, true, (status) => - sleepingStatuses.push(status) - ) - ).toBe(server); + expect(await connectClickGatt(sleepingDevice, true)).toBe(server); expect(attempts).toBe(2); expect(advertisementWatches).toBe(1); - expect(sleepingStatuses).toEqual([ - 'Connecting controllers…', - 'Finding controllers…', - 'Connecting controllers…', - ]); } finally { if (originalWindow) { Object.defineProperty(globalThis, 'window', originalWindow); diff --git a/tests/fixtures/saved-session.ts b/tests/fixtures/saved-session.ts new file mode 100644 index 0000000..413a874 --- /dev/null +++ b/tests/fixtures/saved-session.ts @@ -0,0 +1,49 @@ +import { emptyMetrics } from '../../src/constants'; +import { CONTROL_MODE } from '../../src/lib/control-mode'; +import type { SavedSession } from '../../src/types'; + +export const SAVED_SESSION_STARTED_AT = Date.UTC(2026, 6, 18, 16); + +export const savedSessionFixture: SavedSession = { + aggregates: { + cadence: { count: 2, maximum: 82, sum: 162 }, + gear: { count: 0, maximum: 0, sum: 0 }, + heartRate: { count: 2, maximum: 142, sum: 282 }, + power: { count: 2, maximum: 210, sum: 410 }, + resistance: { count: 2, maximum: 45, sum: 85 }, + }, + calories: 220, + comments: 'Hard & fun ', + controlMode: CONTROL_MODE.RESISTANCE, + distance: 1.5, + elapsedSeconds: 2, + endedAt: SAVED_SESSION_STARTED_AT + 2000, + feeling: 'good', + history: [ + { + cadence: 80, + elapsedSeconds: 1, + heartRate: 140, + power: 200, + resistance: 40, + speed: 28, + }, + { + cadence: 82, + elapsedSeconds: 2, + heartRate: 142, + power: 210, + resistance: 45, + speed: 30, + }, + ], + id: 'saved-session', + maximums: { + ...emptyMetrics, + cadence: 82, + heartRate: 142, + power: 210, + speed: 30, + }, + startedAt: SAVED_SESSION_STARTED_AT, +}; diff --git a/tests/format-chart.test.ts b/tests/format-chart.test.ts index 50b30b5..f5a0c78 100644 --- a/tests/format-chart.test.ts +++ b/tests/format-chart.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { chartPath, roundedChartMaximum, storedChartMode } from '../src/lib/chart'; -import { formatAggregateAverage, formatChartSeconds, formatDuration } from '../src/lib/format'; +import { + aggregateMaximum, + formatAggregateAverage, + formatChartSeconds, + formatDuration, +} from '../src/lib/format'; describe('format utilities', () => { test('formats ride durations', () => { @@ -15,6 +20,8 @@ describe('format utilities', () => { test('formats aggregate averages', () => { expect(formatAggregateAverage({ count: 2, sum: 11 }, 1)).toBe('5.5'); expect(formatAggregateAverage({ count: 0, sum: 0 }, 0)).toBe('0'); + expect(aggregateMaximum({ count: 2, maximum: 8, sum: 11 })).toBe(8); + expect(aggregateMaximum({ count: 2, sum: 11 })).toBe(0); }); }); diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts index 78c0083..7a86825 100644 --- a/tests/keyboard.test.ts +++ b/tests/keyboard.test.ts @@ -54,6 +54,14 @@ describe('keyboard shortcuts', () => { expect(historyShortcutForKey('D')).toBe('deleteSession'); expect(historyShortcutForKey('Enter')).toBe('confirmDelete'); expect(historyKeyboardShortcuts[2]?.keys).toEqual(['d']); + expect(historyKeyboardShortcuts.map(({ group }) => group)).toEqual([ + 'Navigation', + 'Navigation', + 'Session', + 'Session', + 'General', + 'General', + ]); expect(historyShortcutForKey('?')).toBe('help'); expect(historyShortcutForKey('Escape')).toBe('close'); expect(historyShortcutForKey('ArrowLeft')).toBeUndefined(); diff --git a/tests/preferences-store.test.ts b/tests/preferences-store.test.ts new file mode 100644 index 0000000..e41aa23 --- /dev/null +++ b/tests/preferences-store.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test'; +import { createPreferencesStore } from '../src/stores/preferences-store'; + +function memoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + values, + }; +} + +describe('preferences store', () => { + test('restores and persists shared display preferences', () => { + const storage = memoryStorage({ 'speed-unit': 'kmh', 'trainer-chart-mode': 'power' }); + const store = createPreferencesStore(storage); + + expect(store.get()).toEqual({ chartMode: 'power', speedUnit: 'kmh' }); + + store.actions.selectChartMode('cadence'); + store.actions.selectSpeedUnit('mph'); + + expect(store.get()).toEqual({ chartMode: 'cadence', speedUnit: 'mph' }); + expect(storage.values.get('trainer-chart-mode')).toBe('cadence'); + expect(storage.values.get('speed-unit')).toBe('mph'); + }); +}); diff --git a/tests/saved-sessions.test.ts b/tests/saved-sessions.test.ts index 4377f62..e25d298 100644 --- a/tests/saved-sessions.test.ts +++ b/tests/saved-sessions.test.ts @@ -10,6 +10,7 @@ import { formatSessionTime, formatSessionTimeRange, groupSessionsByDate, + isImportedSession, normalizeSavedSession, requestPersistentSessionStorage, saveSessionRecords, @@ -75,6 +76,9 @@ describe('saved session utilities', () => { startedAt: snapshot.startedAt, }); expect('history' in summary).toBe(false); + expect(sessionSummary({ ...session, importedAt: 5678 }).importedAt).toBe(5678); + expect(isImportedSession({ id: 'tcx:legacy-import' })).toBe(true); + expect(isImportedSession({ id: session.id })).toBe(false); }); test('writes and deletes both full and summary session records', () => { @@ -126,11 +130,17 @@ describe('saved session utilities', () => { } as unknown as SavedSession; expect(normalizeSavedSession(legacy).aggregates.resistance).toEqual({ count: 1, + maximum: 42, sum: 42, }); - expect(normalizeSavedSession(session).aggregates.resistance).toBe( - session.aggregates.resistance - ); + expect(normalizeSavedSession(session).aggregates.resistance).toEqual({ + count: 0, + maximum: 42, + sum: 0, + }); + expect( + normalizeSavedSession({ ...session, importedAt: Number.NaN }).importedAt + ).toBeUndefined(); }); test('restores gear aggregates for virtual shifting sessions', () => { @@ -154,7 +164,7 @@ describe('saved session utilities', () => { } as unknown as SavedSession; const normalized = normalizeSavedSession(gearSession); expect(normalized.controlMode).toBe('gear'); - expect(normalized.aggregates.gear).toEqual({ count: 1, sum: 14 }); + expect(normalized.aggregates.gear).toEqual({ count: 1, maximum: 14, sum: 14 }); }); test('groups ordered summaries by their local calendar date', () => { diff --git a/tests/session-store.test.ts b/tests/session-store.test.ts new file mode 100644 index 0000000..5669c86 --- /dev/null +++ b/tests/session-store.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, test } from 'bun:test'; +import { emptyMetrics, emptySession } from '../src/constants'; +import { + createSessionStore, + persistSessionState, + sessionSnapshotFromState, + storedSessionFromState, +} from '../src/stores/session-store'; +import type { Metrics, StoredSession } from '../src/types'; + +function restoredSession(overrides: Partial = {}): StoredSession { + return { + ...emptySession, + aggregates: { + cadence: { ...emptySession.aggregates.cadence }, + gear: { ...emptySession.aggregates.gear }, + heartRate: { ...emptySession.aggregates.heartRate }, + power: { ...emptySession.aggregates.power }, + resistance: { ...emptySession.aggregates.resistance }, + }, + maximums: { ...emptyMetrics }, + ...overrides, + }; +} + +const liveMetrics: Metrics = { + cadence: 82, + calories: 0, + distance: 0, + heartRate: 145, + power: 200, + speed: 36, +}; + +describe('session store', () => { + test('records a complete ride tick as one atomic update', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + let updates = 0; + const subscription = store.subscribe(() => { + updates += 1; + }); + + store.actions.recordTick({ + control: { gear: 12, mode: 'resistance', resistance: 42 }, + metrics: liveMetrics, + seconds: 2, + }); + + const state = store.get(); + expect(updates).toBe(1); + expect(state.elapsedSeconds).toBe(2); + expect(state.distance).toBeCloseTo(0.02); + expect(state.calories).toBeCloseTo(400 / (4184 * 0.24)); + expect(state.history).toEqual([ + { + cadence: 82, + elapsedSeconds: 2, + heartRate: 145, + power: 200, + resistance: 42, + speed: 36, + }, + ]); + expect(state.aggregates.power).toEqual({ count: 1, maximum: 200, sum: 200 }); + expect(state.aggregates.resistance).toEqual({ count: 1, maximum: 42, sum: 42 }); + subscription.unsubscribe(); + }); + + test('uses trainer distance deltas and records virtual gears independently', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + store.actions.recordTick({ + control: { gear: 16, mode: 'gear', resistance: 42 }, + distanceDelta: 0.01, + metrics: liveMetrics, + seconds: 1, + }); + + expect(store.get().distance).toBe(0.01); + expect(store.get().controlMode).toBe('gear'); + expect(store.get().history[0]?.gear).toBe(16); + expect(store.get().history[0]?.resistance).toBeUndefined(); + expect(store.get().aggregates.gear).toEqual({ count: 1, maximum: 16, sum: 16 }); + expect(store.get().aggregates.resistance).toEqual({ count: 0, sum: 0 }); + }); + + test('retains resistance and gear maxima after lower control samples', () => { + const resistanceStore = createSessionStore(restoredSession(), 1000); + resistanceStore.actions.syncRiding(true); + for (const resistance of [70, 35]) { + resistanceStore.actions.recordTick({ + control: { gear: 12, mode: 'resistance', resistance }, + metrics: liveMetrics, + seconds: 1, + }); + } + expect(resistanceStore.get().aggregates.resistance.maximum).toBe(70); + + const gearStore = createSessionStore(restoredSession(), 1000); + gearStore.actions.syncRiding(true); + for (const gear of [20, 12]) { + gearStore.actions.recordTick({ + control: { gear, mode: 'gear', resistance: 42 }, + metrics: liveMetrics, + seconds: 1, + }); + } + expect(gearStore.get().aggregates.gear.maximum).toBe(20); + }); + + test('coordinates pause, end, reset, and continuation transitions', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.syncRiding(true); + expect(store.get().isRiding).toBeTrue(); + + store.actions.togglePause(true); + expect(store.get()).toMatchObject({ isRiding: false, manuallyPaused: true }); + store.actions.togglePause(true); + expect(store.get()).toMatchObject({ isRiding: true, manuallyPaused: false }); + + store.actions.endSession(5000); + expect(store.get()).toMatchObject({ + ended: true, + endedAt: 5000, + isRiding: false, + manuallyPaused: false, + }); + store.actions.reset('gear', 6000); + expect(store.get()).toMatchObject({ + controlMode: 'gear', + elapsedSeconds: 0, + ended: false, + startedAt: 6000, + }); + + store.actions.continueFrom({ + aggregates: emptySession.aggregates, + calories: 100, + controlMode: 'resistance', + distance: 12, + elapsedSeconds: 900, + endedAt: 4000, + history: [], + maximums: emptyMetrics, + startedAt: 2000, + }); + expect(store.get()).toMatchObject({ + calories: 100, + distance: 12, + elapsedSeconds: 900, + ended: false, + endedAt: 0, + startedAt: 2000, + }); + expect(store.get().savedSessionId).toBeUndefined(); + }); + + test('records an intentional discard until the session is reset or saved', () => { + const store = createSessionStore(restoredSession(), 1000); + store.actions.endSession(5000); + store.actions.markDiscarded(); + expect(store.get()).toMatchObject({ discarded: true, savedSessionId: undefined }); + expect(storedSessionFromState(store.get()).discarded).toBe(true); + + store.actions.markSaved('saved-session'); + expect(store.get()).toMatchObject({ discarded: false, savedSessionId: 'saved-session' }); + + store.actions.markDiscarded(); + store.actions.reset('resistance', 6000); + expect(store.get().discarded).toBe(false); + expect(store.get().savedSessionId).toBeUndefined(); + }); + + test('updates maxima without publishing unchanged snapshots or ended rides', () => { + const store = createSessionStore(restoredSession(), 1000); + let updates = 0; + const subscription = store.subscribe(() => { + updates += 1; + }); + + store.actions.observeMetrics(liveMetrics); + expect(updates).toBe(1); + expect(store.get().maximums).toMatchObject({ + cadence: 82, + heartRate: 145, + power: 200, + speed: 36, + }); + store.actions.observeMetrics(liveMetrics); + expect(updates).toBe(1); + store.actions.endSession(5000); + store.actions.observeMetrics({ ...liveMetrics, power: 300 }); + expect(store.get().maximums.power).toBe(200); + subscription.unsubscribe(); + }); + + test('persists only durable state and caps stored history', () => { + const history = Array.from({ length: 3601 }, (_, index) => ({ + cadence: 80, + elapsedSeconds: index, + heartRate: 140, + power: 180, + resistance: 40, + speed: 30, + })); + const store = createSessionStore(restoredSession({ history }), 1000); + store.actions.syncRiding(true); + const saved = storedSessionFromState(store.get()); + expect(saved.history).toHaveLength(3600); + expect(saved.history[0]?.elapsedSeconds).toBe(1); + expect(saved).not.toHaveProperty('isRiding'); + expect(saved).not.toHaveProperty('manuallyPaused'); + expect(sessionSnapshotFromState(store.get())).not.toHaveProperty('ended'); + + let storedKey = ''; + let storedValue = ''; + persistSessionState(store.get(), { + setItem: (key, value) => { + storedKey = key; + storedValue = value; + }, + }); + expect(storedKey).toBe('trainer-session'); + expect(JSON.parse(storedValue).history).toHaveLength(3600); + }); +}); diff --git a/tests/session-workflow.test.ts b/tests/session-workflow.test.ts new file mode 100644 index 0000000..ef16046 --- /dev/null +++ b/tests/session-workflow.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test'; +import { SESSION_WORKFLOW_INTENT, SESSION_WORKFLOW_PHASE } from '../src/lib/session-workflow'; +import { + createSessionWorkflowStore, + initialSessionWorkflowState, +} from '../src/stores/session-workflow-store'; +import type { SavedSession } from '../src/types'; + +describe('session workflow store', () => { + test('opens with the ended-session intent when an unsaved session is restored', () => { + expect(initialSessionWorkflowState(true)).toEqual({ + intent: { kind: SESSION_WORKFLOW_INTENT.END }, + phase: SESSION_WORKFLOW_PHASE.PROMPT, + }); + expect(initialSessionWorkflowState(false)).toEqual({ + phase: SESSION_WORKFLOW_PHASE.CLOSED, + }); + }); + + test('preserves the requested next session while saving', () => { + const session = { id: 'saved-session' } as SavedSession; + const store = createSessionWorkflowStore(false); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.CONTINUE, session }); + const prompt = store.get(); + expect(prompt).toEqual({ + intent: { kind: SESSION_WORKFLOW_INTENT.CONTINUE, session }, + phase: SESSION_WORKFLOW_PHASE.PROMPT, + }); + store.actions.startSaving(); + expect(store.get()).toEqual({ + intent: { kind: SESSION_WORKFLOW_INTENT.CONTINUE, session }, + phase: SESSION_WORKFLOW_PHASE.SAVING, + }); + store.actions.saveFailed(); + expect(store.get()).toEqual(prompt); + }); + + test('ignores invalid transitions and closes atomically', () => { + const store = createSessionWorkflowStore(false); + const closed = store.get(); + store.actions.startSaving(); + expect(store.get()).toBe(closed); + store.actions.saveFailed(); + expect(store.get()).toBe(closed); + store.actions.open({ kind: SESSION_WORKFLOW_INTENT.NEW }); + store.actions.close(); + expect(store.get()).toEqual({ phase: SESSION_WORKFLOW_PHASE.CLOSED }); + }); +}); diff --git a/tests/session.test.ts b/tests/session.test.ts index eab82cd..3adbfbc 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { emptySession } from '../src/constants'; +import { nonNegativeNumber } from '../src/lib/numbers'; import { addAggregate, addMetricAggregates, aggregateResistance, loadStoredSession, - nonNegativeNumber, requestUnloadConfirmation, restoreAggregate, sessionContinuation, @@ -70,9 +70,9 @@ describe('session utilities', () => { test('adds aggregate samples according to zero policy', () => { const initial = { count: 2, sum: 10 }; - expect(addAggregate(initial, 5, false)).toEqual({ count: 3, sum: 15 }); + expect(addAggregate(initial, 5, false)).toEqual({ count: 3, maximum: 5, sum: 15 }); expect(addAggregate(initial, 0, false)).toBe(initial); - expect(addAggregate(initial, 0, true)).toEqual({ count: 3, sum: 10 }); + expect(addAggregate(initial, 0, true)).toEqual({ count: 3, maximum: 0, sum: 10 }); }); test('aggregates cadence, heart rate, power, and resistance', () => { @@ -86,9 +86,9 @@ describe('session utilities', () => { ).toEqual({ cadence: { count: 0, sum: 0 }, gear: { count: 0, sum: 0 }, - heartRate: { count: 1, sum: 145 }, - power: { count: 1, sum: 0 }, - resistance: { count: 1, sum: 42 }, + heartRate: { count: 1, maximum: 145, sum: 145 }, + power: { count: 1, maximum: 0, sum: 0 }, + resistance: { count: 1, maximum: 42, sum: 42 }, }); }); @@ -101,7 +101,7 @@ describe('session utilities', () => { power: 200, }) ).toMatchObject({ - gear: { count: 1, sum: 14 }, + gear: { count: 1, maximum: 14, sum: 14 }, resistance: { count: 0, sum: 0 }, }); }); @@ -117,17 +117,20 @@ describe('session utilities', () => { ]) ).toEqual({ count: 3, + maximum: 100, sum: 125, }); }); test('restores valid aggregate values and falls back when absent', () => { - expect(restoreAggregate({ count: -2, sum: 20 }, { count: 1, sum: 2 })).toEqual({ + expect(restoreAggregate({ count: -2, sum: 20 }, { count: 1, maximum: 8, sum: 2 })).toEqual({ count: 0, + maximum: 8, sum: 20, }); - expect(restoreAggregate(undefined, { count: 1, sum: 2 })).toEqual({ + expect(restoreAggregate(undefined, { count: 1, maximum: 2, sum: 2 })).toEqual({ count: 1, + maximum: 2, sum: 2, }); }); @@ -156,14 +159,15 @@ describe('session utilities', () => { const session = loadStoredSession(storageWith(stored)); expect(session.calories).toBe(0); expect(session.distance).toBe(12); + expect(session.discarded).toBe(false); expect(session.ended).toBe(true); expect(session.endedAt).toBe(5000); expect(session.history[0]?.speed).toBe(0); expect(session.history[0]?.resistance).toBe(42); expect(session.history[0]?.gear).toBeUndefined(); expect(session.controlMode).toBe('resistance'); - expect(session.aggregates.power).toEqual({ count: 1, sum: 200 }); - expect(session.aggregates.resistance).toEqual({ count: 1, sum: 42 }); + expect(session.aggregates.power).toEqual({ count: 1, maximum: 200, sum: 200 }); + expect(session.aggregates.resistance).toEqual({ count: 1, maximum: 42, sum: 42 }); expect(session.maximums.speed).toBe(35); expect(session.savedSessionId).toBe('saved-session'); expect(session.startedAt).toBe(1000); diff --git a/tests/tcx-archive.test.ts b/tests/tcx-archive.test.ts new file mode 100644 index 0000000..8c88d14 --- /dev/null +++ b/tests/tcx-archive.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test'; +import { strFromU8, unzipSync } from 'fflate'; +import { + createSessionTcxArchive, + downloadSessionTcxArchive, + sessionTcxArchiveEntries, + sessionTcxArchiveFilename, +} from '../src/lib/tcx-archive'; +import { savedSessionFixture } from './fixtures/saved-session'; + +const FIRST_ARCHIVE_PATH = 'ride-control-tcx/ride-control-2026-07-18T16-00-00.000Z.tcx'; +const SECOND_ARCHIVE_PATH = 'ride-control-tcx/ride-control-2026-07-18T16-00-00.000Z-2.tcx'; + +function archiveFile(files: Record, path: string): Uint8Array { + const file = files[path]; + if (!file) { + throw new Error(`Missing archive file: ${path}`); + } + return file; +} + +describe('TCX archive export', () => { + test('places every saved session in one folder with collision-safe filenames', async () => { + const secondSession = { ...savedSessionFixture, id: 'second-session' }; + const entries = sessionTcxArchiveEntries([savedSessionFixture, secondSession]); + expect(Object.keys(entries)).toEqual([FIRST_ARCHIVE_PATH, SECOND_ARCHIVE_PATH]); + + const archive = await createSessionTcxArchive([savedSessionFixture, secondSession]); + const files = unzipSync(archive); + expect(Object.keys(files)).toEqual([FIRST_ARCHIVE_PATH, SECOND_ARCHIVE_PATH]); + expect(strFromU8(archiveFile(files, FIRST_ARCHIVE_PATH))).toContain( + 'saved-session' + ); + expect(strFromU8(archiveFile(files, SECOND_ARCHIVE_PATH))).toContain( + 'second-session' + ); + }); + + test('creates a dated ZIP filename', () => { + expect(sessionTcxArchiveFilename(Date.UTC(2026, 6, 19, 12))).toBe( + 'ride-control-tcx-2026-07-19.zip' + ); + }); + + test('does not download an empty session archive', async () => { + await expect(downloadSessionTcxArchive([])).rejects.toThrow( + 'There are no saved sessions to download.' + ); + }); +}); diff --git a/tests/tcx-import.test.ts b/tests/tcx-import.test.ts new file mode 100644 index 0000000..31e3e79 --- /dev/null +++ b/tests/tcx-import.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'bun:test'; +import { DOMParser } from '@xmldom/xmldom'; +import { strToU8, zipSync } from 'fflate'; +import { CONTROL_MODE } from '../src/lib/control-mode'; +import { sessionToTcx } from '../src/lib/tcx'; +import { importTcxUpload, parseTcxSessions, tcxImportResultMessage } from '../src/lib/tcx-import'; +import type { SavedSession } from '../src/types'; +import { savedSessionFixture } from './fixtures/saved-session'; + +Object.defineProperty(globalThis, 'DOMParser', { configurable: true, value: DOMParser }); + +const SESSION_ID_ELEMENT = /\s*.*<\/rc:SessionId>/; +const session: SavedSession = { + ...savedSessionFixture, + aggregates: { + ...savedSessionFixture.aggregates, + gear: { count: 2, maximum: 10, sum: 19 }, + resistance: { count: 0, maximum: 0, sum: 0 }, + }, + comments: 'Imported ride notes', + controlMode: CONTROL_MODE.GEAR, + history: savedSessionFixture.history.map(({ resistance: _resistance, ...sample }, index) => ({ + ...sample, + gear: 9 + index, + })), + id: 'unique-session-id', +}; + +describe('TCX import', () => { + test('round trips Ride Control session data and its unique identifier', () => { + const [imported] = parseTcxSessions(sessionToTcx(session)); + expect(imported?.id).toBe(session.id); + expect(imported?.controlMode).toBe(CONTROL_MODE.GEAR); + expect(imported?.history).toHaveLength(2); + expect(imported?.history[1]).toMatchObject({ + cadence: 82, + gear: 10, + heartRate: 142, + power: 210, + }); + expect(imported?.distance).toBe(1.5); + expect(imported?.calories).toBe(220); + expect(imported?.feeling).toBe('good'); + expect(imported?.comments).toBe('Imported ride notes'); + expect(imported?.aggregates.gear.maximum).toBe(10); + }); + + test('creates a stable fallback identifier for third-party TCX files', () => { + const withoutSessionId = sessionToTcx(session).replace(SESSION_ID_ELEMENT, ''); + const [first] = parseTcxSessions(withoutSessionId); + const [second] = parseTcxSessions(withoutSessionId); + expect(first?.id).toStartWith('tcx:'); + expect(second?.id).toBe(first?.id); + }); + + test('imports TCX files in nested ZIP folders and skips duplicate sessions', async () => { + const tcx = strToU8(sessionToTcx(session)); + const archive = zipSync({ + 'first/ride.tcx': tcx, + 'notes/readme.txt': strToU8('ignored'), + 'second/ride-copy.TCX': tcx, + }); + const saved = new Map(); + const result = await importTcxUpload(new File([archive], 'rides.zip'), { + listSessions: () => Promise.resolve([...saved.values()]), + saveSession: (imported) => { + saved.set(imported.id, imported); + return Promise.resolve(); + }, + }); + expect(result.tcxFileCount).toBe(2); + expect(result.importedSessions).toHaveLength(1); + expect(result.importedSessions[0]?.importedAt).toBeNumber(); + expect(result.duplicateCount).toBe(1); + expect(result.failures).toHaveLength(0); + expect(tcxImportResultMessage(result)).toBe('Imported 1 session · 1 duplicate skipped'); + }); + + test('reports invalid files without preventing valid ZIP entries from importing', async () => { + const archive = zipSync({ + 'broken.tcx': strToU8(''), + 'valid.tcx': strToU8(sessionToTcx(session)), + }); + const result = await importTcxUpload(new File([archive], 'rides.zip'), { + listSessions: () => Promise.resolve([]), + saveSession: () => Promise.resolve(), + }); + expect(result.importedSessions).toHaveLength(1); + expect(result.failures).toEqual([ + { + fileName: 'broken.tcx', + message: 'The file is not a Training Center XML document.', + }, + ]); + }); + + test('skips a legacy Ride Control export without an embedded session id', async () => { + const legacyTcx = sessionToTcx(savedSessionFixture).replace(SESSION_ID_ELEMENT, ''); + let saveCount = 0; + const result = await importTcxUpload(new File([legacyTcx], 'legacy-ride.tcx'), { + listSessions: () => Promise.resolve([savedSessionFixture]), + saveSession: () => { + saveCount += 1; + return Promise.resolve(); + }, + }); + expect(result.importedSessions).toHaveLength(0); + expect(result.duplicateCount).toBe(1); + expect(saveCount).toBe(0); + }); + + test('rejects unsupported uploads and ZIP files without TCX entries', async () => { + await expect(importTcxUpload(new File(['no'], 'ride.fit'))).rejects.toThrow( + 'Choose a .tcx file or a .zip containing TCX files.' + ); + const archive = zipSync({ 'readme.txt': strToU8('nothing here') }); + await expect(importTcxUpload(new File([archive], 'rides.zip'))).rejects.toThrow( + 'The ZIP contains no TCX files.' + ); + }); +}); diff --git a/tests/tcx.test.ts b/tests/tcx.test.ts index eddc801..7611c6f 100644 --- a/tests/tcx.test.ts +++ b/tests/tcx.test.ts @@ -1,52 +1,7 @@ import { describe, expect, test } from 'bun:test'; -import { emptyMetrics } from '../src/constants'; import { sessionTcxFilename, sessionToTcx } from '../src/lib/tcx'; import type { SavedSession } from '../src/types'; - -const startedAt = Date.UTC(2026, 6, 18, 16); -const session: SavedSession = { - aggregates: { - cadence: { count: 2, sum: 162 }, - gear: { count: 0, sum: 0 }, - heartRate: { count: 2, sum: 282 }, - power: { count: 2, sum: 410 }, - resistance: { count: 2, sum: 85 }, - }, - calories: 220, - comments: 'Hard & fun ', - controlMode: 'resistance', - distance: 1.5, - elapsedSeconds: 2, - endedAt: startedAt + 2000, - feeling: 'good', - history: [ - { - cadence: 80, - elapsedSeconds: 1, - heartRate: 140, - power: 200, - resistance: 40, - speed: 28, - }, - { - cadence: 82, - elapsedSeconds: 2, - heartRate: 142, - power: 210, - resistance: 45, - speed: 30, - }, - ], - id: 'saved-session', - maximums: { - ...emptyMetrics, - cadence: 82, - heartRate: 142, - power: 210, - speed: 30, - }, - startedAt, -}; +import { savedSessionFixture as session } from './fixtures/saved-session'; describe('TCX export', () => { test('exports an indoor cycling activity with standard and Ride Control data', () => { @@ -61,7 +16,9 @@ describe('TCX export', () => { expect(tcx).toContain('8.333'); expect(tcx).toContain('210'); expect(tcx).toContain('45.0'); + expect(tcx).toContain('saved-session'); expect(tcx).toContain('42.5'); + expect(tcx).toContain('45.0'); expect(tcx).toContain('Comments: Hard & fun <again>'); expect(tcx).not.toContain('NaN'); }); @@ -75,8 +32,8 @@ describe('TCX export', () => { ...session, aggregates: { ...session.aggregates, - gear: { count: 2, sum: 27 }, - resistance: { count: 0, sum: 0 }, + gear: { count: 2, maximum: 14, sum: 27 }, + resistance: { count: 0, maximum: 0, sum: 0 }, }, controlMode: 'gear', history: session.history.map(({ resistance: _resistance, ...sample }, index) => ({ @@ -87,6 +44,7 @@ describe('TCX export', () => { const tcx = sessionToTcx(gearSession); expect(tcx).toContain('14'); expect(tcx).toContain('13.5'); + expect(tcx).toContain('14'); expect(tcx).not.toContain(''); }); }); diff --git a/tests/type-guards.test.ts b/tests/type-guards.test.ts new file mode 100644 index 0000000..662845c --- /dev/null +++ b/tests/type-guards.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test'; +import { isFiniteNumber, isFunction, isRecord, isString } from '../src/lib/type-guards'; + +describe('runtime type guards', () => { + test('accepts only finite numbers', () => { + expect(isFiniteNumber(42)).toBeTrue(); + expect(isFiniteNumber(Number.NaN)).toBeFalse(); + expect(isFiniteNumber(Number.POSITIVE_INFINITY)).toBeFalse(); + expect(isFiniteNumber('42')).toBeFalse(); + }); + + test('identifies functions, records, and strings at untyped boundaries', () => { + expect(isFunction(() => undefined)).toBeTrue(); + expect(isFunction(undefined)).toBeFalse(); + expect(isRecord({ value: 1 })).toBeTrue(); + expect(isRecord([])).toBeFalse(); + expect(isRecord(null)).toBeFalse(); + expect(isString('value')).toBeTrue(); + expect(isString(1)).toBeFalse(); + }); +}); diff --git a/tests/units.test.ts b/tests/units.test.ts new file mode 100644 index 0000000..2edd624 --- /dev/null +++ b/tests/units.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test'; +import { + averageSpeed, + convertDistance, + convertSpeed, + formatDistance, + formatDistanceValue, + kilometersForMeters, + kilometersTraveled, + metersForKilometers, + metersPerSecond, + millisecondsForSeconds, + secondsForMilliseconds, + storedSpeedUnit, +} from '../src/lib/units'; + +describe('unit conversions', () => { + test('converts metric ride values to imperial display values', () => { + expect(convertDistance(1.609_344, 'mph')).toBeCloseTo(1); + expect(convertSpeed(32.186_88, 'mph')).toBeCloseTo(20); + expect(formatDistance(16.093_44, 'mph')).toBe('10.00 mi'); + expect(formatDistance(16.093_44, 'kmh')).toBe('16.09 km'); + expect(formatDistanceValue(16.093_44, 'mph')).toBe('10.00'); + }); + + test('converts stored SI and ride timing values consistently', () => { + expect(metersForKilometers(2.5)).toBe(2500); + expect(kilometersForMeters(2500)).toBe(2.5); + expect(metersPerSecond(36)).toBe(10); + expect(millisecondsForSeconds(2.5)).toBe(2500); + expect(secondsForMilliseconds(2500)).toBe(2.5); + expect(kilometersTraveled(30, 1800)).toBe(15); + expect(averageSpeed(15, 1800)).toBe(30); + }); + + test('restores supported speed units with the existing mph fallback', () => { + expect(storedSpeedUnit({ getItem: () => 'kmh' })).toBe('kmh'); + expect(storedSpeedUnit({ getItem: () => 'mph' })).toBe('mph'); + expect(storedSpeedUnit({ getItem: () => null })).toBe('mph'); + }); +});