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 ( + + + } /> + } /> + + + ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/PlanTimeline.jsx b/frontend/src/components/PlanTimeline.jsx index 493f65a..c349a62 100644 --- a/frontend/src/components/PlanTimeline.jsx +++ b/frontend/src/components/PlanTimeline.jsx @@ -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 [expandedWeeks, setExpandedWeeks] = useState({}); +const PlanTimeline = ({ planId }) => { + const [planData, setPlanData] = useState(null) + const [versionHistory, setVersionHistory] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) - const toggleWeek = (weekNumber) => { - setExpandedWeeks(prev => ({ - ...prev, - [weekNumber]: !prev[weekNumber] - })); - }; + useEffect(() => { + const fetchPlanData = async () => { + try { + 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 ( +
+
+
+
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+
+
+ ) + } + + if (error) { + return ( +
+ Error loading plan: {error} +
+ ) + } return ( -
-
-
-

{plan.name || 'Training Plan'}

-

Version {plan.version} • Created {new Date(plan.created_at).toLocaleDateString()}

-
-
- {plan.jsonb_plan.overview.focus.replace(/_/g, ' ')} -
+
+ {/* Current Plan Header */} +
+

{planData.jsonb_plan.overview.focus} Training Plan

+

+ {planData.jsonb_plan.overview.duration_weeks} weeks •{' '} + {planData.jsonb_plan.overview.total_weekly_hours} hours/week +

- {versions.length > 1 && ( -
-

Version History

-
- - - - - - - - - - - {versions.map(version => ( - - - - - - - ))} - -
VersionCreatedTriggerChanges
- v{version.version} - - {new Date(version.created_at).toLocaleDateString()} - - {version.evolution_trigger?.replace(/_/g, ' ') || 'initial'} - - {version.changes_summary || 'Initial version'} -
-
-
- )} - -
-

Plan Overview

-
-
- Duration - - {plan.jsonb_plan.overview.duration_weeks} weeks - -
-
- Weekly Hours - - {plan.jsonb_plan.overview.total_weekly_hours} hours - -
-
- Focus - - {plan.jsonb_plan.overview.focus.replace(/_/g, ' ')} - -
-
-
- -
-

Weekly Schedule

- - {plan.jsonb_plan.weeks.map((week, weekIndex) => ( -
-
toggleWeek(weekIndex)} - > -

Week {week.week_number} • {week.focus.replace(/_/g, ' ')}

-
- - {week.total_hours} hours • {week.workouts.length} workouts - - - - -
+ {/* Week Timeline */} +
+ {planData.jsonb_plan.weeks.map((week, index) => ( +
+
+
+

Week {week.week_number}

+

{week.focus}

- - {expandedWeeks[weekIndex] && ( -
- {week.workouts.map((workout, workoutIndex) => ( -
-
-
- {workout.type.replace(/_/g, ' ')} - • {workout.day} -
- {workout.duration_minutes} min +
+ {week.workouts.map((workout, wIndex) => ( +
+
+
+

{workout.type.replace(/_/g, ' ')}

+

{workout.description}

- -
- - {workout.intensity.replace(/_/g, ' ')} - - {workout.route_id && ( - - Route: {workout.route_name || workout.route_id} - - )} - - TSS: {workout.tss_target || 'N/A'} - +
+

{workout.duration_minutes} minutes

+

{workout.intensity}

- - {workout.description && ( -

{workout.description}

- )}
- ))} -
- )} +
+ ))} +
))}
-
- ); -}; -export default PlanTimeline; \ No newline at end of file + {/* Version History */} + {versionHistory.length > 0 && ( +
+

Version History

+
+ {versionHistory.map((version, index) => ( +
+
+
+

Version {version.version}

+

+ {new Date(version.created_at).toLocaleDateString()} +

+ {version.changes_summary && ( +

+ {version.changes_summary} +

+ )} +
+ + View → + +
+
+ ))} +
+
+ )} +
+ ) +} + +export default PlanTimeline \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 12ff040..0ceb674 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,56 +1,37 @@ -import React, { useState, useEffect } from 'react'; -import GarminSync from '../components/GarminSync'; -import WorkoutCharts from '../components/WorkoutCharts'; -import PlanTimeline from '../components/PlanTimeline'; -import WorkoutAnalysis from '../components/WorkoutAnalysis'; +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' const Dashboard = () => { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [dashboardData, setDashboardData] = useState({ - recentWorkouts: [], - upcomingWorkouts: [], - currentPlan: null, - planVersions: [], - lastAnalysis: null, - syncStatus: null, - metrics: {} - }); + const [dashboardData, setDashboardData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) useEffect(() => { const fetchDashboardData = async () => { try { - setLoading(true); const response = await fetch('/api/dashboard', { headers: { 'X-API-Key': process.env.REACT_APP_API_KEY } - }); + }) if (!response.ok) { - throw new Error(`Failed to load dashboard: ${response.statusText}`); + throw new Error(`Dashboard load failed: ${response.statusText}`) } - const data = await response.json(); - setDashboardData(data); - setError(null); + const data = await response.json() + setDashboardData(data) + setError(null) } catch (err) { - console.error('Dashboard load error:', err); - setError(err.message); + console.error('Dashboard error:', err) + setError(err.message) } finally { - setLoading(false); + setLoading(false) } - }; - - fetchDashboardData(); - }, []); + } - const handleSyncComplete = (newSyncStatus) => { - setDashboardData(prev => ({ - ...prev, - syncStatus: newSyncStatus - })); - }; + fetchDashboardData() + }, []) if (loading) { return ( @@ -60,194 +41,101 @@ const Dashboard = () => {

Loading your training dashboard...

- ); + ) } if (error) { return (
-
-
- - - -
-

Dashboard Error

-

{error}

-
- ); + ) } return (
-
-

Training Dashboard

-

Your personalized cycling training overview

-
- - {/* Stats Overview */} -
+ {/* Metrics Overview */} +
-

Weekly Hours

+

Weekly Volume

- {dashboardData.metrics.weekly_hours || '0'}h -

-
-
-

Workouts This Week

-

- {dashboardData.metrics.workouts_this_week || '0'} + {dashboardData.metrics.weekly_volume?.toFixed(1) || 0} hours

Plan Progress

- {dashboardData.metrics.plan_progress || '0'}% -

-
-
-

Fitness Level

-

- {dashboardData.metrics.fitness_level || 'N/A'} + {dashboardData.metrics.plan_progress || 0}%

-
- {/* Left Column */} -
- {/* Garmin Sync */} -
- -
- - {/* Current Plan */} - {dashboardData.currentPlan && ( -
-

Current Training Plan

- -
- )} - - {/* Recent Analysis */} - {dashboardData.lastAnalysis && ( -
-

Latest Workout Analysis

- -
- )} -
- - {/* Right Column */} -
- {/* Upcoming Workouts */} -
-

Upcoming Workouts

- {dashboardData.upcomingWorkouts.length > 0 ? ( -
- {dashboardData.upcomingWorkouts.map(workout => ( -
-
-
-

- {workout.type.replace(/_/g, ' ')} -

-

- {new Date(workout.scheduled_date).toLocaleDateString()} • {workout.duration_minutes} min -

-
- - {workout.intensity.replace(/_/g, ' ')} - -
- {workout.description && ( -

{workout.description}

- )} -
- ))} + {/* Recent Workouts */} +
+

Recent Workouts

+
+ {dashboardData.recent_workouts.map(workout => ( +
+
+
+

{new Date(workout.start_time).toLocaleDateString()}

+

{workout.activity_type}

+
+
+

+ {(workout.distance_m / 1000).toFixed(1)} km +

+

+ {Math.floor(workout.duration_seconds / 60)} minutes +

+
- ) : ( -

No upcoming workouts scheduled

- )} -
- - {/* Recent Workouts */} -
-

Recent Workouts

- {dashboardData.recentWorkouts.length > 0 ? ( -
- {dashboardData.recentWorkouts.map(workout => ( -
-
-
-

- {workout.activity_type || 'Cycling'} -

-

- {new Date(workout.start_time).toLocaleDateString()} • {Math.round(workout.duration_seconds / 60)} min -

-
-
- - {workout.distance_m ? `${(workout.distance_m / 1000).toFixed(1)} km` : ''} - - {workout.analysis && workout.analysis.performance_score && ( - - Score: {workout.analysis.performance_score}/10 - - )} -
-
- {workout.analysis && workout.analysis.performance_summary && ( -

- {workout.analysis.performance_summary} -

- )} -
- ))} -
- ) : ( -

No recent workouts recorded

- )} -
- - {/* Quick Actions */} -
-

Quick Actions

-
- - - -
-
+ ))}
+ + {/* Current Plan */} + {dashboardData.current_plan && ( +
+
+

Current Training Plan

+ + View Details → + +
+
+
+

{dashboardData.current_plan.name}

+

+ {dashboardData.current_plan.duration_weeks} week plan +

+
+
+
+
+
+
+ )}
- ); -}; + ) +} -export default Dashboard; \ No newline at end of file +export default Dashboard \ No newline at end of file diff --git a/frontend/src/pages/PlanDetails.jsx b/frontend/src/pages/PlanDetails.jsx new file mode 100644 index 0000000..034a65d --- /dev/null +++ b/frontend/src/pages/PlanDetails.jsx @@ -0,0 +1,16 @@ +import { useParams } from 'react-router-dom' +import PlanTimeline from '../components/PlanTimeline' + +const PlanDetails = () => { + const { planId } = useParams() + + return ( +
+
+ +
+
+ ) +} + +export default PlanDetails \ No newline at end of file