# AI-Assisted Cycling Coach - Updated Implementation Plan ## Overview This document outlines the implementation plan for a single-user, self-hosted AI-assisted cycling coach application with Python backend, PostgreSQL database, GPX file storage, and web frontend. ## Architecture Components - **Backend**: Python FastAPI (async) - **Database**: PostgreSQL with versioning support - **File Storage**: Local directory for GPX files (up to 200 files) - **Frontend**: React/Next.js - **AI Integration**: OpenRouter API - **Garmin Integration**: garth or garmin-connect Python modules - **Authentication**: Simple API key for single-user setup - **Containerization**: Docker + Docker Compose (self-hosted) ## Implementation Phases ### Phase 1: Project Setup and Foundation ✅ (Week 1-2) **Status: Complete** 1. **Initialize Project Structure** ``` / ├── backend/ │ ├── app/ │ │ ├── __init__.py │ │ ├── main.py │ │ ├── models/ │ │ ├── routes/ │ │ ├── services/ │ │ └── utils/ │ ├── requirements.txt │ └── Dockerfile ├── frontend/ │ ├── src/ │ ├── public/ │ ├── package.json │ └── Dockerfile ├── docker-compose.yml ├── .env.example └── README.md ``` 2. **Docker Environment Setup** ```yaml version: '3.9' services: backend: build: ./backend ports: - "8000:8000" volumes: - gpx-data:/app/data/gpx - garmin-sessions:/app/data/sessions environment: - DATABASE_URL=postgresql://postgres:password@db:5432/cycling - GARMIN_USERNAME=${GARMIN_USERNAME} - GARMIN_PASSWORD=${GARMIN_PASSWORD} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - AI_MODEL=${AI_MODEL:-claude-3-sonnet-20240229} - API_KEY=${API_KEY} depends_on: - db frontend: build: ./frontend ports: - "3000:3000" environment: - REACT_APP_API_URL=http://localhost:8000 - REACT_APP_API_KEY=${API_KEY} db: image: postgres:15 restart: always environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_DB: cycling volumes: - postgres-data:/var/lib/postgresql/data volumes: gpx-data: driver: local garmin-sessions: driver: local postgres-data: driver: local ``` 3. **Database Schema with Enhanced Versioning** ```sql -- Routes & Sections CREATE TABLE routes ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT now() ); CREATE TABLE sections ( id SERIAL PRIMARY KEY, route_id INT REFERENCES routes(id), gpx_file_path TEXT NOT NULL, distance_m NUMERIC, grade_avg NUMERIC, min_gear TEXT, est_time_minutes NUMERIC, created_at TIMESTAMP DEFAULT now() ); -- Rules with versioning and evolution tracking CREATE TABLE rules ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, user_defined BOOLEAN DEFAULT true, jsonb_rules JSONB NOT NULL, version INT DEFAULT 1, parent_rule_id INT REFERENCES rules(id), created_at TIMESTAMP DEFAULT now() ); -- Plans with versioning and evolution tracking CREATE TABLE plans ( id SERIAL PRIMARY KEY, jsonb_plan JSONB NOT NULL, version INT NOT NULL, parent_plan_id INT REFERENCES plans(id), created_at TIMESTAMP DEFAULT now() ); -- Workouts with Garmin integration CREATE TABLE workouts ( id SERIAL PRIMARY KEY, plan_id INT REFERENCES plans(id), garmin_activity_id TEXT UNIQUE NOT NULL, activity_type TEXT, start_time TIMESTAMP, duration_seconds INT, distance_m NUMERIC, avg_hr INT, max_hr INT, avg_power NUMERIC, max_power NUMERIC, avg_cadence NUMERIC, elevation_gain_m NUMERIC, metrics JSONB, -- Additional Garmin data created_at TIMESTAMP DEFAULT now() ); -- Analyses with enhanced feedback structure CREATE TABLE analyses ( id SERIAL PRIMARY KEY, workout_id INT REFERENCES workouts(id), analysis_type TEXT DEFAULT 'workout_review', jsonb_feedback JSONB, suggestions JSONB, approved BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT now() ); -- AI Prompts with versioning CREATE TABLE prompts ( id SERIAL PRIMARY KEY, action_type TEXT, -- plan_generation, workout_analysis, rule_parsing, suggestions model TEXT, prompt_text TEXT, version INT DEFAULT 1, active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT now() ); -- Garmin sync status tracking CREATE TABLE garmin_sync_log ( id SERIAL PRIMARY KEY, last_sync_time TIMESTAMP, activities_synced INT DEFAULT 0, status TEXT, -- success, error, in_progress error_message TEXT, created_at TIMESTAMP DEFAULT now() ); ``` ### Phase 2: Core Backend Implementation ✅ (Week 3-5) **Status: Complete** 1. **Database Models with SQLAlchemy** 2. **Basic API Endpoints** 3. **GPX File Handling** 4. **Basic Authentication Middleware** ### Phase 3: Enhanced Backend + Garmin Integration (Week 6-8) #### Week 6: Garmin Integration 1. **Garmin Service Implementation** ```python # backend/app/services/garmin.py import os import garth from typing import List, Dict, Any, Optional from datetime import datetime, timedelta class GarminService: def __init__(self): self.username = os.getenv("GARMIN_USERNAME") self.password = os.getenv("GARMIN_PASSWORD") self.client: Optional[garth.Client] = None self.session_dir = "/app/data/sessions" async def authenticate(self): """Authenticate with Garmin Connect and persist session.""" if not self.client: self.client = garth.Client() try: # Try to load existing session self.client.load(self.session_dir) except Exception: # Fresh authentication await self.client.login(self.username, self.password) self.client.save(self.session_dir) async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]: """Fetch recent activities from Garmin Connect.""" if not self.client: await self.authenticate() if not start_date: start_date = datetime.now() - timedelta(days=7) activities = self.client.get_activities(limit=limit, start=start_date) return activities async def get_activity_details(self, activity_id: str) -> Dict[str, Any]: """Get detailed activity data including metrics.""" if not self.client: await self.authenticate() details = self.client.get_activity(activity_id) return details ``` 2. **Workout Sync Service** ```python # backend/app/services/workout_sync.py from sqlalchemy.ext.asyncio import AsyncSession from app.services.garmin import GarminService from app.models.workout import Workout from app.models.garmin_sync_log import GarminSyncLog class WorkoutSyncService: def __init__(self, db: AsyncSession): self.db = db self.garmin_service = GarminService() async def sync_recent_activities(self, days_back: int = 7): """Sync recent Garmin activities to database.""" try: sync_log = GarminSyncLog(status="in_progress") self.db.add(sync_log) await self.db.commit() start_date = datetime.now() - timedelta(days=days_back) activities = await self.garmin_service.get_activities( limit=50, start_date=start_date ) synced_count = 0 for activity in activities: if await self.activity_exists(activity['activityId']): continue workout_data = await self.parse_activity_data(activity) workout = Workout(**workout_data) self.db.add(workout) synced_count += 1 sync_log.status = "success" sync_log.activities_synced = synced_count sync_log.last_sync_time = datetime.now() await self.db.commit() return synced_count except Exception as e: sync_log.status = "error" sync_log.error_message = str(e) await self.db.commit() raise async def activity_exists(self, garmin_activity_id: str) -> bool: """Check if activity already exists in database.""" result = await self.db.execute( select(Workout).where(Workout.garmin_activity_id == garmin_activity_id) ) return result.scalar_one_or_none() is not None async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]: """Parse Garmin activity data into workout model format.""" return { "garmin_activity_id": activity['activityId'], "activity_type": activity.get('activityType', {}).get('typeKey'), "start_time": datetime.fromisoformat(activity['startTimeLocal'].replace('Z', '+00:00')), "duration_seconds": activity.get('duration'), "distance_m": activity.get('distance'), "avg_hr": activity.get('averageHR'), "max_hr": activity.get('maxHR'), "avg_power": activity.get('avgPower'), "max_power": activity.get('maxPower'), "avg_cadence": activity.get('averageBikingCadenceInRevPerMinute'), "elevation_gain_m": activity.get('elevationGain'), "metrics": activity # Store full Garmin data as JSONB } ``` 3. **Background Tasks Setup** ```python # backend/app/main.py from fastapi import BackgroundTasks from app.services.workout_sync import WorkoutSyncService @app.post("/api/workouts/sync") async def trigger_garmin_sync( background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db) ): """Trigger background sync of recent Garmin activities.""" sync_service = WorkoutSyncService(db) background_tasks.add_task(sync_service.sync_recent_activities, days_back=14) return {"message": "Garmin sync started"} @app.get("/api/workouts/sync-status") async def get_sync_status(db: AsyncSession = Depends(get_db)): """Get the latest sync status.""" result = await db.execute( select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc()).limit(1) ) sync_log = result.scalar_one_or_none() return sync_log ``` #### Week 7: Enhanced AI Integration 1. **Prompt Management System** ```python # backend/app/services/prompt_manager.py class PromptManager: def __init__(self, db: AsyncSession): self.db = db async def get_active_prompt(self, action_type: str, model: str = None) -> Optional[str]: """Get the active prompt for a specific action type.""" query = select(Prompt).where( Prompt.action_type == action_type, Prompt.active == True ) if model: query = query.where(Prompt.model == model) result = await self.db.execute(query.order_by(Prompt.version.desc())) prompt = result.scalar_one_or_none() return prompt.prompt_text if prompt else None async def create_prompt_version( self, action_type: str, prompt_text: str, model: str = None ) -> Prompt: """Create a new version of a prompt.""" # Deactivate previous versions await self.db.execute( update(Prompt) .where(Prompt.action_type == action_type) .values(active=False) ) # Get next version number result = await self.db.execute( select(func.max(Prompt.version)) .where(Prompt.action_type == action_type) ) max_version = result.scalar() or 0 # Create new prompt new_prompt = Prompt( action_type=action_type, model=model, prompt_text=prompt_text, version=max_version + 1, active=True ) self.db.add(new_prompt) await self.db.commit() return new_prompt ``` 2. **Enhanced AI Service** ```python # backend/app/services/ai_service.py import asyncio from typing import Dict, Any, List import httpx from app.services.prompt_manager import PromptManager class AIService: def __init__(self, db: AsyncSession): self.db = db self.prompt_manager = PromptManager(db) self.api_key = os.getenv("OPENROUTER_API_KEY") self.model = os.getenv("AI_MODEL", "anthropic/claude-3-sonnet-20240229") self.base_url = "https://openrouter.ai/api/v1" async def analyze_workout(self, workout: Workout, plan: Optional[Dict] = None) -> Dict[str, Any]: """Analyze a workout using AI and generate feedback.""" prompt_template = await self.prompt_manager.get_active_prompt("workout_analysis") if not prompt_template: raise ValueError("No active workout analysis prompt found") # Build context from workout data workout_context = { "activity_type": workout.activity_type, "duration_minutes": workout.duration_seconds / 60 if workout.duration_seconds else 0, "distance_km": workout.distance_m / 1000 if workout.distance_m else 0, "avg_hr": workout.avg_hr, "avg_power": workout.avg_power, "elevation_gain": workout.elevation_gain_m, "planned_workout": plan } prompt = prompt_template.format(**workout_context) response = await self._make_ai_request(prompt) return self._parse_workout_analysis(response) async def generate_plan(self, rules: List[Dict], goals: Dict[str, Any]) -> Dict[str, Any]: """Generate a training plan using AI.""" prompt_template = await self.prompt_manager.get_active_prompt("plan_generation") context = { "rules": rules, "goals": goals, "current_fitness_level": goals.get("fitness_level", "intermediate") } prompt = prompt_template.format(**context) response = await self._make_ai_request(prompt) return self._parse_plan_response(response) async def parse_rules_from_natural_language(self, natural_language: str) -> Dict[str, Any]: """Parse natural language rules into structured format.""" prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing") prompt = prompt_template.format(user_rules=natural_language) response = await self._make_ai_request(prompt) return self._parse_rules_response(response) async def _make_ai_request(self, prompt: str) -> str: """Make async request to OpenRouter API with retry logic.""" async with httpx.AsyncClient() as client: for attempt in range(3): # Simple retry logic try: response = await client.post( f"{self.base_url}/chat/completions", headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, json={ "model": self.model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 2000, }, timeout=30.0 ) response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"] except Exception as e: if attempt == 2: # Last attempt raise AIServiceError(f"AI request failed after 3 attempts: {str(e)}") await asyncio.sleep(2 ** attempt) # Exponential backoff def _parse_workout_analysis(self, response: str) -> Dict[str, Any]: """Parse AI response for workout analysis.""" # Implementation depends on your prompt design # This is a simplified example try: import json # Assume AI returns JSON clean_response = response.strip() if clean_response.startswith("```json"): clean_response = clean_response[7:-3] return json.loads(clean_response) except json.JSONDecodeError: return {"raw_analysis": response, "structured": False} ``` #### Week 8: Plan Evolution & Analysis Pipeline 1. **Plan Evolution Service** ```python # backend/app/services/plan_evolution.py class PlanEvolutionService: def __init__(self, db: AsyncSession): self.db = db self.ai_service = AIService(db) async def evolve_plan_from_analysis( self, analysis: Analysis, current_plan: Plan ) -> Optional[Plan]: """Create a new plan version based on workout analysis.""" if not analysis.approved: return None suggestions = analysis.suggestions if not suggestions: return None # Generate new plan incorporating suggestions evolution_context = { "current_plan": current_plan.jsonb_plan, "workout_analysis": analysis.jsonb_feedback, "suggestions": suggestions, "evolution_type": "workout_feedback" } new_plan_data = await self.ai_service.evolve_plan(evolution_context) # Create new plan version new_plan = Plan( jsonb_plan=new_plan_data, version=current_plan.version + 1, parent_plan_id=current_plan.id ) self.db.add(new_plan) await self.db.commit() return new_plan ``` 2. **Enhanced API Endpoints** ```python # backend/app/routes/workouts.py @router.post("/workouts/{workout_id}/analyze") async def analyze_workout( workout_id: int, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db) ): """Trigger AI analysis of a specific workout.""" workout = await get_workout_by_id(db, workout_id) if not workout: raise HTTPException(status_code=404, detail="Workout not found") ai_service = AIService(db) background_tasks.add_task( analyze_and_store_workout, db, workout, ai_service ) return {"message": "Analysis started", "workout_id": workout_id} @router.post("/analyses/{analysis_id}/approve") async def approve_analysis( analysis_id: int, db: AsyncSession = Depends(get_db) ): """Approve analysis suggestions and trigger plan evolution.""" analysis = await get_analysis_by_id(db, analysis_id) analysis.approved = True # Trigger plan evolution if suggestions exist if analysis.suggestions: evolution_service = PlanEvolutionService(db) current_plan = await get_current_active_plan(db) if current_plan: new_plan = await evolution_service.evolve_plan_from_analysis( analysis, current_plan ) return {"message": "Analysis approved", "new_plan_id": new_plan.id} await db.commit() return {"message": "Analysis approved"} ``` ### Phase 4: Frontend Implementation (Week 9-11) #### Week 9: Core Components 1. **Garmin Sync Interface** ```jsx // frontend/src/components/GarminSync.jsx import { useState, useEffect } from 'react'; const GarminSync = () => { const [syncStatus, setSyncStatus] = useState(null); const [syncing, setSyncing] = useState(false); const triggerSync = async () => { setSyncing(true); try { await fetch('/api/workouts/sync', { method: 'POST' }); // Poll for status updates pollSyncStatus(); } catch (error) { console.error('Sync failed:', error); setSyncing(false); } }; const pollSyncStatus = () => { const interval = setInterval(async () => { const response = await fetch('/api/workouts/sync-status'); const status = await response.json(); setSyncStatus(status); if (status.status !== 'in_progress') { setSyncing(false); clearInterval(interval); } }, 2000); }; return (

