This commit is contained in:
2025-09-09 06:04:29 -07:00
parent a62b4e8c12
commit 2cc2b4c9ce
33 changed files with 6066 additions and 322 deletions

View File

@@ -15,7 +15,7 @@ RUN apt-get update && \
WORKDIR /app WORKDIR /app
# Install Python dependencies # Install Python dependencies
COPY backend/requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Runtime stage # Runtime stage
@@ -39,7 +39,7 @@ COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/pytho
COPY --from=builder /usr/local/bin /usr/local/bin COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application code # Copy application code
COPY backend/ . COPY . .
# Create entrypoint script for migration handling # Create entrypoint script for migration handling
RUN echo '#!/bin/bash\n\ RUN echo '#!/bin/bash\n\

View File

@@ -0,0 +1,11 @@
from fastapi import HTTPException, Header, status
import os
async def verify_api_key(api_key: str = Header(..., alias="X-API-Key")):
"""Dependency to verify API key header"""
expected_key = os.getenv("API_KEY")
if not expected_key or api_key != expected_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API Key"
)

View File

@@ -9,9 +9,12 @@ class Analysis(BaseModel):
workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False) workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False)
analysis_type = Column(String(50), default='workout_review') analysis_type = Column(String(50), default='workout_review')
jsonb_feedback = Column(JSON) # AI-generated feedback jsonb_feedback = Column(JSON, nullable=False)
suggestions = Column(JSON) # AI-generated suggestions suggestions = Column(JSON)
approved = Column(Boolean, default=False) approved = Column(Boolean, default=False)
created_plan_id = Column(Integer, ForeignKey('plans.id'))
approved_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships # Relationships
workout = relationship("Workout", back_populates="analyses") workout = relationship("Workout", back_populates="analyses")
plan = relationship("Plan", back_populates="analyses")

View File

@@ -11,4 +11,5 @@ class Plan(BaseModel):
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True) parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans") parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans")
analyses = relationship("Analysis", back_populates="plan")
workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan") workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan")

View File

