This commit is contained in:
2025-09-12 07:32:32 -07:00
parent 4d5fca6a5e
commit 49208df277
2978 changed files with 421237 additions and 394 deletions

View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import GarminSync from '../src/components/garmin/GarminSync';
import WorkoutChart from '../src/components/analysis/WorkoutCharts';
import PlanTimeline from '../src/components/plans/PlanTimeline';
import { useAuth } from '../src/context/AuthContext';
import LoadingSpinner from '../src/components/LoadingSpinner';
const Dashboard = () => {
const { apiKey, loading: apiLoading } = useAuth();
const isBuildTime = typeof window === 'undefined';
const [recentWorkouts, setRecentWorkouts] = useState([]);
const [currentPlan, setCurrentPlan] = useState(null);
const [stats, setStats] = useState({ totalWorkouts: 0, totalDistance: 0 });
const [healthStatus, setHealthStatus] = useState(null);
const [localLoading, setLocalLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchDashboardData = async () => {
try {
const [workoutsRes, planRes, statsRes, healthRes] = await Promise.all([
fetch(`${process.env.REACT_APP_API_URL}/api/workouts?limit=3`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${process.env.REACT_APP_API_URL}/api/plans/active`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${process.env.REACT_APP_API_URL}/api/stats`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${process.env.REACT_APP_API_URL}/api/health`, {
headers: { 'X-API-Key': apiKey }
})
]);
const errors = [];
if (!workoutsRes.ok) errors.push('Failed to fetch workouts');
if (!planRes.ok) errors.push('Failed to fetch plan');
if (!statsRes.ok) errors.push('Failed to fetch stats');
if (!healthRes.ok) errors.push('Failed to fetch health status');
if (errors.length > 0) throw new Error(errors.join(', '));
const [workoutsData, planData, statsData, healthData] = await Promise.all([
workoutsRes.json(),
planRes.json(),
statsRes.json(),
healthRes.json()
]);
setRecentWorkouts(workoutsData.workouts || []);
setCurrentPlan(planData);
setStats(statsData.workouts || { totalWorkouts: 0, totalDistance: 0 });
setHealthStatus(healthData);
} catch (err) {
setError(err.message);
} finally {
setLocalLoading(false);
}
};
fetchDashboardData();
}, [apiKey]);
if (isBuildTime) {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold">Training Dashboard</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading dashboard data...</p>
</div>
</div>
);
}
if (localLoading || apiLoading) return <LoadingSpinner />;
if (error) return <div className="p-6 text-red-500">{error}</div>;
// Calculate total distance in km
const totalDistanceKm = (stats.totalDistance / 1000).toFixed(0);
return (
<div className="p-6 max-w-7xl mx-auto space-y-8">
<h1 className="text-3xl font-bold">Training Dashboard</h1>
<div className="mb-8">
<GarminSync apiKey={apiKey} />
</div>
{/* Stats Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Total Workouts</h3>
<p className="text-2xl font-bold">{stats.totalWorkouts}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Total Distance</h3>
<p className="text-2xl font-bold">{totalDistanceKm} km</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Current Plan</h3>
<p className="text-2xl font-bold">
{currentPlan ? `v${currentPlan.version}` : 'None'}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">System Status</h3>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${
healthStatus?.status === 'healthy' ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className="text-xl font-bold capitalize">
{healthStatus?.status || 'unknown'}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
<div className="sm:col-span-2 space-y-3 sm:space-y-4 lg:space-y-6">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Performance Metrics</h2>
<WorkoutChart workouts={recentWorkouts} />
</div>
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Recent Activities</h2>
{recentWorkouts.length > 0 ? (
<div className="space-y-4">
{recentWorkouts.map(workout => (
<div key={workout.id} className="p-3 sm:p-4 border rounded-lg hover:bg-gray-50">
<div className="flex justify-between items-center gap-2">
<div>
<h3 className="text-sm sm:text-base font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3>
<p className="text-xs sm:text-sm text-gray-600">{workout.activity_type}</p>
</div>
<div className="text-right">
<p className="text-sm sm:text-base font-medium">{(workout.distance_m / 1000).toFixed(1)} km</p>
<p className="text-xs sm:text-sm text-gray-600">{Math.round(workout.duration_seconds / 60)} mins</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-center py-4">No recent activities found</div>
)}
</div>
</div>
<div className="space-y-6">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Current Plan</h2>
{currentPlan ? (
<PlanTimeline plan={currentPlan} />
) : (
<div className="text-gray-500 text-center py-4">No active training plan</div>
)}
</div>
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Upcoming Workouts</h2>
{currentPlan?.jsonb_plan.weeks[0]?.workouts.map((workout, index) => (
<div key={index} className="p-2 sm:p-3 border-b last:border-b-0">
<div className="flex justify-between items-center">
<span className="capitalize">{workout.day}</span>
<span className="text-xs sm:text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{workout.type.replace('_', ' ')}
</span>
</div>
<p className="text-xs sm:text-sm text-gray-600 mt-1">{workout.description}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,38 @@
import { useRouter } from 'next/router'
import PlanTimeline from '../src/components/PlanTimeline'
const PlanDetails = () => {
const router = useRouter()
const { planId } = router.query
// If the planId is not available yet (still loading), show a loading state
if (!planId) {
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
<div className="max-w-4xl mx-auto">
<div className="p-4 space-y-4">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
<div className="max-w-4xl mx-auto">
<PlanTimeline planId={planId} />
</div>
</div>
)
}
export default PlanDetails

View File

@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../src/context/AuthContext';
import GoalSelector from '../src/components/plans/GoalSelector';
import PlanParameters from '../src/components/plans/PlanParameters';
import { generatePlan } from '../src/services/planService';
import ProgressTracker from '../src/components/ui/ProgressTracker';
const PlanGeneration = () => {
const { apiKey } = useAuth();
const router = useRouter();
const [step, setStep] = useState(1);
const [goals, setGoals] = useState([]);
const [rules, setRules] = useState([]);
const [params, setParams] = useState({
duration: 4,
weeklyHours: 8,
availableDays: []
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleGenerate = async () => {
try {
setLoading(true);
const plan = await generatePlan(apiKey, {
goals,
ruleIds: rules,
...params
});
router.push(`/plans/${plan.id}/preview`);
} catch (err) {
setError('Failed to generate plan. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<ProgressTracker currentStep={step} totalSteps={3} />
{step === 1 && (
<GoalSelector
goals={goals}
onSelect={setGoals}
onNext={() => setStep(2)}
/>
)}
{step === 2 && (
<PlanParameters
values={params}
onChange={setParams}
onBack={() => setStep(1)}
onNext={() => setStep(3)}
/>
)}
{step === 3 && (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-4">Review and Generate</h2>
<div className="mb-6">
<h3 className="font-semibold mb-2">Selected Goals:</h3>
<ul className="list-disc pl-5">
{goals.map((goal, index) => (
<li key={index}>{goal}</li>
))}
</ul>
</div>
<button
onClick={handleGenerate}
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading ? 'Generating...' : 'Generate Plan'}
</button>
{error && <p className="text-red-500 mt-2">{error}</p>}
</div>
)}
</div>
);
};
export default PlanGeneration;

10
frontend/pages/Plans.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
const Plans = () => (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Training Plans</h1>
<p className="text-gray-600">Training plans page under development</p>
</div>
);
export default Plans;

View File

@@ -0,0 +1,75 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../src/context/AuthContext';
import FileUpload from '../src/components/routes/FileUpload';
import RouteList from '../src/components/routes/RouteList';
import RouteFilter from '../src/components/routes/RouteFilter';
import LoadingSpinner from '../src/components/LoadingSpinner';
const RoutesPage = () => {
const { apiKey } = useAuth();
const [routes, setRoutes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
searchQuery: '',
minDistance: 0,
maxDistance: 500,
difficulty: 'all',
});
useEffect(() => {
const fetchRoutes = async () => {
try {
const response = await fetch('/api/routes', {
headers: { 'X-API-Key': apiKey }
});
if (!response.ok) throw new Error('Failed to fetch routes');
const data = await response.json();
setRoutes(data.routes);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchRoutes();
}, [apiKey]);
const filteredRoutes = routes.filter(route => {
const matchesSearch = route.name.toLowerCase().includes(filters.searchQuery.toLowerCase());
const matchesDistance = route.distance >= filters.minDistance &&
route.distance <= filters.maxDistance;
const matchesDifficulty = filters.difficulty === 'all' ||
route.difficulty === filters.difficulty;
return matchesSearch && matchesDistance && matchesDifficulty;
});
const handleUploadSuccess = (newRoute) => {
setRoutes(prev => [...prev, newRoute]);
};
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Routes</h1>
<div className="space-y-8">
<div className="bg-white p-6 rounded-lg shadow-md">
<RouteFilter filters={filters} onFilterChange={setFilters} />
<FileUpload onUploadSuccess={handleUploadSuccess} />
</div>
{loading ? (
<LoadingSpinner />
) : error ? (
<div className="text-red-600 bg-red-50 p-4 rounded-md">{error}</div>
) : (
<RouteList routes={filteredRoutes} />
)}
</div>
</div>
);
};
export default RoutesPage;

10
frontend/pages/Rules.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
const Rules = () => (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Training Rules</h1>
<p className="text-gray-600">Training rules page under development</p>
</div>
);
export default Rules;

View File

@@ -0,0 +1,10 @@
import React from 'react';
const Workouts = () => (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Workouts</h1>
<p className="text-gray-600">Workouts page under development</p>
</div>
);
export default Workouts;

11
frontend/pages/_app.js Normal file
View File

@@ -0,0 +1,11 @@
import { AuthProvider } from '../src/context/AuthContext';
function MyApp({ Component, pageProps }) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
export default MyApp;

13
frontend/pages/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export default function Home() {
const router = useRouter();
useEffect(() => {
// Redirect to dashboard
router.push('/dashboard');
}, [router]);
return null; // or a loading spinner
}