Garmin Connect Sync

{syncStatus && (

Last sync: {new Date(syncStatus.last_sync_time).toLocaleString()}

Status: {syncStatus.status}

{syncStatus.activities_synced > 0 && (

Activities synced: {syncStatus.activities_synced}

)}
)}
); }; ``` 2. **Workout Analysis Interface** ```jsx // frontend/src/components/WorkoutAnalysis.jsx const WorkoutAnalysis = ({ workout, analysis }) => { const [approving, setApproving] = useState(false); const approveAnalysis = async () => { setApproving(true); try { const response = await fetch(`/api/analyses/${analysis.id}/approve`, { method: 'POST' }); const result = await response.json(); if (result.new_plan_id) { // Navigate to new plan or show success message console.log('New plan created:', result.new_plan_id); } } catch (error) { console.error('Approval failed:', error); } finally { setApproving(false); } }; return (

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

Duration: {Math.round(workout.duration_seconds / 60)}min Distance: {(workout.distance_m / 1000).toFixed(1)}km {workout.avg_power && Avg Power: {workout.avg_power}W} {workout.avg_hr && Avg HR: {workout.avg_hr}bpm}
{analysis && (

AI Analysis

{analysis.jsonb_feedback.summary}
{analysis.suggestions && (
Suggestions
    {analysis.suggestions.map((suggestion, index) => (
  • {suggestion}
  • ))}
{!analysis.approved && ( )}
)}
)}
); }; ``` #### Week 10: Data Visualization 1. **Workout Charts** ```jsx // Using recharts for workout data visualization import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; const WorkoutChart = ({ workoutData }) => { return (

