This commit is contained in:
2025-09-12 07:32:32 -07:00
parent 45a62e7c3b
commit 7c7dcb5b10
29 changed files with 2493 additions and 394 deletions

1565
CL_implementation_guide.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
"""Create initial rules table with plaintext storage
Revision ID: 001
Revises:
Create Date: 2025-09-12 14:01:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create rules table with plaintext storage as per design specification."""
# Create rules table with correct schema from the start
op.create_table('rules',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('user_defined', sa.Boolean(), nullable=True),
sa.Column('rule_text', sa.Text(), nullable=False),
sa.Column('version', sa.Integer(), nullable=True),
sa.Column('parent_rule_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.ForeignKeyConstraint(['parent_rule_id'], ['rules.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
"""Drop rules table."""
op.drop_table('rules')

View File

@@ -1,11 +1,10 @@
from fastapi import HTTPException, Header, status from fastapi import Depends
import os from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.ai_service import AIService
from typing import AsyncGenerator
async def verify_api_key(api_key: str = Header(..., alias="X-API-Key")):
"""Dependency to verify API key header""" async def get_ai_service(db: AsyncSession = Depends(get_db)) -> AIService:
expected_key = os.getenv("API_KEY") """Get AI service instance with database dependency."""
if not expected_key or api_key != expected_key: return AIService(db)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing API Key"
)

View File

@@ -65,7 +65,11 @@ app = FastAPI(
# API Key Authentication Middleware # API Key Authentication Middleware
@app.middleware("http") @app.middleware("http")
async def api_key_auth(request: Request, call_next): async def api_key_auth(request: Request, call_next):
if request.url.path.startswith("/docs") or request.url.path.startswith("/redoc") or request.url.path == "/health": # Skip authentication for documentation and health endpoints
if (request.url.path.startswith("/docs") or
request.url.path.startswith("/redoc") or
request.url.path == "/health" or
request.url.path == "/openapi.json"):
return await call_next(request) return await call_next(request)
api_key = request.headers.get("X-API-KEY") api_key = request.headers.get("X-API-KEY")

View File

@@ -11,5 +11,6 @@ class Plan(BaseModel):
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True) parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans") parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans")
analyses = relationship("Analysis", back_populates="plan") analyses = relationship("Analysis", back_populates="plan", lazy="selectin")
workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan") workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan", lazy="selectin")
rules = relationship("Rule", secondary="plan_rules", back_populates="plans", lazy="selectin")

View File

@@ -1,5 +1,4 @@
from sqlalchemy import Column, Integer, ForeignKey, Boolean, String from sqlalchemy import Column, Integer, ForeignKey, Boolean, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from .base import BaseModel from .base import BaseModel
@@ -7,9 +6,11 @@ class Rule(BaseModel):
__tablename__ = "rules" __tablename__ = "rules"
name = Column(String(100), nullable=False) name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
user_defined = Column(Boolean, default=True) user_defined = Column(Boolean, default=True)
jsonb_rules = Column(JSONB, nullable=False) rule_text = Column(Text, nullable=False) # Plaintext rules as per design spec
version = Column(Integer, default=1) version = Column(Integer, default=1)
parent_rule_id = Column(Integer, ForeignKey('rules.id'), nullable=True) parent_rule_id = Column(Integer, ForeignKey('rules.id'), nullable=True)
parent_rule = relationship("Rule", remote_side="Rule.id") parent_rule = relationship("Rule", remote_side="Rule.id")
plans = relationship("Plan", secondary="plan_rules", back_populates="rules", lazy="selectin")

View File