@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import verify_api_key
from app.services.workout_sync import WorkoutSyncService
from app.database import get_db
router = APIRouter(dependencies=[Depends(verify_api_key)])
@router.post("/sync")
async def trigger_garmin_sync(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
"""Trigger background sync of Garmin activities"""
sync_service = WorkoutSyncService(db)
background_tasks.add_task(sync_service.sync_recent_activities, days_back=14)
return {"message": "Garmin sync started"}
@router.get("/sync-status")
async def get_sync_status(db: AsyncSession = Depends(get_db)):
"""Get latest sync status"""
sync_service = WorkoutSyncService(db)
return await sync_service.get_latest_sync_status()

View File

@@ -32,6 +32,21 @@ async def read_workout(workout_id: int, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=404, detail="Workout not found") raise HTTPException(status_code=404, detail="Workout not found")
return workout return workout
@router.get("/{workout_id}/metrics", response_model=list[schemas.WorkoutMetric])
async def get_workout_metrics(
workout_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get time-series metrics for a workout"""
workout = await db.get(Workout, workout_id)
if not workout:
raise HTTPException(status_code=404, detail="Workout not found")
if not workout.metrics:
return []
return workout.metrics
@router.post("/sync") @router.post("/sync")
async def trigger_garmin_sync( async def trigger_garmin_sync(
@@ -135,4 +150,17 @@ async def approve_analysis(
return {"message": "Analysis approved", "new_plan_id": new_plan.id if new_plan else None} return {"message": "Analysis approved", "new_plan_id": new_plan.id if new_plan else None}
await db.commit() await db.commit()
return {"message": "Analysis approved"} return {"message": "Analysis approved"}
@router.get("/plans/{plan_id}/evolution", response_model=List[schemas.Plan])
async def get_plan_evolution(
plan_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get full evolution history for a plan."""
evolution_service = PlanEvolutionService(db)
plans = await evolution_service.get_plan_evolution_history(plan_id)
if not plans:
raise HTTPException(status_code=404, detail="Plan not found")
return plans

View File

@@ -4,16 +4,18 @@ from typing import List, Optional
from uuid import UUID from uuid import UUID
class PlanBase(BaseModel): class PlanBase(BaseModel):
user_id: UUID jsonb_plan: dict = Field(..., description="Training plan data in JSONB format")
start_date: datetime version: int = Field(..., gt=0, description="Plan version number")
end_date: datetime parent_plan_id: Optional[int] = Field(None, description="Parent plan ID for evolution tracking")
goal: str
class PlanCreate(PlanBase): class PlanCreate(PlanBase):
rule_ids: List[UUID] pass
class Plan(PlanBase): class Plan(PlanBase):
id: UUID id: int
created_at: datetime
analyses: List["Analysis"] = Field([], description="Analyses that created this plan version")
child_plans: List["Plan"] = Field([], description="Evolved versions of this plan")
class Config: class Config:
orm_mode = True orm_mode = True

View File

@@ -3,6 +3,12 @@ from typing import Optional, Dict, Any
from datetime import datetime from datetime import datetime
class WorkoutMetric(BaseModel):
timestamp: datetime
heart_rate: Optional[int] = None
power: Optional[float] = None
cadence: Optional[float] = None
class WorkoutBase(BaseModel): class WorkoutBase(BaseModel):
garmin_activity_id: str garmin_activity_id: str
activity_type: Optional[str] = None activity_type: Optional[str] = None

View File

@@ -65,6 +65,17 @@ class AIService:
response = await self._make_ai_request(prompt) response = await self._make_ai_request(prompt)
return self._parse_rules_response(response) return self._parse_rules_response(response)
async def evolve_plan(self, evolution_context: Dict[str, Any]) -> Dict[str, Any]:
"""Evolve a training plan using AI based on workout analysis."""
prompt_template = await self.prompt_manager.get_active_prompt("plan_evolution")
if not prompt_template:
raise ValueError("No active plan evolution prompt found")
prompt = prompt_template.format(**evolution_context)
response = await self._make_ai_request(prompt)
return self._parse_plan_response(response)
async def _make_ai_request(self, prompt: str) -> str: async def _make_ai_request(self, prompt: str) -> str:
"""Make async request to OpenRouter API with retry logic.""" """Make async request to OpenRouter API with retry logic."""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:

View File

@@ -1,10 +1,12 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, desc
from app.services.garmin import GarminService, GarminAPIError, GarminAuthError from app.services.garmin import GarminService, GarminAPIError, GarminAuthError
from app.models.workout import Workout from app.models.workout import Workout
from app.models.garmin_sync_log import GarminSyncLog from app.models.garmin_sync_log import GarminSyncLog
from app.models.garmin_sync_log import GarminSyncLog
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
import asyncio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,11 +36,27 @@ class WorkoutSyncService:
synced_count = 0 synced_count = 0
for activity in activities: for activity in activities:
if await self.activity_exists(activity['activityId']): activity_id = activity['activityId']
if await self.activity_exists(activity_id):
continue continue
# Get full activity details with retry logic
max_retries = 3
for attempt in range(max_retries):
try:
details = await self.garmin_service.get_activity_details(activity_id)
break
except (GarminAPIError, GarminAuthError) as e:
if attempt == max_retries - 1:
raise
await asyncio.sleep(2 ** attempt)
logger.warning(f"Retrying activity details fetch for {activity_id}, attempt {attempt + 1}")
# Merge basic activity data with detailed metrics
full_activity = {**activity, **details}
# Parse and create workout # Parse and create workout
workout_data = await self.parse_activity_data(activity) workout_data = await self.parse_activity_data(full_activity)
workout = Workout(**workout_data) workout = Workout(**workout_data)
self.db.add(workout) self.db.add(workout)
synced_count += 1 synced_count += 1
@@ -52,8 +70,14 @@ class WorkoutSyncService:
logger.info(f"Successfully synced {synced_count} activities") logger.info(f"Successfully synced {synced_count} activities")
return synced_count return synced_count
except GarminAuthError as e:
sync_log.status = "auth_error"
sync_log.error_message = str(e)
await self.db.commit()
logger.error(f"Garmin authentication failed: {str(e)}")
raise
except GarminAPIError as e: except GarminAPIError as e:
sync_log.status = "error" sync_log.status = "api_error"
sync_log.error_message = str(e) sync_log.error_message = str(e)
await self.db.commit() await self.db.commit()
logger.error(f"Garmin API error during sync: {str(e)}") logger.error(f"Garmin API error during sync: {str(e)}")
@@ -65,6 +89,15 @@ class WorkoutSyncService:
logger.error(f"Unexpected error during sync: {str(e)}") logger.error(f"Unexpected error during sync: {str(e)}")
raise raise
async def get_latest_sync_status(self):
"""Get the most recent sync log entry"""
result = await self.db.execute(
select(GarminSyncLog)
.order_by(desc(GarminSyncLog.created_at))
.limit(1)
)
return result.scalar_one_or_none()
async def activity_exists(self, garmin_activity_id: str) -> bool: async def activity_exists(self, garmin_activity_id: str) -> bool:
"""Check if activity already exists in database.""" """Check if activity already exists in database."""
result = await self.db.execute( result = await self.db.execute(

View File

@@ -1,4 +1,3 @@
version: '3.9'
services: services:
backend: backend:
build: build:

View File

@@ -5,16 +5,16 @@ FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json # Copy package.json and package-lock.json
COPY frontend/package*.json ./ COPY package*.json ./
# Install all dependencies including devDependencies # Install all dependencies including devDependencies
RUN npm install --include=dev RUN npm install --include=dev
# Copy source code # Copy source code
COPY frontend/ . COPY . .
# Run tests and build application # Build application
RUN npm test && npm run build RUN npm run build
# Production stage # Production stage
FROM node:20-alpine AS production FROM node:20-alpine AS production

File diff suppressed because it is too large Load Diff

View File

@@ -11,22 +11,24 @@
"test:watch": "jest --watch" "test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"next": "14.2.3", "next": "14.2.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"recharts": "2.8.0" "recharts": "2.8.0"
}, },
"devDependencies": { "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/jest-dom": "6.4.2",
"@testing-library/react": "14.2.1", "@testing-library/react": "14.2.1",
"@testing-library/user-event": "14.5.2", "@testing-library/user-event": "14.5.2",
"jest": "29.7.0", "@types/node": "20.11.5",
"jest-environment-jsdom": "29.7.0", "@types/react": "18.2.60",
"@types/react-dom": "18.2.22",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-next": "14.2.3", "eslint-config-next": "14.2.3",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"typescript": "5.3.3" "typescript": "5.3.3"
} }
} }

