Files
AICyclingCoach/CL_implementation_guide.md
2025-09-12 07:32:32 -07:00

1565 lines
46 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Junior Developer Implementation Guide
## AI Cycling Coach - Critical Features Implementation
This guide provides step-by-step instructions to implement the missing core features identified in the codebase evaluation.
---
## 🎯 **Implementation Phases Overview**
| Phase | Focus | Duration | Difficulty |
|-------|-------|----------|------------|
| **Phase 1** | Backend Core APIs | 2-3 weeks | Medium |
| **Phase 2** | Frontend Core Features | 3-4 weeks | Medium |
| **Phase 3** | Integration & Testing | 1-2 weeks | Easy-Medium |
| **Phase 4** | Polish & Production | 1-2 weeks | Easy |
---
# Phase 1: Backend Core APIs Implementation
## Step 1.1: Plan Generation Endpoint
### **File:** `backend/app/routes/plan.py`
**Add this endpoint to the existing router:**
```python
from app.schemas.plan import PlanGenerationRequest, PlanGenerationResponse
from app.services.ai_service import AIService, AIServiceError
@router.post("/generate", response_model=PlanGenerationResponse)
async def generate_plan(
request: PlanGenerationRequest,
db: AsyncSession = Depends(get_db)
):
"""Generate a new training plan using AI based on rules and goals."""
try:
# Fetch rules from database
rules_query = select(Rule).where(Rule.id.in_(request.rule_ids))
result = await db.execute(rules_query)
rules = result.scalars().all()
if len(rules) != len(request.rule_ids):
raise HTTPException(status_code=404, detail="One or more rules not found")
# Get plaintext rules
rule_texts = [rule.rule_text for rule in rules]
# Initialize AI service
ai_service = AIService(db)
# Generate plan
plan_data = await ai_service.generate_plan(rule_texts, request.goals.dict())
# Create plan record
db_plan = Plan(
jsonb_plan=plan_data,
version=1,
parent_plan_id=None
)
db.add(db_plan)
await db.commit()
await db.refresh(db_plan)
return PlanGenerationResponse(
plan=db_plan,
generation_metadata={
"rules_used": len(rules),
"goals": request.goals.dict(),
"generated_at": datetime.utcnow().isoformat()
}
)
except AIServiceError as e:
raise HTTPException(status_code=503, detail=f"AI service error: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Plan generation failed: {str(e)}")
```
### **File:** `backend/app/schemas/plan.py`
**Add these new schemas:**
```python
from typing import Dict, List, Optional, Any
from pydantic import BaseModel, Field
from uuid import UUID
class TrainingGoals(BaseModel):
"""Training goals for plan generation."""
primary_goal: str = Field(..., description="Primary training goal")
target_weekly_hours: int = Field(..., ge=3, le=20, description="Target hours per week")
fitness_level: str = Field(..., description="Current fitness level")
event_date: Optional[str] = Field(None, description="Target event date (YYYY-MM-DD)")
preferred_routes: List[int] = Field(default=[], description="Preferred route IDs")
avoid_days: List[str] = Field(default=[], description="Days to avoid training")
class PlanGenerationRequest(BaseModel):
"""Request schema for plan generation."""
rule_ids: List[UUID] = Field(..., description="Rule set IDs to apply")
goals: TrainingGoals = Field(..., description="Training goals")
duration_weeks: int = Field(4, ge=1, le=20, description="Plan duration in weeks")
user_preferences: Optional[Dict[str, Any]] = Field(None, description="Additional preferences")
class PlanGenerationResponse(BaseModel):
"""Response schema for plan generation."""
plan: Plan = Field(..., description="Generated training plan")
generation_metadata: Dict[str, Any] = Field(..., description="Generation metadata")
class Config:
orm_mode = True
```
---
<REMOVED>
<REMOVED>
**Add these endpoints after the existing routes:**
```python
from app.schemas.rule import NaturalLanguageRuleRequest, ParsedRuleResponse
@router.post("/parse-natural-language", response_model=ParsedRuleResponse)
async def parse_natural_language_rules(
request: NaturalLanguageRuleRequest,
db: AsyncSession = Depends(get_db)
):
"""Parse natural language text into structured training rules."""
try:
# Initialize AI service
ai_service = AIService(db)
# Parse rules using AI
parsed_data = await ai_service.parse_rules_from_natural_language(
request.natural_language_text
)
# Validate parsed rules
validation_result = _validate_parsed_rules(parsed_data)
return ParsedRuleResponse(
parsed_rules=parsed_data,
confidence_score=parsed_data.get("confidence", 0.0),
suggestions=validation_result.get("suggestions", []),
validation_errors=validation_result.get("errors", []),
rule_name=request.rule_name
)
except AIServiceError as e:
raise HTTPException(status_code=503, detail=f"AI parsing failed: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Rule parsing failed: {str(e)}")
@router.post("/validate-rules")
async def validate_rule_consistency(
rules_data: Dict[str, Any],
db: AsyncSession = Depends(get_db)
):
"""Validate rule consistency and detect conflicts."""
validation_result = _validate_parsed_rules(rules_data)
return {
"is_valid": len(validation_result.get("errors", [])) == 0,
"errors": validation_result.get("errors", []),
"warnings": validation_result.get("warnings", []),
"suggestions": validation_result.get("suggestions", [])
}
def _validate_parsed_rules(parsed_rules: Dict[str, Any]) -> Dict[str, List[str]]:
"""Validate parsed rules for consistency and completeness."""
errors = []
warnings = []
suggestions = []
# Check for required fields
required_fields = ["max_rides_per_week", "min_rest_between_hard"]
for field in required_fields:
if field not in parsed_rules:
errors.append(f"Missing required field: {field}")
# Validate numeric ranges
max_rides = parsed_rules.get("max_rides_per_week", 0)
if max_rides > 7:
errors.append("Maximum rides per week cannot exceed 7")
elif max_rides < 1:
errors.append("Must have at least 1 ride per week")
# Check for conflicts
max_hours = parsed_rules.get("max_duration_hours", 0)
if max_rides and max_hours:
avg_duration = max_hours / max_rides
if avg_duration > 5:
warnings.append("Very long average ride duration detected")
elif avg_duration < 0.5:
warnings.append("Very short average ride duration detected")
# Provide suggestions
if "weather_constraints" not in parsed_rules:
suggestions.append("Consider adding weather constraints for outdoor rides")
return {
"errors": errors,
"warnings": warnings,
"suggestions": suggestions
}
```
### **File:** `backend/app/schemas/rule.py`
**Replace the existing content with:**
```python
from pydantic import BaseModel, Field, validator
from typing import Optional, Dict, Any, List
class NaturalLanguageRuleRequest(BaseModel):
"""Request schema for natural language rule parsing."""
natural_language_text: str = Field(
...,
min_length=10,
max_length=5000,
description="Natural language rule description"
)
rule_name: str = Field(..., min_length=1, max_length=100, description="Rule set name")
@validator('natural_language_text')
def validate_text_content(cls, v):
# Check for required keywords
required_keywords = ['ride', 'week', 'hour', 'day', 'rest', 'training']
if not any(keyword in v.lower() for keyword in required_keywords):
raise ValueError("Text must contain training-related keywords")
return v
class ParsedRuleResponse(BaseModel):
"""Response schema for parsed rules."""
parsed_rules: Dict[str, Any] = Field(..., description="Structured rule data")
confidence_score: Optional[float] = Field(None, ge=0.0, le=1.0, description="Parsing confidence")
suggestions: List[str] = Field(default=[], description="Improvement suggestions")
validation_errors: List[str] = Field(default=[], description="Validation errors")
rule_name: str = Field(..., description="Rule set name")
class RuleBase(BaseModel):
"""Base rule schema."""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
user_defined: bool = Field(True, description="Whether rule is user-defined")
jsonb_rules: Dict[str, Any] = Field(..., description="Structured rule data")
version: int = Field(1, ge=1, description="Rule version")
parent_rule_id: Optional[int] = Field(None, description="Parent rule for versioning")
class RuleCreate(RuleBase):
"""Schema for creating new rules."""
pass
class Rule(RuleBase):
"""Complete rule schema with database fields."""
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
```
---
<REMOVED>
<REMOVED>
**Add these enhanced methods:**
```python
async def parse_rules_from_natural_language(self, natural_language: str) -> Dict[str, Any]:
"""Enhanced natural language rule parsing with better prompts."""
prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing")
if not prompt_template:
# Fallback prompt if none exists in database
prompt_template = """
Parse the following natural language training rules into structured JSON format.
Input: "{user_rules}"
Required output format:
{{
"max_rides_per_week": <number>,
"min_rest_between_hard": <number>,
"max_duration_hours": <number>,
"intensity_limits": {{
"max_zone_5_minutes_per_week": <number>,
"max_consecutive_hard_days": <number>
}},
"weather_constraints": {{
"min_temperature": <number>,
"max_wind_speed": <number>,
"no_rain": <boolean>
}},
"schedule_constraints": {{
"preferred_days": [<day_names>],
"avoid_days": [<day_names>]
}},
"confidence": <0.0-1.0>
}}
Extract specific numbers and constraints. If information is missing, omit the field.
"""
prompt = prompt_template.format(user_rules=natural_language)
response = await self._make_ai_request(prompt)
parsed_data = self._parse_rules_response(response)
# Add confidence scoring
if "confidence" not in parsed_data:
parsed_data["confidence"] = self._calculate_parsing_confidence(
natural_language, parsed_data
)
return parsed_data
def _calculate_parsing_confidence(self, input_text: str, parsed_data: Dict) -> float:
"""Calculate confidence score for rule parsing."""
confidence = 0.5 # Base confidence
# Increase confidence for explicit numbers
import re
numbers = re.findall(r'\d+', input_text)
if len(numbers) >= 2:
confidence += 0.2
# Increase confidence for key training terms
training_terms = ['rides', 'hours', 'week', 'rest', 'recovery', 'training']
found_terms = sum(1 for term in training_terms if term in input_text.lower())
confidence += min(0.3, found_terms * 0.05)
# Decrease confidence if parsed data is sparse
if len(parsed_data) < 3:
confidence -= 0.2
return max(0.0, min(1.0, confidence))
```
---
# Phase 2: Frontend Core Features Implementation
## Step 2.1: Simplified Rules Management
### **File:** `frontend/src/pages/Rules.jsx`
**Replace with simplified plaintext rules interface:**
```jsx
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import RuleEditor from '../components/rules/RuleEditor';
import RulePreview from '../components/rules/RulePreview';
import RulesList from '../components/rules/RulesList';
import { useAuth } from '../context/AuthContext';
import * as ruleService from '../services/ruleService';
const Rules = () => {
const { apiKey } = useAuth();
const [activeTab, setActiveTab] = useState('list');
const [rules, setRules] = useState([]);
const [selectedRule, setSelectedRule] = useState(null);
const [naturalLanguageText, setNaturalLanguageText] = useState('');
const [parsedRules, setParsedRules] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadRules();
}, []);
const loadRules = async () => {
try {
const response = await ruleService.getRules();
setRules(response.data);
} catch (error) {
console.error('Failed to load rules:', error);
toast.error('Failed to load rules');
}
};
const handleParseRules = async (parsedData) => {
setParsedRules(parsedData);
setActiveTab('preview');
};
const handleSaveRules = async (ruleName, finalRules) => {
setIsLoading(true);
try {
const ruleData = {
name: ruleName,
jsonb_rules: finalRules,
user_defined: true,
version: 1
};
await ruleService.createRule(ruleData);
toast.success('Rules saved successfully!');
// Reset form and reload rules
setNaturalLanguageText('');
setParsedRules(null);
setActiveTab('list');
await loadRules();
} catch (error) {
console.error('Failed to save rules:', error);
toast.error('Failed to save rules');
} finally {
setIsLoading(false);
}
};
const handleEditRule = (rule) => {
setSelectedRule(rule);
setNaturalLanguageText(rule.description || '');
setParsedRules(rule.jsonb_rules);
setActiveTab('edit');
};
return (
<div className="max-w-6xl mx-auto p-6">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Training Rules</h1>
<p className="text-gray-600 mt-2">
Define your training constraints and preferences using natural language
</p>
</div>
<button
onClick={() => setActiveTab('create')}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Create New Rules
</button>
</div>
{/* Tab Navigation */}
<div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8">
{[
{ key: 'list', label: 'All Rules' },
{ key: 'create', label: 'Create Rules' },
{ key: 'preview', label: 'Preview' }
].map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === tab.key
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'list' && (
<RulesList
rules={rules}
onEdit={handleEditRule}
onDelete={async (ruleId) => {
try {
await ruleService.deleteRule(ruleId);
toast.success('Rule deleted');
await loadRules();
} catch (error) {
toast.error('Failed to delete rule');
}
}}
/>
)}
{isEditing ? (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rule Name
</label>
<input
type="text"
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rule Text
</label>
<textarea
value={ruleText}
onChange={(e) => setRuleText(e.target.value)}
rows={5}
className="w-full p-3 border border-gray-300 rounded-lg"
placeholder="Enter your training rules in plain text"
/>
</div>
<button
onClick={handleSaveRule}
className="bg-blue-600 text-white px-6 py-2 rounded-lg"
>
Save Rule
</button>
</div>
) : null}
</div>
);
};
export default Rules;
```
<REMOVED>
**Create the missing component:**
```jsx
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import ReactJsonTree from 'react-json-tree';
const RulePreview = ({ naturalLanguageText, parsedRules, onSave, isLoading, onEdit }) => {
const [ruleName, setRuleName] = useState('');
const [editedRules, setEditedRules] = useState(parsedRules);
const [showJsonEditor, setShowJsonEditor] = useState(false);
const handleSave = () => {
if (!ruleName.trim()) {
alert('Please enter a rule name');
return;
}
onSave(ruleName, editedRules);
};
const theme = {
scheme: 'monokai',
author: 'wimer hazenberg',
base00: '#272822',
base01: '#383830',
base02: '#49483e',
base03: '#75715e',
base04: '#a59f85',
base05: '#f8f8f2',
base06: '#f5f4f1',
base07: '#f9f8f5',
base08: '#f92672',
base09: '#fd971f',
base0A: '#f4bf75',
base0B: '#a6e22e',
base0C: '#a1efe4',
base0D: '#66d9ef',
base0E: '#ae81ff',
base0F: '#cc6633'
};
return (
<div className="space-y-6">
{/* Rule Name Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rule Set Name
</label>
<input
type="text"
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
placeholder="e.g., Winter Training Rules"
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Natural Language Summary */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-lg font-semibold text-blue-800 mb-2">
Original Input
</h3>
<p className="text-blue-700">{naturalLanguageText}</p>
</div>
{/* Parsed Rules Display */}
<div className="bg-white border rounded-lg">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-lg font-semibold">Parsed Rules</h3>
<div className="flex space-x-2">
<button
onClick={() => setShowJsonEditor(!showJsonEditor)}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
{showJsonEditor ? 'View Tree' : 'Edit JSON'}
</button>
<button
onClick={onEdit}
className="px-3 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Edit Input
</button>
</div>
</div>
<div className="p-4">
{showJsonEditor ? (
<textarea
value={JSON.stringify(editedRules, null, 2)}
onChange={(e) => {
try {
setEditedRules(JSON.parse(e.target.value));
} catch (err) {
// Handle JSON parsing errors
}
}}
className="w-full h-64 p-3 border rounded font-mono text-sm"
/>
) : (
<div className="bg-gray-900 rounded p-4 overflow-auto max-h-64">
<ReactJsonTree
data={editedRules}
theme={theme}
invertTheme={false}
shouldExpandNode={() => true}
/>
</div>
)}
</div>
</div>
{/* Validation Summary */}
{parsedRules.confidence && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-green-800 font-medium">Parsing Confidence</span>
<div className="flex items-center">
<div className="w-32 h-2 bg-green-200 rounded-full mr-3">
<div
className="h-full bg-green-600 rounded-full"
style={{ width: `${parsedRules.confidence * 100}%` }}
/>
</div>
<span className="text-green-800 font-mono">
{(parsedRules.confidence * 100).toFixed(1)}%
</span>
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex space-x-4 pt-4 border-t">
<button
onClick={handleSave}
disabled={isLoading || !ruleName.trim()}
className={`px-6 py-2 rounded-lg font-medium ${
isLoading || !ruleName.trim()
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isLoading ? 'Saving...' : 'Save Rules'}
</button>
<button
onClick={onEdit}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Back to Editor
</button>
</div>
</div>
);
};
RulePreview.propTypes = {
naturalLanguageText: PropTypes.string.isRequired,
parsedRules: PropTypes.object.isRequired,
onSave: PropTypes.func.isRequired,
isLoading: PropTypes.bool,
onEdit: PropTypes.func.isRequired
};
export default RulePreview;
```
---
## Step 2.2: Plan Generation Workflow
### **File:** `frontend/src/pages/PlanGeneration.jsx`
**Create the complete plan generation interface:**
```jsx
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import { useAuth } from '../context/AuthContext';
import GoalSelector from '../components/plans/GoalSelector';
import PlanParameters from '../components/plans/PlanParameters';
import ProgressTracker from '../components/ui/ProgressTracker';
import PlanTimeline from '../components/plans/PlanTimeline';
import * as planService from '../services/planService';
import * as ruleService from '../services/ruleService';
const PlanGeneration = () => {
const { apiKey } = useAuth();
const [currentStep, setCurrentStep] = useState(0);
const [isGenerating, setIsGenerating] = useState(false);
const [generatedPlan, setGeneratedPlan] = useState(null);
// Form data
const [selectedRules, setSelectedRules] = useState([]);
const [goals, setGoals] = useState({
primary_goal: '',
target_weekly_hours: 8,
fitness_level: 'intermediate',
event_date: '',
preferred_routes: [],
avoid_days: []
});
const [parameters, setParameters] = useState({
duration_weeks: 4,
user_preferences: {}
});
const [availableRules, setAvailableRules] = useState([]);
useEffect(() => {
loadAvailableRules();
}, []);
const loadAvailableRules = async () => {
try {
const response = await ruleService.getRules();
setAvailableRules(response.data);
} catch (error) {
console.error('Failed to load rules:', error);
toast.error('Failed to load available rules');
}
};
const steps = [
{ name: 'Select Rules', component: 'rules' },
{ name: 'Set Goals', component: 'goals' },
{ name: 'Parameters', component: 'parameters' },
{ name: 'Generate', component: 'generate' },
{ name: 'Review', component: 'review' }
];
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1);
}
};
const handlePrev = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1);
}
};
const handleGeneratePlan = async () => {
setIsGenerating(true);
try {
const request = {
rule_ids: selectedRules.map(rule => rule.id),
goals: goals,
duration_weeks: parameters.duration_weeks,
user_preferences: parameters.user_preferences
};
const response = await planService.generatePlan(request);
setGeneratedPlan(response.data);
setCurrentStep(4); // Move to review step
toast.success('Training plan generated successfully!');
} catch (error) {
console.error('Failed to generate plan:', error);
toast.error('Failed to generate training plan');
} finally {
setIsGenerating(false);
}
};
const renderStepContent = () => {
switch (steps[currentStep].component) {
case 'rules':
return (
<RulesSelection
availableRules={availableRules}
selectedRules={selectedRules}
onSelectionChange={setSelectedRules}
/>
);
case 'goals':
return (
<GoalSelector
goals={goals}
onChange={setGoals}
/>
);
case 'parameters':
return (
<PlanParameters
parameters={parameters}
onChange={setParameters}
/>
);
case 'generate':
return (
<GenerationSummary
selectedRules={selectedRules}
goals={goals}
parameters={parameters}
onGenerate={handleGeneratePlan}
isGenerating={isGenerating}
/>
);
case 'review':
return generatedPlan ? (
<PlanTimeline plan={generatedPlan.plan} mode="view" />
) : (
<div>No plan generated</div>
);
default:
return <div>Unknown step</div>;
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Generate Training Plan
</h1>
<p className="text-gray-600">
Create a personalized training plan based on your rules and goals
</p>
</div>
{/* Progress Tracker */}
<ProgressTracker
steps={steps.map(step => step.name)}
currentStep={currentStep}
completedSteps={currentStep}
/>
{/* Step Content */}
<div className="bg-white rounded-lg shadow-md p-6 mt-8">
<h2 className="text-xl font-semibold mb-6">
{steps[currentStep].name}
</h2>
{renderStepContent()}
</div>
{/* Navigation Buttons */}
<div className="flex justify-between mt-8">
<button
onClick={handlePrev}
disabled={currentStep === 0}
className={`px-6 py-2 rounded-lg ${
currentStep === 0
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-gray-600 text-white hover:bg-gray-700'
}`}
>
Previous
</button>
{currentStep < 3 ? (
<button
onClick={handleNext}
disabled={!canProceed()}
className={`px-6 py-2 rounded-lg ${
!canProceed()
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
Next
</button>
) : currentStep === 4 ? (
<button
onClick={() => toast.info('Plan saved successfully!')}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Save Plan
</button>
) : null}
</div>
</div>
);
function canProceed() {
switch (currentStep) {
case 0: return selectedRules.length > 0;
case 1: return goals.primary_goal && goals.target_weekly_hours > 0;
case 2: return parameters.duration_weeks >= 1;
default: return true;
}
}
};
// Supporting Components
const RulesSelection = ({ availableRules, selectedRules, onSelectionChange }) => {
const handleRuleToggle = (rule) => {
const isSelected = selectedRules.some(r => r.id === rule.id);
if (isSelected) {
onSelectionChange(selectedRules.filter(r => r.id !== rule.id));
} else {
onSelectionChange([...selectedRules, rule]);
}
};
return (
<div className="space-y-4">
<p className="text-gray-600 mb-4">
Select the rule sets that should apply to your training plan:
</p>
{availableRules.map(rule => (
<div
key={rule.id}
className={`border rounded-lg p-4 cursor-pointer transition-all ${
selectedRules.some(r => r.id === rule.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}`}
onClick={() => handleRuleToggle(rule)}
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">{rule.name}</h3>
<p className="text-sm text-gray-600 mt-1">
{rule.description || 'No description'}
</p>
</div>
<input
type="checkbox"
checked={selectedRules.some(r => r.id === rule.id)}
onChange={() => {}} // Handled by div onClick
className="w-5 h-5 text-blue-600"
/>
</div>
</div>
))}
</div>
);
};
const GenerationSummary = ({ selectedRules, goals, parameters, onGenerate, isGenerating }) => {
return (
<div className="space-y-6">
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-medium mb-3">Plan Summary</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Rules Applied:</span>
<span className="ml-2">{selectedRules.length} rule sets</span>
</div>
<div>
<span className="font-medium">Primary Goal:</span>
<span className="ml-2">{goals.primary_goal}</span>
</div>
<div>
<span className="font-medium">Weekly Hours:</span>
<span className="ml-2">{goals.target_weekly_hours}h</span>
</div>
<div>
<span className="font-medium">Duration:</span>
<span className="ml-2">{parameters.duration_weeks} weeks</span>
</div>
</div>
</div>
<button
onClick={onGenerate}
disabled={isGenerating}
className={`w-full py-3 rounded-lg font-medium ${
isGenerating
? 'bg-gray-300 text-gray-500'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isGenerating ? 'Generating Plan...' : 'Generate Training Plan'}
</button>
</div>
);
};
export default PlanGeneration;
```
---
## Step 2.3: Service Layer Implementation
### **File:** `frontend/src/services/planService.js`
**Create the plan service for API communication:**
```javascript
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
// Create axios instance with default config
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth interceptor
api.interceptors.request.use((config) => {
const apiKey = localStorage.getItem('apiKey');
if (apiKey) {
config.headers['X-API-Key'] = apiKey;
}
return config;
});
// Add error handling interceptor
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('apiKey');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export const generatePlan = async (planRequest) => {
try {
const response = await api.post('/plans/generate', planRequest);
return response;
} catch (error) {
console.error('Plan generation failed:', error);
throw error;
}
};
export const getPlans = async () => {
return await api.get('/plans');
};
export const getPlan = async (planId) => {
return await api.get(`/plans/${planId}`);
};
export const updatePlan = async (planId, planData) => {
return await api.put(`/plans/${planId}`, planData);
};
export const deletePlan = async (planId) => {
return await api.delete(`/plans/${planId}`);
};
export const getPlanEvolution = async (planId) => {
return await api.get(`/workouts/plans/${planId}/evolution`);
};
```
<REMOVED>
**Update the rule service with new endpoints:**
```javascript
import axios from 'axios';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth interceptor
api.interceptors.request.use((config) => {
const apiKey = localStorage.getItem('apiKey');
if (apiKey) {
config.headers['X-API-Key'] = apiKey;
}
return config;
});
export const parseRule = async (naturalLanguageText, ruleName) => {
try {
const response = await api.post('/rules/parse-natural-language', {
natural_language_text: naturalLanguageText,
rule_name: ruleName
});
return response;
} catch (error) {
console.error('Rule parsing failed:', error);
throw error;
}
};
export const validateRules = async (rulesData) => {
try {
const response = await api.post('/rules/validate-rules', rulesData);
return response;
} catch (error) {
console.error('Rule validation failed:', error);
throw error;
}
};
export const getRules = async () => {
return await api.get('/rules');
};
export const getRule = async (ruleId) => {
return await api.get(`/rules/${ruleId}`);
};
export const createRule = async (ruleData) => {
return await api.post('/rules', ruleData);
};
export const updateRule = async (ruleId, ruleData) => {
return await api.put(`/rules/${ruleId}`, ruleData);
};
export const deleteRule = async (ruleId) => {
return await api.delete(`/rules/${ruleId}`);
};
```
---
# Phase 3: Integration & Testing
## Step 3.1: Component Testing
### **File:** `frontend/src/components/rules/__tests__/RuleEditor.test.jsx`
**Add comprehensive component tests:**
```jsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import RuleEditor from '../RuleEditor';
import * as ruleService from '../../../services/ruleService';
// Mock the service
jest.mock('../../../services/ruleService');
describe('RuleEditor', () => {
const mockProps = {
value: '',
onChange: jest.fn(),
onParse: jest.fn()
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', () => {
render(<RuleEditor {...mockProps} />);
expect(screen.getByText('Natural Language Editor')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Enter your training rules/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Parse Rules/ })).toBeInTheDocument();
});
it('validates input correctly', () => {
render(<RuleEditor {...mockProps} />);
const textarea = screen.getByPlaceholderText(/Enter your training rules/);
const parseButton = screen.getByRole('button', { name: /Parse Rules/ });
// Should be disabled for invalid input
expect(parseButton).toBeDisabled();
// Enter valid training rule
fireEvent.change(textarea, {
target: { value: 'Maximum 4 rides per week with at least one rest day between hard workouts' }
});
// Should be enabled for valid input
expect(parseButton).toBeEnabled();
});
it('calls parse function when button clicked', async () => {
const mockParseResponse = {
data: {
jsonRules: {
max_rides_per_week: 4,
min_rest_between_hard: 1
}
}
};
ruleService.parseRule.mockResolvedValue(mockParseResponse);
render(<RuleEditor {...mockProps} />);
const textarea = screen.getByPlaceholderText(/Enter your training rules/);
const parseButton = screen.getByRole('button', { name: /Parse Rules/ });
fireEvent.change(textarea, {
target: { value: 'Maximum 4 rides per week with recovery between sessions' }
});
fireEvent.click(parseButton);
await waitFor(() => {
expect(mockProps.onParse).toHaveBeenCalledWith(mockParseResponse.data.jsonRules);
});
});
it('shows template suggestions', () => {
render(<RuleEditor {...mockProps} />);
const templatesButton = screen.getByText('Templates');
fireEvent.click(templatesButton);
// Should show template suggestions
expect(screen.getByText(/Maximum 4 rides per week/)).toBeInTheDocument();
});
});
```
## Step 3.2: Backend API Testing (Updated)
### **File:** `backend/tests/routes/test_plan.py`
**Add updated endpoint tests (removed rule parsing tests):**
```python
import pytest
from httpx import AsyncClient
from app.main import app
from app.models import Plan, Rule
from app.services.ai_service import AIService
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
class TestPlanGeneration:
async def test_generate_plan_success(self, async_client: AsyncClient, db_session, api_headers):
"""Test successful plan generation."""
# Create test rule
test_rule = Rule(
name="Test Rules",
jsonb_rules={"max_rides_per_week": 4, "min_rest_between_hard": 1},
version=1
)
db_session.add(test_rule)
await db_session.commit()
await db_session.refresh(test_rule)
# Mock AI service response
mock_plan_data = {
"plan_overview": {
"duration_weeks": 4,
"weekly_hours": 8,
"focus": "endurance"
},
"weeks": [
{
"week_number": 1,
"focus": "base_building",
"workouts": [
{
"day": "tuesday",
"type": "easy_ride",
"duration_minutes": 90
}
]
}
]
}
with patch.object(AIService, 'generate_plan', return_value=mock_plan_data):
response = await async_client.post(
"/plans/generate",
json={
"rule_ids": [str(test_rule.id)],
"goals": {
"primary_goal": "endurance",
"target_weekly_hours": 8,
"fitness_level": "intermediate"
},
"duration_weeks": 4
},
headers=api_headers
)
assert response.status_code == 200
data = response.json()
assert "plan" in data
assert data["plan"]["jsonb_plan"]["plan_overview"]["focus"] == "endurance"
async def test_generate_plan_missing_rules(self, async_client: AsyncClient, api_headers):
"""Test plan generation with non-existent rules."""
response = await async_client.post(
"/plans/generate",
json={
"rule_ids": ["non-existent-id"],
"goals": {
"primary_goal": "endurance",
"target_weekly_hours": 8,
"fitness_level": "intermediate"
}
},
headers=api_headers
)
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
async def test_generate_plan_invalid_goals(self, async_client: AsyncClient, api_headers):
"""Test plan generation with invalid goals."""
response = await async_client.post(
"/plans/generate",
json={
"rule_ids": [],
"goals": {
"target_weekly_hours": -1 # Invalid value
}
},
headers=api_headers
)
assert response.status_code == 422 # Validation error
<REMOVED>
```
---
# Phase 4: Production Polish
## Step 4.1: Error Handling & User Experience
### **File:** `frontend/src/components/GlobalErrorHandler.jsx`
**Create comprehensive error handling:**
```jsx
import React from 'react';
import { toast } from 'react-toastify';
class GlobalErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Global error caught:', error, errorInfo);
// Log to monitoring service in production
if (process.env.NODE_ENV === 'production') {
// logErrorToService(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
<div className="text-center">
<div className="text-red-500 text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
Something went wrong
</h1>
<p className="text-gray-600 mb-6">
An unexpected error occurred. Please try refreshing the page.
</p>
<button
onClick={() => window.location.reload()}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Refresh Page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
// Hook for handling API errors
export const useErrorHandler = () => {
const handleError = (error) => {
console.error('API Error:', error);
if (error.response) {
const status = error.response.status;
const message = error.response.data?.detail || error.message;
switch (status) {
case 400:
toast.error(`Invalid request: ${message}`);
break;
case 401:
toast.error('Authentication failed. Please log in again.');
localStorage.removeItem('apiKey');
window.location.href = '/login';
break;
case 403:
toast.error('Access denied. You do not have permission.');
break;
case 404:
toast.error('Resource not found.');
break;
case 422:
toast.error(`Validation error: ${message}`);
break;
case 500:
toast.error('Server error. Please try again later.');
break;
case 503:
toast.error('Service temporarily unavailable.');
break;
default:
toast.error(`Error: ${message}`);
}
} else if (error.request) {
toast.error('Network error. Please check your connection.');
} else {
toast.error('An unexpected error occurred.');
}
};
return { handleError };
};
export default GlobalErrorBoundary;
```
## Step 4.2: Performance Optimization
### **File:** `frontend/src/hooks/useDebounce.js`
**Add debouncing for better performance:**
```javascript
import { useState, useEffect } from 'react';
export function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage in RuleEditor component
export function useDebouncedValidation(input, validationFn, delay = 500) {
const debouncedInput = useDebounce(input, delay);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
useEffect(() => {
if (debouncedInput) {
setIsValidating(true);
validationFn(debouncedInput)
.then(result => {
setValidationResult(result);
setIsValidating(false);
})
.catch(error => {
console.error('Validation error:', error);
setIsValidating(false);
});
}
}, [debouncedInput, validationFn]);
return { isValidating, validationResult };
}
```
---
## 🚀 **Implementation Timeline & Checkpoints**
### **Week 1-2: Backend Foundation**
- [ ] Implement plan generation endpoint
- [ ] Add natural language rule parsing
- [ ] Create comprehensive test suite
- [ ] **Checkpoint:** Can generate plans via API
### **Week 3-4: Frontend Core**
- [ ] Build rules management interface
- [ ] Create plan generation workflow
- [ ] Add error handling and loading states
- [ ] **Checkpoint:** Complete user workflows working
### **Week 5-6: Integration & Testing**
- [ ] Connect frontend to backend APIs
- [ ] Add comprehensive error handling
- [ ] Performance optimization
- [ ] **Checkpoint:** Full feature integration
### **Week 7: Production Polish**
- [ ] UI/UX improvements
- [ ] Performance monitoring
- [ ] Documentation updates
- [ ] **Checkpoint:** Production-ready application
---
## 📝 **Junior Developer Tips**
### **Common Pitfalls to Avoid:**
1. **API Integration**: Always handle loading states and errors
2. **State Management**: Use React hooks properly, avoid stale closures
3. **Async/Await**: Always wrap async operations in try-catch blocks
4. **Type Safety**: Use PropTypes or TypeScript for better debugging
5. **Performance**: Debounce user input for API calls
### **Testing Strategy:**
1. **Start Small**: Test individual components before integration
2. **Mock Services**: Use jest mocks for external API calls
3. **Error Cases**: Test both success and failure scenarios
4. **User Flows**: Test complete user workflows end-to-end
### **Debugging Approach:**
1. **Console Logging**: Add structured logging for API calls
2. **React DevTools**: Use for inspecting component state
3. **Network Tab**: Monitor API requests and responses
4. **Error Boundaries**: Implement proper error catching
This implementation guide provides a clear, step-by-step approach for a junior developer to successfully implement the missing core features while maintaining code quality and following best practices.