diff --git a/backend/app/main.py b/backend/app/main.py
index 63da741..849b366 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -11,6 +11,7 @@ from .routes import rule as rule_routes
from .routes import plan as plan_routes
from .routes import workouts as workout_routes
from .routes import prompts as prompt_routes
+from .routes import dashboard as dashboard_routes
from .config import settings
app = FastAPI(
@@ -46,6 +47,7 @@ app.include_router(rule_routes.router)
app.include_router(plan_routes.router)
app.include_router(workout_routes.router, prefix="/workouts", tags=["workouts"])
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():
"""Check if database migrations are up to date."""
diff --git a/backend/app/routes/dashboard.py b/backend/app/routes/dashboard.py
new file mode 100644
index 0000000..3519f96
--- /dev/null
+++ b/backend/app/routes/dashboard.py
@@ -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)}")
\ No newline at end of file
diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py
new file mode 100644
index 0000000..bf870a7
--- /dev/null
+++ b/backend/app/routes/health.py
@@ -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()
\ No newline at end of file
diff --git a/backend/app/services/health_monitor.py b/backend/app/services/health_monitor.py
new file mode 100644
index 0000000..0678115
--- /dev/null
+++ b/backend/app/services/health_monitor.py
@@ -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)}")
\ No newline at end of file
diff --git a/backend/app/services/workout_sync.py b/backend/app/services/workout_sync.py
index 7879d65..fe496c1 100644
--- a/backend/app/services/workout_sync.py
+++ b/backend/app/services/workout_sync.py
@@ -1,6 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
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.garmin_sync_log import GarminSyncLog
from datetime import datetime, timedelta
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..db33de0
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -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:
\ No newline at end of file
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index cfc58bd..c8d6fc5 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -7,14 +7,14 @@ WORKDIR /app
# Copy package.json and package-lock.json
COPY frontend/package*.json ./
-# Install dependencies
-RUN npm install
+# Install all dependencies including devDependencies
+RUN npm install --include=dev
# Copy source code
COPY frontend/ .
-# Build application
-RUN npm run build
+# Run tests and build application
+RUN npm test && npm run build
# Production stage
FROM node:20-alpine AS production
diff --git a/frontend/package.json b/frontend/package.json
index 371fcec..f2533fd 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,7 +14,7 @@
"next": "14.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
- "recharts": "3.4.2"
+ "recharts": "2.8.0"
},
"devDependencies": {
"@types/node": "20.11.5",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..fd32f3f
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -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 (
+
Version {plan.version} • Created {new Date(plan.created_at).toLocaleDateString()}
-+ {planData.jsonb_plan.overview.duration_weeks} weeks •{' '} + {planData.jsonb_plan.overview.total_weekly_hours} hours/week +
| Version | -Created | -Trigger | -Changes | -
|---|---|---|---|
| - v{version.version} - | -- {new Date(version.created_at).toLocaleDateString()} - | -- {version.evolution_trigger?.replace(/_/g, ' ') || 'initial'} - | -- {version.changes_summary || 'Initial version'} - | -
{week.focus}
{workout.description}
{workout.duration_minutes} minutes
+{workout.intensity}
{workout.description}
- )}+ {new Date(version.created_at).toLocaleDateString()} +
+ {version.changes_summary && ( ++ {version.changes_summary} +
+ )} +Loading your training dashboard...
{error}
-Your personalized cycling training overview
-- {dashboardData.metrics.weekly_hours || '0'}h -
-- {dashboardData.metrics.workouts_this_week || '0'} + {dashboardData.metrics.weekly_volume?.toFixed(1) || 0} hours
- {dashboardData.metrics.plan_progress || '0'}% -
-- {dashboardData.metrics.fitness_level || 'N/A'} + {dashboardData.metrics.plan_progress || 0}%
- {new Date(workout.scheduled_date).toLocaleDateString()} • {workout.duration_minutes} min -
-{workout.description}
- )} -{workout.activity_type}
++ {(workout.distance_m / 1000).toFixed(1)} km +
++ {Math.floor(workout.duration_seconds / 60)} minutes +
+No upcoming workouts scheduled
- )} -- {new Date(workout.start_time).toLocaleDateString()} • {Math.round(workout.duration_seconds / 60)} min -
-- {workout.analysis.performance_summary} -
- )} -No recent workouts recorded
- )} -+ {dashboardData.current_plan.duration_weeks} week plan +
+