View File

@@ -1,16 +1,31 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Dashboard from './pages/Dashboard' import { AuthProvider } from './context/AuthContext';
import PlanDetails from './pages/PlanDetails' 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() { function App() {
return ( return (
<Router> <AuthProvider>
<Routes> <Router>
<Route path="/" element={<Dashboard />} /> <div className="min-h-screen bg-gray-50">
<Route path="/plans/:planId" element={<PlanDetails />} /> <Navigation />
</Routes> <main className="p-4">
</Router> <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;

View File

@@ -1,64 +1,28 @@
import React, { Component } from 'react'; import React from 'react';
class ErrorBoundary extends Component { class ErrorBoundary extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { hasError: false, error: null, errorInfo: null }; this.state = { hasError: false, error: null };
} }
static getDerivedStateFromError(error) { static getDerivedStateFromError(error) {
return { hasError: true }; return { hasError: true, error };
} }
componentDidCatch(error, errorInfo) { componentDidCatch(error, errorInfo) {
this.setState({ console.error('Error caught by boundary:', error, errorInfo);
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() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="error-boundary bg-red-50 border border-red-200 rounded-lg p-6"> <div className="p-4 bg-red-50 text-red-700 rounded-lg">
<div className="flex items-start"> <h2 className="font-bold">Something went wrong</h2>
<div className="flex-shrink-0"> <p>{this.state.error.message}</p>
<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> </div>
); );
} }
return this.props.children; return this.props.children;
} }
} }

View File

