init code for the trainer app

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

380
src/App.tsx Normal file
View File

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

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

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

1
src/index.css Normal file
View File

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

13
src/main.tsx Normal file
View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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