mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-03-13 00:15:49 +00:00
sync
This commit is contained in:
1565
CL_implementation_guide.md
Normal file
1565
CL_implementation_guide.md
Normal file
File diff suppressed because it is too large
Load Diff
40
backend/alembic/versions/001_create_rules_table.py
Normal file
40
backend/alembic/versions/001_create_rules_table.py
Normal 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')
|
||||
@@ -1,11 +1,10 @@
|
||||
from fastapi import HTTPException, Header, status
|
||||
import os
|
||||
from fastapi import Depends
|
||||
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"""
|
||||
expected_key = os.getenv("API_KEY")
|
||||
if not expected_key or api_key != expected_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing API Key"
|
||||
)
|
||||
|
||||
async def get_ai_service(db: AsyncSession = Depends(get_db)) -> AIService:
|
||||
"""Get AI service instance with database dependency."""
|
||||
return AIService(db)
|
||||
@@ -65,7 +65,11 @@ app = FastAPI(
|
||||
# API Key Authentication Middleware
|
||||
@app.middleware("http")
|
||||
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)
|
||||
|
||||
api_key = request.headers.get("X-API-KEY")
|
||||
|
||||
@@ -11,5 +11,6 @@ class Plan(BaseModel):
|
||||
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
|
||||
|
||||
parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans")
|
||||
analyses = relationship("Analysis", back_populates="plan")
|
||||
workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan")
|
||||
analyses = relationship("Analysis", back_populates="plan", lazy="selectin")
|
||||
workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan", lazy="selectin")
|
||||
rules = relationship("Rule", secondary="plan_rules", back_populates="plans", lazy="selectin")
|
||||
@@ -1,5 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey, Boolean, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import Column, Integer, ForeignKey, Boolean, String, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from .base import BaseModel
|
||||
|
||||
@@ -7,9 +6,11 @@ class Rule(BaseModel):
|
||||
__tablename__ = "rules"
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=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)
|
||||
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")
|
||||
@@ -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 import select
|
||||
from app.database import get_db
|
||||
from app.models import Plan, PlanRule, Rule
|
||||
from app.schemas.plan import PlanCreate, Plan as PlanSchema
|
||||
from uuid import UUID
|
||||
from app.models.plan import Plan as PlanModel
|
||||
from app.models.rule import Rule
|
||||
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"])
|
||||
|
||||
@@ -14,20 +19,12 @@ async def create_plan(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
# Create plan
|
||||
db_plan = Plan(
|
||||
user_id=plan.user_id,
|
||||
start_date=plan.start_date,
|
||||
end_date=plan.end_date,
|
||||
goal=plan.goal
|
||||
db_plan = PlanModel(
|
||||
jsonb_plan=plan.jsonb_plan,
|
||||
version=plan.version,
|
||||
parent_plan_id=plan.parent_plan_id
|
||||
)
|
||||
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.refresh(db_plan)
|
||||
return db_plan
|
||||
@@ -37,16 +34,16 @@ async def read_plan(
|
||||
plan_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
plan = await db.get(Plan, plan_id)
|
||||
plan = await db.get(PlanModel, plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
return plan
|
||||
|
||||
@router.get("/", response_model=list[PlanSchema])
|
||||
@router.get("/", response_model=List[PlanSchema])
|
||||
async def read_plans(
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
result = await db.execute(select(Plan))
|
||||
result = await db.execute(select(PlanModel))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.put("/{plan_id}", response_model=PlanSchema)
|
||||
@@ -55,21 +52,14 @@ async def update_plan(
|
||||
plan: PlanCreate,
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
# Update plan fields
|
||||
db_plan.user_id = plan.user_id
|
||||
db_plan.start_date = plan.start_date
|
||||
db_plan.end_date = plan.end_date
|
||||
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)
|
||||
db_plan.jsonb_plan = plan.jsonb_plan
|
||||
db_plan.version = plan.version
|
||||
db_plan.parent_plan_id = plan.parent_plan_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_plan)
|
||||
@@ -80,10 +70,63 @@ async def delete_plan(
|
||||
plan_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
plan = await db.get(Plan, plan_id)
|
||||
plan = await db.get(PlanModel, plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
await db.delete(plan)
|
||||
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)}"
|
||||
)
|
||||
@@ -1,9 +1,13 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models import Rule
|
||||
from app.schemas.rule import RuleCreate, Rule as RuleSchema
|
||||
from app.models.rule import Rule
|
||||
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 typing import List
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["Rules"])
|
||||
|
||||
@@ -12,55 +16,107 @@ async def create_rule(
|
||||
rule: RuleCreate,
|
||||
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)
|
||||
await db.commit()
|
||||
await db.refresh(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)
|
||||
async def read_rule(
|
||||
async def get_rule(
|
||||
rule_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific rule set."""
|
||||
rule = await db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
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)
|
||||
async def update_rule(
|
||||
rule_id: UUID,
|
||||
rule: RuleCreate,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update rule set - creates new version as per design spec."""
|
||||
db_rule = await db.get(Rule, rule_id)
|
||||
if not db_rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
for key, value in rule.dict().items():
|
||||
setattr(db_rule, key, value)
|
||||
# Create new version instead of updating in place
|
||||
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.refresh(db_rule)
|
||||
return db_rule
|
||||
await db.refresh(new_version)
|
||||
return new_version
|
||||
|
||||
@router.delete("/{rule_id}")
|
||||
async def delete_rule(
|
||||
rule_id: UUID,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete rule set."""
|
||||
rule = await db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
|
||||
await db.delete(rule)
|
||||
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)}"
|
||||
)
|
||||
@@ -154,7 +154,7 @@ async def approve_analysis(
|
||||
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(
|
||||
plan_id: int,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
|
||||
@@ -18,7 +18,7 @@ class Analysis(AnalysisBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AnalysisUpdate(BaseModel):
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from typing import List, Optional, Dict, Any
|
||||
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):
|
||||
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")
|
||||
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):
|
||||
pass
|
||||
|
||||
class Plan(PlanBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
analyses: List["Analysis"] = Field([], description="Analyses that created this plan version")
|
||||
child_plans: List["Plan"] = Field([], description="Evolved versions of this plan")
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: Optional[datetime] = Field(default=None)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = {"from_attributes": 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}
|
||||
@@ -1,17 +1,49 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
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):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
condition: str
|
||||
priority: int = 0
|
||||
"""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")
|
||||
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):
|
||||
pass
|
||||
|
||||
class Rule(RuleBase):
|
||||
id: str
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -43,12 +43,12 @@ class AIService:
|
||||
response = await self._make_ai_request(prompt)
|
||||
return self._parse_workout_analysis(response)
|
||||
|
||||
async def generate_plan(self, rules: List[Dict], goals: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Generate a training plan using AI."""
|
||||
async def generate_plan(self, rules_text: str, goals: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Generate a training plan using AI with plaintext rules as per design spec."""
|
||||
prompt_template = await self.prompt_manager.get_active_prompt("plan_generation")
|
||||
|
||||
context = {
|
||||
"rules": rules,
|
||||
"rules_text": rules_text, # Use plaintext rules directly
|
||||
"goals": goals,
|
||||
"current_fitness_level": goals.get("fitness_level", "intermediate")
|
||||
}
|
||||
@@ -57,13 +57,80 @@ class AIService:
|
||||
response = await self._make_ai_request(prompt)
|
||||
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]:
|
||||
"""Parse natural language rules into structured format."""
|
||||
prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing")
|
||||
prompt = prompt_template.format(user_rules=natural_language)
|
||||
|
||||
response = await self._make_ai_request(prompt)
|
||||
return self._parse_rules_response(response)
|
||||
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]:
|
||||
"""Evolve a training plan using AI based on workout analysis."""
|
||||
|
||||
24
designdoc.md
24
designdoc.md
@@ -20,7 +20,7 @@
|
||||
**Workflow Overview**
|
||||
|
||||
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
|
||||
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
|
||||
@@ -33,7 +33,7 @@
|
||||
**Tasks:**
|
||||
|
||||
* **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
|
||||
* **Workout Analysis:** Fetch Garmin activity, run AI analysis, store reports
|
||||
* **AI Integration:** Async calls to OpenRouter
|
||||
@@ -45,7 +45,7 @@
|
||||
| ------ | ------------------- | ------------------------------------------------ |
|
||||
| POST | `/routes/upload` | Upload GPX file for route/section |
|
||||
| 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 |
|
||||
| GET | `/plans/{plan_id}` | Fetch plan JSON & version info |
|
||||
| POST | `/workouts/analyze` | Trigger AI analysis for a synced Garmin activity |
|
||||
@@ -82,14 +82,17 @@ CREATE TABLE sections (
|
||||
created_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
|
||||
-- Rules (hierarchical JSON)
|
||||
-- Rules (plaintext storage)
|
||||
CREATE TABLE rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
user_defined BOOLEAN DEFAULT true,
|
||||
jsonb_rules JSONB NOT NULL,
|
||||
rule_text TEXT NOT NULL, -- Plaintext rules
|
||||
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)
|
||||
@@ -192,9 +195,9 @@ volumes:
|
||||
* View route map + section metadata
|
||||
2. **Rules**
|
||||
|
||||
* Natural language editor
|
||||
* AI parse → preview JSON → save
|
||||
* Switch between rule sets
|
||||
* Plaintext rule editor
|
||||
* Simple create/edit form
|
||||
* Rule version history
|
||||
3. **Plan**
|
||||
|
||||
* Select goal + rule set → generate plan
|
||||
@@ -213,7 +216,7 @@ volumes:
|
||||
### **5.3 User Flow Example**
|
||||
|
||||
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
|
||||
4. Sync Garmin activity → backend fetches metrics → store workout
|
||||
5. AI analyzes → report displayed → user approves → new plan version
|
||||
@@ -229,6 +232,7 @@ volumes:
|
||||
* Configurable model per action
|
||||
* Async calls to OpenRouter
|
||||
* Store raw AI output + processed structured result in DB
|
||||
* Use plaintext rules directly in prompts without parsing
|
||||
|
||||
---
|
||||
|
||||
|
||||
181
frontend/pages/Dashboard.jsx
Normal file
181
frontend/pages/Dashboard.jsx
Normal 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;
|
||||
38
frontend/pages/PlanDetails.jsx
Normal file
38
frontend/pages/PlanDetails.jsx
Normal 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
|
||||
85
frontend/pages/PlanGeneration.jsx
Normal file
85
frontend/pages/PlanGeneration.jsx
Normal 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
10
frontend/pages/Plans.jsx
Normal 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;
|
||||
75
frontend/pages/RoutesPage.jsx
Normal file
75
frontend/pages/RoutesPage.jsx
Normal 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
10
frontend/pages/Rules.jsx
Normal 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;
|
||||
10
frontend/pages/Workouts.jsx
Normal file
10
frontend/pages/Workouts.jsx
Normal 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
11
frontend/pages/_app.js
Normal 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
13
frontend/pages/index.js
Normal 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
|
||||
}
|
||||
@@ -1,80 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import PlanTimeline from '../components/plans/PlanTimeline';
|
||||
import React from 'react';
|
||||
|
||||
const Plans = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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>
|
||||
);
|
||||
};
|
||||
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;
|
||||
@@ -1,85 +1,10 @@
|
||||
import { useState, useEffect } 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';
|
||||
import React from 'react';
|
||||
|
||||
const RulesPage = () => {
|
||||
const [ruleText, setRuleText] = useState('');
|
||||
const [parsedRules, setParsedRules] = useState(null);
|
||||
const [ruleSets, setRuleSets] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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>
|
||||
);
|
||||
|
||||
// Load initial rule sets
|
||||
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;
|
||||
export default Rules;
|
||||
@@ -1,81 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import axios from 'axios';
|
||||
import WorkoutAnalysis from '../components/analysis/WorkoutAnalysis';
|
||||
import React from 'react';
|
||||
|
||||
const Workouts = () => {
|
||||
const { apiKey } = useAuth();
|
||||
const [workouts, setWorkouts] = useState([]);
|
||||
const [selectedWorkout, setSelectedWorkout] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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>
|
||||
);
|
||||
};
|
||||
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;
|
||||
@@ -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
102
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"gpxparser": "^3.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"react-toastify": "^11.0.5"
|
||||
}
|
||||
},
|
||||
@@ -160,6 +161,14 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "0.1.1",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
@@ -636,6 +663,31 @@
|
||||
"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": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
@@ -649,6 +701,42 @@
|
||||
"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": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
|
||||
@@ -782,6 +870,20 @@
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"gpxparser": "^3.0.8",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"react-toastify": "^11.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user