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

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]);
}