This commit is contained in:
2025-09-08 12:51:15 -07:00
commit 574feb1ea1
62 changed files with 10425 additions and 0 deletions

39
frontend/Dockerfile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/extend-expect';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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()
})
})

View 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;

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