This commit is contained in:
2025-09-08 13:29:43 -07:00
parent 574feb1ea1
commit a62b4e8c12
12 changed files with 442 additions and 336 deletions

View File

@@ -11,6 +11,7 @@ from .routes import rule as rule_routes
from .routes import plan as plan_routes from .routes import plan as plan_routes
from .routes import workouts as workout_routes from .routes import workouts as workout_routes
from .routes import prompts as prompt_routes from .routes import prompts as prompt_routes
from .routes import dashboard as dashboard_routes
from .config import settings from .config import settings
app = FastAPI( app = FastAPI(
@@ -46,6 +47,7 @@ app.include_router(rule_routes.router)
app.include_router(plan_routes.router) app.include_router(plan_routes.router)
app.include_router(workout_routes.router, prefix="/workouts", tags=["workouts"]) app.include_router(workout_routes.router, prefix="/workouts", tags=["workouts"])
app.include_router(prompt_routes.router, prefix="/prompts", tags=["prompts"]) app.include_router(prompt_routes.router, prefix="/prompts", tags=["prompts"])
app.include_router(dashboard_routes.router, prefix="/api/dashboard", tags=["dashboard"])
async def check_migration_status(): async def check_migration_status():
"""Check if database migrations are up to date.""" """Check if database migrations are up to date."""

View File

@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.workout import Workout
from app.models.plan import Plan
from app.models.garmin_sync_log import GarminSyncLog
from sqlalchemy import select, desc
from datetime import datetime, timedelta
router = APIRouter()
@router.get("/dashboard")
async def get_dashboard_data(db: AsyncSession = Depends(get_db)):
"""Get consolidated dashboard data"""
try:
# Recent workouts (last 7 days)
workout_result = await db.execute(
select(Workout)
.where(Workout.start_time >= datetime.now() - timedelta(days=7))
.order_by(desc(Workout.start_time))
.limit(5)
)
recent_workouts = [w.to_dict() for w in workout_result.scalars().all()]
# Current active plan
plan_result = await db.execute(
select(Plan)
.where(Plan.active == True)
.order_by(desc(Plan.created_at))
)
current_plan = plan_result.scalar_one_or_none()
# Sync status
sync_result = await db.execute(
select(GarminSyncLog)
.order_by(desc(GarminSyncLog.created_at))
.limit(1)
)
last_sync = sync_result.scalar_one_or_none()
return {
"recent_workouts": recent_workouts,
"current_plan": current_plan.to_dict() if current_plan else None,
"last_sync": last_sync.to_dict() if last_sync else None,
"metrics": {
"weekly_volume": sum(w.duration_seconds for w in recent_workouts) / 3600,
"plan_progress": current_plan.progress if current_plan else 0
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Dashboard data error: {str(e)}")

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from app.services.health_monitor import HealthMonitor
router = APIRouter()
monitor = HealthMonitor()
@router.get("/health")
async def get_health():
return monitor.check_system_health()

View File

@@ -0,0 +1,80 @@
import psutil
from datetime import datetime
import logging
from typing import Dict, Any
from sqlalchemy import text
from app.database import get_db
from app.models.garmin_sync_log import GarminSyncLog, SyncStatus
import requests
from app.config import settings
logger = logging.getLogger(__name__)
class HealthMonitor:
def __init__(self):
self.warning_thresholds = {
'cpu_percent': 80,
'memory_percent': 75,
'disk_percent': 85
}
def check_system_health(self) -> Dict[str, Any]:
"""Check vital system metrics and log warnings"""
metrics = {
'timestamp': datetime.utcnow().isoformat(),
'cpu': psutil.cpu_percent(),
'memory': psutil.virtual_memory().percent,
'disk': psutil.disk_usage('/').percent,
'services': self._check_service_health()
}
self._log_anomalies(metrics)
return metrics
def _check_service_health(self) -> Dict[str, str]:
"""Check critical application services"""
return {
'database': self._check_database(),
'garmin_sync': self._check_garmin_sync(),
'ai_service': self._check_ai_service()
}
def _check_database(self) -> str:
try:
with get_db() as db:
db.execute(text("SELECT 1"))
return "ok"
except Exception as e:
logger.error(f"Database check failed: {str(e)}")
return "down"
def _check_garmin_sync(self) -> str:
try:
last_sync = GarminSyncLog.get_latest()
if last_sync and last_sync.status == SyncStatus.FAILED:
return "warning"
return "ok"
except Exception as e:
logger.error(f"Garmin sync check failed: {str(e)}")
return "down"
def _check_ai_service(self) -> str:
try:
response = requests.get(
f"{settings.AI_SERVICE_URL}/ping",
timeout=5,
headers={"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}"}
)
return "ok" if response.ok else "down"
except Exception as e:
logger.error(f"AI service check failed: {str(e)}")
return "down"
def _log_anomalies(self, metrics: Dict[str, Any]):
alerts = []
for metric, value in metrics.items():
if metric in self.warning_thresholds and value > self.warning_thresholds[metric]:
alerts.append(f"{metric} {value}%")
if alerts:
logger.warning(f"System thresholds exceeded: {', '.join(alerts)}")

View File

@@ -1,6 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.services.garmin import GarminService, GarminAPIError 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 datetime import datetime, timedelta from datetime import datetime, timedelta

57
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,57 @@
version: '3.9'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.prod
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- ./data/gpx:/app/data/gpx
- ./data/sessions:/app/data/sessions
- ./data/logs:/app/logs
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/cycling
- API_KEY=${API_KEY}
- GARMIN_USERNAME=${GARMIN_USERNAME}
- GARMIN_PASSWORD=${GARMIN_PASSWORD}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
- AI_MODEL=${AI_MODEL}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
- REACT_APP_API_URL=http://localhost:8000
depends_on:
backend:
condition: service_healthy
db:
image: postgres:15-alpine
restart: unless-stopped
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:

View File

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

View File

@@ -14,7 +14,7 @@
"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": "3.4.2" "recharts": "2.8.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.11.5", "@types/node": "20.11.5",

16
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,16 @@
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'
import Dashboard from './pages/Dashboard'
import PlanDetails from './pages/PlanDetails'
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/plans/:planId" element={<PlanDetails />} />
</Routes>
</Router>
)
}
export default App

View File

@@ -1,152 +1,138 @@
import React, { useState } from 'react'; import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
const PlanTimeline = ({ plan, versions }) => { const PlanTimeline = ({ planId }) => {
const [expandedWeeks, setExpandedWeeks] = useState({}); const [planData, setPlanData] = useState(null)
const [versionHistory, setVersionHistory] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const toggleWeek = (weekNumber) => { useEffect(() => {
setExpandedWeeks(prev => ({ const fetchPlanData = async () => {
...prev, try {
[weekNumber]: !prev[weekNumber] const [planRes, historyRes] = await Promise.all([
})); fetch(`/api/plans/${planId}`),
}; fetch(`/api/plans/${planId}/evolution`)
])
if (!planRes.ok || !historyRes.ok) {
throw new Error('Failed to load plan data')
}
const plan = await planRes.json()
const history = await historyRes.json()
setPlanData(plan)
setVersionHistory(history.evolution_history || [])
setError(null)
} catch (err) {
console.error('Plan load error:', err)
setError(err.message)
} finally {
setLoading(false)
}
}
fetchPlanData()
}, [planId])
if (loading) {
return (
<div className="p-4 space-y-4">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg"></div>
))}
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="p-4 text-red-600">
Error loading plan: {error}
</div>
)
}
return ( return (
<div className="plan-timeline bg-white rounded-lg shadow-md p-5"> <div className="plan-timeline p-4 bg-white rounded-lg shadow">
<div className="header flex justify-between items-center mb-6"> {/* Current Plan Header */}
<div> <div className="mb-6">
<h2 className="text-xl font-bold text-gray-800">{plan.name || 'Training Plan'}</h2> <h2 className="text-2xl font-semibold">{planData.jsonb_plan.overview.focus} Training Plan</h2>
<p className="text-gray-600">Version {plan.version} Created {new Date(plan.created_at).toLocaleDateString()}</p> <p className="text-gray-600">
</div> {planData.jsonb_plan.overview.duration_weeks} weeks {' '}
<div className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"> {planData.jsonb_plan.overview.total_weekly_hours} hours/week
{plan.jsonb_plan.overview.focus.replace(/_/g, ' ')} </p>
</div>
</div> </div>
{versions.length > 1 && ( {/* Week Timeline */}
<div className="version-history mb-8"> <div className="space-y-8">
<h3 className="text-lg font-medium text-gray-800 mb-3">Version History</h3> {planData.jsonb_plan.weeks.map((week, index) => (
<div className="overflow-x-auto"> <div key={index} className="relative pl-6 border-l-2 border-gray-200">
<table className="min-w-full divide-y divide-gray-200"> <div className="absolute w-4 h-4 bg-blue-500 rounded-full -left-[9px] top-0"></div>
<thead className="bg-gray-50"> <div className="mb-2">
<tr> <h3 className="text-lg font-semibold">Week {week.week_number}</h3>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th> <p className="text-gray-600">{week.focus}</p>
<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> </div>
<div className="space-y-4">
{expandedWeeks[weekIndex] && ( {week.workouts.map((workout, wIndex) => (
<div className="workouts-list p-4 bg-white"> <div key={wIndex} className="p-4 bg-gray-50 rounded-lg">
{week.workouts.map((workout, workoutIndex) => ( <div className="flex justify-between items-start">
<div key={workoutIndex} className="workout-item border-b border-gray-100 py-3 last:border-0"> <div>
<div className="flex justify-between"> <h4 className="font-medium">{workout.type.replace(/_/g, ' ')}</h4>
<div> <p className="text-sm text-gray-600">{workout.description}</p>
<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>
<div className="text-right">
<div className="mt-1 flex flex-wrap gap-2"> <p className="text-gray-900">{workout.duration_minutes} minutes</p>
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full capitalize"> <p className="text-sm text-gray-500 capitalize">{workout.intensity}</p>
{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> </div>
{workout.description && (
<p className="mt-2 text-gray-700 text-sm">{workout.description}</p>
)}
</div> </div>
))} </div>
</div> ))}
)} </div>
</div> </div>
))} ))}
</div> </div>
</div>
);
};
export default PlanTimeline; {/* Version History */}
{versionHistory.length > 0 && (
<div className="mt-8 pt-6 border-t border-gray-200">
<h3 className="text-lg font-semibold mb-4">Version History</h3>
<div className="space-y-4">
{versionHistory.map((version, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium">Version {version.version}</h4>
<p className="text-sm text-gray-600">
{new Date(version.created_at).toLocaleDateString()}
</p>
{version.changes_summary && (
<p className="text-sm mt-2 text-gray-600">
{version.changes_summary}
</p>
)}
</div>
<Link
to={`/plans/${version.parent_plan_id}`}
className="text-blue-500 hover:text-blue-700 text-sm"
>
View
</Link>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
export default PlanTimeline

View File

@@ -1,56 +1,37 @@
import React, { useState, useEffect } from 'react'; import { useEffect, useState } from 'react'
import GarminSync from '../components/GarminSync'; import { Link } from 'react-router-dom'
import WorkoutCharts from '../components/WorkoutCharts';
import PlanTimeline from '../components/PlanTimeline';
import WorkoutAnalysis from '../components/WorkoutAnalysis';
const Dashboard = () => { const Dashboard = () => {
const [loading, setLoading] = useState(true); const [dashboardData, setDashboardData] = useState(null)
const [error, setError] = useState(null); const [loading, setLoading] = useState(true)
const [dashboardData, setDashboardData] = useState({ const [error, setError] = useState(null)
recentWorkouts: [],
upcomingWorkouts: [],
currentPlan: null,
planVersions: [],
lastAnalysis: null,
syncStatus: null,
metrics: {}
});
useEffect(() => { useEffect(() => {
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
try { try {
setLoading(true);
const response = await fetch('/api/dashboard', { const response = await fetch('/api/dashboard', {
headers: { headers: {
'X-API-Key': process.env.REACT_APP_API_KEY 'X-API-Key': process.env.REACT_APP_API_KEY
} }
}); })
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load dashboard: ${response.statusText}`); throw new Error(`Dashboard load failed: ${response.statusText}`)
} }
const data = await response.json(); const data = await response.json()
setDashboardData(data); setDashboardData(data)
setError(null); setError(null)
} catch (err) { } catch (err) {
console.error('Dashboard load error:', err); console.error('Dashboard error:', err)
setError(err.message); setError(err.message)
} finally { } finally {
setLoading(false); setLoading(false)
} }
}; }
fetchDashboardData();
}, []);
const handleSyncComplete = (newSyncStatus) => { fetchDashboardData()
setDashboardData(prev => ({ }, [])
...prev,
syncStatus: newSyncStatus
}));
};
if (loading) { if (loading) {
return ( return (
@@ -60,194 +41,101 @@ const Dashboard = () => {
<p className="mt-4 text-gray-600">Loading your training dashboard...</p> <p className="mt-4 text-gray-600">Loading your training dashboard...</p>
</div> </div>
</div> </div>
); )
} }
if (error) { if (error) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50"> <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-center text-red-600">
<div className="text-red-500 text-center mb-4"> <h2 className="text-xl font-semibold">Error loading dashboard</h2>
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <p className="mt-2">{error}</p>
<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" /> <button
</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()} onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors" className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
> >
Try Again Try Again
</button> </button>
</div> </div>
</div> </div>
); )
} }
return ( return (
<div className="dashboard bg-gray-50 min-h-screen p-4 md:p-6"> <div className="dashboard bg-gray-50 min-h-screen p-4 md:p-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="mb-6"> {/* Metrics Overview */}
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">Training Dashboard</h1> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<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"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-gray-500 text-sm font-medium">Weekly Hours</h3> <h3 className="text-gray-500 text-sm font-medium">Weekly Volume</h3>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{dashboardData.metrics.weekly_hours || '0'}h {dashboardData.metrics.weekly_volume?.toFixed(1) || 0} hours
</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> </p>
</div> </div>
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-gray-500 text-sm font-medium">Plan Progress</h3> <h3 className="text-gray-500 text-sm font-medium">Plan Progress</h3>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{dashboardData.metrics.plan_progress || '0'}% {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> </p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* Recent Workouts */}
{/* Left Column */} <div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="lg:col-span-2 space-y-6"> <h2 className="text-xl font-semibold mb-4">Recent Workouts</h2>
{/* Garmin Sync */} <div className="space-y-4">
<div className="bg-white rounded-lg shadow-md p-5"> {dashboardData.recent_workouts.map(workout => (
<GarminSync onSyncComplete={handleSyncComplete} /> <div key={workout.id} className="border-b pb-4">
</div> <div className="flex justify-between items-center">
<div>
{/* Current Plan */} <h3 className="font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3>
{dashboardData.currentPlan && ( <p className="text-gray-600">{workout.activity_type}</p>
<div className="bg-white rounded-lg shadow-md p-5"> </div>
<h2 className="text-xl font-bold text-gray-800 mb-4">Current Training Plan</h2> <div className="text-right">
<PlanTimeline <p className="text-gray-900 font-medium">
plan={dashboardData.currentPlan} {(workout.distance_m / 1000).toFixed(1)} km
versions={dashboardData.planVersions} </p>
/> <p className="text-sm text-gray-500">
</div> {Math.floor(workout.duration_seconds / 60)} minutes
)} </p>
</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> </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> </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,16 @@
import { useParams } from 'react-router-dom'
import PlanTimeline from '../components/PlanTimeline'
const PlanDetails = () => {
const { planId } = useParams()
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
<div className="max-w-4xl mx-auto">
<PlanTimeline planId={planId} />
</div>
</div>
)
}
export default PlanDetails