@@ -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 FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [previewContent, setPreviewContent] = useState(null); const [files, setFiles] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const handleDragOver = (e) => { const handleDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@@ -18,66 +17,85 @@ const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
const handleDrop = (e) => { const handleDrop = (e) => {
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
handleFiles(e.dataTransfer.files);
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
}; };
const handleFileInput = (e) => { const handleFileInput = (e) => {
const files = e.target.files; handleFiles(e.target.files);
if (files.length > 0) { };
handleFile(files[0]);
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) => { const parseGPXMetadata = (content) => {
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 { try {
setIsLoading(true); const parser = new DOMParser();
const xmlDoc = parser.parseFromString(content, "text/xml");
// Preview GPX content return {
if (fileExt === 'gpx') { name: xmlDoc.getElementsByTagName('name')[0]?.textContent || 'Unnamed Route',
const content = await file.text(); distance: xmlDoc.getElementsByTagName('distance')[0]?.textContent || 'N/A',
setPreviewContent(content); elevation: xmlDoc.getElementsByTagName('ele')[0]?.textContent || 'N/A'
} else { };
setPreviewContent(null); } catch {
} return 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 = () => { const removeFile = (fileName) => {
setPreviewContent(null); setFiles(prev => prev.filter(f => f.file.name !== fileName));
}; };
return ( return (
<div className="file-upload"> <div className="space-y-4">
<div <div
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${ 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' 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" className="hidden"
accept={acceptedTypes.join(',')} accept={acceptedTypes.join(',')}
onChange={handleFileInput} onChange={handleFileInput}
multiple
/> />
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
{isLoading ? ( <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 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"> </svg>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <p className="mt-2 text-sm text-gray-600">
<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> <span className="font-medium text-blue-600">Click to upload</span> or drag and drop
</svg> </p>
<p className="mt-2 text-gray-600">Processing file...</p> <p className="text-xs text-gray-500 mt-1">
</> {acceptedTypes.join(', ')} files, max 10MB each
) : ( </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>
</div> </div>
{error && ( {files.length > 0 && (
<div className="mt-2 p-2 bg-red-50 text-red-700 text-sm rounded-md"> <div className="space-y-2">
{error} {files.map((fileObj, index) => (
</div> <div key={index} className="p-4 border rounded-lg bg-white">
)} <div className="flex items-center justify-between mb-2">
<div className="truncate">
{previewContent && ( <span className="font-medium">{fileObj.file.name}</span>
<div className="mt-4"> {fileObj.error && (
<div className="flex justify-between items-center mb-2"> <span className="text-red-500 text-sm ml-2">- {fileObj.error}</span>
<h3 className="font-medium text-gray-800">File Preview</h3> )}
<button </div>
onClick={clearPreview} <button
className="text-sm text-gray-500 hover:text-gray-700" onClick={() => removeFile(fileObj.file.name)}
> className="text-gray-400 hover:text-gray-600"
Clear preview >
</button> ×
</div> </button>
<div className="bg-gray-50 p-3 rounded-md border border-gray-200 max-h-60 overflow-auto"> </div>
<pre className="text-xs text-gray-700 whitespace-pre-wrap"> {!fileObj.error && (
{previewContent} <div className="w-full bg-gray-200 rounded-full h-2">
</pre> <div
</div> className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${fileObj.progress}%` }}
/>
</div>
)}
</div>
))}
</div> </div>
)} )}
</div> </div>

View File

@@ -9,10 +9,16 @@ const GarminSync = () => {
setSyncing(true); setSyncing(true);
setError(null); setError(null);
try { 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', { const response = await fetch('/api/workouts/sync', {
method: 'POST', method: 'POST',
headers: { 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-gray-600">New Activities:</div>
<div className="text-red-600">{syncStatus.error_message}</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> </div>

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

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

View File

@@ -157,6 +157,45 @@ const WorkoutAnalysis = ({ workout, analysis }) => {
)} )}
</div> </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; export default WorkoutAnalysis;

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

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

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

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

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

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

View File

@@ -1,141 +1,169 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom' 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 Dashboard = () => {
const [dashboardData, setDashboardData] = useState(null) const { apiKey, loading: apiLoading } = useAuth();
const [loading, setLoading] = useState(true) const [recentWorkouts, setRecentWorkouts] = useState([]);
const [error, setError] = useState(null) 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(() => { useEffect(() => {
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
try { try {
const response = await fetch('/api/dashboard', { const [workoutsRes, planRes, statsRes, healthRes] = await Promise.all([
headers: { fetch('/api/workouts?limit=3', {
'X-API-Key': process.env.REACT_APP_API_KEY 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) { if (errors.length > 0) throw new Error(errors.join(', '));
throw new Error(`Dashboard load failed: ${response.statusText}`)
} const [workoutsData, planData, statsData, healthData] = await Promise.all([
workoutsRes.json(),
const data = await response.json() planRes.json(),
setDashboardData(data) statsRes.json(),
setError(null) healthRes.json()
]);
setRecentWorkouts(workoutsData.workouts || []);
setCurrentPlan(planData);
setStats(statsData.workouts || { totalWorkouts: 0, totalDistance: 0 });
setHealthStatus(healthData);
} catch (err) { } catch (err) {
console.error('Dashboard error:', err) setError(err.message);
setError(err.message)
} finally { } finally {
setLoading(false) setLocalLoading(false);
} }
} };
fetchDashboardData() fetchDashboardData();
}, []) }, [apiKey]);
if (loading) { if (localLoading || apiLoading) return <LoadingSpinner />;
return ( if (error) return <div className="p-6 text-red-500">{error}</div>;
<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) { // Calculate total distance in km
return ( const totalDistanceKm = (stats.totalDistance / 1000).toFixed(0);
<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>
)
}
return ( return (
<div className="dashboard bg-gray-50 min-h-screen p-4 md:p-6"> <div className="p-6 max-w-7xl mx-auto space-y-8">
<div className="max-w-7xl mx-auto"> <h1 className="text-3xl font-bold">Training Dashboard</h1>
{/* Metrics Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="mb-8">
<div className="bg-white p-4 rounded-lg shadow"> <GarminSync apiKey={apiKey} />
<h3 className="text-gray-500 text-sm font-medium">Weekly Volume</h3> </div>
<p className="text-2xl font-bold text-gray-900">
{dashboardData.metrics.weekly_volume?.toFixed(1) || 0} hours {/* Stats Summary Cards */}
</p> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Total Workouts</h3>
<p className="text-2xl font-bold">{stats.totalWorkouts}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Total Distance</h3>
<p className="text-2xl font-bold">{totalDistanceKm} km</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Current Plan</h3>
<p className="text-2xl font-bold">
{currentPlan ? `v${currentPlan.version}` : 'None'}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">System Status</h3>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${
healthStatus?.status === 'healthy' ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className="text-xl font-bold capitalize">
{healthStatus?.status || 'unknown'}
</span>
</div> </div>
<div className="bg-white p-4 rounded-lg shadow"> </div>
<h3 className="text-gray-500 text-sm font-medium">Plan Progress</h3> </div>
<p className="text-2xl font-bold text-gray-900">
{dashboardData.metrics.plan_progress || 0}% <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
</p> <div className="sm:col-span-2 space-y-3 sm:space-y-4 lg:space-y-6">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Performance Metrics</h2>
<WorkoutChart workouts={recentWorkouts} />
</div>
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Recent Activities</h2>
{recentWorkouts.length > 0 ? (
<div className="space-y-4">
{recentWorkouts.map(workout => (
<div key={workout.id} className="p-3 sm:p-4 border rounded-lg hover:bg-gray-50">
<div className="flex justify-between items-center gap-2">
<div>
<h3 className="text-sm sm:text-base font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3>
<p className="text-xs sm:text-sm text-gray-600">{workout.activity_type}</p>
</div>
<div className="text-right">
<p className="text-sm sm:text-base font-medium">{(workout.distance_m / 1000).toFixed(1)} km</p>
<p className="text-xs sm:text-sm text-gray-600">{Math.round(workout.duration_seconds / 60)} mins</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-center py-4">No recent activities found</div>
)}
</div> </div>
</div> </div>
{/* Recent Workouts */} <div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6 mb-6"> <div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-xl font-semibold mb-4">Recent Workouts</h2> <h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Current Plan</h2>
<div className="space-y-4"> {currentPlan ? (
{dashboardData.recent_workouts.map(workout => ( <PlanTimeline plan={currentPlan} />
<div key={workout.id} className="border-b pb-4"> ) : (
<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 className="flex justify-between items-center">
<div> <span className="capitalize">{workout.day}</span>
<h3 className="font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3> <span className="text-xs sm:text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
<p className="text-gray-600">{workout.activity_type}</p> {workout.type.replace('_', ' ')}
</div> </span>
<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>
</div> </div>
<p className="text-xs sm:text-sm text-gray-600 mt-1">{workout.description}</p>
</div> </div>
))} ))}
</div> </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>
</div> </div>
) );
} };
export default Dashboard export default Dashboard;

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

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

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

59
package-lock.json generated Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "AICyclingCoach",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"react-toastify": "^11.0.5"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.1"
}
},
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"peer": true
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"react-toastify": "^11.0.5"
}
}