Workout Metrics

); }; ``` 2. **Plan Timeline View** ```jsx // Plan visualization with version history const PlanTimeline = ({ plan, versions }) => { return (

Training Plan - Version {plan.version}

{versions.length > 1 && (

Version History

{versions.map(version => (
v{version.version} {new Date(version.created_at).toLocaleDateString()} {version.parent_plan_id && → Evolved from analysis}
))}
)}
{plan.jsonb_plan.weeks.map((week, index) => (

Week {index + 1}

{week.workouts.map((workout, wIndex) => (
{workout.type} {workout.duration}min {workout.intensity}
))}
))}
); }; ``` #### Week 11: Integration & Polish 1. **Dashboard Overview** 2. **File Upload Improvements** 3. **Error Handling & Loading States** 4. **Responsive Design** ### Phase 5: Testing and Deployment (Week 12-13) #### Week 12: Testing 1. **Backend Testing** ```python # tests/test_garmin_service.py import pytest from unittest.mock import AsyncMock, patch from app.services.garmin import GarminService @pytest.mark.asyncio async def test_garmin_authentication(): with patch('garth.Client') as mock_client: service = GarminService() await service.authenticate() mock_client.return_value.login.assert_called_once() @pytest.mark.asyncio async def test_activity_sync(db_session): # Test workout sync functionality pass ``` 2. **Integration Tests** 3. **Frontend Component Tests** #### Week 13: Deployment Preparation 1. **Environment Configuration** ```bash # .env.production GARMIN_USERNAME=your_garmin_email GARMIN_PASSWORD=your_garmin_password OPENROUTER_API_KEY=your_openrouter_key AI_MODEL=anthropic/claude-3-sonnet-20240229 API_KEY=your_secure_api_key ``` 2. **Production Docker Setup** 3. **Backup Strategy for Database and GPX Files** 4. **Monitoring and Logging** ## Key Technical Decisions ### Single-User Simplifications - **Authentication**: Simple API key instead of complex user management - **File Storage**: Local filesystem (200 GPX files easily manageable) - **Database**: Single tenant, no multi-user complexity - **Deployment**: Self-hosted container, no cloud scaling needs ### Garmin Integration Strategy - **garth library**: Python library for Garmin Connect API - **Session persistence**: Store auth sessions in mounted volume - **Background sync**: Async background tasks for activity fetching - **Retry logic**: Handle API rate limits and temporary failures ### AI Integration Approach - **Prompt versioning**: Database-stored prompts with version control - **Async processing**: Non-blocking AI calls with background tasks - **Cost management**: Simple retry logic, no complex rate limiting needed for single user - **Response parsing**: Flexible parsing for different AI response formats ### Database Design Philosophy - **Versioning everywhere**: Plans, rules, and prompts all support evolution - **JSONB storage**: Flexible storage for AI responses and complex data - **Audit trail**: Track plan evolution and analysis approval history ## Environment Variables ```bash # Required environment variables GARMIN_USERNAME=your_garmin_email GARMIN_PASSWORD=your_garmin_password OPENROUTER_API_KEY=your_openrouter_key AI_MODEL=anthropic/claude-3-sonnet-20240229 API_KEY=your_secure_api_key # Optional DATABASE_URL=postgresql://postgres:password@db:5432/cycling REACT_APP_API_URL=http://localhost:8000 REACT_APP_API_KEY=your_secure_api_key ``` ## Python Standards and Best Practices ### Code Style and Structure ```python # Example: Proper async service implementation from typing import Optional, List, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update import logging logger = logging.getLogger(__name__) class WorkoutAnalysisService: """Service for analyzing workout data with AI assistance.""" def __init__(self, db: AsyncSession): self.db = db self.ai_service = AIService(db) async def analyze_workout_performance( self, workout_id: int, comparison_metrics: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Analyze workout performance against planned metrics. Args: workout_id: The workout to analyze comparison_metrics: Optional baseline metrics for comparison Returns: Dict containing analysis results and suggestions Raises: WorkoutNotFoundError: If workout doesn't exist AIServiceError: If AI analysis fails """ try: workout = await self._get_workout(workout_id) if not workout: raise WorkoutNotFoundError(f"Workout {workout_id} not found") analysis_data = await self._prepare_analysis_context(workout, comparison_metrics) ai_analysis = await self.ai_service.analyze_workout(analysis_data) # Store analysis results analysis_record = await self._store_analysis(workout_id, ai_analysis) logger.info(f"Successfully analyzed workout {workout_id}") return { "analysis_id": analysis_record.id, "feedback": ai_analysis.get("feedback"), "suggestions": ai_analysis.get("suggestions"), "performance_score": ai_analysis.get("score") } except Exception as e: logger.error(f"Failed to analyze workout {workout_id}: {str(e)}") raise async def _get_workout(self, workout_id: int) -> Optional[Workout]: """Retrieve workout by ID.""" result = await self.db.execute( select(Workout).where(Workout.id == workout_id) ) return result.scalar_one_or_none() async def _prepare_analysis_context( self, workout: Workout, comparison_metrics: Optional[Dict[str, Any]] ) -> Dict[str, Any]: """Prepare context data for AI analysis.""" context = { "workout_data": { "duration_minutes": workout.duration_seconds / 60 if workout.duration_seconds else 0, "distance_km": workout.distance_m / 1000 if workout.distance_m else 0, "avg_power": workout.avg_power, "avg_heart_rate": workout.avg_hr, "elevation_gain": workout.elevation_gain_m }, "activity_type": workout.activity_type, "date": workout.start_time.isoformat() if workout.start_time else None } if comparison_metrics: context["baseline_metrics"] = comparison_metrics return context ``` ### Error Handling Patterns ```python # Custom exceptions for better error handling class CyclingCoachError(Exception): """Base exception for cycling coach application.""" pass class WorkoutNotFoundError(CyclingCoachError): """Raised when a workout cannot be found.""" pass class GarminSyncError(CyclingCoachError): """Raised when Garmin synchronization fails.""" pass class AIServiceError(CyclingCoachError): """Raised when AI service requests fail.""" pass # Middleware for consistent error responses @app.exception_handler(CyclingCoachError) async def cycling_coach_exception_handler(request: Request, exc: CyclingCoachError): return JSONResponse( status_code=400, content={ "error": exc.__class__.__name__, "message": str(exc), "timestamp": datetime.now().isoformat() } ) ``` ### Database Patterns ```python # Proper async database patterns with context managers from contextlib import asynccontextmanager class DatabaseService: """Base service class with database session management.""" def __init__(self, db: AsyncSession): self.db = db @asynccontextmanager async def transaction(self): """Context manager for database transactions.""" try: yield self.db await self.db.commit() except Exception: await self.db.rollback() raise async def get_or_create(self, model_class, **kwargs): """Get existing record or create new one.""" result = await self.db.execute( select(model_class).filter_by(**kwargs) ) instance = result.scalar_one_or_none() if not instance: instance = model_class(**kwargs) self.db.add(instance) await self.db.flush() # Get ID without committing return instance ``` ## Sample Prompt Templates ### Workout Analysis Prompt ```sql INSERT INTO prompts (action_type, model, prompt_text, version, active) VALUES ( 'workout_analysis', 'anthropic/claude-3-sonnet-20240229', 'Analyze the following cycling workout data and provide structured feedback: Workout Details: - Activity Type: {activity_type} - Duration: {duration_minutes} minutes - Distance: {distance_km} km - Average Power: {avg_power}W - Average Heart Rate: {avg_hr} bpm - Elevation Gain: {elevation_gain}m Please provide your analysis in the following JSON format: {{ "performance_summary": "Brief overall assessment", "strengths": ["strength 1", "strength 2"], "areas_for_improvement": ["area 1", "area 2"], "training_suggestions": ["suggestion 1", "suggestion 2"], "next_workout_recommendations": {{ "intensity": "easy/moderate/hard", "focus": "endurance/power/recovery", "duration_minutes": 60 }}, "performance_score": 8.5 }} Focus on actionable insights and specific recommendations for improvement.', 1, true ); ``` ### Plan Generation Prompt ```sql INSERT INTO prompts (action_type, model, prompt_text, version, active) VALUES ( 'plan_generation', 'anthropic/claude-3-sonnet-20240229', 'Create a personalized cycling training plan based on the following information: Training Rules: {rules} Goals: {goals} Generate a 4-week training plan in the following JSON format: {{ "plan_overview": {{ "duration_weeks": 4, "focus": "endurance/power/mixed", "weekly_hours": 8 }}, "weeks": [ {{ "week_number": 1, "focus": "base building", "workouts": [ {{ "day": "monday", "type": "easy_ride", "duration_minutes": 60, "intensity": "zone_1_2", "description": "Easy recovery ride" }} ] }} ], "progression_notes": "How the plan builds over the weeks" }} Ensure all workouts respect the training rules provided.', 1, true ); ``` ## Deployment Configuration ### Production Docker Compose ```yaml # docker-compose.prod.yml version: '3.9' services: backend: build: context: ./backend dockerfile: Dockerfile.prod restart: unless-stopped ports: - "8000:8000" volumes: - gpx-data:/app/data/gpx:rw - garmin-sessions:/app/data/sessions:rw - ./logs:/app/logs:rw environment: - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/cycling - GARMIN_USERNAME=${GARMIN_USERNAME} - GARMIN_PASSWORD=${GARMIN_PASSWORD} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - AI_MODEL=${AI_MODEL} - API_KEY=${API_KEY} - LOG_LEVEL=INFO depends_on: - db healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 10s retries: 3 frontend: build: context: ./frontend dockerfile: Dockerfile.prod restart: unless-stopped ports: - "3000:3000" environment: - REACT_APP_API_URL=http://localhost:8000 - NODE_ENV=production depends_on: - backend db: image: postgres:15-alpine restart: unless-stopped environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: cycling volumes: - postgres-data:/var/lib/postgresql/data:rw - ./backups:/backups:rw healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 volumes: gpx-data: driver: local driver_opts: type: none o: bind device: /home/user/cycling-coach/data/gpx garmin-sessions: driver: local driver_opts: type: none o: bind device: /home/user/cycling-coach/data/sessions postgres-data: driver: local driver_opts: type: none o: bind device: /home/user/cycling-coach/data/postgres networks: default: name: cycling-coach ``` ### Backup Script ```bash #!/bin/bash # backup.sh - Daily backup script BACKUP_DIR="/home/user/cycling-coach/backups" DATE=$(date +%Y%m%d_%H%M%S) # Create backup directory mkdir -p "$BACKUP_DIR" # Backup database docker exec cycling-coach-db-1 pg_dump -U postgres cycling > "$BACKUP_DIR/db_backup_$DATE.sql" # Backup GPX files tar -czf "$BACKUP_DIR/gpx_backup_$DATE.tar.gz" -C /home/user/cycling-coach/data/gpx . # Backup Garmin sessions tar -czf "$BACKUP_DIR/sessions_backup_$DATE.tar.gz" -C /home/user/cycling-coach/data/sessions . # Keep only last 30 days of backups find "$BACKUP_DIR" -name "*backup*" -type f -mtime +30 -delete echo "Backup completed: $DATE" ``` ### Health Monitoring ```python # backend/app/routes/health.py from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from app.database import get_db from app.services.garmin import GarminService router = APIRouter() @router.get("/health") async def health_check(db: AsyncSession = Depends(get_db)): """Health check endpoint for monitoring.""" health_status = { "status": "healthy", "timestamp": datetime.now().isoformat(), "services": {} } # Check database try: await db.execute(text("SELECT 1")) health_status["services"]["database"] = "healthy" except Exception as e: health_status["services"]["database"] = f"error: {str(e)}" health_status["status"] = "unhealthy" # Check Garmin service try: garmin_service = GarminService() # Simple connectivity check without full auth health_status["services"]["garmin"] = "configured" except Exception as e: health_status["services"]["garmin"] = f"error: {str(e)}" # Check file system try: gpx_dir = "/app/data/gpx" if os.path.exists(gpx_dir) and os.access(gpx_dir, os.W_OK): health_status["services"]["file_storage"] = "healthy" else: health_status["services"]["file_storage"] = "error: directory not writable" health_status["status"] = "unhealthy" except Exception as e: health_status["services"]["file_storage"] = f"error: {str(e)}" health_status["status"] = "unhealthy" if health_status["status"] == "unhealthy": raise HTTPException(status_code=503, detail=health_status) return health_status ``` ## Post-Deployment Setup ### Initial Data Setup ```python # scripts/init_prompts.py """Initialize default AI prompts in the database.""" import asyncio from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from app.models.prompt import Prompt async def init_default_prompts(): """Initialize the database with default AI prompts.""" engine = create_async_engine(DATABASE_URL) async with AsyncSession(engine) as session: # Add default prompts for each action type default_prompts = [ # Workout analysis prompt (from above) # Plan generation prompt (from above) # Rule parsing prompt ] for prompt_data in default_prompts: prompt = Prompt(**prompt_data) session.add(prompt) await session.commit() print("Default prompts initialized successfully") if __name__ == "__main__": asyncio.run(init_default_prompts()) ``` ### Maintenance Tasks ```python # scripts/maintenance.py """Maintenance tasks for the cycling coach application.""" async def cleanup_old_analyses(): """Remove analyses older than 6 months.""" cutoff_date = datetime.now() - timedelta(days=180) async with AsyncSession(engine) as session: result = await session.execute( delete(Analysis).where(Analysis.created_at < cutoff_date) ) await session.commit() print(f"Deleted {result.rowcount} old analyses") async def optimize_database(): """Run database maintenance tasks.""" async with AsyncSession(engine) as session: await session.execute(text("VACUUM ANALYZE")) await session.commit() print("Database optimization completed") ``` This comprehensive implementation plan addresses all the key requirements for your single-user, self-hosted AI-assisted cycling coach application. The plan includes: 1. **Complete Garmin integration** using environment variables and the garth library 2. **Enhanced database schema** with proper versioning for plans and rules 3. **Robust AI integration** with prompt management and error handling 4. **Production-ready deployment** configuration with health checks and backups 5. **Comprehensive testing strategy** for both backend and frontend 6. **Maintenance and monitoring** tools for long-term operation The plan is well-suited for your scale (single user, 200 GPX files) and deployment target (self-hosted container), with practical simplifications that avoid unnecessary complexity while maintaining professional software engineering standards.