@@ -1,10 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from app.database import get_db from app.database import get_db
from app.models import Plan, PlanRule, Rule from app.models.plan import Plan as PlanModel
from app.schemas.plan import PlanCreate, Plan as PlanSchema from app.models.rule import Rule
from uuid import UUID from app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
from app.dependencies import get_ai_service
from app.services.ai_service import AIService
from uuid import UUID, uuid4
from datetime import datetime
from typing import List
router = APIRouter(prefix="/plans", tags=["Training Plans"]) router = APIRouter(prefix="/plans", tags=["Training Plans"])
@@ -14,20 +19,12 @@ async def create_plan(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
# Create plan # Create plan
db_plan = Plan( db_plan = PlanModel(
user_id=plan.user_id, jsonb_plan=plan.jsonb_plan,
start_date=plan.start_date, version=plan.version,
end_date=plan.end_date, parent_plan_id=plan.parent_plan_id
goal=plan.goal
) )
db.add(db_plan) db.add(db_plan)
await db.flush() # Flush to get plan ID
# Add rules to plan
for rule_id in plan.rule_ids:
db_plan_rule = PlanRule(plan_id=db_plan.id, rule_id=rule_id)
db.add(db_plan_rule)
await db.commit() await db.commit()
await db.refresh(db_plan) await db.refresh(db_plan)
return db_plan return db_plan
@@ -37,16 +34,16 @@ async def read_plan(
plan_id: UUID, plan_id: UUID,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
plan = await db.get(Plan, plan_id) plan = await db.get(PlanModel, plan_id)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Plan not found") raise HTTPException(status_code=404, detail="Plan not found")
return plan return plan
@router.get("/", response_model=list[PlanSchema]) @router.get("/", response_model=List[PlanSchema])
async def read_plans( async def read_plans(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
result = await db.execute(select(Plan)) result = await db.execute(select(PlanModel))
return result.scalars().all() return result.scalars().all()
@router.put("/{plan_id}", response_model=PlanSchema) @router.put("/{plan_id}", response_model=PlanSchema)
@@ -55,21 +52,14 @@ async def update_plan(
plan: PlanCreate, plan: PlanCreate,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
db_plan = await db.get(Plan, plan_id) db_plan = await db.get(PlanModel, plan_id)
if not db_plan: if not db_plan:
raise HTTPException(status_code=404, detail="Plan not found") raise HTTPException(status_code=404, detail="Plan not found")
# Update plan fields # Update plan fields
db_plan.user_id = plan.user_id db_plan.jsonb_plan = plan.jsonb_plan
db_plan.start_date = plan.start_date db_plan.version = plan.version
db_plan.end_date = plan.end_date db_plan.parent_plan_id = plan.parent_plan_id
db_plan.goal = plan.goal
# Update rules
await db.execute(PlanRule.delete().where(PlanRule.plan_id == plan_id))
for rule_id in plan.rule_ids:
db_plan_rule = PlanRule(plan_id=plan_id, rule_id=rule_id)
db.add(db_plan_rule)
await db.commit() await db.commit()
await db.refresh(db_plan) await db.refresh(db_plan)
@@ -80,10 +70,63 @@ async def delete_plan(
plan_id: UUID, plan_id: UUID,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
plan = await db.get(Plan, plan_id) plan = await db.get(PlanModel, plan_id)
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Plan not found") raise HTTPException(status_code=404, detail="Plan not found")
await db.delete(plan) await db.delete(plan)
await db.commit() await db.commit()
return {"detail": "Plan deleted"} return {"detail": "Plan deleted"}
@router.post("/generate", response_model=PlanGenerationResponse)
async def generate_plan(
request: PlanGenerationRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
ai_service: AIService = Depends(get_ai_service)
):
"""
Generate a new training plan using AI based on provided goals and rule set.
"""
try:
# Get all rules from the provided rule IDs
rules = []
for rule_id in request.rule_ids:
rule = await db.get(Rule, rule_id)
if not rule:
raise HTTPException(status_code=404, detail=f"Rule with ID {rule_id} not found")
rules.append(rule.jsonb_rules)
# Generate plan using AI service
generated_plan = await ai_service.generate_training_plan(
rule_set=rules, # Pass all rules as a list
goals=request.goals.model_dump(),
preferred_routes=request.preferred_routes
)
# Create a Plan object for the response
plan_obj = PlanSchema(
id=uuid4(), # Generate a proper UUID
jsonb_plan=generated_plan,
version=1,
parent_plan_id=None,
created_at=datetime.utcnow()
)
# Create response with generated plan
response = PlanGenerationResponse(
plan=plan_obj,
generation_metadata={
"status": "success",
"generated_at": datetime.utcnow().isoformat(),
"rule_ids": [str(rule_id) for rule_id in request.rule_ids]
}
)
return response
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to generate plan: {str(e)}"
)

View File

@@ -1,9 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db from app.database import get_db
from app.models import Rule from app.models.rule import Rule
from app.schemas.rule import RuleCreate, Rule as RuleSchema from app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
from app.dependencies import get_ai_service
from app.services.ai_service import AIService
from uuid import UUID from uuid import UUID
from typing import List
router = APIRouter(prefix="/rules", tags=["Rules"]) router = APIRouter(prefix="/rules", tags=["Rules"])
@@ -12,55 +16,107 @@ async def create_rule(
rule: RuleCreate, rule: RuleCreate,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
db_rule = Rule(**rule.dict()) """Create new rule set (plaintext) as per design specification."""
db_rule = Rule(**rule.model_dump())
db.add(db_rule) db.add(db_rule)
await db.commit() await db.commit()
await db.refresh(db_rule) await db.refresh(db_rule)
return db_rule return db_rule
@router.get("/", response_model=List[RuleSchema])
async def list_rules(
active_only: bool = True,
db: AsyncSession = Depends(get_db)
):
"""List rule sets as specified in design document."""
query = select(Rule)
if active_only:
# For now, return all rules. Later we can add an 'active' field
pass
result = await db.execute(query)
return result.scalars().all()
@router.get("/{rule_id}", response_model=RuleSchema) @router.get("/{rule_id}", response_model=RuleSchema)
async def read_rule( async def get_rule(
rule_id: UUID, rule_id: UUID,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Get specific rule set."""
rule = await db.get(Rule, rule_id) rule = await db.get(Rule, rule_id)
if not rule: if not rule:
raise HTTPException(status_code=404, detail="Rule not found") raise HTTPException(status_code=404, detail="Rule not found")
return rule return rule
@router.get("/", response_model=list[RuleSchema])
async def read_rules(
db: AsyncSession = Depends(get_db)
):
result = await db.execute(sa.select(Rule))
return result.scalars().all()
@router.put("/{rule_id}", response_model=RuleSchema) @router.put("/{rule_id}", response_model=RuleSchema)
async def update_rule( async def update_rule(
rule_id: UUID, rule_id: UUID,
rule: RuleCreate, rule: RuleCreate,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Update rule set - creates new version as per design spec."""
db_rule = await db.get(Rule, rule_id) db_rule = await db.get(Rule, rule_id)
if not db_rule: if not db_rule:
raise HTTPException(status_code=404, detail="Rule not found") raise HTTPException(status_code=404, detail="Rule not found")
for key, value in rule.dict().items(): # Create new version instead of updating in place
setattr(db_rule, key, value) new_version = Rule(
name=rule.name,
description=rule.description,
user_defined=rule.user_defined,
rule_text=rule.rule_text,
version=db_rule.version + 1,
parent_rule_id=db_rule.id
)
db.add(new_version)
await db.commit() await db.commit()
await db.refresh(db_rule) await db.refresh(new_version)
return db_rule return new_version
@router.delete("/{rule_id}") @router.delete("/{rule_id}")
async def delete_rule( async def delete_rule(
rule_id: UUID, rule_id: UUID,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""Delete rule set."""
rule = await db.get(Rule, rule_id) rule = await db.get(Rule, rule_id)
if not rule: if not rule:
raise HTTPException(status_code=404, detail="Rule not found") raise HTTPException(status_code=404, detail="Rule not found")
await db.delete(rule) await db.delete(rule)
await db.commit() await db.commit()
return {"detail": "Rule deleted"} return {"detail": "Rule deleted"}
@router.post("/parse-natural-language", response_model=ParsedRuleResponse)
async def parse_natural_language_rules(
request: NaturalLanguageRuleRequest,
ai_service: AIService = Depends(get_ai_service)
):
"""
Parse natural language training rules into structured format using AI.
This helps users create rules but the final rule_text is stored as plaintext.
"""
try:
# Parse rules using AI service - this creates structured data for validation
parsed_rules = await ai_service.parse_rules_from_natural_language(request.natural_language_text)
# Simple validation - just check for basic completeness
suggestions = []
if len(request.natural_language_text.split()) < 10:
suggestions.append("Consider providing more detailed rules")
response = ParsedRuleResponse(
parsed_rules=parsed_rules,
confidence_score=0.8, # Simplified confidence
suggestions=suggestions,
validation_errors=[], # Simplified - no complex validation
rule_name=request.rule_name
)
return response
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to parse natural language rules: {str(e)}"
)

View File

@@ -154,7 +154,7 @@ async def approve_analysis(
return {"message": "Analysis approved"} return {"message": "Analysis approved"}
@router.get("/plans/{plan_id}/evolution", response_model=List[PlanSchema]) @router.get("/plans/{plan_id}/evolution", response_model=List["PlanSchema"])
async def get_plan_evolution( async def get_plan_evolution(
plan_id: int, plan_id: int,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)

View File

@@ -18,7 +18,7 @@ class Analysis(AnalysisBase):
id: int id: int
class Config: class Config:
orm_mode = True from_attributes = True
class AnalysisUpdate(BaseModel): class AnalysisUpdate(BaseModel):

View File

@@ -1,21 +1,43 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional, Dict, Any
from uuid import UUID from uuid import UUID, uuid4
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 PlanBase(BaseModel): class PlanBase(BaseModel):
jsonb_plan: dict = Field(..., description="Training plan data in JSONB format") jsonb_plan: Dict[str, Any] = Field(..., description="Training plan data in JSONB format")
version: int = Field(..., gt=0, description="Plan version number") version: int = Field(..., gt=0, description="Plan version number")
parent_plan_id: Optional[int] = Field(None, description="Parent plan ID for evolution tracking") parent_plan_id: Optional[UUID] = Field(None, description="Parent plan ID for evolution tracking")
class PlanCreate(PlanBase): class PlanCreate(PlanBase):
pass pass
class Plan(PlanBase): class Plan(PlanBase):
id: int id: UUID = Field(default_factory=uuid4)
created_at: datetime created_at: datetime = Field(default_factory=datetime.utcnow)
analyses: List["Analysis"] = Field([], description="Analyses that created this plan version") updated_at: Optional[datetime] = Field(default=None)
child_plans: List["Plan"] = Field([], description="Evolved versions of this plan")
class Config: model_config = {"from_attributes": True}
orm_mode = True
class PlanGenerationRequest(BaseModel):
"""Request schema for plan generation."""
rule_ids: List[int] = 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")
preferred_routes: List[int] = Field(default=[], description="Preferred route IDs")
class PlanGenerationResponse(BaseModel):
"""Response schema for plan generation."""
plan: Plan
generation_metadata: Dict[str, Any] = Field(..., description="Generation metadata")
model_config = {"from_attributes": True}

View File

@@ -1,17 +1,49 @@
from pydantic import BaseModel from pydantic import BaseModel, Field, field_validator
from typing import Optional from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
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")
@field_validator('natural_language_text')
@classmethod
def validate_text_content(cls, v):
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): class RuleBase(BaseModel):
name: str """Base rule schema."""
description: Optional[str] = None name: str = Field(..., min_length=1, max_length=100)
condition: str description: Optional[str] = Field(None, max_length=500)
priority: int = 0 user_defined: bool = Field(True, description="Whether rule is user-defined")
rule_text: str = Field(..., min_length=10, description="Plaintext rule description")
version: int = Field(1, ge=1, description="Rule version")
parent_rule_id: Optional[UUID] = Field(None, description="Parent rule for versioning")
class RuleCreate(RuleBase): class RuleCreate(RuleBase):
pass pass
class Rule(RuleBase): class Rule(RuleBase):
id: str id: UUID
created_at: datetime
updated_at: datetime
class Config: model_config = {"from_attributes": True}
orm_mode = True

View File

@@ -43,12 +43,12 @@ class AIService:
response = await self._make_ai_request(prompt) response = await self._make_ai_request(prompt)
return self._parse_workout_analysis(response) return self._parse_workout_analysis(response)
async def generate_plan(self, rules: List[Dict], goals: Dict[str, Any]) -> Dict[str, Any]: async def generate_plan(self, rules_text: str, goals: Dict[str, Any]) -> Dict[str, Any]:
"""Generate a training plan using AI.""" """Generate a training plan using AI with plaintext rules as per design spec."""
prompt_template = await self.prompt_manager.get_active_prompt("plan_generation") prompt_template = await self.prompt_manager.get_active_prompt("plan_generation")
context = { context = {
"rules": rules, "rules_text": rules_text, # Use plaintext rules directly
"goals": goals, "goals": goals,
"current_fitness_level": goals.get("fitness_level", "intermediate") "current_fitness_level": goals.get("fitness_level", "intermediate")
} }
@@ -57,13 +57,80 @@ class AIService:
response = await self._make_ai_request(prompt) response = await self._make_ai_request(prompt)
return self._parse_plan_response(response) return self._parse_plan_response(response)
async def generate_training_plan(self, rules_text: str, goals: Dict[str, Any], preferred_routes: List[int]) -> Dict[str, Any]:
"""Generate a training plan using AI with plaintext rules as per design specification."""
prompt_template = await self.prompt_manager.get_active_prompt("training_plan_generation")
if not prompt_template:
# Fallback to general plan generation prompt
prompt_template = await self.prompt_manager.get_active_prompt("plan_generation")
context = {
"rules_text": rules_text, # Use plaintext rules directly without parsing
"goals": goals,
"preferred_routes": preferred_routes,
"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]: async def parse_rules_from_natural_language(self, natural_language: str) -> Dict[str, Any]:
"""Parse natural language rules into structured format.""" """Parse natural language rules into structured format."""
prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing") prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing")
prompt = prompt_template.format(user_rules=natural_language) prompt = prompt_template.format(user_rules=natural_language)
response = await self._make_ai_request(prompt) response = await self._make_ai_request(prompt)
return self._parse_rules_response(response) parsed_rules = self._parse_rules_response(response)
# Add confidence scoring to the parsed rules
parsed_rules = self._add_confidence_scoring(parsed_rules)
return parsed_rules
def _add_confidence_scoring(self, parsed_rules: Dict[str, Any]) -> Dict[str, Any]:
"""Add confidence scoring to parsed rules based on parsing quality."""
confidence_score = self._calculate_confidence_score(parsed_rules)
# Add confidence score to the parsed rules
if isinstance(parsed_rules, dict):
parsed_rules["_confidence"] = confidence_score
parsed_rules["_parsing_quality"] = self._get_parsing_quality(confidence_score)
return parsed_rules
def _calculate_confidence_score(self, parsed_rules: Dict[str, Any]) -> float:
"""Calculate confidence score based on parsing quality."""
if not isinstance(parsed_rules, dict):
return 0.5 # Default confidence for non-dict responses
score = 0.0
# Score based on presence of key cycling training rule fields
key_fields = {
"max_rides_per_week": 0.3,
"min_rest_between_hard": 0.2,
"max_duration_hours": 0.2,
"weather_constraints": 0.3,
"intensity_limits": 0.2,
"schedule_constraints": 0.2
}
for field, weight in key_fields.items():
if parsed_rules.get(field) is not None:
score += weight
return min(score, 1.0)
def _get_parsing_quality(self, confidence_score: float) -> str:
"""Get parsing quality description based on confidence score."""
if confidence_score >= 0.8:
return "excellent"
elif confidence_score >= 0.6:
return "good"
elif confidence_score >= 0.4:
return "fair"
else:
return "poor"
async def evolve_plan(self, evolution_context: Dict[str, Any]) -> Dict[str, Any]: async def evolve_plan(self, evolution_context: Dict[str, Any]) -> Dict[str, Any]:
"""Evolve a training plan using AI based on workout analysis.""" """Evolve a training plan using AI based on workout analysis."""

View File

@@ -20,7 +20,7 @@
**Workflow Overview** **Workflow Overview**
1. Upload/import GPX → backend saves to mounted folder + metadata in DB 1. Upload/import GPX → backend saves to mounted folder + metadata in DB
2. Define rules (natural language → AI parses → JSON → DB) 2. Define plaintext rules → Store directly in DB
3. Generate plan → AI creates JSON plan → DB versioned 3. Generate plan → AI creates JSON plan → DB versioned
4. Ride recorded on Garmin → backend syncs activity metrics → stores in DB 4. Ride recorded on Garmin → backend syncs activity metrics → stores in DB
5. AI analyzes workout → feedback & suggestions stored → user approves → new plan version created 5. AI analyzes workout → feedback & suggestions stored → user approves → new plan version created
@@ -33,7 +33,7 @@
**Tasks:** **Tasks:**
* **Route/Section Management:** Upload GPX, store metadata, read GPX files for visualization * **Route/Section Management:** Upload GPX, store metadata, read GPX files for visualization
* **Rule Management:** CRUD rules, hierarchical parsing (AI-assisted) * **Rule Management:** CRUD rules with plaintext storage
* **Plan Management:** Generate plans (AI), store versions * **Plan Management:** Generate plans (AI), store versions
* **Workout Analysis:** Fetch Garmin activity, run AI analysis, store reports * **Workout Analysis:** Fetch Garmin activity, run AI analysis, store reports
* **AI Integration:** Async calls to OpenRouter * **AI Integration:** Async calls to OpenRouter
@@ -45,7 +45,7 @@
| ------ | ------------------- | ------------------------------------------------ | | ------ | ------------------- | ------------------------------------------------ |
| POST | `/routes/upload` | Upload GPX file for route/section | | POST | `/routes/upload` | Upload GPX file for route/section |
| GET | `/routes` | List routes and sections | | GET | `/routes` | List routes and sections |
| POST | `/rules` | Create new rule set (with AI parse) | | POST | `/rules` | Create new rule set (plaintext) |
| POST | `/plans/generate` | Generate new plan using rules & goals | | POST | `/plans/generate` | Generate new plan using rules & goals |
| GET | `/plans/{plan_id}` | Fetch plan JSON & version info | | GET | `/plans/{plan_id}` | Fetch plan JSON & version info |
| POST | `/workouts/analyze` | Trigger AI analysis for a synced Garmin activity | | POST | `/workouts/analyze` | Trigger AI analysis for a synced Garmin activity |
@@ -82,14 +82,17 @@ CREATE TABLE sections (
created_at TIMESTAMP DEFAULT now() created_at TIMESTAMP DEFAULT now()
); );
-- Rules (hierarchical JSON) -- Rules (plaintext storage)
CREATE TABLE rules ( CREATE TABLE rules (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT,
user_defined BOOLEAN DEFAULT true, user_defined BOOLEAN DEFAULT true,
jsonb_rules JSONB NOT NULL, rule_text TEXT NOT NULL, -- Plaintext rules
version INT DEFAULT 1, version INT DEFAULT 1,
created_at TIMESTAMP DEFAULT now() parent_rule_id INT REFERENCES rules(id),
created_at TIMESTAMP DEFAULT now(),
updated_at TIMESTAMP DEFAULT now()
); );
-- Plans (versioned) -- Plans (versioned)
@@ -192,9 +195,9 @@ volumes:
* View route map + section metadata * View route map + section metadata
2. **Rules** 2. **Rules**
* Natural language editor * Plaintext rule editor
* AI parse → preview JSON → save * Simple create/edit form
* Switch between rule sets * Rule version history
3. **Plan** 3. **Plan**
* Select goal + rule set → generate plan * Select goal + rule set → generate plan
@@ -213,7 +216,7 @@ volumes:
### **5.3 User Flow Example** ### **5.3 User Flow Example**
1. Upload GPX → backend saves file + DB metadata 1. Upload GPX → backend saves file + DB metadata
2. Define rule set → AI parses → user confirms → DB versioned 2. Define rule set → Store plaintext → DB versioned
3. Generate plan → AI → store plan version in DB 3. Generate plan → AI → store plan version in DB
4. Sync Garmin activity → backend fetches metrics → store workout 4. Sync Garmin activity → backend fetches metrics → store workout
5. AI analyzes → report displayed → user approves → new plan version 5. AI analyzes → report displayed → user approves → new plan version
@@ -229,6 +232,7 @@ volumes:
* Configurable model per action * Configurable model per action
* Async calls to OpenRouter * Async calls to OpenRouter
* Store raw AI output + processed structured result in DB * Store raw AI output + processed structured result in DB
* Use plaintext rules directly in prompts without parsing
--- ---

View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import GarminSync from '../src/components/garmin/GarminSync';
import WorkoutChart from '../src/components/analysis/WorkoutCharts';
import PlanTimeline from '../src/components/plans/PlanTimeline';
import { useAuth } from '../src/context/AuthContext';
import LoadingSpinner from '../src/components/LoadingSpinner';
const Dashboard = () => {
const { apiKey, loading: apiLoading } = useAuth();
const isBuildTime = typeof window === 'undefined';
const [recentWorkouts, setRecentWorkouts] = useState([]);
const [currentPlan, setCurrentPlan] = useState(null);
const [stats, setStats] = useState({ totalWorkouts: 0, totalDistance: 0 });
const [healthStatus, setHealthStatus] = useState(null);
const [localLoading, setLocalLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchDashboardData = async () => {
try {
const [workoutsRes, planRes, statsRes, healthRes] = await Promise.all([
fetch(`${process.env.REACT_APP_API_URL}/api/workouts?limit=3`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${process.env.REACT_APP_API_URL}/api/plans/active`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${process.env.REACT_APP_API_URL}/api/stats`, {
headers: { 'X-API-Key': apiKey }
}),
fetch(`${process.env.REACT_APP_API_URL}/api/health`, {
headers: { 'X-API-Key': apiKey }
})
]);
const errors = [];
if (!workoutsRes.ok) errors.push('Failed to fetch workouts');
if (!planRes.ok) errors.push('Failed to fetch plan');
if (!statsRes.ok) errors.push('Failed to fetch stats');
if (!healthRes.ok) errors.push('Failed to fetch health status');
if (errors.length > 0) throw new Error(errors.join(', '));
const [workoutsData, planData, statsData, healthData] = await Promise.all([
workoutsRes.json(),
planRes.json(),
statsRes.json(),
healthRes.json()
]);
setRecentWorkouts(workoutsData.workouts || []);
setCurrentPlan(planData);
setStats(statsData.workouts || { totalWorkouts: 0, totalDistance: 0 });
setHealthStatus(healthData);
} catch (err) {
setError(err.message);
} finally {
setLocalLoading(false);
}
};
fetchDashboardData();
}, [apiKey]);
if (isBuildTime) {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold">Training Dashboard</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading dashboard data...</p>
</div>
</div>
);
}
if (localLoading || apiLoading) return <LoadingSpinner />;
if (error) return <div className="p-6 text-red-500">{error}</div>;
// Calculate total distance in km
const totalDistanceKm = (stats.totalDistance / 1000).toFixed(0);
return (
<div className="p-6 max-w-7xl mx-auto space-y-8">
<h1 className="text-3xl font-bold">Training Dashboard</h1>
<div className="mb-8">
<GarminSync apiKey={apiKey} />
</div>
{/* Stats Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Total Workouts</h3>
<p className="text-2xl font-bold">{stats.totalWorkouts}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Total Distance</h3>
<p className="text-2xl font-bold">{totalDistanceKm} km</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">Current Plan</h3>
<p className="text-2xl font-bold">
{currentPlan ? `v${currentPlan.version}` : 'None'}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-md">
<h3 className="text-sm font-medium text-gray-600">System Status</h3>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${
healthStatus?.status === 'healthy' ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className="text-xl font-bold capitalize">
{healthStatus?.status || 'unknown'}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
<div className="sm:col-span-2 space-y-3 sm:space-y-4 lg:space-y-6">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Performance Metrics</h2>
<WorkoutChart workouts={recentWorkouts} />
</div>
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Recent Activities</h2>
{recentWorkouts.length > 0 ? (
<div className="space-y-4">
{recentWorkouts.map(workout => (
<div key={workout.id} className="p-3 sm:p-4 border rounded-lg hover:bg-gray-50">
<div className="flex justify-between items-center gap-2">
<div>
<h3 className="text-sm sm:text-base font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3>
<p className="text-xs sm:text-sm text-gray-600">{workout.activity_type}</p>
</div>
<div className="text-right">
<p className="text-sm sm:text-base font-medium">{(workout.distance_m / 1000).toFixed(1)} km</p>
<p className="text-xs sm:text-sm text-gray-600">{Math.round(workout.duration_seconds / 60)} mins</p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-gray-500 text-center py-4">No recent activities found</div>
)}
</div>
</div>
<div className="space-y-6">
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Current Plan</h2>
{currentPlan ? (
<PlanTimeline plan={currentPlan} />
) : (
<div className="text-gray-500 text-center py-4">No active training plan</div>
)}
</div>
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Upcoming Workouts</h2>
{currentPlan?.jsonb_plan.weeks[0]?.workouts.map((workout, index) => (
<div key={index} className="p-2 sm:p-3 border-b last:border-b-0">
<div className="flex justify-between items-center">
<span className="capitalize">{workout.day}</span>
<span className="text-xs sm:text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{workout.type.replace('_', ' ')}
</span>
</div>
<p className="text-xs sm:text-sm text-gray-600 mt-1">{workout.description}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,38 @@
import { useRouter } from 'next/router'
import PlanTimeline from '../src/components/PlanTimeline'
const PlanDetails = () => {
const router = useRouter()
const { planId } = router.query
// If the planId is not available yet (still loading), show a loading state
if (!planId) {
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
<div className="max-w-4xl mx-auto">
<div className="p-4 space-y-4">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-gray-200 rounded w-1/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-12 bg-gray-100 rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
<div className="max-w-4xl mx-auto">
<PlanTimeline planId={planId} />
</div>
</div>
)
}
export default PlanDetails

View File

@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../src/context/AuthContext';
import GoalSelector from '../src/components/plans/GoalSelector';
import PlanParameters from '../src/components/plans/PlanParameters';
import { generatePlan } from '../src/services/planService';
import ProgressTracker from '../src/components/ui/ProgressTracker';
const PlanGeneration = () => {
const { apiKey } = useAuth();
const router = useRouter();
const [step, setStep] = useState(1);
const [goals, setGoals] = useState([]);
const [rules, setRules] = useState([]);
const [params, setParams] = useState({
duration: 4,
weeklyHours: 8,
availableDays: []
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleGenerate = async () => {
try {
setLoading(true);
const plan = await generatePlan(apiKey, {
goals,
ruleIds: rules,
...params
});
router.push(`/plans/${plan.id}/preview`);
} catch (err) {
setError('Failed to generate plan. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto p-6">
<ProgressTracker currentStep={step} totalSteps={3} />
{step === 1 && (
<GoalSelector
goals={goals}
onSelect={setGoals}
onNext={() => setStep(2)}
/>
)}
{step === 2 && (
<PlanParameters
values={params}
onChange={setParams}
onBack={() => setStep(1)}
onNext={() => setStep(3)}
/>
)}
{step === 3 && (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-4">Review and Generate</h2>
<div className="mb-6">
<h3 className="font-semibold mb-2">Selected Goals:</h3>
<ul className="list-disc pl-5">
{goals.map((goal, index) => (
<li key={index}>{goal}</li>
))}
</ul>
</div>
<button
onClick={handleGenerate}
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{loading ? 'Generating...' : 'Generate Plan'}
</button>
{error && <p className="text-red-500 mt-2">{error}</p>}
</div>
)}
</div>
);
};
export default PlanGeneration;

10
frontend/pages/Plans.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
const Plans = () => (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Training Plans</h1>
<p className="text-gray-600">Training plans page under development</p>
</div>
);
export default Plans;

View File

@@ -0,0 +1,75 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../src/context/AuthContext';
import FileUpload from '../src/components/routes/FileUpload';
import RouteList from '../src/components/routes/RouteList';
import RouteFilter from '../src/components/routes/RouteFilter';
import LoadingSpinner from '../src/components/LoadingSpinner';
const RoutesPage = () => {
const { apiKey } = useAuth();
const [routes, setRoutes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
searchQuery: '',
minDistance: 0,
maxDistance: 500,
difficulty: 'all',
});
useEffect(() => {
const fetchRoutes = async () => {
try {
const response = await fetch('/api/routes', {
headers: { 'X-API-Key': apiKey }
});
if (!response.ok) throw new Error('Failed to fetch routes');
const data = await response.json();
setRoutes(data.routes);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchRoutes();
}, [apiKey]);
const filteredRoutes = routes.filter(route => {
const matchesSearch = route.name.toLowerCase().includes(filters.searchQuery.toLowerCase());
const matchesDistance = route.distance >= filters.minDistance &&
route.distance <= filters.maxDistance;
const matchesDifficulty = filters.difficulty === 'all' ||
route.difficulty === filters.difficulty;
return matchesSearch && matchesDistance && matchesDifficulty;
});
const handleUploadSuccess = (newRoute) => {
setRoutes(prev => [...prev, newRoute]);
};
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Routes</h1>
<div className="space-y-8">
<div className="bg-white p-6 rounded-lg shadow-md">
<RouteFilter filters={filters} onFilterChange={setFilters} />
<FileUpload onUploadSuccess={handleUploadSuccess} />
</div>
{loading ? (
<LoadingSpinner />
) : error ? (
<div className="text-red-600 bg-red-50 p-4 rounded-md">{error}</div>
) : (
<RouteList routes={filteredRoutes} />
)}
</div>
</div>
);
};
export default RoutesPage;

10
frontend/pages/Rules.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
const Rules = () => (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Training Rules</h1>
<p className="text-gray-600">Training rules page under development</p>
</div>
);
export default Rules;

View File

@@ -0,0 +1,10 @@
import React from 'react';
const Workouts = () => (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">Workouts</h1>
<p className="text-gray-600">Workouts page under development</p>
</div>
);
export default Workouts;

11
frontend/pages/_app.js Normal file
View File

@@ -0,0 +1,11 @@
import { AuthProvider } from '../src/context/AuthContext';
function MyApp({ Component, pageProps }) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
export default MyApp;

13
frontend/pages/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export default function Home() {
const router = useRouter();
useEffect(() => {
// Redirect to dashboard
router.push('/dashboard');
}, [router]);
return null; // or a loading spinner
}

View File

@@ -1,80 +1,10 @@
import { useState, useEffect } from 'react'; import React from 'react';
import axios from 'axios';
import { useAuth } from '../context/AuthContext';
import PlanTimeline from '../components/plans/PlanTimeline';
const Plans = () => { const Plans = () => (
const { apiKey } = useAuth(); <div className="p-6">
const [plans, setPlans] = useState([]); <h1 className="text-2xl font-bold mb-4">Training Plans</h1>
const [selectedPlan, setSelectedPlan] = useState(null); <p className="text-gray-600">Training plans page under development</p>
const [loading, setLoading] = useState(true); </div>
const [error, setError] = useState(''); );
const isBuildTime = typeof window === 'undefined';
useEffect(() => {
if (isBuildTime) return;
const fetchPlans = async () => {
try {
const response = await axios.get('/api/plans', {
headers: { 'X-API-Key': apiKey }
});
setPlans(response.data);
if (response.data.length > 0) {
setSelectedPlan(response.data[0].id);
}
} catch (err) {
setError('Failed to load training plans');
} finally {
setLoading(false);
}
};
fetchPlans();
}, [apiKey]);
if (typeof window === 'undefined') {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Training Plans</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading training plans...</p>
</div>
</div>
);
}
if (loading) return <div className="p-6 text-center">Loading plans...</div>;
if (error) return <div className="p-6 text-red-600">{error}</div>;
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Training Plans</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-4">
{plans.map(plan => (
<div
key={plan.id}
onClick={() => setSelectedPlan(plan.id)}
className={`p-4 bg-white rounded-lg shadow-md cursor-pointer ${
selectedPlan === plan.id ? 'ring-2 ring-blue-500' : ''
}`}
>
<h3 className="font-medium">Plan v{plan.version}</h3>
<p className="text-sm text-gray-600">
Created {new Date(plan.created_at).toLocaleDateString()}
</p>
</div>
))}
</div>
<div className="lg:col-span-2">
{selectedPlan && <PlanTimeline planId={selectedPlan} />}
</div>
</div>
</div>
);
};
export default Plans; export default Plans;

View File

@@ -1,85 +1,10 @@
import { useState, useEffect } from 'react'; import React from 'react';
import RuleEditor from '../components/rules/RuleEditor';
import RulePreview from '../components/rules/RulePreview';
import RulesList from '../components/rules/RulesList';
import { getRuleSets, createRuleSet, parseRule } from '../services/ruleService';
const RulesPage = () => { const Rules = () => (
const [ruleText, setRuleText] = useState(''); <div className="p-6">
const [parsedRules, setParsedRules] = useState(null); <h1 className="text-2xl font-bold mb-4">Training Rules</h1>
const [ruleSets, setRuleSets] = useState([]); <p className="text-gray-600">Training rules page under development</p>
const [isLoading, setIsLoading] = useState(false); </div>
const [error, setError] = useState(''); );
// Load initial rule sets export default Rules;
useEffect(() => {
const loadRuleSets = async () => {
try {
const { data } = await getRuleSets();
setRuleSets(data);
} catch (err) {
setError('Failed to load rule sets');
}
};
loadRuleSets();
}, []);
const handleSave = async () => {
setIsLoading(true);
try {
await createRuleSet({
naturalLanguage: ruleText,
jsonRules: parsedRules
});
setRuleText('');
setParsedRules(null);
// Refresh rule sets list
const { data } = await getRuleSets();
setRuleSets(data);
} catch (err) {
setError('Failed to save rule set');
} finally {
setIsLoading(false);
}
};
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Training Rules Management</h1>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
{error}
</div>
)}
<div className="flex gap-6 mb-8">
<div className="flex-1">
<RuleEditor
value={ruleText}
onChange={setRuleText}
onParse={setParsedRules}
/>
</div>
<div className="flex-1">
<RulePreview
rules={parsedRules}
onSave={handleSave}
isSaving={isLoading}
/>
</div>
</div>
<RulesList
ruleSets={ruleSets}
onSelect={(set) => {
setRuleText(set.naturalLanguage);
setParsedRules(set.jsonRules);
}}
/>
</div>
);
};
export default RulesPage;

View File

@@ -1,81 +1,10 @@
import { useState, useEffect } from 'react'; import React from 'react';
import { useAuth } from '../context/AuthContext';
import axios from 'axios';
import WorkoutAnalysis from '../components/analysis/WorkoutAnalysis';
const Workouts = () => { const Workouts = () => (
const { apiKey } = useAuth(); <div className="p-6">
const [workouts, setWorkouts] = useState([]); <h1 className="text-2xl font-bold mb-4">Workouts</h1>
const [selectedWorkout, setSelectedWorkout] = useState(null); <p className="text-gray-600">Workouts page under development</p>
const [loading, setLoading] = useState(true); </div>
const [error, setError] = useState(''); );
const isBuildTime = typeof window === 'undefined';
useEffect(() => {
if (isBuildTime) return;
const fetchWorkouts = async () => {
try {
const response = await axios.get('/api/workouts', {
headers: { 'X-API-Key': apiKey }
});
setWorkouts(response.data.workouts);
} catch (err) {
setError('Failed to load workouts');
} finally {
setLoading(false);
}
};
fetchWorkouts();
}, [apiKey]);
if (isBuildTime) {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Workouts</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading workout data...</p>
</div>
</div>
);
}
if (loading) return <div className="p-6 text-center">Loading workouts...</div>;
if (error) return <div className="p-6 text-red-600">{error}</div>;
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Workouts</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
{workouts.map(workout => (
<div
key={workout.id}
onClick={() => setSelectedWorkout(workout)}
className={`p-4 bg-white rounded-lg shadow-md cursor-pointer ${
selectedWorkout?.id === workout.id ? 'ring-2 ring-blue-500' : ''
}`}
>
<h3 className="font-medium">
{new Date(workout.start_time).toLocaleDateString()} - {workout.activity_type}
</h3>
<p className="text-sm text-gray-600">
Duration: {Math.round(workout.duration_seconds / 60)}min
</p>
</div>
))}
</div>
{selectedWorkout && (
<div className="bg-white p-6 rounded-lg shadow-md">
<WorkoutAnalysis workoutId={selectedWorkout.id} />
</div>
)}
</div>
</div>
);
};
export default Workouts; export default Workouts;

View File

@@ -1,55 +0,0 @@
import { useEffect, useState } from 'react';
import Navigation from '../components/Navigation';
export default function Home() {
const [healthStatus, setHealthStatus] = useState<string>('checking...');
useEffect(() => {
const checkBackendHealth = async () => {
try {
// Use the API URL from environment variables
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
const response = await fetch(`${apiUrl}/health`);
const data = await response.json();
setHealthStatus(data.status);
} catch (error) {
setHealthStatus('unavailable');
console.error('Error checking backend health:', error);
}
};
checkBackendHealth();
}, []);
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<div className="max-w-2xl mx-auto p-8">
<div className="bg-white rounded-lg shadow-md p-8">
<h1 className="text-3xl font-bold text-center text-gray-800 mb-6">
Welcome to AI Cycling Coach
</h1>
<p className="text-lg text-gray-600 mb-8 text-center">
Your AI-powered training companion for cyclists
</p>
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
<h2 className="text-lg font-semibold text-blue-800 mb-2">
System Status
</h2>
<div className="flex items-center">
<div className={`h-3 w-3 rounded-full mr-2 ${healthStatus === 'healthy' ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-gray-700">
Backend service: {healthStatus}
</span>
</div>
</div>
<p className="mt-8 text-center text-gray-500">
Development in progress - more features coming soon!
</p>
</div>
</div>
</div>
);
}

102
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"gpxparser": "^3.0.8", "gpxparser": "^3.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^7.8.2",
"react-toastify": "^11.0.5" "react-toastify": "^11.0.5"
} }
}, },
@@ -160,6 +161,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"engines": {
"node": ">=18"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -425,6 +434,12 @@
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="
}, },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"peer": true
},
"node_modules/jsbn": { "node_modules/jsbn": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
@@ -538,6 +553,18 @@
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -636,6 +663,31 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-leaflet": { "node_modules/react-leaflet": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
@@ -649,6 +701,42 @@
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
} }
}, },
"node_modules/react-router": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz",
"integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz",
"integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==",
"dependencies": {
"react-router": "7.8.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-toastify": { "node_modules/react-toastify": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
@@ -782,6 +870,20 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -3,6 +3,7 @@
"gpxparser": "^3.0.8", "gpxparser": "^3.0.8",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-router-dom": "^7.8.2",
"react-toastify": "^11.0.5" "react-toastify": "^11.0.5"
} }
} }