init code for the trainer app
This commit is contained in:
294
src/hooks/useBluetooth.ts
Normal file
294
src/hooks/useBluetooth.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
HeartRateService,
|
||||
CyclingPowerService,
|
||||
CadenceService,
|
||||
FTMSService,
|
||||
} from '../services/bluetooth';
|
||||
import type {
|
||||
DeviceType,
|
||||
BluetoothDeviceInfo,
|
||||
HeartRateData,
|
||||
CyclingPowerData,
|
||||
CadenceData,
|
||||
IndoorBikeData,
|
||||
} from '../types/bluetooth';
|
||||
|
||||
interface BluetoothState {
|
||||
isSupported: boolean;
|
||||
devices: Record<DeviceType, BluetoothDeviceInfo | null>;
|
||||
isConnecting: DeviceType | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SensorData {
|
||||
heartRate: number;
|
||||
power: number;
|
||||
cadence: number;
|
||||
speed: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
export function useBluetooth() {
|
||||
const [state, setState] = useState<BluetoothState>({
|
||||
isSupported: typeof navigator !== 'undefined' && 'bluetooth' in navigator,
|
||||
devices: {
|
||||
heartRate: null,
|
||||
power: null,
|
||||
cadence: null,
|
||||
trainer: null,
|
||||
},
|
||||
isConnecting: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [sensorData, setSensorData] = useState<SensorData>({
|
||||
heartRate: 0,
|
||||
power: 0,
|
||||
cadence: 0,
|
||||
speed: 0,
|
||||
distance: 0,
|
||||
});
|
||||
|
||||
const heartRateService = useRef(new HeartRateService());
|
||||
const powerService = useRef(new CyclingPowerService());
|
||||
const cadenceService = useRef(new CadenceService());
|
||||
const ftmsService = useRef(new FTMSService());
|
||||
|
||||
const distanceRef = useRef(0);
|
||||
const distanceOffsetRef = useRef<number | null>(null);
|
||||
const isTrackingDistanceRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
heartRateService.current.onData((data: HeartRateData) => {
|
||||
setSensorData((prev) => ({ ...prev, heartRate: data.heartRate }));
|
||||
});
|
||||
|
||||
powerService.current.onData((data: CyclingPowerData) => {
|
||||
setSensorData((prev) => ({
|
||||
...prev,
|
||||
power: data.instantaneousPower,
|
||||
cadence: powerService.current.cadence || prev.cadence,
|
||||
}));
|
||||
});
|
||||
|
||||
cadenceService.current.onCadenceData((data: CadenceData) => {
|
||||
setSensorData((prev) => ({ ...prev, cadence: data.calculatedCadence }));
|
||||
});
|
||||
|
||||
cadenceService.current.onSpeedData(() => {
|
||||
setSensorData((prev) => ({
|
||||
...prev,
|
||||
speed: cadenceService.current.speed,
|
||||
}));
|
||||
});
|
||||
|
||||
ftmsService.current.onData((data: IndoorBikeData) => {
|
||||
setSensorData((prev) => {
|
||||
let adjustedDistance = prev.distance;
|
||||
const currentSpeed = data.instantaneousSpeed ?? prev.speed;
|
||||
const currentPower = data.instantaneousPower ?? prev.power;
|
||||
|
||||
const isActive = currentSpeed > 0 || currentPower > 0;
|
||||
|
||||
if (data.totalDistance !== undefined && data.totalDistance !== null && isActive && isTrackingDistanceRef.current) {
|
||||
const distanceInMeters = data.totalDistance;
|
||||
|
||||
if (distanceOffsetRef.current === null) {
|
||||
distanceOffsetRef.current = distanceInMeters;
|
||||
adjustedDistance = 0;
|
||||
} else {
|
||||
const sessionDistanceMeters = distanceInMeters - distanceOffsetRef.current;
|
||||
adjustedDistance = Math.round((sessionDistanceMeters / 1000) * 100) / 100;
|
||||
|
||||
if (adjustedDistance < 0) {
|
||||
distanceOffsetRef.current = distanceInMeters;
|
||||
adjustedDistance = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
power: data.instantaneousPower ?? prev.power,
|
||||
cadence: data.instantaneousCadence ?? prev.cadence,
|
||||
speed: currentSpeed,
|
||||
distance: adjustedDistance,
|
||||
heartRate: data.heartRate ?? prev.heartRate,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
heartRateService.current.disconnect();
|
||||
powerService.current.disconnect();
|
||||
cadenceService.current.disconnect();
|
||||
ftmsService.current.disconnect();
|
||||
|
||||
distanceOffsetRef.current = null;
|
||||
distanceRef.current = 0;
|
||||
|
||||
setState({
|
||||
isSupported: typeof navigator !== 'undefined' && 'bluetooth' in navigator,
|
||||
devices: {
|
||||
heartRate: null,
|
||||
power: null,
|
||||
cadence: null,
|
||||
trainer: null,
|
||||
},
|
||||
isConnecting: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
setSensorData({
|
||||
heartRate: 0,
|
||||
power: 0,
|
||||
cadence: 0,
|
||||
speed: 0,
|
||||
distance: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hasAnyDevice = Object.values(state.devices).some((d) => d?.connected);
|
||||
if (!hasAnyDevice) {
|
||||
setSensorData({
|
||||
heartRate: 0,
|
||||
power: 0,
|
||||
cadence: 0,
|
||||
speed: 0,
|
||||
distance: 0,
|
||||
});
|
||||
}
|
||||
}, [state.devices]);
|
||||
|
||||
const connectDevice = useCallback(async (type: DeviceType, preSelectedDevice?: BluetoothDevice) => {
|
||||
if (!state.isSupported) {
|
||||
setState((prev) => ({ ...prev, error: 'Web Bluetooth not supported' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isConnecting: type, error: null }));
|
||||
|
||||
try {
|
||||
let device: BluetoothDevice;
|
||||
|
||||
switch (type) {
|
||||
case 'heartRate':
|
||||
device = await heartRateService.current.connect(preSelectedDevice);
|
||||
break;
|
||||
case 'power':
|
||||
device = await powerService.current.connect(preSelectedDevice);
|
||||
break;
|
||||
case 'cadence':
|
||||
device = await cadenceService.current.connect(preSelectedDevice);
|
||||
break;
|
||||
case 'trainer':
|
||||
device = await ftmsService.current.connect(preSelectedDevice);
|
||||
await ftmsService.current.requestControl();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown device type: ${type}`);
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isConnecting: null,
|
||||
devices: {
|
||||
...prev.devices,
|
||||
[type]: {
|
||||
id: device.id,
|
||||
name: device.name || 'Unknown Device',
|
||||
type,
|
||||
connected: true,
|
||||
device,
|
||||
},
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isConnecting: null,
|
||||
error: message,
|
||||
}));
|
||||
}
|
||||
}, [state.isSupported]);
|
||||
|
||||
const disconnectDevice = useCallback((type: DeviceType) => {
|
||||
switch (type) {
|
||||
case 'heartRate':
|
||||
heartRateService.current.disconnect();
|
||||
setSensorData((prev) => ({ ...prev, heartRate: 0 }));
|
||||
break;
|
||||
case 'power':
|
||||
powerService.current.disconnect();
|
||||
setSensorData((prev) => ({ ...prev, power: 0 }));
|
||||
break;
|
||||
case 'cadence':
|
||||
cadenceService.current.disconnect();
|
||||
setSensorData((prev) => ({ ...prev, cadence: 0, speed: 0 }));
|
||||
break;
|
||||
case 'trainer':
|
||||
ftmsService.current.disconnect();
|
||||
distanceOffsetRef.current = null;
|
||||
setSensorData((prev) => ({ ...prev, power: 0, cadence: 0, speed: 0, distance: 0 }));
|
||||
break;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
devices: {
|
||||
...prev.devices,
|
||||
[type]: null,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setTargetPower = useCallback(async (watts: number) => {
|
||||
if (ftmsService.current.isConnected && ftmsService.current.isControlled) {
|
||||
await ftmsService.current.setTargetPower(watts);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setTargetResistance = useCallback(async (level: number) => {
|
||||
if (ftmsService.current.isConnected && ftmsService.current.isControlled) {
|
||||
await ftmsService.current.setTargetResistance(level);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setSimulation = useCallback(async (grade: number, crr?: number, cw?: number) => {
|
||||
if (ftmsService.current.isConnected && ftmsService.current.isControlled) {
|
||||
await ftmsService.current.setSimulation(grade, crr, cw);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetDistance = useCallback(() => {
|
||||
distanceRef.current = 0;
|
||||
distanceOffsetRef.current = null;
|
||||
isTrackingDistanceRef.current = true;
|
||||
setSensorData((prev) => ({ ...prev, distance: 0 }));
|
||||
}, []);
|
||||
|
||||
const stopDistanceTracking = useCallback(() => {
|
||||
isTrackingDistanceRef.current = false;
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
sensorData,
|
||||
connectDevice,
|
||||
disconnectDevice,
|
||||
setTargetPower,
|
||||
setTargetResistance,
|
||||
setSimulation,
|
||||
resetDistance,
|
||||
stopDistanceTracking,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
25
src/hooks/useBluetoothStartupCheck.ts
Normal file
25
src/hooks/useBluetoothStartupCheck.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { checkBluetoothSupport, type BluetoothDiagnosticResult } from '../utils/bluetoothDiagnostics';
|
||||
|
||||
export function useBluetoothStartupCheck() {
|
||||
const [diagnostics, setDiagnostics] = useState<BluetoothDiagnosticResult | null>(null);
|
||||
const [hasChecked, setHasChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function runStartupCheck() {
|
||||
if (hasChecked) return;
|
||||
|
||||
const result = await checkBluetoothSupport();
|
||||
setDiagnostics(result);
|
||||
setHasChecked(true);
|
||||
}
|
||||
|
||||
runStartupCheck();
|
||||
}, [hasChecked]);
|
||||
|
||||
return {
|
||||
diagnostics,
|
||||
hasIssues: diagnostics ? (diagnostics.errors.length > 0 || !diagnostics.available) : false,
|
||||
isReady: diagnostics ? (diagnostics.supported && diagnostics.available && diagnostics.errors.length === 0) : false,
|
||||
};
|
||||
}
|
||||
208
src/hooks/useCalendarWorkouts.ts
Normal file
208
src/hooks/useCalendarWorkouts.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
getWorkoutsByMonth,
|
||||
getTodaysWorkouts,
|
||||
getWeeksWorkouts,
|
||||
markWorkoutComplete,
|
||||
} from '../services/api';
|
||||
import type { ApiWorkout } from '../types/api';
|
||||
import type { Workout, WorkoutInterval } from '../types/workout';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
interface UseCalendarWorkoutsReturn {
|
||||
workouts: ApiWorkout[];
|
||||
todaysWorkouts: ApiWorkout[];
|
||||
weeksWorkouts: ApiWorkout[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
completeWorkout: (
|
||||
workoutId: number,
|
||||
metrics: {
|
||||
duration: number;
|
||||
distance?: number;
|
||||
avgPower?: number;
|
||||
avgHr?: number;
|
||||
maxPower?: number;
|
||||
maxHr?: number;
|
||||
caloriesBurned?: number;
|
||||
}
|
||||
) => Promise<void>;
|
||||
convertToTrainerWorkout: (apiWorkout: ApiWorkout) => Workout | null;
|
||||
}
|
||||
|
||||
function apiToTrainerWorkout(apiWorkout: ApiWorkout, ftp: number): Workout | null {
|
||||
if (!apiWorkout.workout_data?.segments) {
|
||||
return {
|
||||
id: String(apiWorkout.id),
|
||||
name: apiWorkout.title,
|
||||
description: apiWorkout.description || '',
|
||||
duration: (apiWorkout.duration || 60) * 60,
|
||||
tss: apiWorkout.tss,
|
||||
createdAt: apiWorkout.created_at,
|
||||
updatedAt: apiWorkout.updated_at,
|
||||
intervals: [
|
||||
{
|
||||
id: `${apiWorkout.id}-1`,
|
||||
name: apiWorkout.title,
|
||||
duration: (apiWorkout.duration || 60) * 60,
|
||||
targetPower: Math.round(ftp * 0.7),
|
||||
intensity: 'endurance',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const segments = apiWorkout.workout_data.segments;
|
||||
const intervals: WorkoutInterval[] = segments.map((segment, idx) => {
|
||||
const powerPercent = segment.power || segment.power_high || segment.power_low || 0.6;
|
||||
const targetPower = Math.round(powerPercent * ftp);
|
||||
|
||||
const intensityMap: Record<string, WorkoutInterval['intensity']> = {
|
||||
warmup: 'recovery',
|
||||
cooldown: 'recovery',
|
||||
recovery: 'recovery',
|
||||
steady: 'endurance',
|
||||
freeride: 'endurance',
|
||||
interval: powerPercent >= 1.05 ? 'vo2max' : powerPercent >= 0.9 ? 'threshold' : 'tempo',
|
||||
ramp: 'tempo',
|
||||
};
|
||||
|
||||
return {
|
||||
id: `${apiWorkout.id}-${idx}`,
|
||||
name: segment.name || segment.type || 'Interval',
|
||||
duration: segment.duration,
|
||||
targetPower,
|
||||
targetPowerLow: segment.power_low ? Math.round(segment.power_low * ftp) : undefined,
|
||||
targetPowerHigh: segment.power_high ? Math.round(segment.power_high * ftp) : undefined,
|
||||
targetCadence: segment.cadence,
|
||||
intensity: intensityMap[segment.type] || 'endurance',
|
||||
};
|
||||
});
|
||||
|
||||
const expandedIntervals: WorkoutInterval[] = [];
|
||||
segments.forEach((segment, idx) => {
|
||||
const interval = intervals[idx];
|
||||
const repeatCount = segment.repeat || 1;
|
||||
|
||||
for (let r = 0; r < repeatCount; r++) {
|
||||
expandedIntervals.push({
|
||||
...interval,
|
||||
id: `${interval.id}-r${r}`,
|
||||
name: repeatCount > 1 ? `${interval.name} (${r + 1}/${repeatCount})` : interval.name,
|
||||
});
|
||||
|
||||
if (segment.rest_between && r < repeatCount - 1) {
|
||||
expandedIntervals.push({
|
||||
id: `${interval.id}-rest${r}`,
|
||||
name: 'Recovery',
|
||||
duration: segment.rest_between,
|
||||
targetPower: Math.round(ftp * 0.5),
|
||||
intensity: 'recovery',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalDuration = expandedIntervals.reduce((sum, i) => sum + i.duration, 0);
|
||||
|
||||
return {
|
||||
id: String(apiWorkout.id),
|
||||
name: apiWorkout.title,
|
||||
description: apiWorkout.description || '',
|
||||
duration: totalDuration,
|
||||
tss: apiWorkout.tss || apiWorkout.workout_data.tss,
|
||||
intensityFactor: apiWorkout.workout_data.intensity_factor,
|
||||
createdAt: apiWorkout.created_at,
|
||||
updatedAt: apiWorkout.updated_at,
|
||||
intervals: expandedIntervals,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCalendarWorkouts(): UseCalendarWorkoutsReturn {
|
||||
const { isAuthenticated, profile } = useAuth();
|
||||
const [workouts, setWorkouts] = useState<ApiWorkout[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ftp = profile?.ftp || 200;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
setWorkouts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const monthWorkouts = await getWorkoutsByMonth(now.getFullYear(), now.getMonth() + 1);
|
||||
setWorkouts(monthWorkouts);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load workouts';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const todaysWorkouts = getTodaysWorkouts(workouts);
|
||||
const weeksWorkouts = getWeeksWorkouts(workouts);
|
||||
|
||||
const completeWorkout = useCallback(
|
||||
async (
|
||||
workoutId: number,
|
||||
metrics: {
|
||||
duration: number;
|
||||
distance?: number;
|
||||
avgPower?: number;
|
||||
avgHr?: number;
|
||||
maxPower?: number;
|
||||
maxHr?: number;
|
||||
caloriesBurned?: number;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
await markWorkoutComplete(workoutId, {
|
||||
duration: Math.round(metrics.duration / 60),
|
||||
distance: metrics.distance,
|
||||
avg_power: metrics.avgPower,
|
||||
avg_hr: metrics.avgHr,
|
||||
max_power: metrics.maxPower,
|
||||
max_hr: metrics.maxHr,
|
||||
calories_burned: metrics.caloriesBurned,
|
||||
});
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to complete workout';
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[refresh]
|
||||
);
|
||||
|
||||
const convertToTrainerWorkout = useCallback(
|
||||
(apiWorkout: ApiWorkout): Workout | null => {
|
||||
return apiToTrainerWorkout(apiWorkout, ftp);
|
||||
},
|
||||
[ftp]
|
||||
);
|
||||
|
||||
return {
|
||||
workouts,
|
||||
todaysWorkouts,
|
||||
weeksWorkouts,
|
||||
isLoading,
|
||||
error,
|
||||
refresh,
|
||||
completeWorkout,
|
||||
convertToTrainerWorkout,
|
||||
};
|
||||
}
|
||||
14
src/hooks/useDeepLink.ts
Normal file
14
src/hooks/useDeepLink.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { DeepLinkData } from '../types/electron';
|
||||
|
||||
export function useDeepLink(onDeepLink: (data: DeepLinkData) => void) {
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.electronAPI.onDeepLink((data: DeepLinkData) => {
|
||||
onDeepLink(data);
|
||||
});
|
||||
}, [onDeepLink]);
|
||||
}
|
||||
Reference in New Issue
Block a user