mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-14 11:22:30 +00:00
sync
This commit is contained in:
@@ -5,16 +5,16 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY frontend/package*.json ./
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies including devDependencies
|
||||
RUN npm install --include=dev
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
COPY . .
|
||||
|
||||
# Run tests and build application
|
||||
RUN npm test && npm run build
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
4889
frontend/package-lock.json
generated
4889
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,22 +11,24 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"recharts": "2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.11.5",
|
||||
"@types/react": "18.2.60",
|
||||
"@types/react-dom": "18.2.22",
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "14.2.1",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"@types/node": "20.11.5",
|
||||
"@types/react": "18.2.60",
|
||||
"@types/react-dom": "18.2.22",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import PlanDetails from './pages/PlanDetails'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Navigation from './components/Navigation';
|
||||
import Workouts from './pages/Workouts';
|
||||
import Plans from './pages/Plans';
|
||||
import RoutesPage from './pages/Routes';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/plans/:planId" element={<PlanDetails />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
)
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<main className="p-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/workouts" element={<Workouts />} />
|
||||
<Route path="/plans" element={<Plans />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
@@ -1,64 +1,28 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
// Log error to analytics service in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-boundary bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-12 w-12 text-red-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-lg font-medium text-red-800">Something went wrong</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>We're sorry - an unexpected error occurred. Please try refreshing the page.</p>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-3">
|
||||
<summary className="font-medium cursor-pointer">Error details</summary>
|
||||
<div className="mt-2 bg-red-100 p-2 rounded-md overflow-auto max-h-48">
|
||||
<p className="font-mono text-xs">{this.state.error && this.state.error.toString()}</p>
|
||||
<pre className="text-xs mt-2">{this.state.errorInfo?.componentStack}</pre>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
|
||||
<h2 className="font-bold">Something went wrong</h2>
|
||||
<p>{this.state.error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [previewContent, setPreviewContent] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -18,66 +17,85 @@ const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
handleFiles(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
handleFiles(e.target.files);
|
||||
};
|
||||
|
||||
const handleFiles = (newFiles) => {
|
||||
const validFiles = Array.from(newFiles).map(file => {
|
||||
const fileExt = file.name.split('.').pop().toLowerCase();
|
||||
|
||||
// Validate file type
|
||||
if (!acceptedTypes.includes(`.${fileExt}`)) {
|
||||
return { file, error: 'Invalid file type', progress: 0 };
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return { file, error: 'File too large', progress: 0 };
|
||||
}
|
||||
|
||||
return { file, progress: 0, error: null };
|
||||
});
|
||||
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
uploadFiles(validFiles.filter(f => !f.error));
|
||||
};
|
||||
|
||||
const uploadFiles = async (filesToUpload) => {
|
||||
for (const fileObj of filesToUpload) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('files', fileObj.file);
|
||||
|
||||
const response = await fetch('/api/routes/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.file === fileObj.file ? { ...f, progress: 100 } : f
|
||||
));
|
||||
|
||||
toast.success(`${fileObj.file.name} uploaded successfully`);
|
||||
|
||||
if (onUpload) onUpload(fileObj.file);
|
||||
|
||||
} catch (err) {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.file === fileObj.file ? { ...f, error: err.message } : f
|
||||
));
|
||||
toast.error(`${fileObj.file.name} upload failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFile = async (file) => {
|
||||
setError(null);
|
||||
|
||||
// Validate file type
|
||||
const fileExt = file.name.split('.').pop().toLowerCase();
|
||||
if (!acceptedTypes.includes(`.${fileExt}`)) {
|
||||
setError(`Invalid file type. Supported types: ${acceptedTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('File size exceeds 10MB limit');
|
||||
return;
|
||||
}
|
||||
|
||||
const parseGPXMetadata = (content) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Preview GPX content
|
||||
if (fileExt === 'gpx') {
|
||||
const content = await file.text();
|
||||
setPreviewContent(content);
|
||||
} else {
|
||||
setPreviewContent(null);
|
||||
}
|
||||
|
||||
// Pass file to parent component for upload
|
||||
if (onUpload) {
|
||||
onUpload(file);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('File processing error:', err);
|
||||
setError('Failed to process file');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(content, "text/xml");
|
||||
return {
|
||||
name: xmlDoc.getElementsByTagName('name')[0]?.textContent || 'Unnamed Route',
|
||||
distance: xmlDoc.getElementsByTagName('distance')[0]?.textContent || 'N/A',
|
||||
elevation: xmlDoc.getElementsByTagName('ele')[0]?.textContent || 'N/A'
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
setPreviewContent(null);
|
||||
const removeFile = (fileName) => {
|
||||
setFiles(prev => prev.filter(f => f.file.name !== fileName));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-upload">
|
||||
<div
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
@@ -92,55 +110,49 @@ const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
|
||||
className="hidden"
|
||||
accept={acceptedTypes.join(',')}
|
||||
onChange={handleFileInput}
|
||||
multiple
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="mt-2 text-gray-600">Processing file...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-10 w-10 text-gray-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-medium text-blue-600">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{acceptedTypes.join(', ')} files, max 10MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<svg className="h-10 w-10 text-gray-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="font-medium text-blue-600">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{acceptedTypes.join(', ')} files, max 10MB each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 p-2 bg-red-50 text-red-700 text-sm rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewContent && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-medium text-gray-800">File Preview</h3>
|
||||
<button
|
||||
onClick={clearPreview}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Clear preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded-md border border-gray-200 max-h-60 overflow-auto">
|
||||
<pre className="text-xs text-gray-700 whitespace-pre-wrap">
|
||||
{previewContent}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((fileObj, index) => (
|
||||
<div key={index} className="p-4 border rounded-lg bg-white">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="truncate">
|
||||
<span className="font-medium">{fileObj.file.name}</span>
|
||||
{fileObj.error && (
|
||||
<span className="text-red-500 text-sm ml-2">- {fileObj.error}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(fileObj.file.name)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{!fileObj.error && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${fileObj.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,16 @@ const GarminSync = () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Check API key configuration
|
||||
if (!process.env.REACT_APP_API_KEY) {
|
||||
throw new Error('API key missing - check environment configuration');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/workouts/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': process.env.REACT_APP_API_KEY
|
||||
'X-API-Key': process.env.REACT_APP_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,10 +111,28 @@ const GarminSync = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{syncStatus.error_message && (
|
||||
<div className="text-gray-600">Last Updated:</div>
|
||||
<div className="text-gray-800">
|
||||
{syncStatus.last_sync_time
|
||||
? new Date(syncStatus.last_sync_time).toLocaleTimeString([], {
|
||||
hour: '2-digit', minute: '2-digit', hour12: true
|
||||
})
|
||||
: 'Never'}
|
||||
</div>
|
||||
|
||||
{syncStatus.activities_synced > 0 && (
|
||||
<>
|
||||
<div className="text-gray-600">Error:</div>
|
||||
<div className="text-red-600">{syncStatus.error_message}</div>
|
||||
<div className="text-gray-600">New Activities:</div>
|
||||
<div className="text-green-600 font-medium">{syncStatus.activities_synced}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{syncStatus.warnings?.length > 0 && (
|
||||
<>
|
||||
<div className="text-gray-600">Warnings:</div>
|
||||
<div className="text-yellow-600 text-sm">
|
||||
{syncStatus.warnings.join(', ')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
9
frontend/src/components/LoadingSpinner.jsx
Normal file
9
frontend/src/components/LoadingSpinner.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
||||
38
frontend/src/components/Navigation.jsx
Normal file
38
frontend/src/components/Navigation.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Navigation = () => {
|
||||
return (
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/workouts"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
>
|
||||
Workouts
|
||||
</Link>
|
||||
<Link
|
||||
to="/plans"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
>
|
||||
Plans
|
||||
</Link>
|
||||
<Link
|
||||
to="/routes"
|
||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
||||
>
|
||||
Routes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
@@ -157,6 +157,45 @@ const WorkoutAnalysis = ({ workout, analysis }) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/workouts/${workout.id}/metrics`);
|
||||
const data = await response.json();
|
||||
setMetrics(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching workout metrics:', err);
|
||||
} finally {
|
||||
setLoadingMetrics(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (workout?.id) {
|
||||
fetchMetrics();
|
||||
}
|
||||
}, [workout]);
|
||||
|
||||
return (
|
||||
<div className="workout-analysis bg-white rounded-lg shadow-md p-5 space-y-6">
|
||||
{/* Workout Summary */}
|
||||
<div className="workout-summary border-b border-gray-200 pb-4 mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-800">
|
||||
{workout.activity_type || 'Cycling'} - {new Date(workout.start_time).toLocaleDateString()}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
|
||||
{/* Existing metric cards */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Charts */}
|
||||
{/* Existing chart implementation */}
|
||||
|
||||
{/* Analysis Content */}
|
||||
{/* Existing analysis content */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutAnalysis;
|
||||
97
frontend/src/components/analysis/WorkoutAnalysis.jsx
Normal file
97
frontend/src/components/analysis/WorkoutAnalysis.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import WorkoutMetrics from './WorkoutMetrics';
|
||||
|
||||
const WorkoutAnalysis = ({ workoutId }) => {
|
||||
const { apiKey } = useAuth();
|
||||
const [analysis, setAnalysis] = useState(null);
|
||||
const [isApproving, setIsApproving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnalysis = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/analyses/${workoutId}`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setAnalysis(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching analysis:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (workoutId) {
|
||||
fetchAnalysis();
|
||||
}
|
||||
}, [workoutId, apiKey]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setIsApproving(true);
|
||||
try {
|
||||
await axios.post(`/api/analyses/${analysis.id}/approve`, {}, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
// Refresh analysis data
|
||||
const response = await axios.get(`/api/analyses/${analysis.id}`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setAnalysis(response.data);
|
||||
} catch (error) {
|
||||
console.error('Approval failed:', error);
|
||||
}
|
||||
setIsApproving(false);
|
||||
};
|
||||
|
||||
if (!analysis) return <div>Loading analysis...</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-xl font-semibold mb-4">
|
||||
{analysis.workout.activity_type} Analysis
|
||||
</h3>
|
||||
|
||||
<WorkoutMetrics workout={analysis.workout} />
|
||||
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium mb-2">AI Feedback</h4>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="mb-3">{analysis.jsonb_feedback.summary}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h5 className="font-medium">Strengths</h5>
|
||||
<ul className="list-disc pl-5">
|
||||
{analysis.jsonb_feedback.strengths.map((s, i) => (
|
||||
<li key={i} className="text-green-700">{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium">Improvements</h5>
|
||||
<ul className="list-disc pl-5">
|
||||
{analysis.jsonb_feedback.areas_for_improvement.map((s, i) => (
|
||||
<li key={i} className="text-orange-700">{s}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.suggestions?.length > 0 && !analysis.approved && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={isApproving}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{isApproving ? 'Approving...' : 'Approve Suggestions'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WorkoutCharts metrics={analysis.workout.metrics} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutAnalysis;
|
||||
76
frontend/src/components/analysis/WorkoutCharts.jsx
Normal file
76
frontend/src/components/analysis/WorkoutCharts.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
|
||||
const WorkoutCharts = ({ metrics }) => {
|
||||
if (!metrics?.time_series?.length) return null;
|
||||
|
||||
// Process metrics data for charting
|
||||
const chartData = metrics.time_series.map(entry => ({
|
||||
time: new Date(entry.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
power: entry.avg_power,
|
||||
heartRate: entry.avg_heart_rate,
|
||||
cadence: entry.avg_cadence
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="h-64">
|
||||
<h4 className="font-medium mb-2">Power Output</h4>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis unit="W" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="power"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="h-64">
|
||||
<h4 className="font-medium mb-2">Heart Rate</h4>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis unit="bpm" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="heartRate"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="h-64">
|
||||
<h4 className="font-medium mb-2">Cadence</h4>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis unit="rpm" />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cadence"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutCharts;
|
||||
43
frontend/src/components/analysis/WorkoutMetrics.jsx
Normal file
43
frontend/src/components/analysis/WorkoutMetrics.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
const WorkoutMetrics = ({ workout }) => {
|
||||
const formatDuration = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins} minutes`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600">Duration</p>
|
||||
<p className="text-lg font-medium">
|
||||
{formatDuration(workout.duration_seconds)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600">Distance</p>
|
||||
<p className="text-lg font-medium">
|
||||
{(workout.distance_m / 1000).toFixed(1)} km
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600">Avg Power</p>
|
||||
<p className="text-lg font-medium">
|
||||
{workout.avg_power?.toFixed(0) || '-'} W
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600">Avg HR</p>
|
||||
<p className="text-lg font-medium">
|
||||
{workout.avg_hr?.toFixed(0) || '-'} bpm
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-md">
|
||||
<p className="text-sm text-gray-600">Elevation</p>
|
||||
<p className="text-lg font-medium">
|
||||
{workout.elevation_gain_m?.toFixed(0) || '-'} m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutMetrics;
|
||||
86
frontend/src/components/garmin/GarminSync.jsx
Normal file
86
frontend/src/components/garmin/GarminSync.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const GarminSync = ({ apiKey }) => {
|
||||
const [syncStatus, setSyncStatus] = useState(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/workouts/sync-status', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setSyncStatus(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch sync status');
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSync = async () => {
|
||||
setIsSyncing(true);
|
||||
setError('');
|
||||
try {
|
||||
await axios.post('/api/workouts/sync', {}, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
// Poll status every 2 seconds
|
||||
const interval = setInterval(fetchSyncStatus, 2000);
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
setIsSyncing(false);
|
||||
}, 30000);
|
||||
} catch (err) {
|
||||
setError('Failed to start sync');
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSyncStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold">Garmin Connect Sync</h2>
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
disabled={isSyncing}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
isSyncing ? 'bg-gray-300' : 'bg-blue-600 hover:bg-blue-700'
|
||||
} text-white transition-colors`}
|
||||
>
|
||||
{isSyncing ? 'Syncing...' : 'Sync Now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">{error}</div>
|
||||
)}
|
||||
|
||||
{syncStatus && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
Last sync: {syncStatus.last_sync_time ?
|
||||
formatDistanceToNow(new Date(syncStatus.last_sync_time)) + ' ago' : 'Never'}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Status: <span className="font-medium">{syncStatus.status}</span>
|
||||
</p>
|
||||
{syncStatus.activities_synced > 0 && (
|
||||
<p className="text-sm">
|
||||
Activities synced: {syncStatus.activities_synced}
|
||||
</p>
|
||||
)}
|
||||
{syncStatus.error_message && (
|
||||
<p className="text-sm text-red-600">{syncStatus.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GarminSync;
|
||||
93
frontend/src/components/plans/PlanTimeline.jsx
Normal file
93
frontend/src/components/plans/PlanTimeline.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
const PlanTimeline = ({ planId }) => {
|
||||
const { apiKey } = useAuth();
|
||||
const [evolution, setEvolution] = useState([]);
|
||||
const [selectedVersion, setSelectedVersion] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEvolution = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/plans/${planId}/evolution`, {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setEvolution(response.data.evolution_history);
|
||||
setSelectedVersion(response.data.current_version);
|
||||
} catch (error) {
|
||||
console.error('Error fetching plan evolution:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (planId) {
|
||||
fetchEvolution();
|
||||
}
|
||||
}, [planId, apiKey]);
|
||||
|
||||
if (loading) return <div>Loading plan history...</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<h3 className="text-xl font-semibold mb-4">Plan Evolution</h3>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/3 space-y-4">
|
||||
{evolution.map((version, idx) => (
|
||||
<div
|
||||
key={version.version}
|
||||
onClick={() => setSelectedVersion(version)}
|
||||
className={`p-4 border-l-4 cursor-pointer transition-colors ${
|
||||
selectedVersion?.version === version.version
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">v{version.version}</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatDistanceToNow(new Date(version.created_at))} ago
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{version.trigger || 'Initial version'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedVersion && (
|
||||
<div className="md:w-2/3 p-4 bg-gray-50 rounded-md">
|
||||
<h4 className="font-medium mb-4">
|
||||
Version {selectedVersion.version} Details
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
<span className="font-medium">Created:</span>{' '}
|
||||
{new Date(selectedVersion.created_at).toLocaleString()}
|
||||
</p>
|
||||
{selectedVersion.changes_summary && (
|
||||
<p>
|
||||
<span className="font-medium">Changes:</span>{' '}
|
||||
{selectedVersion.changes_summary}
|
||||
</p>
|
||||
)}
|
||||
{selectedVersion.parent_plan_id && (
|
||||
<p>
|
||||
<span className="font-medium">Parent Version:</span>{' '}
|
||||
v{selectedVersion.parent_plan_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanTimeline;
|
||||
51
frontend/src/context/AuthContext.jsx
Normal file
51
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [apiKey] = useState(process.env.REACT_APP_API_KEY);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleError = (error) => {
|
||||
toast.error(error.message || 'API request failed');
|
||||
throw error;
|
||||
};
|
||||
|
||||
const authFetch = async (url, options = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-API-Key': apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ apiKey, authFetch, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,141 +1,169 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react';
|
||||
import GarminSync from '../components/garmin/GarminSync';
|
||||
import WorkoutChart from '../components/analysis/WorkoutCharts';
|
||||
import PlanTimeline from '../components/plans/PlanTimeline';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [dashboardData, setDashboardData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const { apiKey, loading: apiLoading } = useAuth();
|
||||
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 response = await fetch('/api/dashboard', {
|
||||
headers: {
|
||||
'X-API-Key': process.env.REACT_APP_API_KEY
|
||||
}
|
||||
})
|
||||
const [workoutsRes, planRes, statsRes, healthRes] = await Promise.all([
|
||||
fetch('/api/workouts?limit=3', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
}),
|
||||
fetch('/api/plans/active', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
}),
|
||||
fetch('/api/stats', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
}),
|
||||
fetch('/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 (!response.ok) {
|
||||
throw new Error(`Dashboard load failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setDashboardData(data)
|
||||
setError(null)
|
||||
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) {
|
||||
console.error('Dashboard error:', err)
|
||||
setError(err.message)
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLocalLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData()
|
||||
}, [])
|
||||
fetchDashboardData();
|
||||
}, [apiKey]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading your training dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (localLoading || apiLoading) return <LoadingSpinner />;
|
||||
if (error) return <div className="p-6 text-red-500">{error}</div>;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center text-red-600">
|
||||
<h2 className="text-xl font-semibold">Error loading dashboard</h2>
|
||||
<p className="mt-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Calculate total distance in km
|
||||
const totalDistanceKm = (stats.totalDistance / 1000).toFixed(0);
|
||||
|
||||
return (
|
||||
<div className="dashboard bg-gray-50 min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Metrics Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-gray-500 text-sm font-medium">Weekly Volume</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{dashboardData.metrics.weekly_volume?.toFixed(1) || 0} hours
|
||||
</p>
|
||||
<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 className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-gray-500 text-sm font-medium">Plan Progress</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{dashboardData.metrics.plan_progress || 0}%
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* Recent Workouts */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Recent Workouts</h2>
|
||||
<div className="space-y-4">
|
||||
{dashboardData.recent_workouts.map(workout => (
|
||||
<div key={workout.id} className="border-b pb-4">
|
||||
<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">
|
||||
<div>
|
||||
<h3 className="font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3>
|
||||
<p className="text-gray-600">{workout.activity_type}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-gray-900 font-medium">
|
||||
{(workout.distance_m / 1000).toFixed(1)} km
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{Math.floor(workout.duration_seconds / 60)} minutes
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Current Plan */}
|
||||
{dashboardData.current_plan && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Current Training Plan</h2>
|
||||
<Link
|
||||
to={`/plans/${dashboardData.current_plan.id}`}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
View Details →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{dashboardData.current_plan.name}</h3>
|
||||
<p className="text-gray-600">
|
||||
{dashboardData.current_plan.duration_weeks} week plan
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-24 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{ width: `${dashboardData.current_plan.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard;
|
||||
66
frontend/src/pages/Plans.jsx
Normal file
66
frontend/src/pages/Plans.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import PlanTimeline from '../components/plans/PlanTimeline';
|
||||
|
||||
const Plans = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/plans', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setPlans(response.data);
|
||||
if (response.data.length > 0) {
|
||||
setSelectedPlan(response.data[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load training plans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlans();
|
||||
}, [apiKey]);
|
||||
|
||||
if (loading) return <div className="p-6 text-center">Loading plans...</div>;
|
||||
if (error) return <div className="p-6 text-red-600">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Training Plans</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-4">
|
||||
{plans.map(plan => (
|
||||
<div
|
||||
key={plan.id}
|
||||
onClick={() => setSelectedPlan(plan.id)}
|
||||
className={`p-4 bg-white rounded-lg shadow-md cursor-pointer ${
|
||||
selectedPlan === plan.id ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-medium">Plan v{plan.version}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Created {new Date(plan.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{selectedPlan && <PlanTimeline planId={selectedPlan} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Plans;
|
||||
16
frontend/src/pages/RoutesPage.jsx
Normal file
16
frontend/src/pages/RoutesPage.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const RoutesPage = () => {
|
||||
const { apiKey } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Routes</h1>
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<p className="text-gray-600">Route management will be displayed here</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoutesPage;
|
||||
67
frontend/src/pages/Workouts.jsx
Normal file
67
frontend/src/pages/Workouts.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import axios from 'axios';
|
||||
import WorkoutAnalysis from '../components/analysis/WorkoutAnalysis';
|
||||
|
||||
const Workouts = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const [workouts, setWorkouts] = useState([]);
|
||||
const [selectedWorkout, setSelectedWorkout] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkouts = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/workouts', {
|
||||
headers: { 'X-API-Key': apiKey }
|
||||
});
|
||||
setWorkouts(response.data.workouts);
|
||||
} catch (err) {
|
||||
setError('Failed to load workouts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchWorkouts();
|
||||
}, [apiKey]);
|
||||
|
||||
if (loading) return <div className="p-6 text-center">Loading workouts...</div>;
|
||||
if (error) return <div className="p-6 text-red-600">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-3xl font-bold mb-8">Workouts</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
{workouts.map(workout => (
|
||||
<div
|
||||
key={workout.id}
|
||||
onClick={() => setSelectedWorkout(workout)}
|
||||
className={`p-4 bg-white rounded-lg shadow-md cursor-pointer ${
|
||||
selectedWorkout?.id === workout.id ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-medium">
|
||||
{new Date(workout.start_time).toLocaleDateString()} - {workout.activity_type}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Duration: {Math.round(workout.duration_seconds / 60)}min
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedWorkout && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||
<WorkoutAnalysis workoutId={selectedWorkout.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Workouts;
|
||||
Reference in New Issue
Block a user