init code for the trainer app

This commit is contained in:
Cipher Vance
2026-01-25 09:56:41 -06:00
parent b29d7481e7
commit 9eab5ed98b
47 changed files with 13572 additions and 25 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://127.0.0.1:5000

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
node_modules
dist
dist-ssr
build
release
*.AppImage
*.dmg
*.exe
*.deb
*.rpm
*.snap
*.pkg
*.msi
.env
.env.local
*.local
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
.cache
.eslintcache
*.tsbuildinfo
coverage
tmp
temp
*.tmp

View File

@@ -1,38 +1,73 @@
# RideAware Trainer Application
# React + TypeScript + Vite
**Status: Work in Progress**
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
This README provides a brief overview of the RideAware Trainer Application. Please note that this project is currently under active development, and the information provided below is subject to change. This document will be updated with more detailed information as the application progresses.
Currently, two official plugins are available:
## Overview
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
The RideAware Trainer Application serves as a streamlined and immediate access point for users to engage with their planned workouts. Users will have the option to either select a pre-defined workout or load their specific plan from the day
## React Compiler
A key feature of the RideAware Trainer Application is its seamless integration with the RideAware Calendar. Users will be able to launch the Trainer Application directly from the Calendar when selecting the workout they want to do for that day, automatically loading the training planned for that day.
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
**Important:** All training plans are created and managed within the core RideAware platform. The Trainer Application serves solely as a streamlined way for users to load the day's RideAware calendar workout or select from a subset of training plans
## Expanding the ESLint configuration
## Features (Planned)
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
The following features are planned for future releases of the RideAware Trainer Application:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
* **Launch from RideAware Calendar:** Load your RideAware calendar workout for the day or select from a subset of training plans
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
## Getting Started (Not Yet Available)
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
Instructions for setting up and running the application will be provided in a future update.
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
## Contributing (Not Yet Available)
Details on how to contribute to the project will be shared in a future update.
## License
[License information will be added soon]
## Contact
For inquiries, please contact blake@rideaware.org.
**Stay tuned for more updates!**
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

292
electron/main.cjs Normal file
View File

