mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-02 12:32:21 +00:00
sync
This commit is contained in:
@@ -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."""
|
||||||
|
|||||||
52
backend/app/routes/dashboard.py
Normal file
52
backend/app/routes/dashboard.py
Normal 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)}")
|
||||||
9
backend/app/routes/health.py
Normal file
9
backend/app/routes/health.py
Normal 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()
|
||||||
80
backend/app/services/health_monitor.py
Normal file
80
backend/app/services/health_monitor.py
Normal 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)}")
|
||||||
@@ -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
57
docker-compose.prod.yml
Normal 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:
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
16
frontend/src/App.jsx
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
16
frontend/src/pages/PlanDetails.jsx
Normal file
16
frontend/src/pages/PlanDetails.jsx
Normal 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
|
||||||
Reference in New Issue
Block a user