init code for the trainer app
This commit is contained in:
380
src/App.tsx
Normal file
380
src/App.tsx
Normal 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
1
src/assets/react.svg
Normal 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 |
139
src/components/BluetoothDiagnostics.tsx
Normal file
139
src/components/BluetoothDiagnostics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/components/CalendarWorkouts.tsx
Normal file
169
src/components/CalendarWorkouts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
218
src/components/DeviceManager.tsx
Normal file
218
src/components/DeviceManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
src/components/DevicePicker.tsx
Normal file
251
src/components/DevicePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/LoginForm.tsx
Normal file
87
src/components/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/MetricsDisplay.tsx
Normal file
77
src/components/MetricsDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/WorkoutControls.tsx
Normal file
54
src/components/WorkoutControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/components/WorkoutDisplay.tsx
Normal file
95
src/components/WorkoutDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/WorkoutSelector.tsx
Normal file
52
src/components/WorkoutSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/contexts/AuthContext.tsx
Normal file
108
src/contexts/AuthContext.tsx
Normal 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
208
src/data/sampleWorkouts.ts
Normal 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
294
src/hooks/useBluetooth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
src/hooks/useBluetoothStartupCheck.ts
Normal file
25
src/hooks/useBluetoothStartupCheck.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
208
src/hooks/useCalendarWorkouts.ts
Normal file
208
src/hooks/useCalendarWorkouts.ts
Normal 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
14
src/hooks/useDeepLink.ts
Normal 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
1
src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal 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
48
src/services/api/auth.ts
Normal 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
131
src/services/api/client.ts
Normal 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' }),
|
||||
};
|
||||
3
src/services/api/index.ts
Normal file
3
src/services/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { api, setTokens, clearTokens, getAccessToken, isAuthenticated } from './client';
|
||||
export * from './auth';
|
||||
export * from './workouts';
|
||||
108
src/services/api/workouts.ts
Normal file
108
src/services/api/workouts.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
195
src/services/bluetooth/CadenceService.ts
Normal file
195
src/services/bluetooth/CadenceService.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
154
src/services/bluetooth/CyclingPowerService.ts
Normal file
154
src/services/bluetooth/CyclingPowerService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
330
src/services/bluetooth/FTMSService.ts
Normal file
330
src/services/bluetooth/FTMSService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
109
src/services/bluetooth/HeartRateService.ts
Normal file
109
src/services/bluetooth/HeartRateService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
src/services/bluetooth/index.ts
Normal file
4
src/services/bluetooth/index.ts
Normal 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
153
src/types/api.ts
Normal 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
104
src/types/bluetooth.ts
Normal 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
50
src/types/electron.d.ts
vendored
Normal 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
43
src/types/workout.ts
Normal 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;
|
||||
}
|
||||
108
src/utils/bluetoothDiagnostics.ts
Normal file
108
src/utils/bluetoothDiagnostics.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user