@@ -0,0 +1,292 @@
const { app, BrowserWindow, protocol, ipcMain } = require('electron');
const path = require('path');
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
app.commandLine.appendSwitch('enable-web-bluetooth');
app.commandLine.appendSwitch('enable-features', 'WebBluetooth');
if (process.platform === 'linux') {
app.commandLine.appendSwitch('disable-gpu-sandbox');
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-seccomp-filter-sandbox');
app.commandLine.appendSwitch('enable-logging');
app.commandLine.appendSwitch('v', '1');
}
const isDev = process.env.ELECTRON_IS_DEV === '1';
let mainWindow;
let deepLinkUrl = null;
let bluetoothSelectCallback = null;
let discoveredDevices = new Map();
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
app.on('second-instance', (event, commandLine) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
const url = commandLine.find(arg => arg.startsWith('rideaware://'));
if (url) {
handleDeepLink(url);
}
}
});
app.whenReady().then(() => {
if (!isDev) {
protocol.registerFileProtocol('rideaware', (request, callback) => {
callback({ path: '' });
});
}
createWindow();
app.on('open-url', (event, url) => {
event.preventDefault();
handleDeepLink(url);
});
if (deepLinkUrl) {
handleDeepLink(deepLinkUrl);
deepLinkUrl = null;
}
});
}
app.on('open-url', (event, url) => {
event.preventDefault();
if (mainWindow) {
handleDeepLink(url);
} else {
deepLinkUrl = url;
}
});
if (process.platform !== 'darwin') {
const url = process.argv.find(arg => arg.startsWith('rideaware://'));
if (url) {
deepLinkUrl = url;
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 900,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
nodeIntegration: false,
contextIsolation: true,
enableBlinkFeatures: 'WebBluetooth',
webSecurity: true,
sandbox: process.platform !== 'linux',
},
backgroundColor: '#0a0a0a',
show: false,
});
mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission) => {
if (permission === 'bluetooth') {
return true;
}
return false;
});
mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => {
if (permission === 'bluetooth') {
callback(true);
} else {
callback(false);
}
});
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
if (isDev) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
require('electron').shell.openExternal(url);
return { action: 'deny' };
});
mainWindow.webContents.on('select-bluetooth-device', (event, deviceList, callback) => {
event.preventDefault();
bluetoothSelectCallback = callback;
deviceList.forEach((device) => {
discoveredDevices.set(device.deviceId, {
id: device.deviceId,
name: device.deviceName || 'Unknown Device',
});
});
const devices = Array.from(discoveredDevices.values());
mainWindow.webContents.send('bluetooth:devices-updated', devices);
});
}
ipcMain.on('bluetooth:select-device', (event, deviceId) => {
if (bluetoothSelectCallback) {
bluetoothSelectCallback(deviceId);
bluetoothSelectCallback = null;
}
});
ipcMain.on('bluetooth:cancel-selection', () => {
if (bluetoothSelectCallback) {
bluetoothSelectCallback('');
bluetoothSelectCallback = null;
}
discoveredDevices.clear();
});
ipcMain.on('bluetooth:clear-devices', () => {
discoveredDevices.clear();
});
function handleDeepLink(urlString) {
if (!mainWindow) return;
try {
const parsedUrl = new URL(urlString);
if (parsedUrl.protocol === 'rideaware:') {
const action = parsedUrl.hostname;
const params = {};
parsedUrl.searchParams.forEach((value, key) => {
params[key] = value;
});
mainWindow.webContents.send('deep-link', {
action,
params,
});
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
} catch (error) {
console.error('Failed to parse deep link:', error);
}
}
ipcMain.handle('get-version', () => {
return app.getVersion();
});
ipcMain.handle('get-platform', () => {
return process.platform;
});
ipcMain.handle('bluetooth:check-availability', async () => {
return {
available: true,
platform: process.platform,
flags: {
webBluetooth: app.commandLine.hasSwitch('enable-web-bluetooth'),
experimentalFeatures: app.commandLine.hasSwitch('enable-experimental-web-platform-features'),
}
};
});
ipcMain.handle('bluetooth:check-system', async () => {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const result = {
platform: process.platform,
available: false,
adapter: null,
error: null,
guidance: []
};
try {
if (process.platform === 'linux') {
try {
const { stdout } = await execAsync('systemctl is-active bluetooth');
result.available = stdout.trim() === 'active';
if (result.available) {
try {
const { stdout: hciOutput } = await execAsync('hciconfig 2>&1');
result.adapter = hciOutput.includes('UP RUNNING') ? 'enabled' : 'disabled';
if (!hciOutput.includes('UP RUNNING')) {
result.guidance.push('Bluetooth adapter is not powered on. Try: sudo hciconfig hci0 up');
}
} catch {
result.adapter = 'unknown';
result.guidance.push('Unable to check Bluetooth adapter status. Install bluez-utils if not present.');
}
} else {
result.guidance.push('Bluetooth service is not running. Start it with: sudo systemctl start bluetooth');
}
} catch {
result.available = false;
result.guidance.push('Bluetooth service not found. Install bluez package.');
}
} else if (process.platform === 'darwin') {
result.available = true;
result.adapter = 'system';
} else if (process.platform === 'win32') {
result.available = true;
result.adapter = 'system';
}
} catch (error) {
result.error = error.message;
}
return result;
});
ipcMain.handle('minimize-window', () => {
if (mainWindow) mainWindow.minimize();
});
ipcMain.handle('maximize-window', () => {
if (mainWindow) {
if (mainWindow.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow.maximize();
}
}
});
ipcMain.handle('close-window', () => {
if (mainWindow) mainWindow.close();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

27
electron/preload.cjs Normal file
View File

@@ -0,0 +1,27 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('get-version'),
getPlatform: () => ipcRenderer.invoke('get-platform'),
onDeepLink: (callback) => {
ipcRenderer.on('deep-link', (event, data) => callback(data));
},
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
maximizeWindow: () => ipcRenderer.invoke('maximize-window'),
closeWindow: () => ipcRenderer.invoke('close-window'),
bluetooth: {
checkAvailability: () => ipcRenderer.invoke('bluetooth:check-availability'),
checkSystem: () => ipcRenderer.invoke('bluetooth:check-system'),
onDevicesUpdated: (callback) => {
ipcRenderer.on('bluetooth:devices-updated', (event, devices) => callback(devices));
},
selectDevice: (deviceId) => ipcRenderer.send('bluetooth:select-device', deviceId),
cancelSelection: () => ipcRenderer.send('bluetooth:cancel-selection'),
clearDevices: () => ipcRenderer.send('bluetooth:clear-devices'),
},
isElectron: true,
});

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>trainer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9001
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

95
package.json Normal file
View File

@@ -0,0 +1,95 @@
{
"name": "rideaware-trainer",
"private": true,
"version": "1.0.0",
"description": "RideAware Indoor Cycling Trainer",
"author": "RideAware <info@rideaware.com>",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env ELECTRON_IS_DEV=1 electron .\"",
"electron:build": "npm run build && electron-builder",
"electron:build:win": "npm run build && electron-builder --win",
"electron:build:mac": "npm run build && electron-builder --mac",
"electron:build:linux": "npm run build && electron-builder --linux"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@types/web-bluetooth": "^0.0.21",
"@vitejs/plugin-react": "^5.1.1",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"electron": "^32.0.0",
"electron-builder": "^25.1.8",
"electron-rebuild": "^3.2.9",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"wait-on": "^8.0.0"
},
"build": {
"appId": "org.rideaware.trainer",
"productName": "RideAware Trainer",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"protocols": [
{
"name": "RideAware Trainer Protocol",
"schemes": [
"rideaware"
]
}
],
"win": {
"target": [
"nsis"
],
"icon": "build/icon.ico"
},
"mac": {
"target": [
"dmg",
"zip"
],
"icon": "build/icon.icns",
"category": "public.app-category.healthcare-fitness"
},
"linux": {
"target": [
"AppImage"
],
"icon": "build/icon.png",
"category": "Utility"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

380
src/App.tsx Normal file
View File

@@ -0,0 +1,380 @@
import { useState, useEffect, useCallback } from 'react';
import { WorkoutDisplay } from './components/WorkoutDisplay';
import { MetricsDisplay } from './components/MetricsDisplay';
import { WorkoutSelector } from './components/WorkoutSelector';
import { WorkoutControls } from './components/WorkoutControls';
import { DeviceManager } from './components/DeviceManager';
import { LoginForm } from './components/LoginForm';
import { CalendarWorkouts } from './components/CalendarWorkouts';
import { sampleWorkouts } from './data/sampleWorkouts';
import { useBluetooth } from './hooks/useBluetooth';
import { useAuth } from './contexts/AuthContext';
import { useCalendarWorkouts } from './hooks/useCalendarWorkouts';
import { useDeepLink } from './hooks/useDeepLink';
import type { Workout, RiderMetrics } from './types/workout';
type AppView = 'select' | 'workout';
type WorkoutSource = 'calendar' | 'library';
function App() {
const { isAuthenticated, isLoading: authLoading, profile, logout } = useAuth();
const calendar = useCalendarWorkouts();
const [view, setView] = useState<AppView>('select');
const [workoutSource, setWorkoutSource] = useState<WorkoutSource>('calendar');
const [showDeviceManager, setShowDeviceManager] = useState(false);
const [selectedWorkout, setSelectedWorkout] = useState<Workout | null>(null);
const [activeWorkoutId, setActiveWorkoutId] = useState<number | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [elapsedTime, setElapsedTime] = useState(0);
const [currentIntervalIndex, setCurrentIntervalIndex] = useState(0);
const bluetooth = useBluetooth();
const hasConnectedDevices = Object.values(bluetooth.devices).some((d) => d?.connected);
const [simulatedMetrics, setSimulatedMetrics] = useState<RiderMetrics>({
power: 0,
cadence: 0,
heartRate: 0,
speed: 0,
distance: 0,
});
const [sessionMetrics, setSessionMetrics] = useState({
maxPower: 0,
maxHr: 0,
totalPower: 0,
powerSamples: 0,
});
const metrics: RiderMetrics = hasConnectedDevices
? {
power: bluetooth.sensorData.power || simulatedMetrics.power,
cadence: bluetooth.sensorData.cadence || simulatedMetrics.cadence,
heartRate: bluetooth.sensorData.heartRate || simulatedMetrics.heartRate,
speed: bluetooth.sensorData.speed || simulatedMetrics.speed,
distance: bluetooth.sensorData.distance || simulatedMetrics.distance,
}
: simulatedMetrics;
useEffect(() => {
if (isRunning && !isPaused) {
setSessionMetrics((prev) => ({
maxPower: Math.max(prev.maxPower, metrics.power),
maxHr: Math.max(prev.maxHr, metrics.heartRate),
totalPower: prev.totalPower + metrics.power,
powerSamples: prev.powerSamples + 1,
}));
}
}, [isRunning, isPaused, metrics.power, metrics.heartRate]);
useEffect(() => {
let interval: number | undefined;
if (isRunning && !isPaused && selectedWorkout) {
interval = window.setInterval(() => {
setElapsedTime((prev) => {
const next = prev + 1;
if (next >= selectedWorkout.duration) {
setIsRunning(false);
return prev;
}
let accumulated = 0;
for (let i = 0; i < selectedWorkout.intervals.length; i++) {
accumulated += selectedWorkout.intervals[i].duration;
if (next < accumulated) {
setCurrentIntervalIndex(i);
break;
}
}
return next;
});
}, 1000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isRunning, isPaused, selectedWorkout]);
useEffect(() => {
if (isRunning && !isPaused && selectedWorkout && bluetooth.devices.trainer?.connected) {
const currentInterval = selectedWorkout.intervals[currentIntervalIndex];
if (currentInterval?.targetPower) {
bluetooth.setTargetPower(currentInterval.targetPower);
}
}
}, [isRunning, isPaused, selectedWorkout, currentIntervalIndex, bluetooth]);
const handleSelectWorkout = useCallback((workout: Workout) => {
setSelectedWorkout(workout);
setActiveWorkoutId(workout.id ? parseInt(workout.id) : null);
}, []);
useDeepLink(
useCallback(
(data) => {
if (data.action === 'trainer' && data.params.workoutId) {
const workoutId = parseInt(data.params.workoutId, 10);
if (isAuthenticated && workoutId) {
const workout = calendar.weeksWorkouts.find((w) => w.id === workoutId);
if (workout) {
const trainerWorkout = calendar.convertToTrainerWorkout(workout);
if (trainerWorkout) {
handleSelectWorkout(trainerWorkout);
setView('select');
}
}
}
}
},
[isAuthenticated, calendar, handleSelectWorkout]
)
);
const handleStart = useCallback(() => {
if (selectedWorkout) {
setView('workout');
setIsRunning(true);
setIsPaused(false);
setElapsedTime(0);
setCurrentIntervalIndex(0);
setSessionMetrics({ maxPower: 0, maxHr: 0, totalPower: 0, powerSamples: 0 });
bluetooth.resetDistance();
}
}, [selectedWorkout, bluetooth]);
const handlePause = useCallback(() => {
setIsPaused(true);
}, []);
const handleResume = useCallback(() => {
setIsPaused(false);
}, []);
const handleStop = useCallback(async () => {
bluetooth.stopDistanceTracking();
if (activeWorkoutId && elapsedTime > 60) {
const avgPower =
sessionMetrics.powerSamples > 0
? Math.round(sessionMetrics.totalPower / sessionMetrics.powerSamples)
: undefined;
try {
await calendar.completeWorkout(activeWorkoutId, {
duration: elapsedTime,
distance: metrics.distance,
avgPower,
avgHr: metrics.heartRate,
maxPower: sessionMetrics.maxPower || undefined,
maxHr: sessionMetrics.maxHr || undefined,
});
} catch (error) {
console.error('Failed to save workout:', error);
}
}
setIsRunning(false);
setIsPaused(false);
setView('select');
setElapsedTime(0);
setCurrentIntervalIndex(0);
setActiveWorkoutId(null);
setSimulatedMetrics({
power: 0,
cadence: 0,
heartRate: 0,
speed: 0,
distance: 0,
});
}, [activeWorkoutId, elapsedTime, sessionMetrics, metrics, calendar, bluetooth]);
const currentInterval = selectedWorkout?.intervals[currentIntervalIndex];
const connectedCount = Object.values(bluetooth.devices).filter((d) => d?.connected).length;
if (authLoading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<div className="text-gray-400 animate-pulse">Loading...</div>
</div>
);
}
if (!isAuthenticated) {
return <LoginForm />;
}
return (
<div className="min-h-screen bg-gray-950 text-white">
<header className="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<h1 className="text-2xl font-bold">
<span className="text-blue-500">Ride</span>Aware Trainer
</h1>
<div className="flex items-center gap-4">
{profile && (
<span className="text-gray-400 text-sm">
FTP: <span className="text-white font-mono">{profile.ftp}W</span>
</span>
)}
<button
onClick={() => setShowDeviceManager(!showDeviceManager)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
showDeviceManager
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
<span>📡</span>
<span>Devices</span>
{connectedCount > 0 && (
<span className="bg-green-500 text-white text-xs px-2 py-0.5 rounded-full">
{connectedCount}
</span>
)}
</button>
{view === 'workout' ? (
<button
onClick={handleStop}
className="text-gray-400 hover:text-white transition-colors"
>
End Workout
</button>
) : (
<button
onClick={logout}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
Sign Out
</button>
)}
</div>
</div>
</header>
<main className="max-w-6xl mx-auto p-6">
<div className={`grid gap-8 ${showDeviceManager ? 'lg:grid-cols-3' : ''}`}>
{showDeviceManager && (
<div className="lg:col-span-1">
<DeviceManager
isSupported={bluetooth.isSupported}
devices={bluetooth.devices}
isConnecting={bluetooth.isConnecting}
error={bluetooth.error}
onConnect={bluetooth.connectDevice}
onDisconnect={bluetooth.disconnectDevice}
onClearError={bluetooth.clearError}
/>
</div>
)}
<div className={showDeviceManager ? 'lg:col-span-2' : ''}>
{view === 'select' ? (
<div className="grid md:grid-cols-2 gap-8">
<div>
<div className="flex gap-2 mb-4">
<button
onClick={() => setWorkoutSource('calendar')}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
workoutSource === 'calendar'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
📅 My Calendar
</button>
<button
onClick={() => setWorkoutSource('library')}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition-colors ${
workoutSource === 'library'
? 'bg-blue-600 text-white'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
📚 Sample Library
</button>
</div>
{workoutSource === 'calendar' ? (
<CalendarWorkouts
todaysWorkouts={calendar.todaysWorkouts}
weeksWorkouts={calendar.weeksWorkouts}
isLoading={calendar.isLoading}
error={calendar.error}
onSelectWorkout={handleSelectWorkout}
convertToTrainerWorkout={calendar.convertToTrainerWorkout}
selectedId={selectedWorkout?.id}
/>
) : (
<WorkoutSelector
workouts={sampleWorkouts}
onSelect={handleSelectWorkout}
selectedId={selectedWorkout?.id}
/>
)}
</div>
<div>
{selectedWorkout ? (
<div className="space-y-6">
<WorkoutDisplay
workout={selectedWorkout}
currentIntervalIndex={0}
elapsedTime={0}
/>
<WorkoutControls
isRunning={false}
isPaused={false}
onStart={handleStart}
onPause={handlePause}
onResume={handleResume}
onStop={handleStop}
/>
</div>
) : (
<div className="bg-gray-900 rounded-lg p-8 text-center">
<p className="text-gray-400">Select a workout to preview</p>
</div>
)}
</div>
</div>
) : (
<div className="space-y-6">
{selectedWorkout && (
<>
<WorkoutDisplay
workout={selectedWorkout}
currentIntervalIndex={currentIntervalIndex}
elapsedTime={elapsedTime}
/>
<MetricsDisplay
metrics={metrics}
targetPower={currentInterval?.targetPower}
targetCadence={currentInterval?.targetCadence}
/>
<WorkoutControls
isRunning={isRunning}
isPaused={isPaused}
onStart={handleStart}
onPause={handlePause}
onResume={handleResume}
onStop={handleStop}
/>
</>
)}
</div>
)}
</div>
</div>
</main>
</div>
);
}
export default App;

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { checkBluetoothSupport, type BluetoothDiagnosticResult } from '../utils/bluetoothDiagnostics';
interface BluetoothDiagnosticsProps {
onClose?: () => void;
}
export function BluetoothDiagnostics({ onClose }: BluetoothDiagnosticsProps) {
const [diagnostics, setDiagnostics] = useState<BluetoothDiagnosticResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function runDiagnostics() {
setLoading(true);
const result = await checkBluetoothSupport();
setDiagnostics(result);
setLoading(false);
}
runDiagnostics();
}, []);
if (loading || !diagnostics) {
return (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6">
<div className="flex items-center justify-center">
<div className="animate-pulse">Running diagnostics...</div>
</div>
</div>
);
}
const hasErrors = diagnostics.errors.length > 0;
const hasWarnings = diagnostics.warnings.length > 0;
const isHealthy = diagnostics.supported && diagnostics.available && !hasErrors;
return (
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Bluetooth Diagnostics</h3>
{onClose && (
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
>
</button>
)}
</div>
{/* Status Overview */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-2">
<span className="text-2xl">
{isHealthy ? '✅' : hasErrors ? '❌' : '⚠️'}
</span>
<span className="text-white font-medium">
{isHealthy ? 'Bluetooth is ready' : hasErrors ? 'Bluetooth issues detected' : 'Bluetooth warnings'}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="text-gray-400">API Support:</div>
<div className={diagnostics.supported ? 'text-green-400' : 'text-red-400'}>
{diagnostics.supported ? 'Supported' : 'Not supported'}
</div>
<div className="text-gray-400">Availability:</div>
<div className={diagnostics.available ? 'text-green-400' : 'text-red-400'}>
{diagnostics.available ? 'Available' : 'Not available'}
</div>
{diagnostics.systemCheck && (
<>
<div className="text-gray-400">System Service:</div>
<div className={diagnostics.systemCheck.available ? 'text-green-400' : 'text-red-400'}>
{diagnostics.systemCheck.available ? 'Running' : 'Not running'}
</div>
{diagnostics.systemCheck.adapter && (
<>
<div className="text-gray-400">Adapter:</div>
<div className={diagnostics.systemCheck.adapter === 'enabled' ? 'text-green-400' : 'text-yellow-400'}>
{diagnostics.systemCheck.adapter}
</div>
</>
)}
</>
)}
</div>
</div>
{/* Errors */}
{hasErrors && (
<div className="mb-4">
<div className="text-red-400 font-medium mb-2"> Errors</div>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-300">
{diagnostics.errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{/* Warnings */}
{hasWarnings && (
<div className="mb-4">
<div className="text-yellow-400 font-medium mb-2"> Warnings</div>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-300">
{diagnostics.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* Recommendations */}
{diagnostics.recommendations.length > 0 && (
<div className="bg-blue-500/10 border border-blue-500 rounded-lg p-4">
<div className="text-blue-400 font-medium mb-2">💡 Recommendations</div>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-300">
{diagnostics.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</div>
)}
{/* System Check Details (Electron only) */}
{diagnostics.systemCheck && window.electronAPI && (
<div className="mt-4 pt-4 border-t border-gray-700">
<div className="text-xs text-gray-500">
Platform: {diagnostics.systemCheck.platform}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,169 @@
import type { ApiWorkout } from '../types/api';
import type { Workout } from '../types/workout';
interface CalendarWorkoutsProps {
todaysWorkouts: ApiWorkout[];
weeksWorkouts: ApiWorkout[];
isLoading: boolean;
error: string | null;
onSelectWorkout: (workout: Workout) => void;
convertToTrainerWorkout: (apiWorkout: ApiWorkout) => Workout | null;
selectedId?: string;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}
function formatDuration(minutes: number): string {
const hrs = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hrs > 0) {
return `${hrs}h ${mins}m`;
}
return `${mins}m`;
}
interface WorkoutCardProps {
workout: ApiWorkout;
isSelected: boolean;
onClick: () => void;
}
function WorkoutCard({ workout, isSelected, onClick }: WorkoutCardProps) {
const typeIcons: Record<string, string> = {
endurance: '🚴',
tempo: '⚡',
threshold: '🔥',
vo2max: '💨',
sprint: '🚀',
recovery: '🧘',
climbing: '⛰️',
interval: '📊',
freeride: '🌄',
race: '🏁',
};
const icon = typeIcons[workout.type?.toLowerCase()] || '🚴';
return (
<button
onClick={onClick}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
isSelected
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<span className="text-2xl">{icon}</span>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start">
<div>
<h3 className="text-white font-medium truncate">{workout.title}</h3>
<p className="text-gray-500 text-sm">{formatDate(workout.scheduled_date)}</p>
</div>
<div className="text-right">
<div className="text-white font-mono">{formatDuration(workout.duration || 60)}</div>
{workout.tss && <div className="text-gray-500 text-sm">TSS: {workout.tss}</div>}
</div>
</div>
{workout.description && (
<p className="text-gray-400 text-sm mt-1 line-clamp-2">{workout.description}</p>
)}
</div>
</div>
</button>
);
}
export function CalendarWorkouts({
todaysWorkouts,
weeksWorkouts,
isLoading,
error,
onSelectWorkout,
convertToTrainerWorkout,
selectedId,
}: CalendarWorkoutsProps) {
const handleSelect = (apiWorkout: ApiWorkout) => {
const trainerWorkout = convertToTrainerWorkout(apiWorkout);
if (trainerWorkout) {
onSelectWorkout(trainerWorkout);
}
};
if (isLoading) {
return (
<div className="bg-gray-900 rounded-lg p-8 text-center">
<div className="animate-pulse text-gray-400">Loading workouts...</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
<p className="text-red-400">{error}</p>
</div>
);
}
const upcomingThisWeek = weeksWorkouts.filter(
(w) => !todaysWorkouts.some((t) => t.id === w.id) && w.status === 'planned'
);
return (
<div className="space-y-6">
{/* Today's Workouts */}
<div>
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<span className="text-green-500"></span> Today's Workouts
</h2>
{todaysWorkouts.length === 0 ? (
<div className="bg-gray-900 rounded-lg p-4 text-gray-400 text-center">
No workouts scheduled for today
</div>
) : (
<div className="space-y-3">
{todaysWorkouts.map((workout) => (
<WorkoutCard
key={workout.id}
workout={workout}
isSelected={selectedId === String(workout.id)}
onClick={() => handleSelect(workout)}
/>
))}
</div>
)}
</div>
{/* This Week */}
{upcomingThisWeek.length > 0 && (
<div>
<h2 className="text-xl font-semibold text-white mb-4">This Week</h2>
<div className="space-y-3">
{upcomingThisWeek.map((workout) => (
<WorkoutCard
key={workout.id}
workout={workout}
isSelected={selectedId === String(workout.id)}
onClick={() => handleSelect(workout)}
/>
))}
</div>
</div>
)}
{todaysWorkouts.length === 0 && upcomingThisWeek.length === 0 && (
<div className="bg-gray-900 rounded-lg p-8 text-center">
<p className="text-gray-400 mb-2">No planned workouts this week</p>
<p className="text-gray-500 text-sm">
Schedule workouts in the RideAware calendar to see them here
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,218 @@
import { useState } from 'react';
import type { DeviceType, BluetoothDeviceInfo } from '../types/bluetooth';
import { BluetoothDiagnostics } from './BluetoothDiagnostics';
import { DevicePicker } from './DevicePicker';
interface DeviceManagerProps {
isSupported: boolean;
devices: Record<DeviceType, BluetoothDeviceInfo | null>;
isConnecting: DeviceType | null;
error: string | null;
onConnect: (type: DeviceType, device?: BluetoothDevice) => void;
onDisconnect: (type: DeviceType) => void;
onClearError: () => void;
}
interface DeviceCardProps {
type: DeviceType;
label: string;
icon: string;
device: BluetoothDeviceInfo | null;
isConnecting: boolean;
onConnect: () => void;
onDisconnect: () => void;
}
function DeviceCard({
type: _type,
label,
icon,
device,
isConnecting,
onConnect,
onDisconnect,
}: DeviceCardProps) {
const isConnected = device?.connected ?? false;
return (
<div
className={`p-4 rounded-lg border-2 transition-all ${
isConnected
? 'border-green-500 bg-green-500/10'
: 'border-gray-700 bg-gray-800'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div>
<div className="font-medium text-white">{label}</div>
{isConnected ? (
<div className="text-sm text-green-400">{device?.name}</div>
) : (
<div className="text-sm text-gray-500">Not connected</div>
)}
</div>
</div>
<div>
{isConnecting ? (
<button
disabled
className="px-4 py-2 bg-gray-700 text-gray-400 rounded-lg cursor-not-allowed"
>
<span className="animate-pulse">Connecting...</span>
</button>
) : isConnected ? (
<button
onClick={onDisconnect}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
>
Disconnect
</button>
) : (
<button
onClick={onConnect}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
Connect
</button>
)}
</div>
</div>
</div>
);
}
export function DeviceManager({
isSupported,
devices,
isConnecting,
error,
onConnect,
onDisconnect,
onClearError,
}: DeviceManagerProps) {
const [showDiagnostics, setShowDiagnostics] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [pickerDeviceType, setPickerDeviceType] = useState<DeviceType | null>(null);
const handleConnectClick = (type: DeviceType) => {
setPickerDeviceType(type);
setShowPicker(true);
};
const handleDeviceSelected = (device: BluetoothDevice) => {
setShowPicker(false);
if (pickerDeviceType) {
onConnect(pickerDeviceType, device);
}
};
const handlePickerCancel = () => {
setShowPicker(false);
setPickerDeviceType(null);
};
if (!isSupported) {
return (
<div className="bg-yellow-500/10 border border-yellow-500 rounded-lg p-6">
<h3 className="text-yellow-500 font-semibold text-lg mb-3"> Bluetooth Not Supported</h3>
<p className="text-gray-300 mb-4">
Web Bluetooth is not supported in this browser. <strong>Firefox does not support Web Bluetooth API</strong>, which is required to connect to cycling devices.
</p>
<div className="text-gray-300 mb-4">
<p className="font-semibold mb-2">Please use one of these browsers:</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Chrome</strong> (Desktop, Android, ChromeOS)</li>
<li><strong>Microsoft Edge</strong> (Desktop)</li>
<li><strong>Opera</strong> (Desktop, Android)</li>
<li><strong>Brave</strong> (Desktop)</li>
</ul>
</div>
<p className="text-gray-400 text-sm">
Note: Safari and Firefox do not currently support Web Bluetooth for connecting to cycling devices.
</p>
</div>
);
}
const deviceConfigs: Array<{ type: DeviceType; label: string; icon: string }> = [
{ type: 'trainer', label: 'Smart Trainer', icon: '🚴' },
{ type: 'power', label: 'Power Meter', icon: '⚡' },
{ type: 'heartRate', label: 'Heart Rate', icon: '❤️' },
{ type: 'cadence', label: 'Cadence Sensor', icon: '🔄' },
];
return (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Connected Devices</h2>
<button
onClick={() => setShowDiagnostics(!showDiagnostics)}
className="text-sm px-3 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded transition-colors"
>
{showDiagnostics ? 'Hide' : 'Show'} Diagnostics
</button>
</div>
{showDiagnostics && (
<BluetoothDiagnostics onClose={() => setShowDiagnostics(false)} />
)}
{error && (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<p className="text-red-400 flex-1">{error}</p>
<button
onClick={onClearError}
className="text-red-400 hover:text-red-300 ml-2"
>
</button>
</div>
{error.includes('requestDevice') && (
<div className="mt-3 pt-3 border-t border-red-500/30">
<p className="text-sm text-gray-400 mb-2">Troubleshooting tips:</p>
<ul className="text-sm text-gray-400 space-y-1 list-disc list-inside">
<li>Make sure Bluetooth is enabled on your system</li>
<li>Turn on your Bluetooth device and make it discoverable</li>
<li>Try restarting your Bluetooth adapter</li>
<li>Click "Show Diagnostics" above for more information</li>
</ul>
</div>
)}
</div>
)}
<div className="grid gap-3">
{deviceConfigs.map(({ type, label, icon }) => (
<DeviceCard
key={type}
type={type}
label={label}
icon={icon}
device={devices[type]}
isConnecting={isConnecting === type}
onConnect={() => handleConnectClick(type)}
onDisconnect={() => onDisconnect(type)}
/>
))}
</div>
{showPicker && pickerDeviceType && (
<DevicePicker
deviceType={pickerDeviceType}
onSelect={handleDeviceSelected}
onCancel={handlePickerCancel}
/>
)}
<div className="text-xs text-gray-500 mt-4">
<p>
<strong>Tip:</strong> Connect a smart trainer for ERG mode control, or use individual
sensors for data collection.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useState, useEffect } from 'react';
import { BLE_SERVICES, type DeviceType } from '../types/bluetooth';
import type { DiscoveredDevice } from '../types/electron';
interface DevicePickerProps {
deviceType: DeviceType;
onSelect: (device: BluetoothDevice) => void;
onCancel: () => void;
}
export function DevicePicker({ deviceType, onSelect, onCancel }: DevicePickerProps) {
const [devices, setDevices] = useState<DiscoveredDevice[]>([]);
const [isScanning, setIsScanning] = useState(false);
const [scanMode, setScanMode] = useState<'filtered' | 'all'>('filtered');
const [error, setError] = useState<string | null>(null);
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null);
const isElectron = typeof window !== 'undefined' && window.electronAPI?.isElectron;
useEffect(() => {
if (!isElectron) return;
const handleDevicesUpdated = (newDevices: DiscoveredDevice[]) => {
setDevices(newDevices);
};
window.electronAPI?.bluetooth.onDevicesUpdated(handleDevicesUpdated);
return () => {
window.electronAPI?.bluetooth.clearDevices();
};
}, [isElectron]);
const getServiceForType = (type: DeviceType): string => {
switch (type) {
case 'heartRate':
return BLE_SERVICES.HEART_RATE;
case 'power':
return BLE_SERVICES.CYCLING_POWER;
case 'cadence':
return BLE_SERVICES.CYCLING_SPEED_CADENCE;
case 'trainer':
return BLE_SERVICES.FITNESS_MACHINE;
}
};
const getDeviceLabel = (type: DeviceType): string => {
switch (type) {
case 'heartRate':
return 'Heart Rate Monitor';
case 'power':
return 'Power Meter';
case 'cadence':
return 'Cadence Sensor';
case 'trainer':
return 'Smart Trainer';
}
};
const handleStartScan = async () => {
setIsScanning(true);
setError(null);
setDevices([]);
setSelectedDeviceId(null);
if (isElectron) {
window.electronAPI?.bluetooth.clearDevices();
}
try {
const service = getServiceForType(deviceType);
const requestOptions: RequestDeviceOptions = scanMode === 'filtered'
? {
filters: [{ services: [service] }],
optionalServices: [service],
}
: {
acceptAllDevices: true,
optionalServices: Object.values(BLE_SERVICES),
};
const device = await navigator.bluetooth.requestDevice(requestOptions);
onSelect(device);
} catch (err) {
const error = err as Error;
if (error.name !== 'NotFoundError' || error.message !== 'User cancelled the requestDevice() chooser.') {
if (error.name === 'NotFoundError') {
setError('No devices found. Try "Show All Devices" to see all available Bluetooth devices.');
} else if (error.name === 'SecurityError') {
setError('Bluetooth permission denied. Check your browser/system settings.');
} else {
setError(error.message);
}
}
} finally {
setIsScanning(false);
}
};
const handleSelectDevice = (deviceId: string) => {
if (!isElectron) return;
setSelectedDeviceId(deviceId);
window.electronAPI?.bluetooth.selectDevice(deviceId);
};
const handleCancel = () => {
if (isElectron) {
window.electronAPI?.bluetooth.cancelSelection();
}
onCancel();
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
<h3 className="text-lg font-semibold text-white mb-4">
Connect {getDeviceLabel(deviceType)}
</h3>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{!isScanning && devices.length === 0 && (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4">
<div className="text-sm text-gray-400 mb-2">Scan Mode:</div>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scanMode"
value="filtered"
checked={scanMode === 'filtered'}
onChange={(e) => setScanMode(e.target.value as 'filtered' | 'all')}
className="text-blue-600"
/>
<div>
<div className="text-white text-sm">Filtered (Recommended)</div>
<div className="text-gray-500 text-xs">
Only show {getDeviceLabel(deviceType)} devices
</div>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scanMode"
value="all"
checked={scanMode === 'all'}
onChange={(e) => setScanMode(e.target.value as 'filtered' | 'all')}
className="text-blue-600"
/>
<div>
<div className="text-white text-sm">Show All Devices</div>
<div className="text-gray-500 text-xs">
Show all Bluetooth devices (use if filtered scan finds nothing)
</div>
</div>
</label>
</div>
</div>
)}
{isElectron && isScanning && (
<div className="flex-1 min-h-0">
<div className="text-sm text-gray-400 mb-2 flex items-center gap-2">
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
Scanning for devices... ({devices.length} found)
</div>
<div className="bg-gray-900 border border-gray-700 rounded-lg overflow-y-auto max-h-48">
{devices.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
Searching for Bluetooth devices...
</div>
) : (
<div className="divide-y divide-gray-700">
{devices.map((device) => (
<button
key={device.id}
onClick={() => handleSelectDevice(device.id)}
disabled={selectedDeviceId !== null}
className={`w-full p-3 text-left hover:bg-gray-800 transition-colors ${
selectedDeviceId === device.id ? 'bg-blue-600/20' : ''
} ${selectedDeviceId !== null ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className="text-white text-sm font-medium">
{device.name || 'Unknown Device'}
</div>
<div className="text-gray-500 text-xs">
{device.id}
</div>
</button>
))}
</div>
)}
</div>
</div>
)}
{!isScanning && devices.length === 0 && (
<div className="bg-blue-500/10 border border-blue-500 rounded-lg p-4">
<div className="text-blue-400 text-sm font-medium mb-2">Before scanning:</div>
<ul className="text-sm text-gray-300 space-y-1 list-disc list-inside">
<li>Power on your {getDeviceLabel(deviceType).toLowerCase()}</li>
<li>Make sure it's in pairing mode</li>
<li>Ensure Bluetooth is enabled on your system</li>
<li>Keep the device close to your computer</li>
</ul>
</div>
)}
{error && (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-4">
<div className="text-red-400 text-sm">{error}</div>
</div>
)}
<div className="flex gap-3">
{!isScanning ? (
<button
onClick={handleStartScan}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
Scan for Devices
</button>
) : (
<div className="flex-1 px-4 py-2 bg-gray-700 text-gray-400 rounded-lg text-center">
{selectedDeviceId ? 'Connecting...' : 'Select a device above'}
</div>
)}
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
Cancel
</button>
</div>
{isElectron && !isScanning && (
<div className="text-xs text-gray-500 text-center">
Devices will appear in a list as they are discovered
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, type FormEvent } from 'react';
import { useAuth } from '../contexts/AuthContext';
export function LoginForm() {
const { login, isLoading, error, clearError } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
clearError();
await login(username, password);
};
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-white mb-2">
<span className="text-blue-500">Ride</span>Aware Trainer
</h1>
<p className="text-gray-400">Sign in to access your workouts</p>
</div>
<form onSubmit={handleSubmit} className="bg-gray-900 rounded-lg p-6 space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500 rounded-lg p-3 text-red-400 text-sm">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/50 text-white font-semibold rounded-lg transition-colors"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
<p className="text-center text-gray-500 text-sm">
Don't have an account?{' '}
<a
href="https://rideaware.org/signup"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-400"
>
Sign up at RideAware
</a>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import type { RiderMetrics } from '../types/workout';
interface MetricsDisplayProps {
metrics: RiderMetrics;
targetPower?: number;
targetCadence?: number;
}
interface MetricCardProps {
label: string;
value: number;
unit: string;
target?: number;
size?: 'normal' | 'large';
}
function MetricCard({ label, value, unit, target, size = 'normal' }: MetricCardProps) {
const isLarge = size === 'large';
const deviation = target ? ((value - target) / target) * 100 : 0;
let statusColor = 'text-white';
if (target) {
if (Math.abs(deviation) <= 5) statusColor = 'text-green-400';
else if (Math.abs(deviation) <= 10) statusColor = 'text-yellow-400';
else statusColor = 'text-red-400';
}
return (
<div className={`bg-gray-800 rounded-lg p-4 ${isLarge ? 'col-span-2' : ''}`}>
<div className="text-gray-400 text-sm uppercase tracking-wide mb-1">{label}</div>
<div className={`${isLarge ? 'text-5xl' : 'text-3xl'} font-mono font-bold ${statusColor}`}>
{value}
<span className="text-lg text-gray-500 ml-1">{unit}</span>
</div>
{target && (
<div className="text-sm text-gray-500 mt-1">
Target: {target} {unit}
</div>
)}
</div>
);
}
export function MetricsDisplay({ metrics, targetPower, targetCadence }: MetricsDisplayProps) {
return (
<div className="grid grid-cols-2 gap-4">
<MetricCard
label="Power"
value={metrics.power}
unit="W"
target={targetPower}
size="large"
/>
<MetricCard
label="Heart Rate"
value={metrics.heartRate}
unit="BPM"
/>
<MetricCard
label="Cadence"
value={metrics.cadence}
unit="RPM"
target={targetCadence}
/>
<MetricCard
label="Speed"
value={metrics.speed}
unit="km/h"
/>
<MetricCard
label="Distance"
value={metrics.distance}
unit="km"
/>
</div>
);
}

View File

@@ -0,0 +1,54 @@
interface WorkoutControlsProps {
isRunning: boolean;
isPaused: boolean;
onStart: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}
export function WorkoutControls({
isRunning,
isPaused,
onStart,
onPause,
onResume,
onStop,
}: WorkoutControlsProps) {
return (
<div className="flex gap-4 justify-center">
{!isRunning ? (
<button
onClick={onStart}
className="px-8 py-3 bg-green-600 hover:bg-green-500 text-white font-semibold rounded-lg transition-colors"
>
Start Workout
</button>
) : (
<>
{isPaused ? (
<button
onClick={onResume}
className="px-8 py-3 bg-green-600 hover:bg-green-500 text-white font-semibold rounded-lg transition-colors"
>
Resume
</button>
) : (
<button
onClick={onPause}
className="px-8 py-3 bg-yellow-600 hover:bg-yellow-500 text-white font-semibold rounded-lg transition-colors"
>
Pause
</button>
)}
<button
onClick={onStop}
className="px-8 py-3 bg-red-600 hover:bg-red-500 text-white font-semibold rounded-lg transition-colors"
>
End Workout
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import type { Workout, WorkoutInterval } from '../types/workout';
interface WorkoutDisplayProps {
workout: Workout;
currentIntervalIndex: number;
elapsedTime: number;
}
function formatTime(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hrs > 0) {
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function getIntensityColor(intensity: WorkoutInterval['intensity']): string {
const colors: Record<WorkoutInterval['intensity'], string> = {
recovery: 'bg-gray-400',
endurance: 'bg-blue-500',
tempo: 'bg-green-500',
threshold: 'bg-yellow-500',
vo2max: 'bg-orange-500',
anaerobic: 'bg-red-500',
};
return colors[intensity];
}
export function WorkoutDisplay({ workout, currentIntervalIndex, elapsedTime }: WorkoutDisplayProps) {
const currentInterval = workout.intervals[currentIntervalIndex];
const totalDuration = workout.duration;
const progress = (elapsedTime / totalDuration) * 100;
return (
<div className="bg-gray-900 rounded-lg p-6 text-white">
<div className="mb-4">
<h2 className="text-2xl font-bold">{workout.name}</h2>
<p className="text-gray-400">{workout.description}</p>
</div>
{/* Progress bar */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-400 mb-1">
<span>{formatTime(elapsedTime)}</span>
<span>{formatTime(totalDuration)}</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-1000"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Interval visualization */}
<div className="mb-6">
<div className="flex h-16 gap-0.5 rounded overflow-hidden">
{workout.intervals.map((interval, idx) => {
const widthPercent = (interval.duration / totalDuration) * 100;
const isCurrent = idx === currentIntervalIndex;
return (
<div
key={interval.id}
className={`${getIntensityColor(interval.intensity)} ${isCurrent ? 'ring-2 ring-white' : 'opacity-60'} transition-opacity`}
style={{ width: `${widthPercent}%` }}
title={`${interval.name} - ${formatTime(interval.duration)}`}
/>
);
})}
</div>
</div>
{/* Current interval info */}
{currentInterval && (
<div className={`${getIntensityColor(currentInterval.intensity)} rounded-lg p-4`}>
<div className="flex justify-between items-center">
<div>
<h3 className="text-xl font-semibold">{currentInterval.name}</h3>
<p className="text-white/80">
{currentInterval.targetPower && `Target: ${currentInterval.targetPower}W`}
{currentInterval.targetCadence && ` | ${currentInterval.targetCadence} RPM`}
</p>
</div>
<div className="text-3xl font-mono font-bold">
{formatTime(currentInterval.duration)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import type { Workout } from '../types/workout';
interface WorkoutSelectorProps {
workouts: Workout[];
onSelect: (workout: Workout) => void;
selectedId?: string;
}
function formatDuration(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (hrs > 0) {
return `${hrs}h ${mins}m`;
}
return `${mins}m`;
}
export function WorkoutSelector({ workouts, onSelect, selectedId }: WorkoutSelectorProps) {
return (
<div className="space-y-3">
<h2 className="text-xl font-semibold text-white mb-4">Select Workout</h2>
{workouts.length === 0 ? (
<p className="text-gray-400">No workouts available</p>
) : (
workouts.map((workout) => (
<button
key={workout.id}
onClick={() => onSelect(workout)}
className={`w-full text-left p-4 rounded-lg border-2 transition-all ${
selectedId === workout.id
? 'border-blue-500 bg-blue-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<div className="flex justify-between items-start">
<div>
<h3 className="text-white font-medium">{workout.name}</h3>
<p className="text-gray-400 text-sm mt-1">{workout.description}</p>
</div>
<div className="text-right">
<div className="text-white font-mono">{formatDuration(workout.duration)}</div>
{workout.tss && (
<div className="text-gray-500 text-sm">TSS: {workout.tss}</div>
)}
</div>
</div>
</button>
))
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import {
login as apiLogin,
logout as apiLogout,
getProfile,
isAuthenticated as checkAuth,
} from '../services/api';
import type { UserProfile } from '../types/api';
interface AuthContextValue {
isAuthenticated: boolean;
isLoading: boolean;
profile: UserProfile | null;
error: string | null;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
refreshProfile: () => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(checkAuth());
const [isLoading, setIsLoading] = useState(true);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [error, setError] = useState<string | null>(null);
const refreshProfile = useCallback(async () => {
if (!checkAuth()) {
setProfile(null);
setIsAuthenticated(false);
return;
}
try {
const userProfile = await getProfile();
setProfile(userProfile);
setIsAuthenticated(true);
} catch (err) {
console.error('Failed to fetch profile:', err);
setProfile(null);
setIsAuthenticated(false);
}
}, []);
useEffect(() => {
const init = async () => {
setIsLoading(true);
await refreshProfile();
setIsLoading(false);
};
init();
}, [refreshProfile]);
const login = useCallback(async (username: string, password: string): Promise<boolean> => {
setIsLoading(true);
setError(null);
try {
await apiLogin({ username, password });
await refreshProfile();
setIsAuthenticated(true);
setIsLoading(false);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Login failed';
setError(message);
setIsLoading(false);
return false;
}
}, [refreshProfile]);
const logout = useCallback(() => {
apiLogout();
setProfile(null);
setIsAuthenticated(false);
}, []);
const clearError = useCallback(() => {
setError(null);
}, []);
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
profile,
error,
login,
logout,
refreshProfile,
clearError,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

208
src/data/sampleWorkouts.ts Normal file
View File

@@ -0,0 +1,208 @@
import type { Workout } from '../types/workout';
export const sampleWorkouts: Workout[] = [
{
id: '1',
name: 'Endurance Base',
description: '60 minute steady endurance ride',
duration: 3600,
tss: 50,
intensityFactor: 0.70,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
intervals: [
{
id: '1-1',
name: 'Warm Up',
duration: 600,
targetPower: 120,
targetCadence: 85,
intensity: 'recovery',
},
{
id: '1-2',
name: 'Main Set',
duration: 2700,
targetPower: 180,
targetCadence: 90,
intensity: 'endurance',
},
{
id: '1-3',
name: 'Cool Down',
duration: 300,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
],
},
{
id: '2',
name: 'Sweet Spot Intervals',
description: '3x10 minute intervals at 88-93% FTP',
duration: 3600,
tss: 70,
intensityFactor: 0.88,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
intervals: [
{
id: '2-1',
name: 'Warm Up',
duration: 600,
targetPower: 130,
targetCadence: 85,
intensity: 'recovery',
},
{
id: '2-2',
name: 'Sweet Spot 1',
duration: 600,
targetPower: 230,
targetCadence: 90,
intensity: 'tempo',
},
{
id: '2-3',
name: 'Recovery',
duration: 300,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
{
id: '2-4',
name: 'Sweet Spot 2',
duration: 600,
targetPower: 230,
targetCadence: 90,
intensity: 'tempo',
},
{
id: '2-5',
name: 'Recovery',
duration: 300,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
{
id: '2-6',
name: 'Sweet Spot 3',
duration: 600,
targetPower: 230,
targetCadence: 90,
intensity: 'tempo',
},
{
id: '2-7',
name: 'Cool Down',
duration: 600,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
],
},
{
id: '3',
name: 'VO2 Max Blast',
description: '5x3 minute intervals at 105-120% FTP',
duration: 2700,
tss: 80,
intensityFactor: 0.95,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
intervals: [
{
id: '3-1',
name: 'Warm Up',
duration: 600,
targetPower: 140,
targetCadence: 90,
intensity: 'endurance',
},
{
id: '3-2',
name: 'VO2 Interval 1',
duration: 180,
targetPower: 300,
targetCadence: 100,
intensity: 'vo2max',
},
{
id: '3-3',
name: 'Recovery',
duration: 180,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
{
id: '3-4',
name: 'VO2 Interval 2',
duration: 180,
targetPower: 300,
targetCadence: 100,
intensity: 'vo2max',
},
{
id: '3-5',
name: 'Recovery',
duration: 180,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
{
id: '3-6',
name: 'VO2 Interval 3',
duration: 180,
targetPower: 300,
targetCadence: 100,
intensity: 'vo2max',
},
{
id: '3-7',
name: 'Recovery',
duration: 180,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
{
id: '3-8',
name: 'VO2 Interval 4',
duration: 180,
targetPower: 300,
targetCadence: 100,
intensity: 'vo2max',
},
{
id: '3-9',
name: 'Recovery',
duration: 180,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
{
id: '3-10',
name: 'VO2 Interval 5',
duration: 180,
targetPower: 300,
targetCadence: 100,
intensity: 'vo2max',
},
{
id: '3-11',
name: 'Cool Down',
duration: 300,
targetPower: 100,
targetCadence: 80,
intensity: 'recovery',
},
],
},
];

294
src/hooks/useBluetooth.ts Normal file
View File

@@ -0,0 +1,294 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import {
HeartRateService,
CyclingPowerService,
CadenceService,
FTMSService,
} from '../services/bluetooth';
import type {
DeviceType,
BluetoothDeviceInfo,
HeartRateData,
CyclingPowerData,
CadenceData,
IndoorBikeData,
} from '../types/bluetooth';
interface BluetoothState {
isSupported: boolean;
devices: Record<DeviceType, BluetoothDeviceInfo | null>;
isConnecting: DeviceType | null;
error: string | null;
}
interface SensorData {
heartRate: number;
power: number;
cadence: number;
speed: number;
distance: number;
}
export function useBluetooth() {
const [state, setState] = useState<BluetoothState>({
isSupported: typeof navigator !== 'undefined' && 'bluetooth' in navigator,
devices: {
heartRate: null,
power: null,
cadence: null,
trainer: null,
},
isConnecting: null,
error: null,
});
const [sensorData, setSensorData] = useState<SensorData>({
heartRate: 0,
power: 0,
cadence: 0,
speed: 0,
distance: 0,
});
const heartRateService = useRef(new HeartRateService());
const powerService = useRef(new CyclingPowerService());
const cadenceService = useRef(new CadenceService());
const ftmsService = useRef(new FTMSService());
const distanceRef = useRef(0);
const distanceOffsetRef = useRef<number | null>(null);
const isTrackingDistanceRef = useRef(false);
useEffect(() => {
heartRateService.current.onData((data: HeartRateData) => {
setSensorData((prev) => ({ ...prev, heartRate: data.heartRate }));
});
powerService.current.onData((data: CyclingPowerData) => {
setSensorData((prev) => ({
...prev,
power: data.instantaneousPower,
cadence: powerService.current.cadence || prev.cadence,
}));
});
cadenceService.current.onCadenceData((data: CadenceData) => {
setSensorData((prev) => ({ ...prev, cadence: data.calculatedCadence }));
});
cadenceService.current.onSpeedData(() => {
setSensorData((prev) => ({
...prev,
speed: cadenceService.current.speed,
}));
});
ftmsService.current.onData((data: IndoorBikeData) => {
setSensorData((prev) => {
let adjustedDistance = prev.distance;
const currentSpeed = data.instantaneousSpeed ?? prev.speed;
const currentPower = data.instantaneousPower ?? prev.power;
const isActive = currentSpeed > 0 || currentPower > 0;
if (data.totalDistance !== undefined && data.totalDistance !== null && isActive && isTrackingDistanceRef.current) {
const distanceInMeters = data.totalDistance;
if (distanceOffsetRef.current === null) {
distanceOffsetRef.current = distanceInMeters;
adjustedDistance = 0;
} else {
const sessionDistanceMeters = distanceInMeters - distanceOffsetRef.current;
adjustedDistance = Math.round((sessionDistanceMeters / 1000) * 100) / 100;
if (adjustedDistance < 0) {
distanceOffsetRef.current = distanceInMeters;
adjustedDistance = 0;
}
}
}
return {
...prev,
power: data.instantaneousPower ?? prev.power,
cadence: data.instantaneousCadence ?? prev.cadence,
speed: currentSpeed,
distance: adjustedDistance,
heartRate: data.heartRate ?? prev.heartRate,
};
});
});
}, []);
useEffect(() => {
heartRateService.current.disconnect();
powerService.current.disconnect();
cadenceService.current.disconnect();
ftmsService.current.disconnect();
distanceOffsetRef.current = null;
distanceRef.current = 0;
setState({
isSupported: typeof navigator !== 'undefined' && 'bluetooth' in navigator,
devices: {
heartRate: null,
power: null,
cadence: null,
trainer: null,
},
isConnecting: null,
error: null,
});
setSensorData({
heartRate: 0,
power: 0,
cadence: 0,
speed: 0,
distance: 0,
});
}, []);
useEffect(() => {
const hasAnyDevice = Object.values(state.devices).some((d) => d?.connected);
if (!hasAnyDevice) {
setSensorData({
heartRate: 0,
power: 0,
cadence: 0,
speed: 0,
distance: 0,
});
}
}, [state.devices]);
const connectDevice = useCallback(async (type: DeviceType, preSelectedDevice?: BluetoothDevice) => {
if (!state.isSupported) {
setState((prev) => ({ ...prev, error: 'Web Bluetooth not supported' }));
return;
}
setState((prev) => ({ ...prev, isConnecting: type, error: null }));
try {
let device: BluetoothDevice;
switch (type) {
case 'heartRate':
device = await heartRateService.current.connect(preSelectedDevice);
break;
case 'power':
device = await powerService.current.connect(preSelectedDevice);
break;
case 'cadence':
device = await cadenceService.current.connect(preSelectedDevice);
break;
case 'trainer':
device = await ftmsService.current.connect(preSelectedDevice);
await ftmsService.current.requestControl();
break;
default:
throw new Error(`Unknown device type: ${type}`);
}
setState((prev) => ({
...prev,
isConnecting: null,
devices: {
...prev.devices,
[type]: {
id: device.id,
name: device.name || 'Unknown Device',
type,
connected: true,
device,
},
},
}));
} catch (error) {
const message = error instanceof Error ? error.message : 'Connection failed';
setState((prev) => ({
...prev,
isConnecting: null,
error: message,
}));
}
}, [state.isSupported]);
const disconnectDevice = useCallback((type: DeviceType) => {
switch (type) {
case 'heartRate':
heartRateService.current.disconnect();
setSensorData((prev) => ({ ...prev, heartRate: 0 }));
break;
case 'power':
powerService.current.disconnect();
setSensorData((prev) => ({ ...prev, power: 0 }));
break;
case 'cadence':
cadenceService.current.disconnect();
setSensorData((prev) => ({ ...prev, cadence: 0, speed: 0 }));
break;
case 'trainer':
ftmsService.current.disconnect();
distanceOffsetRef.current = null;
setSensorData((prev) => ({ ...prev, power: 0, cadence: 0, speed: 0, distance: 0 }));
break;
}
setState((prev) => ({
...prev,
devices: {
...prev.devices,
[type]: null,
},
}));
}, []);
const setTargetPower = useCallback(async (watts: number) => {
if (ftmsService.current.isConnected && ftmsService.current.isControlled) {
await ftmsService.current.setTargetPower(watts);
}
}, []);
const setTargetResistance = useCallback(async (level: number) => {
if (ftmsService.current.isConnected && ftmsService.current.isControlled) {
await ftmsService.current.setTargetResistance(level);
}
}, []);
const setSimulation = useCallback(async (grade: number, crr?: number, cw?: number) => {
if (ftmsService.current.isConnected && ftmsService.current.isControlled) {
await ftmsService.current.setSimulation(grade, crr, cw);
}
}, []);
const resetDistance = useCallback(() => {
distanceRef.current = 0;
distanceOffsetRef.current = null;
isTrackingDistanceRef.current = true;
setSensorData((prev) => ({ ...prev, distance: 0 }));
}, []);
const stopDistanceTracking = useCallback(() => {
isTrackingDistanceRef.current = false;
}, []);
const clearError = useCallback(() => {
setState((prev) => ({ ...prev, error: null }));
}, []);
return {
...state,
sensorData,
connectDevice,
disconnectDevice,
setTargetPower,
setTargetResistance,
setSimulation,
resetDistance,
stopDistanceTracking,
clearError,
};
}

View File

@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
import { checkBluetoothSupport, type BluetoothDiagnosticResult } from '../utils/bluetoothDiagnostics';
export function useBluetoothStartupCheck() {
const [diagnostics, setDiagnostics] = useState<BluetoothDiagnosticResult | null>(null);
const [hasChecked, setHasChecked] = useState(false);
useEffect(() => {
async function runStartupCheck() {
if (hasChecked) return;
const result = await checkBluetoothSupport();
setDiagnostics(result);
setHasChecked(true);
}
runStartupCheck();
}, [hasChecked]);
return {
diagnostics,
hasIssues: diagnostics ? (diagnostics.errors.length > 0 || !diagnostics.available) : false,
isReady: diagnostics ? (diagnostics.supported && diagnostics.available && diagnostics.errors.length === 0) : false,
};
}

View File

@@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from 'react';
import {
getWorkoutsByMonth,
getTodaysWorkouts,
getWeeksWorkouts,
markWorkoutComplete,
} from '../services/api';
import type { ApiWorkout } from '../types/api';
import type { Workout, WorkoutInterval } from '../types/workout';
import { useAuth } from '../contexts/AuthContext';
interface UseCalendarWorkoutsReturn {
workouts: ApiWorkout[];
todaysWorkouts: ApiWorkout[];
weeksWorkouts: ApiWorkout[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
completeWorkout: (
workoutId: number,
metrics: {
duration: number;
distance?: number;
avgPower?: number;
avgHr?: number;
maxPower?: number;
maxHr?: number;
caloriesBurned?: number;
}
) => Promise<void>;
convertToTrainerWorkout: (apiWorkout: ApiWorkout) => Workout | null;
}
function apiToTrainerWorkout(apiWorkout: ApiWorkout, ftp: number): Workout | null {
if (!apiWorkout.workout_data?.segments) {
return {
id: String(apiWorkout.id),
name: apiWorkout.title,
description: apiWorkout.description || '',
duration: (apiWorkout.duration || 60) * 60,
tss: apiWorkout.tss,
createdAt: apiWorkout.created_at,
updatedAt: apiWorkout.updated_at,
intervals: [
{
id: `${apiWorkout.id}-1`,
name: apiWorkout.title,
duration: (apiWorkout.duration || 60) * 60,
targetPower: Math.round(ftp * 0.7),
intensity: 'endurance',
},
],
};
}
const segments = apiWorkout.workout_data.segments;
const intervals: WorkoutInterval[] = segments.map((segment, idx) => {
const powerPercent = segment.power || segment.power_high || segment.power_low || 0.6;
const targetPower = Math.round(powerPercent * ftp);
const intensityMap: Record<string, WorkoutInterval['intensity']> = {
warmup: 'recovery',
cooldown: 'recovery',
recovery: 'recovery',
steady: 'endurance',
freeride: 'endurance',
interval: powerPercent >= 1.05 ? 'vo2max' : powerPercent >= 0.9 ? 'threshold' : 'tempo',
ramp: 'tempo',
};
return {
id: `${apiWorkout.id}-${idx}`,
name: segment.name || segment.type || 'Interval',
duration: segment.duration,
targetPower,
targetPowerLow: segment.power_low ? Math.round(segment.power_low * ftp) : undefined,
targetPowerHigh: segment.power_high ? Math.round(segment.power_high * ftp) : undefined,
targetCadence: segment.cadence,
intensity: intensityMap[segment.type] || 'endurance',
};
});
const expandedIntervals: WorkoutInterval[] = [];
segments.forEach((segment, idx) => {
const interval = intervals[idx];
const repeatCount = segment.repeat || 1;
for (let r = 0; r < repeatCount; r++) {
expandedIntervals.push({
...interval,
id: `${interval.id}-r${r}`,
name: repeatCount > 1 ? `${interval.name} (${r + 1}/${repeatCount})` : interval.name,
});
if (segment.rest_between && r < repeatCount - 1) {
expandedIntervals.push({
id: `${interval.id}-rest${r}`,
name: 'Recovery',
duration: segment.rest_between,
targetPower: Math.round(ftp * 0.5),
intensity: 'recovery',
});
}
}
});
const totalDuration = expandedIntervals.reduce((sum, i) => sum + i.duration, 0);
return {
id: String(apiWorkout.id),
name: apiWorkout.title,
description: apiWorkout.description || '',
duration: totalDuration,
tss: apiWorkout.tss || apiWorkout.workout_data.tss,
intensityFactor: apiWorkout.workout_data.intensity_factor,
createdAt: apiWorkout.created_at,
updatedAt: apiWorkout.updated_at,
intervals: expandedIntervals,
};
}
export function useCalendarWorkouts(): UseCalendarWorkoutsReturn {
const { isAuthenticated, profile } = useAuth();
const [workouts, setWorkouts] = useState<ApiWorkout[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const ftp = profile?.ftp || 200;
const refresh = useCallback(async () => {
if (!isAuthenticated) {
setWorkouts([]);
return;
}
setIsLoading(true);
setError(null);
try {
const now = new Date();
const monthWorkouts = await getWorkoutsByMonth(now.getFullYear(), now.getMonth() + 1);
setWorkouts(monthWorkouts);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load workouts';
setError(message);
} finally {
setIsLoading(false);
}
}, [isAuthenticated]);
useEffect(() => {
refresh();
}, [refresh]);
const todaysWorkouts = getTodaysWorkouts(workouts);
const weeksWorkouts = getWeeksWorkouts(workouts);
const completeWorkout = useCallback(
async (
workoutId: number,
metrics: {
duration: number;
distance?: number;
avgPower?: number;
avgHr?: number;
maxPower?: number;
maxHr?: number;
caloriesBurned?: number;
}
) => {
try {
await markWorkoutComplete(workoutId, {
duration: Math.round(metrics.duration / 60),
distance: metrics.distance,
avg_power: metrics.avgPower,
avg_hr: metrics.avgHr,
max_power: metrics.maxPower,
max_hr: metrics.maxHr,
calories_burned: metrics.caloriesBurned,
});
await refresh();
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to complete workout';
setError(message);
throw err;
}
},
[refresh]
);
const convertToTrainerWorkout = useCallback(
(apiWorkout: ApiWorkout): Workout | null => {
return apiToTrainerWorkout(apiWorkout, ftp);
},
[ftp]
);
return {
workouts,
todaysWorkouts,
weeksWorkouts,
isLoading,
error,
refresh,
completeWorkout,
convertToTrainerWorkout,
};
}

14
src/hooks/useDeepLink.ts Normal file
View File

@@ -0,0 +1,14 @@
import { useEffect } from 'react';
import type { DeepLinkData } from '../types/electron';
export function useDeepLink(onDeepLink: (data: DeepLinkData) => void) {
useEffect(() => {
if (!window.electronAPI) {
return;
}
window.electronAPI.onDeepLink((data: DeepLinkData) => {
onDeepLink(data);
});
}, [onDeepLink]);
}

1
src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { AuthProvider } from './contexts/AuthContext'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)

48
src/services/api/auth.ts Normal file
View File

@@ -0,0 +1,48 @@
import { api, setTokens, clearTokens } from './client';
import type { AuthResponse, User, UserProfile } from '../../types/api';
export interface LoginCredentials {
username: string;
password: string;
}
export interface SignupData {
username: string;
email: string;
password: string;
first_name?: string;
last_name?: string;
}
export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await api.post<AuthResponse>('/api/login', credentials);
setTokens(response.access_token, response.refresh_token);
return response;
}
export async function signup(data: SignupData): Promise<AuthResponse> {
const response = await api.post<AuthResponse>('/api/signup', data);
setTokens(response.access_token, response.refresh_token);
return response;
}
export function logout(): void {
clearTokens();
}
export async function getProfile(): Promise<UserProfile> {
return api.get<UserProfile>('/protected/profile');
}
export async function updateProfile(data: Partial<UserProfile>): Promise<UserProfile> {
return api.put<UserProfile>('/protected/profile', data);
}
export async function getCurrentUser(): Promise<User> {
const profile = await getProfile();
return {
id: profile.user_id,
username: '', // Not in profile response
email: '', // Not in profile response
};
}

131
src/services/api/client.ts Normal file
View File

@@ -0,0 +1,131 @@
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:5010';
interface TokenStore {
accessToken: string | null;
refreshToken: string | null;
}
let tokens: TokenStore = {
accessToken: localStorage.getItem('accessToken'),
refreshToken: localStorage.getItem('refreshToken'),
};
let refreshPromise: Promise<boolean> | null = null;
export function setTokens(access: string, refresh: string): void {
tokens.accessToken = access;
tokens.refreshToken = refresh;
localStorage.setItem('accessToken', access);
localStorage.setItem('refreshToken', refresh);
}
export function clearTokens(): void {
tokens.accessToken = null;
tokens.refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
export function getAccessToken(): string | null {
return tokens.accessToken;
}
export function isAuthenticated(): boolean {
return !!tokens.accessToken;
}
async function refreshAccessToken(): Promise<boolean> {
if (!tokens.refreshToken) return false;
try {
const response = await fetch(`${API_BASE_URL}/api/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: tokens.refreshToken }),
});
if (!response.ok) {
clearTokens();
return false;
}
const data = await response.json();
setTokens(data.access_token, data.refresh_token || tokens.refreshToken!);
return true;
} catch {
clearTokens();
return false;
}
}
export interface ApiError {
message: string;
status: number;
}
export async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (tokens.accessToken) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${tokens.accessToken}`;
}
let response = await fetch(url, { ...options, headers });
if (response.status === 401 && tokens.refreshToken) {
if (!refreshPromise) {
refreshPromise = refreshAccessToken();
}
const refreshed = await refreshPromise;
refreshPromise = null;
if (refreshed) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${tokens.accessToken}`;
response = await fetch(url, { ...options, headers });
} else {
throw { message: 'Session expired. Please log in again.', status: 401 } as ApiError;
}
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw {
message: errorData.error || errorData.message || 'Request failed',
status: response.status,
} as ApiError;
}
const text = await response.text();
if (!text) return {} as T;
return JSON.parse(text);
}
export const api = {
get: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'GET' }),
post: <T>(endpoint: string, body?: unknown) =>
apiRequest<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(endpoint: string, body?: unknown) =>
apiRequest<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(endpoint: string) => apiRequest<T>(endpoint, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,3 @@
export { api, setTokens, clearTokens, getAccessToken, isAuthenticated } from './client';
export * from './auth';
export * from './workouts';

View File

@@ -0,0 +1,108 @@
import { api } from './client';
import type { ApiWorkout, WorkoutTemplate, WorkoutUpdatePayload } from '../../types/api';
export async function getWorkouts(): Promise<ApiWorkout[]> {
return api.get<ApiWorkout[]>('/protected/workouts');
}
export async function getWorkoutsByMonth(year: number, month: number): Promise<ApiWorkout[]> {
return api.get<ApiWorkout[]>(`/protected/workouts/month?year=${year}&month=${month}`);
}
export async function getWorkoutById(id: number): Promise<ApiWorkout> {
return api.get<ApiWorkout>(`/protected/workouts/${id}`);
}
export async function updateWorkout(id: number, data: WorkoutUpdatePayload): Promise<ApiWorkout> {
return api.put<ApiWorkout>(`/protected/workouts?id=${id}`, data);
}
export async function markWorkoutComplete(
id: number,
metrics: {
duration?: number;
distance?: number;
avg_power?: number;
avg_hr?: number;
max_power?: number;
max_hr?: number;
calories_burned?: number;
}
): Promise<ApiWorkout> {
return api.put<ApiWorkout>(`/protected/workouts?id=${id}`, {
status: 'completed',
...metrics,
});
}
export async function skipWorkout(id: number, notes?: string): Promise<ApiWorkout> {
return api.put<ApiWorkout>(`/protected/workouts?id=${id}`, {
status: 'skipped',
notes,
});
}
export async function getSystemWorkouts(): Promise<WorkoutTemplate[]> {
return api.get<WorkoutTemplate[]>('/protected/library/system');
}
export async function browseWorkouts(params?: {
page?: number;
page_size?: number;
type?: string;
category?: string;
difficulty?: string;
}): Promise<{ workouts: WorkoutTemplate[]; total: number }> {
const searchParams = new URLSearchParams();
if (params?.page) searchParams.set('page', String(params.page));
if (params?.page_size) searchParams.set('page_size', String(params.page_size));
if (params?.type) searchParams.set('type', params.type);
if (params?.category) searchParams.set('category', params.category);
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
const query = searchParams.toString();
return api.get(`/protected/library/browse${query ? `?${query}` : ''}`);
}
export async function searchWorkouts(
q: string,
filters?: { type?: string; category?: string }
): Promise<WorkoutTemplate[]> {
const searchParams = new URLSearchParams({ q });
if (filters?.type) searchParams.set('type', filters.type);
if (filters?.category) searchParams.set('category', filters.category);
return api.get<WorkoutTemplate[]>(`/protected/library/search?${searchParams.toString()}`);
}
export async function getWorkoutTemplate(id: number): Promise<WorkoutTemplate> {
return api.get<WorkoutTemplate>(`/protected/library/${id}`);
}
export function getTodaysWorkouts(workouts: ApiWorkout[]): ApiWorkout[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return workouts.filter((w) => {
const scheduledDate = new Date(w.scheduled_date);
return scheduledDate >= today && scheduledDate < tomorrow && w.status === 'planned';
});
}
export function getWeeksWorkouts(workouts: ApiWorkout[]): ApiWorkout[] {
const today = new Date();
const dayOfWeek = today.getDay();
const monday = new Date(today);
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 7);
return workouts.filter((w) => {
const scheduledDate = new Date(w.scheduled_date);
return scheduledDate >= monday && scheduledDate < sunday;
});
}

View File

@@ -0,0 +1,195 @@
import { BLE_SERVICES, BLE_CHARACTERISTICS, type CadenceData, type SpeedData } from '../../types/bluetooth';
export type CadenceCallback = (data: CadenceData) => void;
export type SpeedCallback = (data: SpeedData) => void;
export class CadenceService {
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private characteristic: BluetoothRemoteGATTCharacteristic | null = null;
private cadenceCallback: CadenceCallback | null = null;
private speedCallback: SpeedCallback | null = null;
private lastCrankRevs = 0;
private lastCrankTime = 0;
private calculatedCadence = 0;
private lastWheelRevs = 0;
private lastWheelTime = 0;
private calculatedSpeed = 0;
private wheelCircumference = 2.105;
async connect(preSelectedDevice?: BluetoothDevice): Promise<BluetoothDevice> {
if (preSelectedDevice) {
this.device = preSelectedDevice;
} else {
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [BLE_SERVICES.CYCLING_SPEED_CADENCE] }],
optionalServices: [BLE_SERVICES.CYCLING_SPEED_CADENCE],
});
}
this.device.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
this.server = await this.device.gatt!.connect();
const service = await this.server.getPrimaryService(BLE_SERVICES.CYCLING_SPEED_CADENCE);
this.characteristic = await service.getCharacteristic(BLE_CHARACTERISTICS.CSC_MEASUREMENT);
await this.characteristic.startNotifications();
this.characteristic.addEventListener('characteristicvaluechanged', this.onMeasurement.bind(this));
return this.device;
}
disconnect(): void {
if (this.characteristic) {
this.characteristic.removeEventListener('characteristicvaluechanged', this.onMeasurement.bind(this));
}
if (this.server?.connected) {
this.server.disconnect();
}
this.device = null;
this.server = null;
this.characteristic = null;
}
onCadenceData(callback: CadenceCallback): void {
this.cadenceCallback = callback;
}
onSpeedData(callback: SpeedCallback): void {
this.speedCallback = callback;
}
setWheelCircumference(circumference: number): void {
this.wheelCircumference = circumference;
}
get isConnected(): boolean {
return this.server?.connected ?? false;
}
get deviceName(): string | undefined {
return this.device?.name;
}
get deviceId(): string | undefined {
return this.device?.id;
}
get cadence(): number {
return this.calculatedCadence;
}
get speed(): number {
return this.calculatedSpeed;
}
private onDisconnected(): void {
this.server = null;
this.characteristic = null;
}
private onMeasurement(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const value = target.value;
if (!value) return;
this.parseMeasurement(value);
}
private parseMeasurement(value: DataView): void {
const flags = value.getUint8(0);
const hasWheelRevolution = (flags & 0x01) !== 0;
const hasCrankRevolution = (flags & 0x02) !== 0;
let offset = 1;
if (hasWheelRevolution) {
const wheelRevolutions = value.getUint32(offset, true);
offset += 4;
const lastWheelEventTime = value.getUint16(offset, true);
offset += 2;
const speedData = this.calculateSpeed(wheelRevolutions, lastWheelEventTime);
if (speedData) {
this.speedCallback?.(speedData);
}
}
if (hasCrankRevolution) {
const crankRevolutions = value.getUint16(offset, true);
offset += 2;
const lastCrankEventTime = value.getUint16(offset, true);
offset += 2;
const cadenceData = this.calculateCadence(crankRevolutions, lastCrankEventTime);
if (cadenceData) {
this.cadenceCallback?.(cadenceData);
}
}
}
private calculateCadence(crankRevs: number, crankTime: number): CadenceData | null {
if (this.lastCrankTime === 0) {
this.lastCrankRevs = crankRevs;
this.lastCrankTime = crankTime;
return null;
}
let revDiff = crankRevs - this.lastCrankRevs;
if (revDiff < 0) revDiff += 65536;
let timeDiff = crankTime - this.lastCrankTime;
if (timeDiff < 0) timeDiff += 65536;
const timeSeconds = timeDiff / 1024;
if (timeSeconds > 0 && timeSeconds < 4) {
this.calculatedCadence = Math.round((revDiff / timeSeconds) * 60);
} else if (timeSeconds >= 4) {
this.calculatedCadence = 0;
}
this.lastCrankRevs = crankRevs;
this.lastCrankTime = crankTime;
return {
crankRevolutions: crankRevs,
lastCrankEventTime: crankTime,
calculatedCadence: this.calculatedCadence,
};
}
private calculateSpeed(wheelRevs: number, wheelTime: number): SpeedData | null {
if (this.lastWheelTime === 0) {
this.lastWheelRevs = wheelRevs;
this.lastWheelTime = wheelTime;
return null;
}
let revDiff = wheelRevs - this.lastWheelRevs;
if (revDiff < 0) revDiff += 4294967296;
let timeDiff = wheelTime - this.lastWheelTime;
if (timeDiff < 0) timeDiff += 65536;
const timeSeconds = timeDiff / 1024;
if (timeSeconds > 0 && timeSeconds < 4) {
const distanceMeters = revDiff * this.wheelCircumference;
this.calculatedSpeed = Math.round((distanceMeters / timeSeconds) * 3.6 * 10) / 10;
} else if (timeSeconds >= 4) {
this.calculatedSpeed = 0;
}
this.lastWheelRevs = wheelRevs;
this.lastWheelTime = wheelTime;
return {
wheelRevolutions: wheelRevs,
lastWheelEventTime: wheelTime,
calculatedSpeed: this.calculatedSpeed,
};
}
}

View File

@@ -0,0 +1,154 @@
import { BLE_SERVICES, BLE_CHARACTERISTICS, type CyclingPowerData } from '../../types/bluetooth';
export type CyclingPowerCallback = (data: CyclingPowerData) => void;
export class CyclingPowerService {
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private characteristic: BluetoothRemoteGATTCharacteristic | null = null;
private callback: CyclingPowerCallback | null = null;
private lastCrankRevs = 0;
private lastCrankTime = 0;
private calculatedCadence = 0;
async connect(preSelectedDevice?: BluetoothDevice): Promise<BluetoothDevice> {
if (preSelectedDevice) {
this.device = preSelectedDevice;
} else {
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [BLE_SERVICES.CYCLING_POWER] }],
optionalServices: [BLE_SERVICES.CYCLING_POWER],
});
}
this.device.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
this.server = await this.device.gatt!.connect();
const service = await this.server.getPrimaryService(BLE_SERVICES.CYCLING_POWER);
this.characteristic = await service.getCharacteristic(BLE_CHARACTERISTICS.CYCLING_POWER_MEASUREMENT);
await this.characteristic.startNotifications();
this.characteristic.addEventListener('characteristicvaluechanged', this.onPowerMeasurement.bind(this));
return this.device;
}
disconnect(): void {
if (this.characteristic) {
this.characteristic.removeEventListener('characteristicvaluechanged', this.onPowerMeasurement.bind(this));
}
if (this.server?.connected) {
this.server.disconnect();
}
this.device = null;
this.server = null;
this.characteristic = null;
}
onData(callback: CyclingPowerCallback): void {
this.callback = callback;
}
get isConnected(): boolean {
return this.server?.connected ?? false;
}
get deviceName(): string | undefined {
return this.device?.name;
}
get deviceId(): string | undefined {
return this.device?.id;
}
get cadence(): number {
return this.calculatedCadence;
}
private onDisconnected(): void {
this.server = null;
this.characteristic = null;
}
private onPowerMeasurement(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const value = target.value;
if (!value) return;
const data = this.parsePowerMeasurement(value);
this.callback?.(data);
}
private parsePowerMeasurement(value: DataView): CyclingPowerData {
const flags = value.getUint16(0, true);
const hasPedalPowerBalance = (flags & 0x0001) !== 0;
const hasAccumulatedTorque = (flags & 0x0004) !== 0;
const hasWheelRevolution = (flags & 0x0010) !== 0;
const hasCrankRevolution = (flags & 0x0020) !== 0;
let offset = 2;
const instantaneousPower = value.getInt16(offset, true);
offset += 2;
const result: CyclingPowerData = { instantaneousPower };
if (hasPedalPowerBalance) {
result.pedalPowerBalance = value.getUint8(offset) * 0.5;
offset += 1;
}
if (hasAccumulatedTorque) {
result.accumulatedTorque = value.getUint16(offset, true) / 32;
offset += 2;
}
if (hasWheelRevolution) {
result.wheelRevolutions = value.getUint32(offset, true);
offset += 4;
result.lastWheelEventTime = value.getUint16(offset, true);
offset += 2;
}
if (hasCrankRevolution) {
const crankRevolutions = value.getUint16(offset, true);
offset += 2;
const lastCrankEventTime = value.getUint16(offset, true);
offset += 2;
result.crankRevolutions = crankRevolutions;
result.lastCrankEventTime = lastCrankEventTime;
this.calculateCadence(crankRevolutions, lastCrankEventTime);
}
return result;
}
private calculateCadence(crankRevs: number, crankTime: number): void {
if (this.lastCrankTime === 0) {
this.lastCrankRevs = crankRevs;
this.lastCrankTime = crankTime;
return;
}
let revDiff = crankRevs - this.lastCrankRevs;
if (revDiff < 0) revDiff += 65536;
let timeDiff = crankTime - this.lastCrankTime;
if (timeDiff < 0) timeDiff += 65536;
const timeSeconds = timeDiff / 1024;
if (timeSeconds > 0 && timeSeconds < 4) {
this.calculatedCadence = Math.round((revDiff / timeSeconds) * 60);
} else if (timeSeconds >= 4) {
this.calculatedCadence = 0;
}
this.lastCrankRevs = crankRevs;
this.lastCrankTime = crankTime;
}
}

View File

@@ -0,0 +1,330 @@
import {
BLE_SERVICES,
BLE_CHARACTERISTICS,
FTMSOpCode,
FTMSResultCode,
type IndoorBikeData,
} from '../../types/bluetooth';
export type IndoorBikeDataCallback = (data: IndoorBikeData) => void;
export type FTMSStatusCallback = (status: string) => void;
export class FTMSService {
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private indoorBikeDataChar: BluetoothRemoteGATTCharacteristic | null = null;
private controlPointChar: BluetoothRemoteGATTCharacteristic | null = null;
private statusChar: BluetoothRemoteGATTCharacteristic | null = null;
private dataCallback: IndoorBikeDataCallback | null = null;
private statusCallback: FTMSStatusCallback | null = null;
private hasControl = false;
async connect(preSelectedDevice?: BluetoothDevice): Promise<BluetoothDevice> {
if (preSelectedDevice) {
this.device = preSelectedDevice;
} else {
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [BLE_SERVICES.FITNESS_MACHINE] }],
optionalServices: [BLE_SERVICES.FITNESS_MACHINE],
});
}
this.device.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
this.server = await this.device.gatt!.connect();
const service = await this.server.getPrimaryService(BLE_SERVICES.FITNESS_MACHINE);
try {
this.indoorBikeDataChar = await service.getCharacteristic(BLE_CHARACTERISTICS.INDOOR_BIKE_DATA);
await this.indoorBikeDataChar.startNotifications();
this.indoorBikeDataChar.addEventListener('characteristicvaluechanged', this.onIndoorBikeData.bind(this));
} catch {
}
try {
this.controlPointChar = await service.getCharacteristic(BLE_CHARACTERISTICS.FITNESS_MACHINE_CONTROL_POINT);
await this.controlPointChar.startNotifications();
this.controlPointChar.addEventListener('characteristicvaluechanged', this.onControlPointResponse.bind(this));
} catch {
}
try {
this.statusChar = await service.getCharacteristic(BLE_CHARACTERISTICS.FITNESS_MACHINE_STATUS);
await this.statusChar.startNotifications();
this.statusChar.addEventListener('characteristicvaluechanged', this.onStatusChange.bind(this));
} catch {
}
return this.device;
}
disconnect(): void {
this.hasControl = false;
if (this.server?.connected) {
this.server.disconnect();
}
this.device = null;
this.server = null;
this.indoorBikeDataChar = null;
this.controlPointChar = null;
this.statusChar = null;
}
onData(callback: IndoorBikeDataCallback): void {
this.dataCallback = callback;
}
onStatus(callback: FTMSStatusCallback): void {
this.statusCallback = callback;
}
get isConnected(): boolean {
return this.server?.connected ?? false;
}
get deviceName(): string | undefined {
return this.device?.name;
}
get deviceId(): string | undefined {
return this.device?.id;
}
get isControlled(): boolean {
return this.hasControl;
}
async requestControl(): Promise<boolean> {
if (!this.controlPointChar) return false;
const data = new Uint8Array([FTMSOpCode.REQUEST_CONTROL]);
await this.controlPointChar.writeValue(data);
this.hasControl = true;
return true;
}
async reset(): Promise<void> {
if (!this.controlPointChar || !this.hasControl) return;
const data = new Uint8Array([FTMSOpCode.RESET]);
await this.controlPointChar.writeValue(data);
}
async setTargetPower(watts: number): Promise<void> {
if (!this.controlPointChar || !this.hasControl) return;
const data = new Uint8Array(3);
const view = new DataView(data.buffer);
view.setUint8(0, FTMSOpCode.SET_TARGET_POWER);
view.setInt16(1, Math.round(watts), true);
await this.controlPointChar.writeValue(data);
}
async setTargetResistance(level: number): Promise<void> {
if (!this.controlPointChar || !this.hasControl) return;
const data = new Uint8Array(3);
const view = new DataView(data.buffer);
view.setUint8(0, FTMSOpCode.SET_TARGET_RESISTANCE);
view.setInt16(1, Math.round(level * 10), true);
await this.controlPointChar.writeValue(data);
}
async setSimulation(
grade: number,
crr: number = 0.004,
cw: number = 0.51
): Promise<void> {
if (!this.controlPointChar || !this.hasControl) return;
const data = new Uint8Array(7);
const view = new DataView(data.buffer);
view.setUint8(0, FTMSOpCode.SET_INDOOR_BIKE_SIMULATION);
view.setInt16(1, 0, true);
view.setInt16(3, Math.round(grade * 100), true);
view.setUint8(5, Math.round(crr * 10000));
view.setUint8(6, Math.round(cw * 100));
await this.controlPointChar.writeValue(data);
}
async start(): Promise<void> {
if (!this.controlPointChar || !this.hasControl) return;
const data = new Uint8Array([FTMSOpCode.START_OR_RESUME]);
await this.controlPointChar.writeValue(data);
}
async stop(pause: boolean = false): Promise<void> {
if (!this.controlPointChar || !this.hasControl) return;
const data = new Uint8Array(2);
data[0] = FTMSOpCode.STOP_OR_PAUSE;
data[1] = pause ? 0x02 : 0x01;
await this.controlPointChar.writeValue(data);
}
private onDisconnected(): void {
this.hasControl = false;
this.server = null;
this.indoorBikeDataChar = null;
this.controlPointChar = null;
this.statusChar = null;
}
private onIndoorBikeData(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const value = target.value;
if (!value) return;
const data = this.parseIndoorBikeData(value);
this.dataCallback?.(data);
}
private onControlPointResponse(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const value = target.value;
if (!value) return;
const responseCode = value.getUint8(0);
if (responseCode === FTMSOpCode.RESPONSE_CODE) {
const resultCode = value.getUint8(2) as FTMSResultCode;
const resultMessages: Record<FTMSResultCode, string> = {
[FTMSResultCode.SUCCESS]: 'Success',
[FTMSResultCode.OP_CODE_NOT_SUPPORTED]: 'Operation not supported',
[FTMSResultCode.INVALID_PARAMETER]: 'Invalid parameter',
[FTMSResultCode.OPERATION_FAILED]: 'Operation failed',
[FTMSResultCode.CONTROL_NOT_PERMITTED]: 'Control not permitted',
};
this.statusCallback?.(resultMessages[resultCode]);
}
}
private onStatusChange(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const value = target.value;
if (!value) return;
const statusCode = value.getUint8(0);
const statusMessages: Record<number, string> = {
0x01: 'Reset',
0x02: 'Fitness Machine Stopped or Paused by User',
0x03: 'Fitness Machine Stopped by Safety Key',
0x04: 'Fitness Machine Started or Resumed by User',
0x05: 'Target Speed Changed',
0x06: 'Target Incline Changed',
0x07: 'Target Resistance Level Changed',
0x08: 'Target Power Changed',
0x09: 'Target Heart Rate Changed',
0x0A: 'Targeted Expended Energy Changed',
0x0B: 'Targeted Number of Steps Changed',
0x0C: 'Targeted Number of Strides Changed',
0x0D: 'Targeted Distance Changed',
0x0E: 'Targeted Training Time Changed',
0x0F: 'Targeted Time in Two Heart Rate Zones Changed',
0x12: 'Indoor Bike Simulation Parameters Changed',
0x13: 'Wheel Circumference Changed',
0x14: 'Spin Down Status',
0x15: 'Target Cadence Changed',
0xFF: 'Control Permission Lost',
};
const message = statusMessages[statusCode] || `Unknown status: ${statusCode}`;
this.statusCallback?.(message);
if (statusCode === 0xFF) {
this.hasControl = false;
}
}
private parseIndoorBikeData(value: DataView): IndoorBikeData {
const flags = value.getUint16(0, true);
const hasMoreData = (flags & 0x0001) !== 0;
const hasAverageSpeed = (flags & 0x0002) !== 0;
const hasInstantaneousCadence = (flags & 0x0004) !== 0;
const hasAverageCadence = (flags & 0x0008) !== 0;
const hasTotalDistance = (flags & 0x0010) !== 0;
const hasResistanceLevel = (flags & 0x0020) !== 0;
const hasInstantaneousPower = (flags & 0x0040) !== 0;
const hasAveragePower = (flags & 0x0080) !== 0;
const hasExpendedEnergy = (flags & 0x0100) !== 0;
const hasHeartRate = (flags & 0x0200) !== 0;
const hasMetabolicEquivalent = (flags & 0x0400) !== 0;
const hasElapsedTime = (flags & 0x0800) !== 0;
const hasRemainingTime = (flags & 0x1000) !== 0;
let offset = 2;
const result: IndoorBikeData = {};
if (!hasMoreData) {
result.instantaneousSpeed = value.getUint16(offset, true) / 100;
offset += 2;
}
if (hasAverageSpeed) {
result.averageSpeed = value.getUint16(offset, true) / 100;
offset += 2;
}
if (hasInstantaneousCadence) {
result.instantaneousCadence = value.getUint16(offset, true) / 2;
offset += 2;
}
if (hasAverageCadence) {
result.averageCadence = value.getUint16(offset, true) / 2;
offset += 2;
}
if (hasTotalDistance) {
const rawDistance = value.getUint16(offset, true) | (value.getUint8(offset + 2) << 16);
result.totalDistance = rawDistance;
offset += 3;
}
if (hasResistanceLevel) {
result.resistanceLevel = value.getInt16(offset, true) / 10;
offset += 2;
}
if (hasInstantaneousPower) {
result.instantaneousPower = value.getInt16(offset, true);
offset += 2;
}
if (hasAveragePower) {
result.averagePower = value.getInt16(offset, true);
offset += 2;
}
if (hasExpendedEnergy) {
result.totalEnergy = value.getUint16(offset, true);
offset += 2;
offset += 3;
}
if (hasHeartRate) {
result.heartRate = value.getUint8(offset);
offset += 1;
}
if (hasMetabolicEquivalent) {
offset += 1;
}
if (hasElapsedTime) {
result.elapsedTime = value.getUint16(offset, true);
offset += 2;
}
if (hasRemainingTime) {
result.remainingTime = value.getUint16(offset, true);
offset += 2;
}
return result;
}
}

View File

@@ -0,0 +1,109 @@
import { BLE_SERVICES, BLE_CHARACTERISTICS, type HeartRateData } from '../../types/bluetooth';
export type HeartRateCallback = (data: HeartRateData) => void;
export class HeartRateService {
private device: BluetoothDevice | null = null;
private server: BluetoothRemoteGATTServer | null = null;
private characteristic: BluetoothRemoteGATTCharacteristic | null = null;
private callback: HeartRateCallback | null = null;
async connect(preSelectedDevice?: BluetoothDevice): Promise<BluetoothDevice> {
if (preSelectedDevice) {
this.device = preSelectedDevice;
} else {
this.device = await navigator.bluetooth.requestDevice({
filters: [{ services: [BLE_SERVICES.HEART_RATE] }],
optionalServices: [BLE_SERVICES.HEART_RATE],
});
}
this.device.addEventListener('gattserverdisconnected', this.onDisconnected.bind(this));
this.server = await this.device.gatt!.connect();
const service = await this.server.getPrimaryService(BLE_SERVICES.HEART_RATE);
this.characteristic = await service.getCharacteristic(BLE_CHARACTERISTICS.HEART_RATE_MEASUREMENT);
await this.characteristic.startNotifications();
this.characteristic.addEventListener('characteristicvaluechanged', this.onHeartRateMeasurement.bind(this));
return this.device;
}
disconnect(): void {
if (this.characteristic) {
this.characteristic.removeEventListener('characteristicvaluechanged', this.onHeartRateMeasurement.bind(this));
}
if (this.server?.connected) {
this.server.disconnect();
}
this.device = null;
this.server = null;
this.characteristic = null;
}
onData(callback: HeartRateCallback): void {
this.callback = callback;
}
get isConnected(): boolean {
return this.server?.connected ?? false;
}
get deviceName(): string | undefined {
return this.device?.name;
}
get deviceId(): string | undefined {
return this.device?.id;
}
private onDisconnected(): void {
this.server = null;
this.characteristic = null;
}
private onHeartRateMeasurement(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const value = target.value;
if (!value) return;
const data = this.parseHeartRateMeasurement(value);
this.callback?.(data);
}
private parseHeartRateMeasurement(value: DataView): HeartRateData {
const flags = value.getUint8(0);
const is16Bit = (flags & 0x01) !== 0;
const hasContactStatus = (flags & 0x02) !== 0;
const contactDetected = (flags & 0x04) !== 0;
const hasEnergyExpended = (flags & 0x08) !== 0;
const hasRRIntervals = (flags & 0x10) !== 0;
let offset = 1;
const heartRate = is16Bit ? value.getUint16(offset, true) : value.getUint8(offset);
offset += is16Bit ? 2 : 1;
const result: HeartRateData = { heartRate };
if (hasContactStatus) {
result.contactDetected = contactDetected;
}
if (hasEnergyExpended) {
result.energyExpended = value.getUint16(offset, true);
offset += 2;
}
if (hasRRIntervals) {
result.rrIntervals = [];
while (offset < value.byteLength) {
result.rrIntervals.push(value.getUint16(offset, true));
offset += 2;
}
}
return result;
}
}

View File

@@ -0,0 +1,4 @@
export { HeartRateService } from './HeartRateService';
export { CyclingPowerService } from './CyclingPowerService';
export { CadenceService } from './CadenceService';
export { FTMSService } from './FTMSService';

153
src/types/api.ts Normal file
View File

@@ -0,0 +1,153 @@
export type WorkoutType =
| 'endurance'
| 'tempo'
| 'threshold'
| 'vo2max'
| 'sprint'
| 'recovery'
| 'climbing'
| 'interval'
| 'freeride'
| 'race';
export type WorkoutCategory = 'base' | 'build' | 'peak' | 'recovery' | 'test' | 'fun';
export type WorkoutStatus = 'planned' | 'completed' | 'skipped';
export type DifficultyLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export type SegmentType = 'warmup' | 'steady' | 'interval' | 'ramp' | 'cooldown' | 'freeride';
export interface WorkoutSegment {
type: SegmentType;
duration: number;
power?: number;
power_low?: number;
power_high?: number;
cadence?: number;
repeat?: number;
rest_between?: number;
name?: string;
}
export interface WorkoutData {
name: string;
author?: string;
total_duration: number;
segments: WorkoutSegment[];
intensity_factor?: number;
tss?: number;
}
export interface ApiWorkout {
id: number;
user_id: number;
title: string;
description: string;
type: string;
status: WorkoutStatus;
scheduled_date: string;
duration: number;
distance?: number;
elev_gain?: number;
avg_power?: number;
avg_hr?: number;
max_power?: number;
max_hr?: number;
calories_burned?: number;
file_type?: 'fit' | 'zwo';
file_url?: string;
workout_data?: WorkoutData;
notes?: string;
tss?: number;
created_at: string;
updated_at: string;
}
export interface WorkoutTemplate {
id: number;
name: string;
description: string;
type: WorkoutType;
category: WorkoutCategory;
difficulty: DifficultyLevel;
duration: number;
tss?: number;
intensity_factor?: number;
structure: {
warmup: WorkoutSegment[];
main: WorkoutSegment[];
cooldown: WorkoutSegment[];
};
tags?: string[];
is_system: boolean;
author_id?: number;
author_name?: string;
is_public: boolean;
usage_count: number;
rating: number;
rating_count: number;
}
export interface UserProfile {
id: number;
user_id: number;
first_name: string;
last_name: string;
bio?: string;
profile_picture?: string;
resting_hr?: number;
max_hr?: number;
ftp: number;
weight?: number;
height?: number;
total_rides: number;
total_distance: number;
total_time: number;
hr_zones?: HRZones;
training_goal?: string;
weekly_hours?: number;
ftp_last_updated?: string;
ftp_source?: string;
onboarding_completed: boolean;
}
export interface HRZones {
zone1_min: number;
zone1_max: number;
zone2_min: number;
zone2_max: number;
zone3_min: number;
zone3_max: number;
zone4_min: number;
zone4_max: number;
zone5_min: number;
zone5_max: number;
method: 'max_hr' | 'lthr';
}
export interface User {
id: number;
username: string;
email: string;
}
export interface AuthResponse {
user: User;
access_token: string;
refresh_token: string;
}
export interface WorkoutUpdatePayload {
title?: string;
description?: string;
status?: WorkoutStatus;
duration?: number;
distance?: number;
avg_power?: number;
avg_hr?: number;
max_power?: number;
max_hr?: number;
calories_burned?: number;
notes?: string;
}

104
src/types/bluetooth.ts Normal file
View File

@@ -0,0 +1,104 @@
export const BLE_SERVICES = {
HEART_RATE: 'heart_rate',
CYCLING_POWER: 'cycling_power',
CYCLING_SPEED_CADENCE: 'cycling_speed_and_cadence',
FITNESS_MACHINE: 'fitness_machine',
} as const;
export const BLE_CHARACTERISTICS = {
HEART_RATE_MEASUREMENT: 'heart_rate_measurement',
CYCLING_POWER_MEASUREMENT: 'cycling_power_measurement',
CYCLING_POWER_FEATURE: 'cycling_power_feature',
CSC_MEASUREMENT: 'csc_measurement',
CSC_FEATURE: 'csc_feature',
FITNESS_MACHINE_FEATURE: 'fitness_machine_feature',
INDOOR_BIKE_DATA: 'indoor_bike_data',
TRAINING_STATUS: 'training_status',
FITNESS_MACHINE_CONTROL_POINT: 'fitness_machine_control_point',
FITNESS_MACHINE_STATUS: 'fitness_machine_status',
} as const;
export type DeviceType = 'heartRate' | 'power' | 'cadence' | 'trainer';
export interface BluetoothDeviceInfo {
id: string;
name: string;
type: DeviceType;
connected: boolean;
device?: BluetoothDevice;
server?: BluetoothRemoteGATTServer;
}
export interface HeartRateData {
heartRate: number;
contactDetected?: boolean;
energyExpended?: number;
rrIntervals?: number[];
}
export interface CyclingPowerData {
instantaneousPower: number;
pedalPowerBalance?: number;
accumulatedTorque?: number;
wheelRevolutions?: number;
lastWheelEventTime?: number;
crankRevolutions?: number;
lastCrankEventTime?: number;
}
export interface CadenceData {
crankRevolutions: number;
lastCrankEventTime: number;
calculatedCadence: number;
}
export interface SpeedData {
wheelRevolutions: number;
lastWheelEventTime: number;
calculatedSpeed: number;
}
export interface IndoorBikeData {
instantaneousSpeed?: number;
averageSpeed?: number;
instantaneousCadence?: number;
averageCadence?: number;
totalDistance?: number;
resistanceLevel?: number;
instantaneousPower?: number;
averagePower?: number;
totalEnergy?: number;
heartRate?: number;
elapsedTime?: number;
remainingTime?: number;
}
export const FTMSOpCode = {
REQUEST_CONTROL: 0x00,
RESET: 0x01,
SET_TARGET_SPEED: 0x02,
SET_TARGET_INCLINATION: 0x03,
SET_TARGET_RESISTANCE: 0x04,
SET_TARGET_POWER: 0x05,
SET_TARGET_HEART_RATE: 0x06,
START_OR_RESUME: 0x07,
STOP_OR_PAUSE: 0x08,
SET_INDOOR_BIKE_SIMULATION: 0x11,
SET_WHEEL_CIRCUMFERENCE: 0x12,
SPIN_DOWN_CONTROL: 0x13,
SET_TARGET_CADENCE: 0x14,
RESPONSE_CODE: 0x80,
} as const;
export const FTMSResultCode = {
SUCCESS: 0x01,
OP_CODE_NOT_SUPPORTED: 0x02,
INVALID_PARAMETER: 0x03,
OPERATION_FAILED: 0x04,
CONTROL_NOT_PERMITTED: 0x05,
} as const;
export type FTMSResultCode = typeof FTMSResultCode[keyof typeof FTMSResultCode];

50
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
export interface DeepLinkData {
action: string;
params: Record<string, string>;
}
export interface BluetoothAvailability {
available: boolean;
platform: string;
flags: {
webBluetooth: boolean;
experimentalFeatures: boolean;
};
}
export interface BluetoothSystemCheck {
platform: string;
available: boolean;
adapter: string | null;
error: string | null;
guidance: string[];
}
export interface DiscoveredDevice {
id: string;
name: string;
}
export interface ElectronAPI {
getVersion: () => Promise<string>;
getPlatform: () => Promise<string>;
onDeepLink: (callback: (data: DeepLinkData) => void) => void;
minimizeWindow: () => Promise<void>;
maximizeWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
bluetooth: {
checkAvailability: () => Promise<BluetoothAvailability>;
checkSystem: () => Promise<BluetoothSystemCheck>;
onDevicesUpdated: (callback: (devices: DiscoveredDevice[]) => void) => void;
selectDevice: (deviceId: string) => void;
cancelSelection: () => void;
clearDevices: () => void;
};
isElectron: boolean;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}

43
src/types/workout.ts Normal file
View File

@@ -0,0 +1,43 @@
export type WorkoutIntensity = 'recovery' | 'endurance' | 'tempo' | 'threshold' | 'vo2max' | 'anaerobic';
export interface WorkoutInterval {
id: string;
name: string;
duration: number; // seconds
targetPower?: number; // watts or percentage of FTP
targetPowerLow?: number;
targetPowerHigh?: number;
targetCadence?: number;
targetCadenceLow?: number;
targetCadenceHigh?: number;
intensity: WorkoutIntensity;
}
export interface Workout {
id: string;
name: string;
description: string;
duration: number; // total seconds
intervals: WorkoutInterval[];
tss?: number; // Training Stress Score
intensityFactor?: number;
createdAt: string;
updatedAt: string;
}
export interface WorkoutSession {
workout: Workout;
startTime: number;
elapsedTime: number;
currentIntervalIndex: number;
isPaused: boolean;
isComplete: boolean;
}
export interface RiderMetrics {
power: number;
cadence: number;
heartRate: number;
speed: number;
distance: number;
}

View File

@@ -0,0 +1,108 @@
export interface BluetoothDiagnosticResult {
supported: boolean;
available: boolean;
systemCheck?: {
platform: string;
available: boolean;
adapter: string | null;
error: string | null;
guidance: string[];
};
errors: string[];
warnings: string[];
recommendations: string[];
}
export async function checkBluetoothSupport(): Promise<BluetoothDiagnosticResult> {
const result: BluetoothDiagnosticResult = {
supported: false,
available: false,
errors: [],
warnings: [],
recommendations: [],
};
if (!navigator.bluetooth) {
result.errors.push('Web Bluetooth API is not available');
result.recommendations.push('Make sure you are running in a supported browser (Chrome, Edge, Opera, Brave)');
result.recommendations.push('Firefox does not support Web Bluetooth');
return result;
}
result.supported = true;
try {
const available = await navigator.bluetooth.getAvailability();
result.available = available;
if (!available) {
result.errors.push('Bluetooth is not available on this system');
result.recommendations.push('Check if Bluetooth is enabled in your system settings');
result.recommendations.push('Make sure you have a Bluetooth adapter');
}
} catch (error) {
result.warnings.push('Could not check Bluetooth availability');
result.recommendations.push('Try enabling Bluetooth in system settings');
}
if (window.electronAPI) {
try {
const electronCheck = await window.electronAPI.bluetooth.checkAvailability();
if (!electronCheck.flags.webBluetooth) {
result.warnings.push('Web Bluetooth flag is not enabled in Electron');
}
const systemCheck = await window.electronAPI.bluetooth.checkSystem();
result.systemCheck = systemCheck;
if (!systemCheck.available) {
result.errors.push('Bluetooth system service is not available');
result.recommendations.push(...systemCheck.guidance);
}
if (systemCheck.adapter === 'disabled') {
result.warnings.push('Bluetooth adapter is not powered on');
result.recommendations.push(...systemCheck.guidance);
}
if (systemCheck.error) {
result.warnings.push(`System check error: ${systemCheck.error}`);
}
} catch (error) {
result.warnings.push(`Electron diagnostics failed: ${(error as Error).message}`);
}
}
return result;
}
export function formatDiagnosticMessage(result: BluetoothDiagnosticResult): string {
const lines: string[] = [];
lines.push('=== Bluetooth Diagnostics ===\n');
if (result.errors.length > 0) {
lines.push('❌ Errors:');
result.errors.forEach(err => lines.push(`${err}`));
lines.push('');
}
if (result.warnings.length > 0) {
lines.push('⚠️ Warnings:');
result.warnings.forEach(warn => lines.push(`${warn}`));
lines.push('');
}
if (result.recommendations.length > 0) {
lines.push('💡 Recommendations:');
result.recommendations.forEach(rec => lines.push(`${rec}`));
lines.push('');
}
if (result.supported && result.available) {
lines.push('✅ Bluetooth is ready to use');
}
return lines.join('\n');
}

28
tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client", "@types/web-bluetooth"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

22
vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react(), tailwindcss()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})