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
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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

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 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)}"
)

View File

@@ -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)}"
)

View File

@@ -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)

View File

@@ -18,7 +18,7 @@ class Analysis(AnalysisBase):
id: int
class Config:
orm_mode = True
from_attributes = True
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 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}

View File

@@ -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}

View File

@@ -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."""

View File

@@ -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
---

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 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;

View File

@@ -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;

View File

@@ -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;

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",
"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",

View File

@@ -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"
}
}