mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-14 11:22:30 +00:00
sync
This commit is contained in:
39
frontend/Dockerfile
Normal file
39
frontend/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy build artifacts and dependencies
|
||||
COPY --from=build /app/package*.json ./
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/public ./public
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
USER appuser
|
||||
|
||||
# Expose application port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run application
|
||||
CMD ["npm", "start"]
|
||||
10
frontend/babel.config.js
Normal file
10
frontend/babel.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['next/babel', {
|
||||
'preset-react': {
|
||||
runtime: 'automatic',
|
||||
importSource: '@emotion/react'
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
4942
frontend/package-lock.json
generated
Normal file
4942
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "aic-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"recharts": "3.4.2"
|
||||
},
|
||||
"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",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
1
frontend/setupTests.js
Normal file
1
frontend/setupTests.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
66
frontend/src/components/ErrorBoundary.jsx
Normal file
66
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
150
frontend/src/components/FileUpload.jsx
Normal file
150
frontend/src/components/FileUpload.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
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 handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInput = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
handleFile(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 clearPreview = () => {
|
||||
setPreviewContent(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="file-upload">
|
||||
<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'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => document.getElementById('file-input').click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={acceptedTypes.join(',')}
|
||||
onChange={handleFileInput}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
121
frontend/src/components/GarminSync.jsx
Normal file
121
frontend/src/components/GarminSync.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const GarminSync = () => {
|
||||
const [syncStatus, setSyncStatus] = useState(null);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const triggerSync = async () => {
|
||||
setSyncing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/workouts/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': process.env.REACT_APP_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Sync failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Start polling for status updates
|
||||
pollSyncStatus();
|
||||
} catch (err) {
|
||||
console.error('Garmin sync failed:', err);
|
||||
setError(err.message);
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pollSyncStatus = () => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/workouts/sync-status');
|
||||
const status = await response.json();
|
||||
setSyncStatus(status);
|
||||
|
||||
// Stop polling when sync is no longer in progress
|
||||
if (status.status !== 'in_progress') {
|
||||
setSyncing(false);
|
||||
clearInterval(interval);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching sync status:', err);
|
||||
setError('Failed to get sync status');
|
||||
setSyncing(false);
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="garmin-sync bg-gray-50 p-4 rounded-lg shadow">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-3">Garmin Connect Sync</h3>
|
||||
|
||||
<button
|
||||
onClick={triggerSync}
|
||||
disabled={syncing}
|
||||
className={`px-4 py-2 rounded-md font-medium ${
|
||||
syncing ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
|
||||
} text-white transition-colors`}
|
||||
>
|
||||
{syncing ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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>
|
||||
Syncing...
|
||||
</span>
|
||||
) : 'Sync Recent Activities'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-2 bg-red-50 text-red-700 rounded-md">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{syncStatus && (
|
||||
<div className="mt-4 p-3 bg-white rounded-md border border-gray-200">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Sync Status</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-gray-600">Last sync:</div>
|
||||
<div className="text-gray-800">
|
||||
{syncStatus.last_sync_time
|
||||
? new Date(syncStatus.last_sync_time).toLocaleString()
|
||||
: 'Never'}
|
||||
</div>
|
||||
|
||||
<div className="text-gray-600">Status:</div>
|
||||
<div className={`font-medium ${
|
||||
syncStatus.status === 'success' ? 'text-green-600' :
|
||||
syncStatus.status === 'error' ? 'text-red-600' : 'text-blue-600'
|
||||
}`}>
|
||||
{syncStatus.status}
|
||||
</div>
|
||||
|
||||
{syncStatus.activities_synced > 0 && (
|
||||
<>
|
||||
<div className="text-gray-600">Activities synced:</div>
|
||||
<div className="text-gray-800">{syncStatus.activities_synced}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{syncStatus.error_message && (
|
||||
<>
|
||||
<div className="text-gray-600">Error:</div>
|
||||
<div className="text-red-600">{syncStatus.error_message}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GarminSync;
|
||||
152
frontend/src/components/PlanTimeline.jsx
Normal file
152
frontend/src/components/PlanTimeline.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const PlanTimeline = ({ plan, versions }) => {
|
||||
const [expandedWeeks, setExpandedWeeks] = useState({});
|
||||
|
||||
const toggleWeek = (weekNumber) => {
|
||||
setExpandedWeeks(prev => ({
|
||||
...prev,
|
||||
[weekNumber]: !prev[weekNumber]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="plan-timeline bg-white rounded-lg shadow-md p-5">
|
||||
<div className="header flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-800">{plan.name || 'Training Plan'}</h2>
|
||||
<p className="text-gray-600">Version {plan.version} • Created {new Date(plan.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">
|
||||
{plan.jsonb_plan.overview.focus.replace(/_/g, ' ')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{versions.length > 1 && (
|
||||
<div className="version-history mb-8">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-3">Version History</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Trigger</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{versions.map(version => (
|
||||
<tr key={version.id} className={version.id === plan.id ? 'bg-blue-50' : ''}>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
v{version.version}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(version.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500 capitalize">
|
||||
{version.evolution_trigger?.replace(/_/g, ' ') || 'initial'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{version.changes_summary || 'Initial version'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="plan-overview bg-gray-50 p-4 rounded-md mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-2">Plan Overview</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Duration</span>
|
||||
<span className="text-xl font-bold text-gray-800">
|
||||
{plan.jsonb_plan.overview.duration_weeks} weeks
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Weekly Hours</span>
|
||||
<span className="text-xl font-bold text-gray-800">
|
||||
{plan.jsonb_plan.overview.total_weekly_hours} hours
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Focus</span>
|
||||
<span className="text-xl font-bold text-gray-800 capitalize">
|
||||
{plan.jsonb_plan.overview.focus.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="weekly-schedule">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-4">Weekly Schedule</h3>
|
||||
|
||||
{plan.jsonb_plan.weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="week-card border border-gray-200 rounded-md mb-4 overflow-hidden">
|
||||
<div
|
||||
className="week-header bg-gray-100 p-3 flex justify-between items-center cursor-pointer hover:bg-gray-200"
|
||||
onClick={() => toggleWeek(weekIndex)}
|
||||
>
|
||||
<h4 className="font-medium text-gray-800">Week {week.week_number} • {week.focus.replace(/_/g, ' ')}</h4>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-gray-600 mr-2">
|
||||
{week.total_hours} hours • {week.workouts.length} workouts
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transform transition-transform ${
|
||||
expandedWeeks[weekIndex] ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedWeeks[weekIndex] && (
|
||||
<div className="workouts-list p-4 bg-white">
|
||||
{week.workouts.map((workout, workoutIndex) => (
|
||||
<div key={workoutIndex} className="workout-item border-b border-gray-100 py-3 last:border-0">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-gray-800 capitalize">{workout.type.replace(/_/g, ' ')}</span>
|
||||
<span className="text-gray-600 ml-2">• {workout.day}</span>
|
||||
</div>
|
||||
<span className="text-gray-600">{workout.duration_minutes} min</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full capitalize">
|
||||
{workout.intensity.replace(/_/g, ' ')}
|
||||
</span>
|
||||
{workout.route_id && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Route: {workout.route_name || workout.route_id}
|
||||
</span>
|
||||
)}
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded-full">
|
||||
TSS: {workout.tss_target || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{workout.description && (
|
||||
<p className="mt-2 text-gray-700 text-sm">{workout.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanTimeline;
|
||||
162
frontend/src/components/WorkoutAnalysis.jsx
Normal file
162
frontend/src/components/WorkoutAnalysis.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const WorkoutAnalysis = ({ workout, analysis }) => {
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const approveAnalysis = async () => {
|
||||
setApproving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/analyses/${analysis.id}/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': process.env.REACT_APP_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Approval failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.new_plan_id) {
|
||||
// Navigate to the new plan
|
||||
navigate(`/plans/${result.new_plan_id}`);
|
||||
} else {
|
||||
// Show success message
|
||||
setApproving(false);
|
||||
alert('Analysis approved successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Approval failed:', err);
|
||||
setError(err.message);
|
||||
setApproving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workout-analysis bg-white rounded-lg shadow-md p-5">
|
||||
<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">
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Duration</span>
|
||||
<span className="font-medium">
|
||||
{Math.round(workout.duration_seconds / 60)} min
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Distance</span>
|
||||
<span className="font-medium">
|
||||
{(workout.distance_m / 1000).toFixed(1)} km
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{workout.avg_power && (
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Avg Power</span>
|
||||
<span className="font-medium">
|
||||
{Math.round(workout.avg_power)}W
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workout.avg_hr && (
|
||||
<div className="metric-card">
|
||||
<span className="text-gray-500">Avg HR</span>
|
||||
<span className="font-medium">
|
||||
{Math.round(workout.avg_hr)} bpm
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<div className="analysis-content">
|
||||
<h4 className="text-lg font-medium text-gray-800 mb-3">AI Analysis</h4>
|
||||
|
||||
<div className="feedback-box bg-blue-50 p-4 rounded-md mb-5">
|
||||
<p className="text-gray-700">{analysis.jsonb_feedback.summary}</p>
|
||||
</div>
|
||||
|
||||
<div className="strengths-improvement grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="strengths">
|
||||
<h5 className="font-medium text-green-700 mb-2">Strengths</h5>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{analysis.jsonb_feedback.strengths.map((strength, index) => (
|
||||
<li key={index} className="text-gray-700">{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="improvements">
|
||||
<h5 className="font-medium text-orange-600 mb-2">Areas for Improvement</h5>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{analysis.jsonb_feedback.areas_for_improvement.map((area, index) => (
|
||||
<li key={index} className="text-gray-700">{area}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.suggestions && analysis.suggestions.length > 0 && (
|
||||
<div className="suggestions bg-yellow-50 p-4 rounded-md mb-5">
|
||||
<h5 className="font-medium text-gray-800 mb-3">Training Suggestions</h5>
|
||||
<ul className="space-y-2">
|
||||
{analysis.suggestions.map((suggestion, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="inline-block w-6 h-6 bg-yellow-100 text-yellow-800 rounded-full text-center mr-2 flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-gray-700">{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{!analysis.approved && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={approveAnalysis}
|
||||
disabled={approving}
|
||||
className={`px-4 py-2 rounded-md font-medium ${
|
||||
approving ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'
|
||||
} text-white transition-colors flex items-center`}
|
||||
>
|
||||
{approving ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" 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>
|
||||
Applying suggestions...
|
||||
</>
|
||||
) : 'Approve & Update Training Plan'}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 text-red-600 text-sm">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutAnalysis;
|
||||
98
frontend/src/components/WorkoutCharts.jsx
Normal file
98
frontend/src/components/WorkoutCharts.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||
Tooltip, Legend, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
|
||||
const WorkoutCharts = ({ timeSeries }) => {
|
||||
// Transform timestamp to minutes from start for X-axis
|
||||
const formatTimeSeries = (data) => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
const startTime = new Date(data[0].timestamp);
|
||||
return data.map(point => ({
|
||||
...point,
|
||||
time: (new Date(point.timestamp) - startTime) / 60000, // Convert to minutes
|
||||
heart_rate: point.heart_rate || null,
|
||||
power: point.power || null,
|
||||
cadence: point.cadence || null
|
||||
}));
|
||||
};
|
||||
|
||||
const formattedData = formatTimeSeries(timeSeries);
|
||||
|
||||
return (
|
||||
<div className="workout-charts bg-white p-4 rounded-lg shadow-md">
|
||||
<h3 className="text-lg font-medium text-gray-800 mb-4">Workout Metrics</h3>
|
||||
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={formattedData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
label={{
|
||||
value: 'Time (minutes)',
|
||||
position: 'insideBottomRight',
|
||||
offset: -5
|
||||
}}
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickCount={6}
|
||||
/>
|
||||
<YAxis yAxisId="left" orientation="left" stroke="#8884d8">
|
||||
<Label value="HR (bpm) / Cadence (rpm)" angle={-90} position="insideLeft" />
|
||||
</YAxis>
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d">
|
||||
<Label value="Power (W)" angle={90} position="insideRight" />
|
||||
</YAxis>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [`${value} ${name === 'power' ? 'W' : name === 'heart_rate' ? 'bpm' : 'rpm'}`, name]}
|
||||
labelFormatter={(value) => `Time: ${value.toFixed(1)} min`}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="heart_rate"
|
||||
name="Heart Rate"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="power"
|
||||
name="Power"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cadence"
|
||||
name="Cadence"
|
||||
stroke="#ffc658"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
<p>Note: Charts show metrics over time during the workout. Hover over points to see exact values.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkoutCharts;
|
||||
34
frontend/src/components/__tests__/FileUpload.test.jsx
Normal file
34
frontend/src/components/__tests__/FileUpload.test.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import FileUpload from '../FileUpload'
|
||||
|
||||
describe('FileUpload Component', () => {
|
||||
test('renders upload button', () => {
|
||||
render(<FileUpload onUpload={() => {}} />)
|
||||
expect(screen.getByText('Upload GPX File')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('file-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('handles file selection', () => {
|
||||
const mockFile = new File(['test content'], 'test.gpx', { type: 'application/gpx+xml' })
|
||||
const mockOnUpload = jest.fn()
|
||||
|
||||
render(<FileUpload onUpload={mockOnUpload} />)
|
||||
|
||||
const input = screen.getByTestId('file-input')
|
||||
fireEvent.change(input, { target: { files: [mockFile] } })
|
||||
|
||||
expect(mockOnUpload).toHaveBeenCalledWith(mockFile)
|
||||
expect(screen.getByText('Selected file: test.gpx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows error for invalid file type', () => {
|
||||
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' })
|
||||
const { container } = render(<FileUpload onUpload={() => {}} />)
|
||||
|
||||
const input = screen.getByTestId('file-input')
|
||||
fireEvent.change(input, { target: { files: [invalidFile] } })
|
||||
|
||||
expect(screen.getByText('Invalid file type. Please upload a GPX file.')).toBeInTheDocument()
|
||||
expect(container.querySelector('.error-message')).toBeVisible()
|
||||
})
|
||||
})
|
||||
253
frontend/src/pages/Dashboard.jsx
Normal file
253
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import GarminSync from '../components/GarminSync';
|
||||
import WorkoutCharts from '../components/WorkoutCharts';
|
||||
import PlanTimeline from '../components/PlanTimeline';
|
||||
import WorkoutAnalysis from '../components/WorkoutAnalysis';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [dashboardData, setDashboardData] = useState({
|
||||
recentWorkouts: [],
|
||||
upcomingWorkouts: [],
|
||||
currentPlan: null,
|
||||
planVersions: [],
|
||||
lastAnalysis: null,
|
||||
syncStatus: null,
|
||||
metrics: {}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/dashboard', {
|
||||
headers: {
|
||||
'X-API-Key': process.env.REACT_APP_API_KEY
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load dashboard: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Dashboard load error:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const handleSyncComplete = (newSyncStatus) => {
|
||||
setDashboardData(prev => ({
|
||||
...prev,
|
||||
syncStatus: newSyncStatus
|
||||
}));
|
||||
};
|
||||
|
||||
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 (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md p-6 bg-white rounded-lg shadow-md">
|
||||
<div className="text-red-500 text-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto" 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>
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-2">Dashboard Error</h2>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard bg-gray-50 min-h-screen p-4 md:p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">Training Dashboard</h1>
|
||||
<p className="text-gray-600">Your personalized cycling training overview</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-1 md: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 Hours</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{dashboardData.metrics.weekly_hours || '0'}h
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-gray-500 text-sm font-medium">Workouts This Week</h3>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{dashboardData.metrics.workouts_this_week || '0'}
|
||||
</p>
|
||||
</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 className="bg-white p-4 rounded-lg shadow">
|
||||
<h3 className="text-gray-500 text-sm font-medium">Fitness Level</h3>
|
||||
<p className="text-2xl font-bold text-gray-900 capitalize">
|
||||
{dashboardData.metrics.fitness_level || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Garmin Sync */}
|
||||
<div className="bg-white rounded-lg shadow-md p-5">
|
||||
<GarminSync onSyncComplete={handleSyncComplete} />
|
||||
</div>
|
||||
|
||||
{/* Current Plan */}
|
||||
{dashboardData.currentPlan && (
|
||||
<div className="bg-white rounded-lg shadow-md p-5">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Current Training Plan</h2>
|
||||
<PlanTimeline
|
||||
plan={dashboardData.currentPlan}
|
||||
versions={dashboardData.planVersions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Analysis */}
|
||||
{dashboardData.lastAnalysis && (
|
||||
<div className="bg-white rounded-lg shadow-md p-5">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Latest Workout Analysis</h2>
|
||||
<WorkoutAnalysis
|
||||
workout={dashboardData.lastAnalysis.workout}
|
||||
analysis={dashboardData.lastAnalysis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming Workouts */}
|
||||
<div className="bg-white rounded-lg shadow-md p-5">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Upcoming Workouts</h2>
|
||||
{dashboardData.upcomingWorkouts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{dashboardData.upcomingWorkouts.map(workout => (
|
||||
<div key={workout.id} className="border-b border-gray-100 pb-3 last:border-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 capitalize">
|
||||
{workout.type.replace(/_/g, ' ')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{new Date(workout.scheduled_date).toLocaleDateString()} • {workout.duration_minutes} min
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full capitalize">
|
||||
{workout.intensity.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
{workout.description && (
|
||||
<p className="mt-1 text-sm text-gray-700">{workout.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">No upcoming workouts scheduled</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Workouts */}
|
||||
<div className="bg-white rounded-lg shadow-md p-5">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Recent Workouts</h2>
|
||||
{dashboardData.recentWorkouts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{dashboardData.recentWorkouts.map(workout => (
|
||||
<div key={workout.id} className="border-b border-gray-100 pb-3 last:border-0">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 capitalize">
|
||||
{workout.activity_type || 'Cycling'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{new Date(workout.start_time).toLocaleDateString()} • {Math.round(workout.duration_seconds / 60)} min
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="block text-sm font-medium">
|
||||
{workout.distance_m ? `${(workout.distance_m / 1000).toFixed(1)} km` : ''}
|
||||
</span>
|
||||
{workout.analysis && workout.analysis.performance_score && (
|
||||
<span className="text-xs px-2 py-0.5 bg-green-100 text-green-800 rounded-full">
|
||||
Score: {workout.analysis.performance_score}/10
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{workout.analysis && workout.analysis.performance_summary && (
|
||||
<p className="mt-1 text-sm text-gray-700 line-clamp-2">
|
||||
{workout.analysis.performance_summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 italic">No recent workouts recorded</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-lg shadow-md p-5">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button className="px-3 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
Generate New Plan
|
||||
</button>
|
||||
<button className="px-3 py-2 bg-green-600 text-white rounded-md text-sm font-medium hover:bg-green-700 transition-colors">
|
||||
Add Custom Workout
|
||||
</button>
|
||||
<button className="px-3 py-2 bg-purple-600 text-white rounded-md text-sm font-medium hover:bg-purple-700 transition-colors">
|
||||
View All Routes
|
||||
</button>
|
||||
<button className="px-3 py-2 bg-yellow-600 text-white rounded-md text-sm font-medium hover:bg-yellow-700 transition-colors">
|
||||
Update Rules
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
49
frontend/src/pages/index.tsx
Normal file
49
frontend/src/pages/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function Home() {
|
||||
const [healthStatus, setHealthStatus] = useState<string>('checking...');
|
||||
|
||||
useEffect(() => {
|
||||
const checkBackendHealth = async () => {
|
||||
try {
|
||||
const response = await fetch('http://backend:8000/health');
|
||||
const data = await response.json();
|
||||
setHealthStatus(data.status);
|
||||
} catch (error) {
|
||||
setHealthStatus('unavailable');
|
||||
console.error('Error checking backend health:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkBackendHealth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50">
|
||||
<div className="max-w-2xl w-full p-8 bg-white rounded-lg shadow-md">
|
||||
<h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
|
||||
Welcome to AI Cycling Coach
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 mb-8 text-center">
|
||||
Your AI-powered training companion for cyclists
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<h2 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
System Status
|
||||
</h2>
|
||||
<div className="flex items-center">
|
||||
<div className={`h-3 w-3 rounded-full mr-2 ${healthStatus === 'healthy' ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-gray-700">
|
||||
Backend service: {healthStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-gray-500">
|
||||
Development in progress - more features coming soon!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user