This commit is contained in:
2025-09-11 07:45:25 -07:00
parent f443e7a64e
commit 651ce46183
46 changed files with 5063 additions and 164 deletions

261
CL_backendfixes.md Normal file
View File

@@ -0,0 +1,261 @@
# 🎯 **Backend Implementation TODO List**
## **Priority 1: Core API Gaps (Essential)**
### **1.1 Plan Generation Endpoint**
- [ ] **Add plan generation endpoint** in `app/routes/plan.py`
```python
@router.post("/generate", response_model=PlanSchema)
async def generate_plan(
plan_request: PlanGenerationRequest,
db: AsyncSession = Depends(get_db)
):
```
- [ ] **Create PlanGenerationRequest schema** in `app/schemas/plan.py`
```python
class PlanGenerationRequest(BaseModel):
rule_ids: List[UUID]
goals: Dict[str, Any]
user_preferences: Optional[Dict[str, Any]] = None
duration_weeks: int = 12
```
- [ ] **Update AIService.generate_plan()** to handle rule fetching from DB
- [ ] **Add validation** for rule compatibility and goal requirements
- [ ] **Add tests** for plan generation workflow
### **1.2 Rule Parsing API**
- [ ] **Add natural language rule parsing endpoint** in `app/routes/rule.py`
```python
@router.post("/parse-natural-language")
async def parse_natural_language_rules(
request: NaturalLanguageRuleRequest,
db: AsyncSession = Depends(get_db)
):
```
- [ ] **Create request/response schemas** in `app/schemas/rule.py`
```python
class NaturalLanguageRuleRequest(BaseModel):
natural_language_text: str
rule_name: str
class ParsedRuleResponse(BaseModel):
parsed_rules: Dict[str, Any]
confidence_score: Optional[float]
suggestions: Optional[List[str]]
```
- [ ] **Enhance AIService.parse_rules_from_natural_language()** with better error handling
- [ ] **Add rule validation** after parsing
- [ ] **Add preview mode** before saving parsed rules
### **1.3 Section Integration with GPX Parsing**
- [ ] **Update `app/services/gpx.py`** to create sections automatically
```python
async def parse_gpx_with_sections(file_path: str, route_id: UUID, db: AsyncSession) -> dict:
# Parse GPX into segments
# Create Section records for each segment
# Return enhanced GPX data with section metadata
```
- [ ] **Modify `app/routes/gpx.py`** to create sections after route creation
- [ ] **Add section creation logic** in GPX upload workflow
- [ ] **Update Section model** to include more GPX-derived metadata
- [ ] **Add section querying endpoints** for route visualization
## **Priority 2: Data Model Enhancements**
### **2.1 Missing Schema Fields**
- [ ] **Add missing fields to User model** in `app/models/user.py`
```python
class User(BaseModel):
name: Optional[str]
email: Optional[str]
fitness_level: Optional[str]
preferences: Optional[JSON]
```
- [ ] **Enhance Plan model** with additional metadata
```python
class Plan(BaseModel):
user_id: Optional[UUID] = Column(ForeignKey("users.id"))
name: str
description: Optional[str]
start_date: Optional[Date]
end_date: Optional[Date]
goal_type: Optional[str]
active: Boolean = Column(default=True)
```
- [ ] **Add plan-rule relationship table** (already exists but ensure proper usage)
- [ ] **Update all schemas** to match enhanced models
### **2.2 Database Relationships**
- [ ] **Fix User-Plan relationship** in models
- [ ] **Add cascade delete rules** where appropriate
- [ ] **Add database constraints** for data integrity
- [ ] **Create missing indexes** for performance
```sql
CREATE INDEX idx_workouts_garmin_activity_id ON workouts(garmin_activity_id);
CREATE INDEX idx_plans_user_active ON plans(user_id, active);
CREATE INDEX idx_analyses_workout_approved ON analyses(workout_id, approved);
```
## **Priority 3: API Completeness**
### **3.1 Export/Import Functionality**
- [ ] **Create export service** `app/services/export_import.py`
```python
class ExportImportService:
async def export_user_data(user_id: UUID) -> bytes:
async def export_routes() -> bytes:
async def import_user_data(data: bytes, user_id: UUID):
```
- [ ] **Add export endpoints** in new `app/routes/export.py`
```python
@router.get("/export/routes")
@router.get("/export/plans/{plan_id}")
@router.get("/export/user-data")
@router.post("/import/routes")
@router.post("/import/plans")
```
- [ ] **Support multiple formats** (JSON, GPX, ZIP)
- [ ] **Add data validation** for imports
- [ ] **Handle version compatibility** for imports
### **3.2 Enhanced Dashboard API**
- [ ] **Expand dashboard data** in `app/routes/dashboard.py`
```python
@router.get("/metrics/weekly")
@router.get("/metrics/monthly")
@router.get("/progress/{plan_id}")
@router.get("/upcoming-workouts")
```
- [ ] **Add aggregation queries** for metrics
- [ ] **Cache dashboard data** for performance
- [ ] **Add real-time updates** capability
### **3.3 Advanced Workout Features**
- [ ] **Add workout comparison endpoint**
```python
@router.get("/workouts/{workout_id}/compare/{compare_workout_id}")
```
- [ ] **Add workout search/filtering**
```python
@router.get("/workouts/search")
async def search_workouts(
activity_type: Optional[str] = None,
date_range: Optional[DateRange] = None,
power_range: Optional[PowerRange] = None
):
```
- [ ] **Add bulk workout operations**
- [ ] **Add workout tagging system**
## **Priority 4: Service Layer Improvements**
### **4.1 AI Service Enhancements**
- [ ] **Add prompt caching** to reduce API calls
- [ ] **Implement prompt A/B testing** framework
- [ ] **Add AI response validation** and confidence scoring
- [ ] **Create AI service health checks**
- [ ] **Add fallback mechanisms** for AI failures
- [ ] **Implement rate limiting** for AI calls
- [ ] **Add cost tracking** for AI API usage
### **4.2 Garmin Service Improvements**
- [ ] **Add incremental sync** instead of full sync
- [ ] **Implement activity deduplication** logic
- [ ] **Add webhook support** for real-time sync
- [ ] **Enhance error recovery** for failed syncs
- [ ] **Add activity type filtering**
- [ ] **Support multiple Garmin accounts** per user
### **4.3 Plan Evolution Enhancements**
- [ ] **Add plan comparison** functionality
- [ ] **Implement plan rollback** mechanism
- [ ] **Add plan branching** for different scenarios
- [ ] **Create plan templates** system
- [ ] **Add automated plan adjustments** based on performance
## **Priority 5: Validation & Error Handling**
### **5.1 Input Validation**
- [ ] **Add comprehensive Pydantic validators** for all schemas
- [ ] **Validate GPX file integrity** before processing
- [ ] **Add business rule validation** (e.g., plan dates, workout conflicts)
- [ ] **Validate AI responses** before storing
- [ ] **Add file size/type restrictions**
### **5.2 Error Handling**
- [ ] **Create custom exception hierarchy**
```python
class CyclingCoachException(Exception):
class GarminSyncError(CyclingCoachException):
class AIServiceError(CyclingCoachException):
class PlanGenerationError(CyclingCoachException):
```
- [ ] **Add global exception handler**
- [ ] **Improve error messages** for user feedback
- [ ] **Add error recovery mechanisms**
- [ ] **Log errors with context** for debugging
## **Priority 6: Performance & Monitoring**
### **6.1 Performance Optimizations**
- [ ] **Add database query optimization**
- [ ] **Implement caching** for frequently accessed data
- [ ] **Add connection pooling** configuration
- [ ] **Optimize GPX file parsing** for large files
- [ ] **Add pagination** to list endpoints
- [ ] **Implement background job queue** for long-running tasks
### **6.2 Enhanced Monitoring**
- [ ] **Add application metrics** (response times, error rates)
- [ ] **Create health check dependencies**
- [ ] **Add performance profiling** endpoints
- [ ] **Implement alerting** for critical errors
- [ ] **Add audit logging** for data changes
## **Priority 7: Security & Configuration**
### **7.1 Security Improvements**
- [ ] **Implement user authentication/authorization**
- [ ] **Add rate limiting** to prevent abuse
- [ ] **Validate file uploads** for security
- [ ] **Add CORS configuration** properly
- [ ] **Implement request/response logging** (without sensitive data)
- [ ] **Add API versioning** support
### **7.2 Configuration Management**
- [ ] **Add environment-specific configs**
- [ ] **Validate configuration** on startup
- [ ] **Add feature flags** system
- [ ] **Implement secrets management**
- [ ] **Add configuration reload** without restart
## **Priority 8: Testing & Documentation**
### **8.1 Testing**
- [ ] **Create comprehensive test suite**
- Unit tests for services
- Integration tests for API endpoints
- Database migration tests
- AI service mock tests
- [ ] **Add test fixtures** for common data
- [ ] **Implement test database** setup/teardown
- [ ] **Add performance tests** for critical paths
- [ ] **Create end-to-end tests** for workflows
### **8.2 Documentation**
- [ ] **Generate OpenAPI documentation**
- [ ] **Add endpoint documentation** with examples
- [ ] **Create service documentation**
- [ ] **Document deployment procedures**
- [ ] **Add troubleshooting guides**
---
## **🎯 Recommended Implementation Order:**
1. **Week 1:** Priority 1 (Core API gaps) - Essential for feature completeness
2. **Week 2:** Priority 2 (Data model) + Priority 5.1 (Validation) - Foundation improvements
3. **Week 3:** Priority 3.1 (Export/Import) + Priority 4.1 (AI improvements) - User-facing features
4. **Week 4:** Priority 6 (Performance) + Priority 8.1 (Testing) - Production readiness
This todo list will bring your backend implementation to 100% design doc compliance and beyond, making it production-ready with enterprise-level features! 🚀

255
CL_frontendfixes.md Normal file
View File

@@ -0,0 +1,255 @@
# Frontend Development TODO List
## 🚨 Critical Missing Features (High Priority)
### 1. Rules Management System
- [ ] **Create Rules page component** (`/src/pages/Rules.jsx`)
- [ ] Natural language textarea editor
- [ ] AI parsing button with loading state
- [ ] JSON preview pane with syntax highlighting
- [ ] Rule validation feedback
- [ ] Save/cancel actions
- [ ] **Create RuleEditor component** (`/src/components/rules/RuleEditor.jsx`)
- [ ] Rich text input with auto-resize
- [ ] Character count and validation
- [ ] Template suggestions dropdown
- [ ] **Create RulePreview component** (`/src/components/rules/RulePreview.jsx`)
- [ ] JSON syntax highlighting (use `react-json-view`)
- [ ] Editable JSON with validation
- [ ] Diff view for rule changes
- [ ] **Create RulesList component** (`/src/components/rules/RulesList.jsx`)
- [ ] Rule set selection dropdown
- [ ] Version history per rule set
- [ ] Delete/duplicate rule sets
- [ ] **API Integration**
- [ ] `POST /api/rules` - Create new rule set
- [ ] `PUT /api/rules/{id}` - Update rule set
- [ ] `GET /api/rules` - List all rule sets
- [ ] `POST /api/rules/{id}/parse` - AI parsing endpoint
### 2. Plan Generation Workflow
- [ ] **Create PlanGeneration page** (`/src/pages/PlanGeneration.jsx`)
- [ ] Goal selection interface
- [ ] Rule set selection
- [ ] Plan parameters (duration, weekly hours)
- [ ] Progress tracking for AI generation
- [ ] **Create GoalSelector component** (`/src/components/plans/GoalSelector.jsx`)
- [ ] Predefined goal templates
- [ ] Custom goal input
- [ ] Goal validation
- [ ] **Create PlanParameters component** (`/src/components/plans/PlanParameters.jsx`)
- [ ] Duration slider (4-20 weeks)
- [ ] Weekly hours slider (5-15 hours)
- [ ] Difficulty level selection
- [ ] Available days checkboxes
- [ ] **Enhance PlanTimeline component**
- [ ] Week-by-week breakdown
- [ ] Workout details expandable cards
- [ ] Progress tracking indicators
- [ ] Edit individual workouts
- [ ] **API Integration**
- [ ] `POST /api/plans/generate` - Generate new plan
- [ ] `GET /api/plans/{id}/preview` - Preview before saving
- [ ] Plan generation status polling
### 3. Route Management & Visualization
- [ ] **Enhance RoutesPage** (`/src/pages/RoutesPage.jsx`)
- [ ] Route list with metadata
- [ ] GPX file upload integration
- [ ] Route preview cards
- [ ] Search and filter functionality
- [ ] **Create RouteVisualization component** (`/src/components/routes/RouteVisualization.jsx`)
- [ ] Interactive map (use Leaflet.js)
- [ ] GPX track overlay
- [ ] Elevation profile chart
- [ ] Distance markers
- [ ] **Create RouteMetadata component** (`/src/components/routes/RouteMetadata.jsx`)
- [ ] Distance, elevation gain, grade analysis
- [ ] Estimated time calculations
- [ ] Difficulty rating
- [ ] Notes/description editing
- [ ] **Create SectionManager component** (`/src/components/routes/SectionManager.jsx`)
- [ ] Split routes into sections
- [ ] Section-specific metadata
- [ ] Gear recommendations per section
- [ ] **Dependencies to add**
- [ ] `npm install leaflet react-leaflet`
- [ ] GPX parsing library integration
### 4. Export/Import System
- [ ] **Create ExportImport page** (`/src/pages/ExportImport.jsx`)
- [ ] Export options (JSON, ZIP)
- [ ] Import validation
- [ ] Bulk operations
- [ ] **Create DataExporter component** (`/src/components/export/DataExporter.jsx`)
- [ ] Selective export (routes, rules, plans)
- [ ] Format selection (JSON, GPX, ZIP)
- [ ] Export progress tracking
- [ ] **Create DataImporter component** (`/src/components/export/DataImporter.jsx`)
- [ ] File validation and preview
- [ ] Conflict resolution interface
- [ ] Import progress tracking
- [ ] **API Integration**
- [ ] `GET /api/export` - Generate export package
- [ ] `POST /api/import` - Import data package
- [ ] `POST /api/import/validate` - Validate before import
## 🔧 Code Quality & Architecture Improvements
### 5. Enhanced Error Handling
- [ ] **Create GlobalErrorHandler** (`/src/components/GlobalErrorHandler.jsx`)
- [ ] Centralized error logging
- [ ] User-friendly error messages
- [ ] Retry mechanisms
- [ ] **Improve API error handling**
- [ ] Consistent error response format
- [ ] Network error recovery
- [ ] Timeout handling
- [ ] **Add error boundaries**
- [ ] Page-level error boundaries
- [ ] Component-level error recovery
### 6. State Management Improvements
- [ ] **Enhance AuthContext**
- [ ] Add user preferences
- [ ] API caching layer
- [ ] Offline capability detection
- [ ] **Create AppStateContext** (`/src/context/AppStateContext.jsx`)
- [ ] Global loading states
- [ ] Toast notifications
- [ ] Modal management
- [ ] **Add React Query** (Optional but recommended)
- [ ] `npm install @tanstack/react-query`
- [ ] API data caching
- [ ] Background refetching
- [ ] Optimistic updates
### 7. UI/UX Enhancements
- [ ] **Improve responsive design**
- [ ] Better mobile navigation
- [ ] Touch-friendly interactions
- [ ] Responsive charts and maps
- [ ] **Add loading skeletons**
- [ ] Replace generic spinners
- [ ] Component-specific skeletons
- [ ] Progressive loading
- [ ] **Create ConfirmDialog component** (`/src/components/ui/ConfirmDialog.jsx`)
- [ ] Delete confirmations
- [ ] Destructive action warnings
- [ ] Custom confirmation messages
- [ ] **Add keyboard shortcuts**
- [ ] Navigation shortcuts
- [ ] Action shortcuts
- [ ] Help overlay
## 🧪 Testing & Quality Assurance
### 8. Testing Infrastructure
- [ ] **Expand component tests**
- [ ] Rules management tests
- [ ] Plan generation tests
- [ ] Route visualization tests
- [ ] **Add integration tests**
- [ ] API integration tests
- [ ] User workflow tests
- [ ] Error scenario tests
- [ ] **Performance testing**
- [ ] Large dataset handling
- [ ] Chart rendering performance
- [ ] Memory leak detection
### 9. Development Experience
- [ ] **Add Storybook** (Optional)
- [ ] Component documentation
- [ ] Design system documentation
- [ ] Interactive component testing
- [ ] **Improve build process**
- [ ] Bundle size optimization
- [ ] Dead code elimination
- [ ] Tree shaking verification
- [ ] **Add development tools**
- [ ] React DevTools integration
- [ ] Performance monitoring
- [ ] Bundle analyzer
## 📚 Documentation & Dependencies
### 10. Missing Dependencies
```json
{
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"react-json-view": "^1.21.3",
"@tanstack/react-query": "^4.32.0",
"react-hook-form": "^7.45.0",
"react-select": "^5.7.4",
"file-saver": "^2.0.5"
}
```
### 11. Configuration Files
- [ ] **Create environment config** (`/src/config/index.js`)
- [ ] API endpoints configuration
- [ ] Feature flags
- [ ] Environment-specific settings
- [ ] **Add TypeScript support** (Optional)
- [ ] Convert critical components
- [ ] Add type definitions
- [ ] Improve IDE support
## 🚀 Deployment & Performance
### 12. Production Readiness
- [ ] **Optimize bundle size**
- [ ] Code splitting implementation
- [ ] Lazy loading for routes
- [ ] Image optimization
- [ ] **Add PWA features** (Optional)
- [ ] Service worker
- [ ] Offline functionality
- [ ] App manifest
- [ ] **Performance monitoring**
- [ ] Core Web Vitals tracking
- [ ] Error tracking integration
- [ ] User analytics
## 📅 Implementation Priority
### Phase 1 (Week 1-2): Core Missing Features
1. Rules Management System
2. Plan Generation Workflow
3. Enhanced Route Management
### Phase 2 (Week 3): Data Management
1. Export/Import System
2. Enhanced Error Handling
3. State Management Improvements
### Phase 3 (Week 4): Polish & Quality
1. UI/UX Enhancements
2. Testing Infrastructure
3. Performance Optimization
### Phase 4 (Ongoing): Maintenance
1. Documentation
2. Monitoring
3. User Feedback Integration
---
## 🎯 Success Criteria
- [ ] All design document workflows implemented
- [ ] 90%+ component test coverage
- [ ] Mobile-responsive design
- [ ] Sub-3s initial page load
- [ ] Accessibility compliance (WCAG 2.1 AA)
- [ ] Cross-browser compatibility (Chrome, Firefox, Safari, Edge)
## 📝 Notes
- **Prioritize user-facing features** over internal architecture improvements
- **Test each feature** as you implement it
- **Consider Progressive Web App features** for offline functionality
- **Plan for internationalization** if expanding globally
- **Monitor bundle size** as you add dependencies

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, Query, HTTPException
from fastapi.responses import FileResponse
from app.services.export_service import ExportService
from pathlib import Path
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/export")
async def export_data(
types: str = Query(..., description="Comma-separated list of data types to export"),
format: str = Query('json', description="Export format (json, zip, gpx)")
):
valid_types = {'routes', 'rules', 'plans'}
requested_types = set(types.split(','))
# Validate requested types
if not requested_types.issubset(valid_types):
raise HTTPException(
status_code=400,
detail=f"Invalid export types. Valid types are: {', '.join(valid_types)}"
)
try:
exporter = ExportService()
export_path = await exporter.create_export(
export_types=list(requested_types),
export_format=format
)
return FileResponse(
export_path,
media_type="application/zip" if format == 'zip' else "application/json",
filename=f"export_{'_'.join(requested_types)}.{format}"
)
except Exception as e:
logger.error(f"Export failed: {str(e)}")
raise HTTPException(status_code=500, detail="Export failed") from e

View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import JSONResponse
from app.services.import_service import ImportService
import logging
from typing import Optional
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/import/validate")
async def validate_import(
file: UploadFile = File(...),
):
try:
importer = ImportService()
validation_result = await importer.validate_import(file)
return JSONResponse(content=validation_result)
except Exception as e:
logger.error(f"Import validation failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) from e
@router.post("/import")
async def execute_import(
file: UploadFile = File(...),
conflict_resolution: str = Form("skip"),
resolutions: Optional[str] = Form(None),
):
try:
importer = ImportService()
import_result = await importer.execute_import(
file,
conflict_resolution,
resolutions
)
return JSONResponse(content=import_result)
except Exception as e:
logger.error(f"Import failed: {str(e)}")
raise HTTPException(status_code=500, detail=str(e)) from e

View File

@@ -0,0 +1,138 @@
import json
from pathlib import Path
from datetime import datetime
import zipfile
from app.database import SessionLocal
from app.models import Route, Rule, Plan
import tempfile
import logging
import shutil
logger = logging.getLogger(__name__)
class ExportService:
def __init__(self):
self.temp_dir = Path(tempfile.gettempdir()) / "cycling_exports"
self.temp_dir.mkdir(exist_ok=True)
async def create_export(self, export_types, export_format):
"""Main export creation entry point"""
export_data = await self._fetch_export_data(export_types)
export_path = self._generate_export_file(export_data, export_format, export_types)
return export_path
async def _fetch_export_data(self, export_types):
"""Fetch data from database based on requested types"""
db = SessionLocal()
try:
data = {}
if 'routes' in export_types:
routes = db.query(Route).all()
data['routes'] = [self._serialize_route(r) for r in routes]
if 'rules' in export_types:
rules = db.query(Rule).all()
data['rules'] = [self._serialize_rule(r) for r in rules]
if 'plans' in export_types:
plans = db.query(Plan).all()
data['plans'] = [self._serialize_plan(p) for p in plans]
return data
finally:
db.close()
def _generate_export_file(self, data, format, types):
"""Generate the export file in specified format"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = f"export_{'_'.join(types)}_{timestamp}"
if format == 'json':
return self._create_json_export(data, base_name)
elif format == 'zip':
return self._create_zip_export(data, base_name)
elif format == 'gpx':
return self._create_gpx_export(data, base_name)
else:
raise ValueError(f"Unsupported format: {format}")
def _create_json_export(self, data, base_name):
"""Create single JSON file export"""
export_path = self.temp_dir / f"{base_name}.json"
with open(export_path, 'w') as f:
json.dump(data, f, indent=2)
return export_path
def _create_zip_export(self, data, base_name):
"""Create ZIP archive with JSON and GPX files"""
zip_path = self.temp_dir / f"{base_name}.zip"
with zipfile.ZipFile(zip_path, 'w') as zipf:
# Add JSON data
json_path = self._create_json_export(data, base_name)
zipf.write(json_path, arcname=json_path.name)
# Add GPX files if exporting routes
if 'routes' in data:
gpx_dir = Path("/app/data/gpx")
for route in data['routes']:
gpx_path = gpx_dir / route['gpx_file_path']
if gpx_path.exists():
zipf.write(gpx_path, arcname=f"gpx/{gpx_path.name}")
return zip_path
def _create_gpx_export(self, data, base_name):
"""Export only GPX files from routes"""
if 'routes' not in data:
raise ValueError("GPX export requires routes to be selected")
zip_path = self.temp_dir / f"{base_name}.zip"
with zipfile.ZipFile(zip_path, 'w') as zipf:
gpx_dir = Path("/app/data/gpx")
for route in data['routes']:
gpx_path = gpx_dir / route['gpx_file_path']
if gpx_path.exists():
zipf.write(gpx_path, arcname=gpx_path.name)
return zip_path
def _serialize_route(self, route):
return {
"id": route.id,
"name": route.name,
"description": route.description,
"category": route.category,
"gpx_file_path": route.gpx_file_path,
"created_at": route.created_at.isoformat(),
"updated_at": route.updated_at.isoformat()
}
def _serialize_rule(self, rule):
return {
"id": rule.id,
"name": rule.name,
"natural_language": rule.natural_language,
"jsonb_rules": rule.jsonb_rules,
"version": rule.version,
"created_at": rule.created_at.isoformat()
}
def _serialize_plan(self, plan):
return {
"id": plan.id,
"name": plan.name,
"jsonb_plan": plan.jsonb_plan,
"version": plan.version,
"created_at": plan.created_at.isoformat()
}
def cleanup_temp_files(self):
"""Clean up temporary export files older than 1 hour"""
cutoff = datetime.now().timestamp() - 3600
for file in self.temp_dir.glob("*"):
if file.stat().st_mtime < cutoff:
try:
file.unlink()
except Exception as e:
logger.warning(f"Failed to clean up temp file {file}: {str(e)}")

View File

@@ -0,0 +1,259 @@
import json
import zipfile
from pathlib import Path
import tempfile
from datetime import datetime
from app.database import SessionLocal
from app.models import Route, Rule, Plan
import shutil
import logging
from sqlalchemy import and_
from typing import Dict, List
logger = logging.getLogger(__name__)
class ImportService:
def __init__(self):
self.temp_dir = Path(tempfile.gettempdir()) / "cycling_imports"
self.temp_dir.mkdir(exist_ok=True)
async def validate_import(self, file: UploadFile) -> dict:
"""Validate import file and detect conflicts"""
try:
# Save uploaded file to temp location
file_path = self.temp_dir / file.filename
with open(file_path, "wb") as f:
shutil.copyfileobj(file.file, f)
# Extract data based on file type
if file.filename.endswith('.zip'):
data = self._process_zip_import(file_path)
elif file.filename.endswith('.json'):
data = self._process_json_import(file_path)
else:
raise ValueError("Unsupported file format")
# Detect conflicts
conflicts = []
if 'routes' in data:
conflicts += self._detect_route_conflicts(data['routes'])
if 'rules' in data:
conflicts += self._detect_rule_conflicts(data['rules'])
if 'plans' in data:
conflicts += self._detect_plan_conflicts(data['plans'])
return {
"valid": True,
"conflicts": conflicts,
"summary": {
"routes": len(data.get('routes', [])),
"rules": len(data.get('rules', [])),
"plans": len(data.get('plans', []))
}
}
except Exception as e:
logger.error(f"Validation error: {str(e)}")
return {"valid": False, "error": str(e)}
async def execute_import(self, file: UploadFile,
conflict_resolution: str,
resolutions: List[dict]) -> dict:
"""Execute the import with specified conflict resolution"""
db = SessionLocal()
try:
db.begin()
# Process file
file_path = self.temp_dir / file.filename
with open(file_path, "wb") as f:
shutil.copyfileobj(file.file, f)
if file.filename.endswith('.zip'):
data = self._process_zip_import(file_path)
gpx_files = self._extract_gpx_files(file_path)
elif file.filename.endswith('.json'):
data = self._process_json_import(file_path)
gpx_files = []
else:
raise ValueError("Unsupported file format")
# Apply resolutions
resolution_map = {r['id']: r['action'] for r in resolutions}
# Import data
results = {
"imported": {"routes": 0, "rules": 0, "plans": 0},
"skipped": {"routes": 0, "rules": 0, "plans": 0},
"errors": []
}
# Import routes
if 'routes' in data:
for route_data in data['routes']:
action = resolution_map.get(route_data['id'], conflict_resolution)
try:
if self._should_import_route(route_data, action, db):
self._import_route(route_data, db)
results["imported"]["routes"] += 1
else:
results["skipped"]["routes"] += 1
except Exception as e:
results["errors"].append(f"Route {route_data['id']}: {str(e)}")
# Import rules
if 'rules' in data:
for rule_data in data['rules']:
action = resolution_map.get(rule_data['id'], conflict_resolution)
try:
if self._should_import_rule(rule_data, action, db):
self._import_rule(rule_data, db)
results["imported"]["rules"] += 1
else:
results["skipped"]["rules"] += 1
except Exception as e:
results["errors"].append(f"Rule {rule_data['id']}: {str(e)}")
# Import plans
if 'plans' in data:
for plan_data in data['plans']:
action = resolution_map.get(plan_data['id'], conflict_resolution)
try:
if self._should_import_plan(plan_data, action, db):
self._import_plan(plan_data, db)
results["imported"]["plans"] += 1
else:
results["skipped"]["plans"] += 1
except Exception as e:
results["errors"].append(f"Plan {plan_data['id']}: {str(e)}")
# Save GPX files
if gpx_files:
gpx_dir = Path("/app/data/gpx")
for gpx in gpx_files:
shutil.move(gpx, gpx_dir / gpx.name)
db.commit()
return results
except Exception as e:
db.rollback()
logger.error(f"Import failed: {str(e)}")
return {"error": str(e)}
finally:
db.close()
self._cleanup_temp_files()
def _process_zip_import(self, file_path: Path) -> dict:
"""Extract and process ZIP file import"""
data = {}
with zipfile.ZipFile(file_path, 'r') as zipf:
# Find data.json
json_files = [f for f in zipf.namelist() if f.endswith('.json')]
if not json_files:
raise ValueError("No JSON data found in ZIP file")
with zipf.open(json_files[0]) as f:
data = json.load(f)
return data
def _process_json_import(self, file_path: Path) -> dict:
"""Process JSON file import"""
with open(file_path) as f:
return json.load(f)
def _extract_gpx_files(self, file_path: Path) -> List[Path]:
"""Extract GPX files from ZIP archive"""
gpx_files = []
extract_dir = self.temp_dir / "gpx"
extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(file_path, 'r') as zipf:
for file in zipf.namelist():
if file.startswith('gpx/') and file.endswith('.gpx'):
zipf.extract(file, extract_dir)
gpx_files.append(extract_dir / file)
return gpx_files
def _detect_route_conflicts(self, routes: List[dict]) -> List[dict]:
conflicts = []
db = SessionLocal()
try:
for route in routes:
existing = db.query(Route).filter(
(Route.id == route['id']) |
(Route.name == route['name'])
).first()
if existing:
conflict = {
"type": "route",
"id": route['id'],
"name": route['name'],
"existing_version": existing.updated_at,
"import_version": datetime.fromisoformat(route['updated_at']),
"resolution_options": ["overwrite", "rename", "skip"]
}
conflicts.append(conflict)
finally:
db.close()
return conflicts
def _should_import_route(self, route_data: dict, action: str, db) -> bool:
existing = db.query(Route).filter(
(Route.id == route_data['id']) |
(Route.name == route_data['name'])
).first()
if not existing:
return True
if action == 'overwrite':
return True
elif action == 'rename':
route_data['name'] = f"{route_data['name']} (Imported)"
return True
elif action == 'skip':
return False
return False
def _import_route(self, route_data: dict, db):
"""Import a single route"""
existing = db.query(Route).get(route_data['id'])
if existing:
# Update existing route
existing.name = route_data['name']
existing.description = route_data['description']
existing.category = route_data['category']
existing.gpx_file_path = route_data['gpx_file_path']
existing.updated_at = datetime.fromisoformat(route_data['updated_at'])
else:
# Create new route
route = Route(
id=route_data['id'],
name=route_data['name'],
description=route_data['description'],
category=route_data['category'],
gpx_file_path=route_data['gpx_file_path'],
created_at=datetime.fromisoformat(route_data['created_at']),
updated_at=datetime.fromisoformat(route_data['updated_at'])
)
db.add(route)
# Similar methods for rules and plans would follow...
def _cleanup_temp_files(self):
"""Clean up temporary files older than 1 hour"""
cutoff = datetime.now().timestamp() - 3600
for file in self.temp_dir.glob("*"):
if file.stat().st_mtime < cutoff:
try:
if file.is_dir():
shutil.rmtree(file)
else:
file.unlink()
except Exception as e:
logger.warning(f"Failed to clean temp file {file}: {str(e)}")

View File

@@ -26,11 +26,14 @@ services:
start_period: 40s
frontend:
build: ./frontend
build:
context: ./frontend
args:
- REACT_APP_API_URL=http://backend:8000
ports:
- "8888:80"
environment:
- REACT_APP_API_URL=http://backend:8000
- REACT_APP_CONTAINER_API_URL=http://backend:8000
- REACT_APP_API_KEY=${API_KEY}
db:

91
export_import_api_spec.md Normal file
View File

@@ -0,0 +1,91 @@
# Export/Import API Specification
## Export Endpoint
`GET /api/export`
### Parameters (query string)
- `types` (required): Comma-separated list of data types to export
- Valid values: `routes`, `rules`, `plans`, `all`
- `format` (required): Export format
- `json`: Single JSON file
- `zip`: ZIP archive with separate files
- `gpx`: Only GPX files (routes only)
### Response
- `200 OK` with file download
- `400 Bad Request` for invalid parameters
- `500 Internal Server Error` for export failures
### Example
```http
GET /api/export?types=routes,plans&format=zip
```
---
## Import Validation
`POST /api/import/validate`
### Request
- Multipart form with `file` field containing import data
### Response
```json
{
"valid": true,
"conflicts": [
{
"type": "route",
"id": 123,
"name": "Mountain Loop",
"existing_version": 2,
"import_version": 3,
"resolution_options": ["overwrite", "rename", "skip"]
}
],
"summary": {
"routes": 15,
"rules": 3,
"plans": 2
}
}
```
---
## Import Execution
`POST /api/import`
### Request
- Multipart form with:
- `file`: Import data file
- `conflict_resolution`: Global strategy (overwrite, skip, rename)
- `resolutions`: JSON array of specific resolutions (optional)
```json
[{"type": "route", "id": 123, "action": "overwrite"}]
```
### Response
```json
{
"imported": {
"routes": 12,
"rules": 3,
"plans": 2
},
"skipped": {
"routes": 3,
"rules": 0,
"plans": 0
},
"errors": []
}
```
### Status Codes
- `200 OK`: Import completed
- `202 Accepted`: Import in progress (async)
- `400 Bad Request`: Invalid input
- `409 Conflict`: Unresolved conflicts
---

View File

@@ -0,0 +1,221 @@
# Export/Import Frontend Implementation
## File Structure
```
src/pages/
ExportImport.jsx # Main page
src/components/export/
DataExporter.jsx # Export functionality
DataImporter.jsx # Import functionality
ConflictDialog.jsx # Conflict resolution UI
ImportSummary.jsx # Post-import report
```
## Component Specifications
### ExportImport.jsx
```jsx
import { useState } from 'react';
import DataExporter from '../components/export/DataExporter';
import DataImporter from '../components/export/DataImporter';
export default function ExportImportPage() {
const [activeTab, setActiveTab] = useState('export');
return (
<div className="export-import-page">
<div className="tabs">
<button onClick={() => setActiveTab('export')}>Export</button>
<button onClick={() => setActiveTab('import')}>Import</button>
</div>
{activeTab === 'export' ? <DataExporter /> : <DataImporter />}
</div>
);
}
```
### DataExporter.jsx
```jsx
import { useState } from 'react';
const EXPORT_TYPES = [
{ id: 'routes', label: 'Routes' },
{ id: 'rules', label: 'Training Rules' },
{ id: 'plans', label: 'Training Plans' }
];
const EXPORT_FORMATS = [
{ id: 'json', label: 'JSON' },
{ id: 'zip', label: 'ZIP Archive' },
{ id: 'gpx', label: 'GPX Files' }
];
export default function DataExporter() {
const [selectedTypes, setSelectedTypes] = useState([]);
const [selectedFormat, setSelectedFormat] = useState('json');
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const handleExport = async () => {
setIsExporting(true);
// API call to /api/export?types=...&format=...
// Track progress and trigger download
};
return (
<div className="exporter">
<h2>Export Data</h2>
<div className="type-selection">
<h3>Select Data to Export</h3>
{EXPORT_TYPES.map(type => (
<label key={type.id}>
<input
type="checkbox"
checked={selectedTypes.includes(type.id)}
onChange={() => toggleType(type.id)}
/>
{type.label}
</label>
))}
</div>
<div className="format-selection">
<h3>Export Format</h3>
<select value={selectedFormat} onChange={e => setSelectedFormat(e.target.value)}>
{EXPORT_FORMATS.map(format => (
<option key={format.id} value={format.id}>{format.label}</option>
))}
</select>
</div>
<button
onClick={handleExport}
disabled={selectedTypes.length === 0 || isExporting}
>
{isExporting ? `Exporting... ${progress}%` : 'Export Data'}
</button>
</div>
);
}
```
### DataImporter.jsx
```jsx
import { useState } from 'react';
import ConflictDialog from './ConflictDialog';
export default function DataImporter() {
const [file, setFile] = useState(null);
const [validation, setValidation] = useState(null);
const [isImporting, setIsImporting] = useState(false);
const [showConflictDialog, setShowConflictDialog] = useState(false);
const handleFileUpload = (e) => {
const file = e.target.files[0];
setFile(file);
// Call /api/import/validate
// Set validation results
};
const handleImport = () => {
if (validation?.conflicts?.length > 0) {
setShowConflictDialog(true);
} else {
startImport();
}
};
const startImport = (resolutions = []) => {
setIsImporting(true);
// Call /api/import with conflict resolutions
};
return (
<div className="importer">
<h2>Import Data</h2>
<input type="file" onChange={handleFileUpload} />
{validation && (
<div className="validation-results">
<h3>Validation Results</h3>
<p>Found: {validation.summary.routes} routes,
{validation.summary.rules} rules,
{validation.summary.plans} plans</p>
{validation.conflicts.length > 0 && (
<p> {validation.conflicts.length} conflicts detected</p>
)}
</div>
)}
<button
onClick={handleImport}
disabled={!file || isImporting}
>
{isImporting ? 'Importing...' : 'Import Data'}
</button>
{showConflictDialog && (
<ConflictDialog
conflicts={validation.conflicts}
onResolve={startImport}
onCancel={() => setShowConflictDialog(false)}
/>
)}
</div>
);
}
```
### ConflictDialog.jsx
```jsx
export default function ConflictDialog({ conflicts, onResolve, onCancel }) {
const [resolutions, setResolutions] = useState({});
const handleResolution = (id, action) => {
setResolutions(prev => ({ ...prev, [id]: action }));
};
const applyResolutions = () => {
const resolutionList = Object.entries(resolutions).map(([id, action]) => ({
id,
action
}));
onResolve(resolutionList);
};
return (
<div className="conflict-dialog">
<h3>Resolve Conflicts</h3>
<div className="conflicts-list">
{conflicts.map(conflict => (
<div key={conflict.id} className="conflict-item">
<h4>{conflict.name} ({conflict.type})</h4>
<p>Existing version: {conflict.existing_version}</p>
<p>Import version: {conflict.import_version}</p>
<select
value={resolutions[conflict.id] || 'skip'}
onChange={e => handleResolution(conflict.id, e.target.value)}
>
<option value="overwrite">Overwrite</option>
<option value="rename">Rename</option>
<option value="skip">Skip</option>
</select>
</div>
))}
</div>
<div className="actions">
<button onClick={onCancel}>Cancel</button>
<button onClick={applyResolutions}>Apply Resolutions</button>
</div>
</div>
);
}
```
## Dependencies to Install
```bash
npm install react-dropzone react-json-view file-saver

View File

@@ -1,6 +1,10 @@
# Stage 1: Build application
FROM node:20-alpine AS builder
# Allow environment variables to be passed at build time
ARG REACT_APP_API_URL
ENV REACT_APP_API_URL=$REACT_APP_API_URL
WORKDIR /app
# Copy package manifests first for optimal caching
@@ -9,7 +13,7 @@ COPY package.json package-lock.json* ./
# Clean cache and install dependencies
RUN npm cache clean --force && \
export NODE_OPTIONS="--max-old-space-size=1024" && \
npm install --include=dev
npm install --omit=dev --legacy-peer-deps
# Copy source files
COPY . .

View File

@@ -10,9 +10,20 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"axios": "^1.7.2",
"date-fns": "^3.6.0",
"gpx-parse": "^0.10.4",
"leaflet": "^1.9.4",
"next": "14.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.0",
"react-icons": "^5.5.0",
"react-json-view": "^1.21.3",
"react-leaflet": "^5.0.0",
"react-router-dom": "^6.22.3",
"react-select": "^5.7.4",
"react-toastify": "^10.0.4",
"recharts": "2.8.0"
},
"devDependencies": {
@@ -762,6 +773,28 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
@@ -1608,6 +1641,24 @@
"node": ">=14"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1658,36 +1709,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz",
@@ -2021,6 +2042,14 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz",
@@ -2755,6 +2784,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asap": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -2773,8 +2807,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
@@ -2800,6 +2833,16 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2948,6 +2991,11 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/base16": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
"integrity": "sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ=="
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -3050,7 +3098,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@@ -3218,6 +3265,14 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -3256,7 +3311,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -3409,6 +3463,14 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3644,6 +3706,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -3770,21 +3841,10 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -3858,7 +3918,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@@ -3990,7 +4049,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -3999,7 +4057,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@@ -4055,7 +4112,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@@ -4067,7 +4123,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
@@ -4725,6 +4780,33 @@
"bser": "2.1.1"
}
},
"node_modules/fbemitter": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fbemitter/-/fbemitter-3.0.0.tgz",
"integrity": "sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw==",
"dependencies": {
"fbjs": "^3.0.0"
}
},
"node_modules/fbjs": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
"dependencies": {
"cross-fetch": "^3.1.5",
"fbjs-css-vars": "^1.0.0",
"loose-envify": "^1.0.0",
"object-assign": "^4.1.0",
"promise": "^7.1.1",
"setimmediate": "^1.0.5",
"ua-parser-js": "^1.0.35"
}
},
"node_modules/fbjs-css-vars": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ=="
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -4790,6 +4872,37 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"node_modules/flux": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/flux/-/flux-4.0.4.tgz",
"integrity": "sha512-NCj3XlayA2UsapRpM7va6wU1+9rE5FIL7qoMcmxWHRzbp0yujihMBm9BBHZ1MDIk5h5o2Bl6eGiCe8rYELAmYw==",
"dependencies": {
"fbemitter": "^3.0.0",
"fbjs": "^3.0.1"
},
"peerDependencies": {
"react": "^15.0.2 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4825,7 +4938,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -4916,7 +5028,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@@ -4949,7 +5060,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@@ -5112,7 +5222,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -5120,6 +5229,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gpx-parse": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/gpx-parse/-/gpx-parse-0.10.4.tgz",
"integrity": "sha512-4PUA2Hzp4m5EjDEa4YB1CKWh5Cjm1cgJuDe+nL6B77DgtFuYR7VtvBbmvbwjx6M40iA96q2BaKyD8/lraez2xw==",
"dependencies": {
"xml2js": "^0.4.4"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -5183,7 +5300,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -5195,7 +5311,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
@@ -7033,6 +7148,11 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -7080,6 +7200,16 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.curry": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
"integrity": "sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA=="
},
"node_modules/lodash.flow": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
"integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -7140,11 +7270,15 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -7177,7 +7311,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -7186,7 +7319,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -7334,6 +7466,44 @@
}
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -7868,6 +8038,14 @@
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
},
"node_modules/promise": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
"dependencies": {
"asap": "~2.0.3"
}
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -7891,6 +8069,11 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -7912,6 +8095,11 @@
"node": ">=6"
}
},
"node_modules/pure-color": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pure-color/-/pure-color-1.3.0.tgz",
"integrity": "sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA=="
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -7965,6 +8153,17 @@
"node": ">=0.10.0"
}
},
"node_modules/react-base16-styling": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/react-base16-styling/-/react-base16-styling-0.6.0.tgz",
"integrity": "sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ==",
"dependencies": {
"base16": "^1.0.0",
"lodash.curry": "^4.0.1",
"lodash.flow": "^3.3.0",
"pure-color": "^1.2.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -7977,11 +8176,62 @@
"react": "^18.2.0"
}
},
"node_modules/react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
"integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-json-view": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/react-json-view/-/react-json-view-1.21.3.tgz",
"integrity": "sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw==",
"dependencies": {
"flux": "^4.0.1",
"react-base16-styling": "^0.6.0",
"react-lifecycles-compat": "^3.0.4",
"react-textarea-autosize": "^8.3.2"
},
"peerDependencies": {
"react": "^17.0.0 || ^16.3.0 || ^15.5.4",
"react-dom": "^17.0.0 || ^16.3.0 || ^15.5.4"
}
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
@@ -7999,6 +8249,80 @@
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-router": {
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"dependencies": {
"@remix-run/router": "1.23.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"dependencies": {
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-select": {
"version": "5.10.2",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
"integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-select/node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/react-select/node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/react-smooth": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-2.0.5.tgz",
@@ -8013,6 +8337,34 @@
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
"dependencies": {
"@babel/runtime": "^7.20.13",
"use-composed-ref": "^1.3.0",
"use-latest": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-toastify": {
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz",
"integrity": "sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==",
"dependencies": {
"clsx": "^2.1.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-transition-group": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
@@ -8333,6 +8685,11 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -8411,6 +8768,11 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -9208,6 +9570,31 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -9324,6 +9711,48 @@
"requires-port": "^1.0.0"
}
},
"node_modules/use-composed-ref": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-latest": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
"dependencies": {
"use-isomorphic-layout-effect": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -9687,6 +10116,26 @@
"node": ">=12"
}
},
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",

View File

@@ -16,10 +16,17 @@
"@emotion/styled": "^11.14.1",
"axios": "^1.7.2",
"date-fns": "^3.6.0",
"@tmcw/togeojson": "^7.1.2",
"leaflet": "^1.9.4",
"next": "14.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.45.0",
"react-icons": "^5.5.0",
"react-json-tree": "^0.20.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.22.3",
"react-select": "^5.7.4",
"react-toastify": "^10.0.4",
"recharts": "2.8.0"
},

View File

@@ -23,6 +23,12 @@ const Navigation = () => {
>
Plans
</Link>
<Link
to="/rules"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
>
Rules
</Link>
<Link
to="/routes"
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"

View File

@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
const EditWorkoutModal = ({ workout, onClose, onSave }) => {
const [formData, setFormData] = useState({
type: '',
duration_minutes: 0,
intensity: '',
description: ''
});
useEffect(() => {
if (workout) {
setFormData({
type: workout.type || '',
duration_minutes: workout.duration_minutes || 0,
intensity: workout.intensity || '',
description: workout.description || ''
});
}
}, [workout]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e) => {
e.preventDefault();
onSave(formData);
};
if (!workout) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Edit Workout</h3>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Workout Type
</label>
<input
type="text"
name="type"
value={formData.type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Duration (minutes)
</label>
<input
type="number"
name="duration_minutes"
value={formData.duration_minutes}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Intensity
</label>
<select
name="intensity"
value={formData.intensity}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select intensity</option>
<option value="zone_1">Zone 1 (Easy)</option>
<option value="zone_2">Zone 2 (Moderate)</option>
<option value="zone_3">Zone 3 (Tempo)</option>
<option value="zone_4">Zone 4 (Threshold)</option>
<option value="zone_5">Zone 5 (VO2 Max)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows="3"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</div>
</div>
);
};
EditWorkoutModal.propTypes = {
workout: PropTypes.object,
onClose: PropTypes.func.isRequired,
onSave: PropTypes.func.isRequired
};
EditWorkoutModal.defaultProps = {
workout: null
};
export default EditWorkoutModal;

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
const predefinedGoals = [
{ id: 'endurance', label: 'Build Endurance', description: 'Focus on longer rides at moderate intensity' },
{ id: 'power', label: 'Increase Power', description: 'High-intensity intervals and strength training' },
{ id: 'weight-loss', label: 'Weight Management', description: 'Calorie-burning rides with nutrition planning' },
{ id: 'event-prep', label: 'Event Preparation', description: 'Targeted training for specific competitions' }
];
const GoalSelector = ({ goals, onSelect, onNext }) => {
const [customGoal, setCustomGoal] = useState('');
const [showCustom, setShowCustom] = useState(false);
const toggleGoal = (goalId) => {
const newGoals = goals.includes(goalId)
? goals.filter(g => g !== goalId)
: [...goals, goalId];
onSelect(newGoals);
};
const addCustomGoal = () => {
if (customGoal.trim()) {
onSelect([...goals, customGoal.trim()]);
setCustomGoal('');
setShowCustom(false);
}
};
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Select Training Goals</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{predefinedGoals.map((goal) => (
<button
key={goal.id}
onClick={() => toggleGoal(goal.id)}
className={`p-4 text-left rounded-lg border-2 transition-colors ${
goals.includes(goal.id)
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-200'
}`}
>
<h3 className="font-semibold mb-2">{goal.label}</h3>
<p className="text-sm text-gray-600">{goal.description}</p>
</button>
))}
</div>
<div className="mb-6">
{!showCustom ? (
<button
onClick={() => setShowCustom(true)}
className="text-blue-600 hover:text-blue-700 flex items-center"
>
<span className="mr-2">+</span> Add Custom Goal
</button>
) : (
<div className="flex gap-2">
<input
type="text"
value={customGoal}
onChange={(e) => setCustomGoal(e.target.value)}
placeholder="Enter custom goal"
className="flex-1 p-2 border rounded-md"
/>
<button
onClick={addCustomGoal}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Add
</button>
</div>
)}
</div>
<div className="flex justify-end">
<button
onClick={onNext}
disabled={goals.length === 0}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
Next
</button>
</div>
</div>
);
};
GoalSelector.propTypes = {
goals: PropTypes.arrayOf(PropTypes.string).isRequired,
onSelect: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired
};
export default GoalSelector;

View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const PlanParameters = ({ values, onChange, onBack, onNext }) => {
const [localValues, setLocalValues] = useState(values);
useEffect(() => {
setLocalValues(values);
}, [values]);
const handleChange = (field, value) => {
const newValues = { ...localValues, [field]: value };
setLocalValues(newValues);
onChange(newValues);
};
const toggleDay = (day) => {
const days = localValues.availableDays.includes(day)
? localValues.availableDays.filter(d => d !== day)
: [...localValues.availableDays, day];
handleChange('availableDays', days);
};
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h2 className="text-2xl font-bold mb-6">Set Plan Parameters</h2>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">
Duration: {localValues.duration} weeks
</label>
<input
type="range"
min="4"
max="20"
value={localValues.duration}
onChange={(e) => handleChange('duration', parseInt(e.target.value))}
className="w-full range-slider"
/>
<div className="flex justify-between text-sm text-gray-600">
<span>4</span>
<span>20</span>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Weekly Hours: {localValues.weeklyHours}h
</label>
<input
type="range"
min="5"
max="15"
value={localValues.weeklyHours}
onChange={(e) => handleChange('weeklyHours', parseInt(e.target.value))}
className="w-full range-slider"
/>
<div className="flex justify-between text-sm text-gray-600">
<span>5</span>
<span>15</span>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Difficulty Level</label>
<select
value={localValues.difficulty || 'intermediate'}
onChange={(e) => handleChange('difficulty', e.target.value)}
className="w-full p-2 border rounded-md"
>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Available Days</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{daysOfWeek.map(day => (
<label key={day} className="flex items-center space-x-2">
<input
type="checkbox"
checked={localValues.availableDays.includes(day)}
onChange={() => toggleDay(day)}
className="form-checkbox h-4 w-4 text-blue-600"
/>
<span className="text-sm">{day}</span>
</label>
))}
</div>
</div>
</div>
<div className="flex justify-between mt-8">
<button
onClick={onBack}
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300"
>
Back
</button>
<button
onClick={onNext}
disabled={localValues.availableDays.length === 0}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
Next
</button>
</div>
</div>
);
};
PlanParameters.propTypes = {
values: PropTypes.shape({
duration: PropTypes.number,
weeklyHours: PropTypes.number,
difficulty: PropTypes.string,
availableDays: PropTypes.arrayOf(PropTypes.string)
}).isRequired,
onChange: PropTypes.func.isRequired,
onBack: PropTypes.func.isRequired,
onNext: PropTypes.func.isRequired
};
export default PlanParameters;

View File

@@ -1,93 +1,104 @@
import { useState, useEffect } from 'react';
import axios from 'axios';
import { useState } from 'react';
import PropTypes from 'prop-types';
import { useAuth } from '../../context/AuthContext';
import { formatDistanceToNow } from 'date-fns';
import axios from 'axios';
import WorkoutCard from './WorkoutCard';
import EditWorkoutModal from './EditWorkoutModal';
const PlanTimeline = ({ planId }) => {
const PlanTimeline = ({ plan, mode = 'view' }) => {
const { apiKey } = useAuth();
const [evolution, setEvolution] = useState([]);
const [selectedVersion, setSelectedVersion] = useState(null);
const [loading, setLoading] = useState(true);
const [currentPlan, setCurrentPlan] = useState(plan?.jsonb_plan);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedWorkout, setSelectedWorkout] = useState(null);
const [selectedWeek, setSelectedWeek] = useState(0);
useEffect(() => {
const fetchEvolution = async () => {
try {
const response = await axios.get(`/api/plans/${planId}/evolution`, {
const handleWorkoutUpdate = async (updatedWorkout) => {
try {
const newPlan = { ...currentPlan };
newPlan.weeks[selectedWeek].workouts = newPlan.weeks[selectedWeek].workouts.map(w =>
w.day === updatedWorkout.day ? updatedWorkout : w
);
if (mode === 'edit') {
await axios.put(`/api/plans/${plan.id}`, newPlan, {
headers: { 'X-API-Key': apiKey }
});
setEvolution(response.data.evolution_history);
setSelectedVersion(response.data.current_version);
} catch (error) {
console.error('Error fetching plan evolution:', error);
} finally {
setLoading(false);
}
};
if (planId) {
fetchEvolution();
setCurrentPlan(newPlan);
setShowEditModal(false);
} catch (error) {
console.error('Error updating workout:', error);
}
}, [planId, apiKey]);
if (loading) return <div>Loading plan history...</div>;
};
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4">Plan Evolution</h3>
<div className="flex flex-col md:flex-row gap-6">
<div className="md:w-1/3 space-y-4">
{evolution.map((version, idx) => (
<div
key={version.version}
onClick={() => setSelectedVersion(version)}
className={`p-4 border-l-4 cursor-pointer transition-colors ${
selectedVersion?.version === version.version
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex justify-between items-center">
<span className="font-medium">v{version.version}</span>
<span className="text-sm text-gray-500">
{formatDistanceToNow(new Date(version.created_at))} ago
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold">
{currentPlan?.plan_overview?.focus} Training Plan
</h3>
<div className="text-gray-600">
{currentPlan?.plan_overview?.duration_weeks} weeks
{currentPlan?.plan_overview?.weekly_hours} hrs/week
</div>
</div>
<div className="space-y-8">
{currentPlan?.weeks?.map((week, weekIndex) => (
<div key={weekIndex} className="border-l-2 border-blue-100 pl-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium">
Week {weekIndex + 1}: {week.focus}
</h4>
<div className="flex items-center space-x-2">
<div className="h-2 w-24 bg-gray-200 rounded-full">
<div
className="h-full bg-blue-600 rounded-full"
style={{ width: `${(weekIndex + 1) / currentPlan.plan_overview.duration_weeks * 100}%` }}
/>
</div>
<span className="text-sm text-gray-600">
{week.workouts.length} workouts
</span>
</div>
<p className="text-sm text-gray-600 mt-1">
{version.trigger || 'Initial version'}
</p>
</div>
))}
</div>
{selectedVersion && (
<div className="md:w-2/3 p-4 bg-gray-50 rounded-md">
<h4 className="font-medium mb-4">
Version {selectedVersion.version} Details
</h4>
<div className="space-y-3">
<p>
<span className="font-medium">Created:</span>{' '}
{new Date(selectedVersion.created_at).toLocaleString()}
</p>
{selectedVersion.changes_summary && (
<p>
<span className="font-medium">Changes:</span>{' '}
{selectedVersion.changes_summary}
</p>
)}
{selectedVersion.parent_plan_id && (
<p>
<span className="font-medium">Parent Version:</span>{' '}
v{selectedVersion.parent_plan_id}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{week.workouts.map((workout, workoutIndex) => (
<WorkoutCard
key={`${weekIndex}-${workoutIndex}`}
workout={workout}
onEdit={() => {
setSelectedWeek(weekIndex);
setSelectedWorkout(workout);
setShowEditModal(true);
}}
editable={mode === 'edit'}
/>
))}
</div>
</div>
)}
))}
</div>
{showEditModal && (
<EditWorkoutModal
workout={selectedWorkout}
onClose={() => setShowEditModal(false)}
onSave={handleWorkoutUpdate}
/>
)}
</div>
);
};
PlanTimeline.propTypes = {
plan: PropTypes.shape({
id: PropTypes.number,
jsonb_plan: PropTypes.object
}),
mode: PropTypes.oneOf(['view', 'edit'])
};
export default PlanTimeline;

View File

@@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
const WorkoutCard = ({ workout, onEdit, editable }) => {
return (
<div className="border rounded-lg p-4 bg-white shadow-sm">
<div className="flex justify-between items-start mb-2">
<h5 className="font-medium capitalize">{workout?.type?.replace('_', ' ') || 'Workout'}</h5>
{editable && (
<button
onClick={onEdit}
className="text-blue-600 hover:text-blue-800 text-sm"
>
Edit
</button>
)}
</div>
<div className="text-sm text-gray-600 space-y-1">
<div>Duration: {workout?.duration_minutes || 0} minutes</div>
<div>Intensity: {workout?.intensity || 'N/A'}</div>
{workout?.description && (
<div className="mt-2 text-gray-700">{workout.description}</div>
)}
</div>
</div>
);
};
WorkoutCard.propTypes = {
workout: PropTypes.shape({
type: PropTypes.string,
duration_minutes: PropTypes.number,
intensity: PropTypes.string,
description: PropTypes.string
}),
onEdit: PropTypes.func,
editable: PropTypes.bool
};
WorkoutCard.defaultProps = {
workout: {},
onEdit: () => {},
editable: false
};
export default WorkoutCard;

View File

@@ -0,0 +1,34 @@
import { render, screen, fireEvent } from '@testing-library/react';
import GoalSelector from '../GoalSelector';
describe('GoalSelector', () => {
const mockOnSelect = jest.fn();
const mockOnNext = jest.fn();
beforeEach(() => {
render(<GoalSelector goals={[]} onSelect={mockOnSelect} onNext={mockOnNext} />);
});
it('allows selection of predefined goals', () => {
const enduranceButton = screen.getByText('Build Endurance');
fireEvent.click(enduranceButton);
expect(mockOnSelect).toHaveBeenCalledWith(['endurance']);
});
it('handles custom goal input', () => {
const addButton = screen.getByText('Add Custom Goal');
fireEvent.click(addButton);
const input = screen.getByPlaceholderText('Enter custom goal');
fireEvent.change(input, { target: { value: 'Custom Goal' } });
const addCustomButton = screen.getByText('Add');
fireEvent.click(addCustomButton);
expect(mockOnSelect).toHaveBeenCalledWith(['Custom Goal']);
});
it('disables next button with no goals selected', () => {
expect(screen.getByText('Next')).toBeDisabled();
});
});

View File

@@ -0,0 +1,231 @@
import { useState, useCallback } from 'react'
import { useAuth } from '../../context/AuthContext'
import LoadingSpinner from '../LoadingSpinner'
import dynamic from 'next/dynamic'
import { gpx } from '@tmcw/togeojson'
const RouteVisualization = dynamic(() => import('./RouteVisualization'), {
ssr: false,
loading: () => <div className="h-64 bg-gray-100 rounded-md flex items-center justify-center">Loading map...</div>
})
const FileUpload = ({ onUploadSuccess }) => {
const { apiKey } = useAuth()
const [isDragging, setIsDragging] = useState(false)
const [previewData, setPreviewData] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const handleFile = async (file) => {
if (file.type !== 'application/gpx+xml') {
setError('Please upload a valid GPX file')
return
}
try {
// Preview parsing
const reader = new FileReader()
reader.onload = (e) => {
try {
const parser = new DOMParser()
const gpxDoc = parser.parseFromString(e.target.result, 'text/xml')
const geoJson = gpx(gpxDoc)
// Extract basic info from GeoJSON
const name = geoJson.features[0]?.properties?.name || 'Unnamed Route'
// Calculate distance and elevation (simplified)
let totalDistance = 0
let elevationGain = 0
let elevationLoss = 0
let maxElevation = 0
// Simple calculation - in a real app you'd want more accurate distance calculation
const coordinates = geoJson.features[0]?.geometry?.coordinates || []
for (let i = 1; i < coordinates.length; i++) {
const [prevLon, prevLat, prevEle] = coordinates[i-1]
const [currLon, currLat, currEle] = coordinates[i]
// Simple distance calculation (you might want to use a more accurate method)
const distance = Math.sqrt(Math.pow(currLon - prevLon, 2) + Math.pow(currLat - prevLat, 2)) * 111000 // rough meters
totalDistance += distance
if (prevEle && currEle) {
const eleDiff = currEle - prevEle
if (eleDiff > 0) {
elevationGain += eleDiff
} else {
elevationLoss += Math.abs(eleDiff)
}
maxElevation = Math.max(maxElevation, currEle)
}
}
const avgGrade = totalDistance > 0 ? ((elevationGain / totalDistance) * 100).toFixed(1) : '0.0'
setPreviewData({
name,
distance: (totalDistance / 1000).toFixed(1) + 'km',
elevationGain: elevationGain.toFixed(0) + 'm',
elevationLoss: elevationLoss.toFixed(0) + 'm',
maxElevation: maxElevation.toFixed(0) + 'm',
avgGrade: avgGrade + '%',
category: 'mixed',
gpxContent: e.target.result
})
} catch (parseError) {
setError('Error parsing GPX file: ' + parseError.message)
}
}
reader.readAsText(file)
} catch (err) {
setError('Error parsing GPX file')
}
}
const handleUpload = async () => {
if (!previewData) return
setIsLoading(true)
try {
const formData = new FormData()
const blob = new Blob([previewData.gpxContent], { type: 'application/gpx+xml' })
formData.append('file', blob, previewData.name + '.gpx')
const response = await fetch('/api/routes/upload', {
method: 'POST',
headers: {
'X-API-Key': apiKey
},
body: formData
})
if (!response.ok) throw new Error('Upload failed')
const result = await response.json()
onUploadSuccess(result)
setPreviewData(null)
setError(null)
} catch (err) {
setError(err.message || 'Upload failed')
} finally {
setIsLoading(false)
}
}
const onDragOver = useCallback((e) => {
e.preventDefault()
setIsDragging(true)
}, [])
const onDragLeave = useCallback((e) => {
e.preventDefault()
setIsDragging(false)
}, [])
const onDrop = useCallback((e) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}, [])
return (
<div className="mb-8">
<div
className={`border-2 border-dashed rounded-lg p-6 text-center ${
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<input
type="file"
id="gpx-upload"
className="hidden"
accept=".gpx,application/gpx+xml"
onChange={(e) => e.target.files[0] && handleFile(e.target.files[0])}
/>
<label htmlFor="gpx-upload" className="cursor-pointer">
<p className="text-gray-600">
Drag and drop GPX file here or{' '}
<span className="text-blue-600 font-medium">browse files</span>
</p>
</label>
</div>
{previewData && (
<div className="mt-6 bg-white p-6 rounded-lg shadow-md">
<h3 className="text-lg font-medium mb-4">Route Preview</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Route Name</label>
<input
type="text"
value={previewData.name}
onChange={(e) => setPreviewData(prev => ({...prev, name: e.target.value}))}
className="w-full p-2 border rounded-md"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
value={previewData.category}
onChange={(e) => setPreviewData(prev => ({...prev, category: e.target.value}))}
className="w-full p-2 border rounded-md"
>
<option value="climbing">Climbing</option>
<option value="flat">Flat</option>
<option value="mixed">Mixed</option>
<option value="intervals">Intervals</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Distance</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.distance}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Gain</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationGain}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Avg Grade</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.avgGrade}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Elevation</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.maxElevation}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Elevation Loss</label>
<p className="p-2 bg-gray-50 rounded-md">{previewData.elevationLoss}</p>
</div>
</div>
<button
onClick={handleUpload}
disabled={isLoading}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
>
{isLoading ? 'Uploading...' : 'Confirm Upload'}
</button>
</div>
<div className="h-64">
<RouteVisualization gpxData={previewData.gpxContent} />
</div>
</div>
</div>
)}
{error && (
<div className="mt-4 text-red-600 bg-red-50 p-3 rounded-md">
{error}
</div>
)}
</div>
)
}
export default FileUpload

View File

@@ -0,0 +1,80 @@
import PropTypes from 'prop-types';
const RouteFilter = ({ filters, onFilterChange }) => {
const handleChange = (field, value) => {
onFilterChange({
...filters,
[field]: value
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<input
type="text"
value={filters.searchQuery}
onChange={(e) => handleChange('searchQuery', e.target.value)}
placeholder="Search routes..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Min Distance (km)
</label>
<input
type="number"
value={filters.minDistance}
onChange={(e) => handleChange('minDistance', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Max Distance (km)
</label>
<input
type="number"
value={filters.maxDistance}
onChange={(e) => handleChange('maxDistance', Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Difficulty
</label>
<select
value={filters.difficulty}
onChange={(e) => handleChange('difficulty', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Difficulties</option>
<option value="easy">Easy</option>
<option value="moderate">Moderate</option>
<option value="hard">Hard</option>
<option value="extreme">Extreme</option>
</select>
</div>
</div>
);
};
RouteFilter.propTypes = {
filters: PropTypes.shape({
searchQuery: PropTypes.string,
minDistance: PropTypes.number,
maxDistance: PropTypes.number,
difficulty: PropTypes.string
}).isRequired,
onFilterChange: PropTypes.func.isRequired
};
export default RouteFilter;

View File

@@ -0,0 +1,107 @@
import { useState } from 'react'
import { useAuth } from '../../context/AuthContext'
import LoadingSpinner from '../LoadingSpinner'
import { format } from 'date-fns'
import { FaStar } from 'react-icons/fa'
const RouteList = ({ routes, onRouteSelect }) => {
const { apiKey } = useAuth()
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState('all')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(null)
const categories = ['all', 'climbing', 'flat', 'mixed', 'intervals']
const filteredRoutes = routes.filter(route => {
const matchesSearch = route.name.toLowerCase().includes(searchQuery.toLowerCase())
const matchesCategory = selectedCategory === 'all' || route.category === selectedCategory
return matchesSearch && matchesCategory
})
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<div className="mb-6 space-y-4">
<input
type="text"
placeholder="Search routes..."
className="w-full p-2 border rounded-md"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex flex-wrap gap-2">
{categories.map(category => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-4 py-2 rounded-full text-sm font-medium ${
selectedCategory === category
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</button>
))}
</div>
</div>
{isLoading ? (
<LoadingSpinner />
) : error ? (
<div className="text-red-600">{error}</div>
) : (
<div className="space-y-4">
{filteredRoutes.map(route => (
<div
key={route.id}
className="p-4 border rounded-md hover:bg-gray-50 cursor-pointer"
onClick={() => onRouteSelect(route)}
>
<div className="flex justify-between items-start">
<h3 className="font-medium text-lg">{route.name}</h3>
<span className="text-sm text-gray-500">
{format(new Date(route.created_at), 'MMM d, yyyy')}
</span>
</div>
<div className="grid grid-cols-4 gap-4 mt-2 text-gray-600">
<div>
<span className="font-medium">Distance: </span>
{(route.distance / 1000).toFixed(1)}km
</div>
<div>
<span className="font-medium">Elevation: </span>
{route.elevation_gain}m
</div>
<div>
<span className="font-medium">Category: </span>
{route.category}
</div>
<div>
<span className="font-medium">Grade: </span>
{route.grade_avg}%
</div>
<div className="flex items-center gap-1">
<span className="font-medium">Difficulty: </span>
{[...Array(5)].map((_, index) => (
<FaStar
key={index}
className={`w-4 h-4 ${
index < Math.round(route.difficulty_rating / 2)
? 'text-yellow-400'
: 'text-gray-300'
}`}
/>
))}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export default RouteList

View File

@@ -0,0 +1,61 @@
import { FaRoute, FaMountain, FaTachometerAlt, FaStar } from 'react-icons/fa'
const RouteMetadata = ({ route }) => {
return (
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-4 flex items-center gap-2">
<FaRoute className="text-blue-600" /> Route Details
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<FaMountain className="text-gray-600" />
<div>
<p className="text-sm text-gray-500">Elevation Gain</p>
<p className="font-medium">{route.elevation_gain}m</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<FaTachometerAlt className="text-gray-600" />
<div>
<p className="text-sm text-gray-500">Avg Grade</p>
<p className="font-medium">{route.grade_avg}%</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<FaStar className="text-gray-600" />
<div>
<p className="text-sm text-gray-500">Difficulty</p>
<div className="flex gap-1 text-yellow-400">
{[...Array(5)].map((_, index) => (
<FaStar
key={index}
className={`w-4 h-4 ${
index < Math.round(route.difficulty_rating / 2)
? 'fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
</div>
</div>
<div className="col-span-2 md:col-span-3 grid grid-cols-2 gap-4 mt-4">
<div className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-500">Distance</p>
<p className="font-medium">{(route.distance / 1000).toFixed(1)}km</p>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-500">Category</p>
<p className="font-medium capitalize">{route.category}</p>
</div>
</div>
</div>
</div>
)
}
export default RouteMetadata

View File

@@ -0,0 +1,93 @@
import { useRef, useEffect, useState } from 'react'
import { MapContainer, TileLayer, Polyline, Marker, Popup } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { gpx } from '@tmcw/togeojson'
// Fix leaflet marker icons
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
})
const RouteVisualization = ({ gpxData }) => {
const mapRef = useRef()
const elevationChartRef = useRef(null)
const [routePoints, setRoutePoints] = useState([])
useEffect(() => {
if (!gpxData) return
try {
const parser = new DOMParser()
const gpxDoc = parser.parseFromString(gpxData, 'text/xml')
const geoJson = gpx(gpxDoc)
if (!geoJson.features[0]) return
const coordinates = geoJson.features[0].geometry.coordinates
const points = coordinates.map(coord => [coord[1], coord[0]]) // [lat, lon]
const bounds = L.latLngBounds(points)
setRoutePoints(points)
if (mapRef.current) {
mapRef.current.flyToBounds(bounds, { padding: [50, 50] })
}
// Plot elevation profile
if (elevationChartRef.current) {
const elevations = coordinates.map(coord => coord[2] || 0)
const distances = []
let distance = 0
for (let i = 1; i < coordinates.length; i++) {
const prevPoint = L.latLng(coordinates[i-1][1], coordinates[i-1][0])
const currPoint = L.latLng(coordinates[i][1], coordinates[i][0])
distance += prevPoint.distanceTo(currPoint)
distances.push(distance)
}
// TODO: Integrate charting library
}
} catch (error) {
console.error('Error parsing GPX data:', error)
}
}, [gpxData])
return (
<div className="h-full w-full relative">
<MapContainer
center={[51.505, -0.09]}
zoom={13}
scrollWheelZoom={false}
className="h-full rounded-md"
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Polyline
positions={routePoints}
color="#3b82f6"
weight={4}
/>
<Marker position={[51.505, -0.09]}>
<Popup>Start/End Point</Popup>
</Marker>
</MapContainer>
<div
ref={elevationChartRef}
className="absolute bottom-4 left-4 right-4 h-32 bg-white/90 backdrop-blur-sm rounded-md p-4 shadow-md"
>
{/* Elevation chart will be rendered here */}
</div>
</div>
)
}
export default RouteVisualization

View File

@@ -0,0 +1,116 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
const SectionList = ({ sections, onSplit, onUpdate }) => {
const [editing, setEditing] = useState(null);
const [localSections, setLocalSections] = useState(sections);
const surfaceTypes = ['road', 'gravel', 'mixed', 'trail'];
const gearOptions = {
road: ['Standard (39x25)', 'Mid-compact (36x30)', 'Compact (34x28)'],
gravel: ['1x System', '2x Gravel', 'Adventure'],
trail: ['MTB Wide-range', 'Fat Bike']
};
const handleEdit = (sectionId) => {
setEditing(sectionId);
setLocalSections(sections);
};
const handleSave = () => {
onUpdate(localSections);
setEditing(null);
};
const handleChange = (sectionId, field, value) => {
setLocalSections(prev => prev.map(section =>
section.id === sectionId ? { ...section, [field]: value } : section
));
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Route Sections</h3>
<div className="space-x-2">
<button
onClick={() => onSplit([Math.floor(sections.length/2)])}
className="bg-green-600 text-white px-3 py-1 rounded-md hover:bg-green-700"
>
Split Route
</button>
{editing && (
<button
onClick={handleSave}
className="bg-blue-600 text-white px-3 py-1 rounded-md hover:bg-blue-700"
>
Save All
</button>
)}
</div>
</div>
{localSections.map((section) => (
<div key={section.id} className="bg-white p-4 rounded-lg shadow-md">
<div className="flex justify-between items-start mb-2">
<h4 className="font-medium">Section {section.id}</h4>
<button
onClick={() => editing === section.id ? handleSave() : handleEdit(section.id)}
className={`text-sm ${editing === section.id ? 'text-blue-600' : 'text-gray-600 hover:text-gray-800'}`}
>
{editing === section.id ? 'Save' : 'Edit'}
</button>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="space-y-1">
<p>Distance: {section.distance} km</p>
<p>Elevation: {section.elevationGain} m</p>
</div>
<div className="space-y-1">
<p>Max Grade: {section.maxGrade}%</p>
<p>Surface:
{editing === section.id ? (
<select
value={section.surfaceType}
onChange={(e) => handleChange(section.id, 'surfaceType', e.target.value)}
className="ml-2 p-1 border rounded"
>
{surfaceTypes.map(type => (
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
))}
</select>
) : (
<span className="ml-2 capitalize">{section.surfaceType}</span>
)}
</p>
</div>
</div>
{editing === section.id && (
<div className="mt-3 pt-2 border-t">
<label className="block text-sm font-medium mb-1">Gear Recommendation</label>
<select
value={section.gearRecommendation}
onChange={(e) => handleChange(section.id, 'gearRecommendation', e.target.value)}
className="w-full p-1 border rounded"
>
{gearOptions[section.surfaceType]?.map(gear => (
<option key={gear} value={gear}>{gear}</option>
))}
</select>
</div>
)}
</div>
))}
</div>
);
};
SectionList.propTypes = {
sections: PropTypes.arrayOf(PropTypes.object).isRequired,
onSplit: PropTypes.func.isRequired,
onUpdate: PropTypes.func.isRequired
};
export default SectionList;

View File

@@ -0,0 +1,104 @@
import { useState, useEffect } from 'react'
import { Button, Input, Select } from '../ui'
import { FaPlus, FaTrash } from 'react-icons/fa'
const SectionManager = ({ route, onSectionsUpdate }) => {
const [sections, setSections] = useState(route.sections || [])
const [newSection, setNewSection] = useState({
name: '',
start: 0,
end: 0,
difficulty: 3,
recommended_gear: 'road'
})
useEffect(() => {
onSectionsUpdate(sections)
}, [sections, onSectionsUpdate])
const addSection = () => {
if (newSection.name && newSection.start < newSection.end) {
setSections([...sections, {
...newSection,
id: Date.now().toString(),
distance: newSection.end - newSection.start
}])
setNewSection({
name: '',
start: 0,
end: 0,
difficulty: 3,
recommended_gear: 'road'
})
}
}
const removeSection = (sectionId) => {
setSections(sections.filter(s => s.id !== sectionId))
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 items-end">
<Input
label="Section Name"
value={newSection.name}
onChange={(e) => setNewSection({...newSection, name: e.target.value})}
/>
<Input
type="number"
label="Start (km)"
value={newSection.start}
onChange={(e) => setNewSection({...newSection, start: +e.target.value})}
/>
<Input
type="number"
label="End (km)"
value={newSection.end}
onChange={(e) => setNewSection({...newSection, end: +e.target.value})}
/>
<Select
label="Difficulty"
value={newSection.difficulty}
options={[1,2,3,4,5].map(n => ({value: n, label: `${n}/5`}))}
onChange={(e) => setNewSection({...newSection, difficulty: +e.target.value})}
/>
<Select
label="Gear"
value={newSection.recommended_gear}
options={[
{value: 'road', label: 'Road Bike'},
{value: 'gravel', label: 'Gravel Bike'},
{value: 'tt', label: 'Time Trial'},
{value: 'climbing', label: 'Climbing Bike'}
]}
onChange={(e) => setNewSection({...newSection, recommended_gear: e.target.value})}
/>
<Button onClick={addSection} className="h-[42px]">
<FaPlus className="mr-2" /> Add Section
</Button>
</div>
<div className="space-y-4">
{sections.map(section => (
<div key={section.id} className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
<div className="flex-1 grid grid-cols-4 gap-4">
<p className="font-medium">{section.name}</p>
<p>{section.start}km - {section.end}km</p>
<p>Difficulty: {section.difficulty}/5</p>
<p className="capitalize">{section.recommended_gear.replace('_', ' ')}</p>
</div>
<button
onClick={() => removeSection(section.id)}
className="text-red-600 hover:text-red-700 p-2"
>
<FaTrash />
</button>
</div>
))}
</div>
</div>
)
}
export default SectionManager

View File

@@ -0,0 +1,26 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import FileUpload from '../FileUpload'
describe('FileUpload', () => {
const mockFile = new File(['<gpx><trk><name>Test Route</name></trk></gpx>'], 'test.gpx', {
type: 'application/gpx+xml'
})
it('handles file upload and preview', async () => {
const mockSuccess = jest.fn()
render(<FileUpload onUploadSuccess={mockSuccess} />)
// Simulate file drop
const dropZone = screen.getByText('Drag and drop GPX file here')
fireEvent.dragOver(dropZone)
fireEvent.drop(dropZone, {
dataTransfer: { files: [mockFile] }
})
// Check preview
await waitFor(() => {
expect(screen.getByText('Test Route')).toBeInTheDocument()
expect(screen.getByText('Confirm Upload')).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react'
import RouteList from '../RouteList'
const mockRoutes = [
{
id: 1,
name: 'Mountain Loop',
distance: 45000,
elevation_gain: 800,
category: 'climbing'
},
{
id: 2,
name: 'Lakeside Ride',
distance: 25000,
elevation_gain: 200,
category: 'flat'
}
]
describe('RouteList', () => {
it('displays routes and handles filtering', () => {
render(<RouteList routes={mockRoutes} />)
// Check initial render
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
// Test search
fireEvent.change(screen.getByPlaceholderText('Search routes...'), {
target: { value: 'mountain' }
})
expect(screen.getByText('Mountain Loop')).toBeInTheDocument()
expect(screen.queryByText('Lakeside Ride')).not.toBeInTheDocument()
// Test category filter
fireEvent.click(screen.getByText('Flat'))
expect(screen.queryByText('Mountain Loop')).not.toBeInTheDocument()
expect(screen.getByText('Lakeside Ride')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react'
import RouteMetadata from '../RouteMetadata'
import { AuthProvider } from '../../../context/AuthContext'
const mockRoute = {
id: 1,
name: 'Test Route',
description: 'Initial description',
category: 'mixed'
}
const Wrapper = ({ children }) => (
<AuthProvider>
{children}
</AuthProvider>
)
describe('RouteMetadata', () => {
it('handles editing and updating route details', async () => {
const mockUpdate = jest.fn()
render(<RouteMetadata route={mockRoute} onUpdate={mockUpdate} />, { wrapper: Wrapper })
// Test initial view mode
expect(screen.getByText('Test Route')).toBeInTheDocument()
expect(screen.getByText('Initial description')).toBeInTheDocument()
// Enter edit mode
fireEvent.click(screen.getByText('Edit'))
// Verify form fields
const nameInput = screen.getByDisplayValue('Test Route')
const descInput = screen.getByDisplayValue('Initial description')
const categorySelect = screen.getByDisplayValue('Mixed')
// Make changes
fireEvent.change(nameInput, { target: { value: 'Updated Route' } })
fireEvent.change(descInput, { target: { value: 'New description' } })
fireEvent.change(categorySelect, { target: { value: 'climbing' } })
// Save changes
fireEvent.click(screen.getByText('Save'))
// Verify update was called
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Route',
description: 'New description',
category: 'climbing'
}))
})
})
})

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react'
import RouteVisualization from '../RouteVisualization'
const mockGPX = `
<gpx>
<trk>
<trkseg>
<trkpt lat="37.7749" lon="-122.4194"><ele>50</ele></trkpt>
<trkpt lat="37.7859" lon="-122.4294"><ele>60</ele></trkpt>
</trkseg>
</trk>
</gpx>
`
describe('RouteVisualization', () => {
it('renders map with GPX track', () => {
render(<RouteVisualization gpxData={mockGPX} />)
// Check map container is rendered
expect(screen.getByRole('presentation')).toBeInTheDocument()
// Check if polyline is created with coordinates
const path = document.querySelector('.leaflet-overlay-pane path')
expect(path).toHaveAttribute('d', expect.stringContaining('M37.7749 -122.4194L37.7859 -122.4294'))
})
})

View File

@@ -0,0 +1,121 @@
import { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import { parseRule } from '../../services/ruleService';
const RuleEditor = ({ value, onChange, onParse }) => {
const [charCount, setCharCount] = useState(0);
const [isValid, setIsValid] = useState(true);
const [showTemplates, setShowTemplates] = useState(false);
// Auto-resize textarea
useEffect(() => {
const textarea = document.getElementById('ruleEditor');
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}, [value]);
// Enhanced validation
useEffect(() => {
const count = value.length;
setCharCount(count);
const hasRequiredKeywords = /(maximum|minimum|at least|no more than)/i.test(value);
const hasNumbersWithUnits = /\d+\s+(rides?|hours?|days?|weeks?)/i.test(value);
const hasConstraints = /(between|recovery|interval|duration)/i.test(value);
setIsValid(
count <= 5000 &&
count >= 10 &&
hasRequiredKeywords &&
hasNumbersWithUnits &&
hasConstraints
);
}, [value]);
const handleParse = async () => {
try {
const { data } = await parseRule(value);
onParse(data.jsonRules);
} catch (err) {
console.error('Parsing failed:', err);
}
};
const templateSuggestions = [
'Maximum 4 rides per week with at least one rest day between hard workouts',
'Long rides limited to 3 hours maximum during weekdays',
'No outdoor rides when temperature drops below 0°C',
'Interval sessions limited to twice weekly with 48h recovery'
];
return (
<div className="border rounded-lg p-4 bg-white shadow-sm">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Natural Language Editor</h2>
<div className="relative">
<button
onClick={() => setShowTemplates(!showTemplates)}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200"
>
Templates
</button>
{showTemplates && (
<div className="absolute right-0 mt-2 w-64 bg-white border rounded-lg shadow-lg z-10">
{templateSuggestions.map((template, index) => (
<div
key={index}
className="p-3 hover:bg-gray-50 cursor-pointer border-b"
onClick={() => {
onChange(template);
setShowTemplates(false);
}}
>
{template}
</div>
))}
</div>
)}
</div>
</div>
<textarea
id="ruleEditor"
value={value}
onChange={(e) => onChange(e.target.value)}
className={`w-full p-3 border rounded-lg focus:ring-2 ${
isValid ? 'focus:ring-blue-500' : 'focus:ring-red-500'
}`}
placeholder="Enter your training rules in natural language..."
rows="5"
/>
<div className="flex justify-between items-center mt-4">
<div className="text-sm text-gray-600">
{charCount}/5000 characters {' '}
<span className={isValid ? 'text-green-600' : 'text-red-600'}>
{isValid ? 'Valid' : 'Invalid input'}
</span>
</div>
<button
onClick={handleParse}
disabled={!isValid}
className={`px-4 py-2 rounded-lg ${
isValid
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
Parse Rules
</button>
</div>
</div>
);
};
RuleEditor.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onParse: PropTypes.func.isRequired
};
export default RuleEditor;

View File

@@ -0,0 +1,46 @@
import { JSONTree } from 'react-json-tree';
import PropTypes from 'prop-types';
const RulePreview = ({ rules, onSave, isSaving }) => {
return (
<div className="border rounded-lg p-4 bg-white shadow-sm">
<h2 className="text-xl font-semibold mb-4">Rule Configuration Preview</h2>
<div className="border rounded-lg overflow-hidden mb-4">
{rules ? (
<JSONTree
data={rules}
theme="harmonic"
hideRoot={false}
shouldExpandNodeInitially={() => true}
style={{ padding: '1rem' }}
/>
) : (
<div className="p-4 text-gray-500">
Parsed rules will appear here...
</div>
)}
</div>
<button
onClick={onSave}
disabled={!rules || isSaving}
className={`w-full py-2 rounded-lg font-medium ${
rules && !isSaving
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
}`}
>
{isSaving ? 'Saving...' : 'Save Rule Set'}
</button>
</div>
);
};
RulePreview.propTypes = {
rules: PropTypes.object,
onSave: PropTypes.func.isRequired,
isSaving: PropTypes.bool.isRequired
};
export default RulePreview;

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { format } from 'date-fns';
const RulesList = ({ ruleSets, onSelect }) => {
const [selectedSet, setSelectedSet] = useState(null);
return (
<div className="border rounded-lg p-4 bg-white shadow-sm">
<h2 className="text-xl font-semibold mb-4">Saved Rule Sets</h2>
{ruleSets.length === 0 ? (
<div className="text-gray-500 text-center py-4">
No rule sets saved yet. Create one using the editor above.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left border-b">
<th className="pb-2">Name</th>
<th className="pb-2">Version</th>
<th className="pb-2">Status</th>
<th className="pb-2">Created</th>
<th className="pb-2">Actions</th>
</tr>
</thead>
<tbody>
{ruleSets.map((set) => (
<tr key={set.id} className="border-b hover:bg-gray-50">
<td className="py-3">{set.name || 'Untitled Rules'}</td>
<td className="py-3">v{set.version}</td>
<td className="py-3">
<span className={`px-2 py-1 rounded-full text-sm ${
set.active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{set.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="py-3">
{format(new Date(set.created_at), 'MMM dd, yyyy')}
</td>
<td className="py-3">
<button
onClick={() => {
setSelectedSet(set);
onSelect(set);
}}
className="text-blue-600 hover:text-blue-800"
>
View/Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Version History Modal */}
{selectedSet && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">
{selectedSet.name} Version History
</h3>
<button
onClick={() => setSelectedSet(null)}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="space-y-2">
{selectedSet.history?.map((version) => (
<div
key={version.version}
className="p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex justify-between items-center">
<div>
<span className="font-medium">v{version.version}</span>
<span className="text-sm text-gray-500 ml-2">
{format(new Date(version.created_at), 'MMM dd, yyyy HH:mm')}
</span>
</div>
<button
onClick={() => onSelect(version)}
className="text-sm text-blue-600 hover:text-blue-800"
>
Restore
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
RulesList.propTypes = {
ruleSets: PropTypes.array.isRequired,
onSelect: PropTypes.func.isRequired
};
export default RulesList;

View File

@@ -0,0 +1,45 @@
import { render, screen, fireEvent } from '@testing-library/react';
import RuleEditor from '../RuleEditor';
describe('RuleEditor', () => {
const mockOnChange = jest.fn();
const mockOnParse = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
test('renders editor with basic functionality', () => {
render(<RuleEditor value="" onChange={mockOnChange} onParse={mockOnParse} />);
expect(screen.getByText('Natural Language Editor')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter your training rules in natural language...')).toBeInTheDocument();
expect(screen.getByText('Parse Rules')).toBeInTheDocument();
});
test('shows character count and validation status', () => {
const { rerender } = render(
<RuleEditor value="Valid rule text" onChange={mockOnChange} onParse={mockOnParse} />
);
expect(screen.getByText(/0\/5000 characters/)).toBeInTheDocument();
expect(screen.getByText('Valid')).toBeInTheDocument();
rerender(<RuleEditor value="Short" onChange={mockOnChange} onParse={mockOnParse} />);
expect(screen.getByText('Invalid input')).toBeInTheDocument();
});
test('shows template suggestions when clicked', async () => {
render(<RuleEditor value="" onChange={mockOnChange} onParse={mockOnParse} />);
fireEvent.click(screen.getByText('Templates'));
expect(screen.getByText('Maximum 4 rides per week with at least one rest day between hard workouts')).toBeInTheDocument();
});
test('triggers parse on button click', () => {
render(<RuleEditor value="Valid rule text" onChange={mockOnChange} onParse={mockOnParse} />);
fireEvent.click(screen.getByText('Parse Rules'));
expect(mockOnParse).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react';
import RulePreview from '../RulePreview';
describe('RulePreview', () => {
const mockRules = {
maxRides: 4,
minRestDays: 1
};
test('renders preview with rules', () => {
render(<RulePreview rules={mockRules} />);
expect(screen.getByText('Rule Configuration Preview')).toBeInTheDocument();
expect(screen.getByText('maxRides')).toBeInTheDocument();
});
test('shows placeholder when no rules', () => {
render(<RulePreview rules={null} />);
expect(screen.getByText('Parsed rules will appear here...')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,32 @@
import { render, screen, fireEvent } from '@testing-library/react';
import RulesList from '../RulesList';
const mockRuleSets = [{
id: 1,
name: 'Winter Rules',
version: 2,
active: true,
created_at: '2024-01-15T11:00:00Z',
history: [
{ version: 1, created_at: '2024-01-01T09:00:00Z' },
{ version: 2, created_at: '2024-01-15T11:00:00Z' }
]
}];
describe('RulesList', () => {
test('renders rule sets table', () => {
render(<RulesList ruleSets={mockRuleSets} />);
expect(screen.getByText('Winter Rules')).toBeInTheDocument();
expect(screen.getByText('v2')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
});
test('shows version history modal', () => {
render(<RulesList ruleSets={mockRuleSets} />);
fireEvent.click(screen.getByText('View/Edit'));
expect(screen.getByText('Winter Rules Version History')).toBeInTheDocument();
expect(screen.getAllByText(/v\d/)).toHaveLength(2);
});
});

View File

@@ -0,0 +1,38 @@
import PropTypes from 'prop-types';
const ProgressTracker = ({ currentStep, totalSteps }) => {
return (
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
{[...Array(totalSteps)].map((_, index) => (
<div key={index} className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
index + 1 <= currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}>
{index + 1}
</div>
{index < totalSteps - 1 && (
<div className={`w-16 h-1 ${
index + 1 < currentStep
? 'bg-blue-600'
: 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
<div className="text-sm text-gray-600 text-center">
Step {currentStep} of {totalSteps}
</div>
</div>
);
};
ProgressTracker.propTypes = {
currentStep: PropTypes.number.isRequired,
totalSteps: PropTypes.number.isRequired
};
export default ProgressTracker;

View File

@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../context/AuthContext';
import GoalSelector from '../components/plans/GoalSelector';
import PlanParameters from '../components/plans/PlanParameters';
import { generatePlan } from '../services/planService';
import ProgressTracker from '../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;

View File

@@ -1,25 +1,72 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import FileUpload from '../components/routes/FileUpload';
import RouteList from '../components/routes/RouteList';
import RouteFilter from '../components/routes/RouteFilter';
import LoadingSpinner from '../components/LoadingSpinner';
const RoutesPage = () => {
const { apiKey } = useAuth();
// Handle build-time case where apiKey is undefined
if (typeof window === 'undefined') {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Routes</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading route management...</p>
</div>
</div>
);
}
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="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Route management will be displayed here</p>
<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>
);

View File

@@ -0,0 +1,85 @@
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';
const RulesPage = () => {
const [ruleText, setRuleText] = useState('');
const [parsedRules, setParsedRules] = useState(null);
const [ruleSets, setRuleSets] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
// 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;

View File

@@ -6,7 +6,9 @@ export default function Home() {
useEffect(() => {
const checkBackendHealth = async () => {
try {
const response = await fetch('http://backend:8000/health');
// 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) {

View File

@@ -0,0 +1,47 @@
import axios from 'axios';
export const generatePlan = async (apiKey, planData) => {
try {
const response = await axios.post('/api/plans/generate', planData, {
headers: { 'X-API-Key': apiKey }
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to generate plan');
}
};
export const getPlanPreview = async (apiKey, planId) => {
try {
const response = await axios.get(`/api/plans/${planId}/preview`, {
headers: { 'X-API-Key': apiKey }
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to load plan preview');
}
};
export const pollPlanStatus = async (apiKey, planId, interval = 2000) => {
return new Promise(async (resolve, reject) => {
const checkStatus = async () => {
try {
const response = await axios.get(`/api/plans/${planId}/status`, {
headers: { 'X-API-Key': apiKey }
});
if (response.data.status === 'completed') {
resolve(response.data.plan);
} else if (response.data.status === 'failed') {
reject(new Error('Plan generation failed'));
} else {
setTimeout(checkStatus, interval);
}
} catch (error) {
reject(error);
}
};
await checkStatus();
});
};

View File

@@ -0,0 +1,64 @@
import axios from 'axios';
import { useAuth } from '../context/AuthContext';
const api = axios.create({
baseURL: '/api'
});
// Request interceptor to add API key header
api.interceptors.request.use(config => {
const { apiKey } = useAuth.getState();
if (apiKey) {
config.headers['X-API-Key'] = apiKey;
}
return config;
});
export const uploadRoute = async (formData) => {
try {
const response = await api.post('/routes/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to upload route');
}
};
export const getRouteDetails = async (routeId) => {
try {
const response = await api.get(`/routes/${routeId}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to fetch route details');
}
};
export const saveRouteMetadata = async (routeId, metadata) => {
try {
const response = await api.put(`/routes/${routeId}/metadata`, metadata);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to save metadata');
}
};
export const saveRouteSections = async (routeId, sections) => {
try {
const response = await api.post(`/routes/${routeId}/sections`, { sections });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to save sections');
}
};
export const deleteRoute = async (routeId) => {
try {
const response = await api.delete(`/routes/${routeId}`);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || 'Failed to delete route');
}
};

View File

@@ -0,0 +1,55 @@
import axios from 'axios';
const API_URL = process.env.REACT_APP_API_URL + '/rules';
const API_KEY = process.env.REACT_APP_API_KEY;
const apiClient = axios.create({
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json'
}
});
export const getRuleSets = async () => {
try {
const response = await apiClient.get(API_URL);
return response.data;
} catch (error) {
throw new Error('Failed to fetch rule sets');
}
};
export const createRuleSet = async (ruleData) => {
try {
const response = await apiClient.post(API_URL, ruleData);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Failed to create rule set');
}
};
export const updateRuleSet = async (id, ruleData) => {
try {
const response = await apiClient.put(`${API_URL}/${id}`, ruleData);
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Failed to update rule set');
}
};
export const deleteRuleSet = async (id) => {
try {
await apiClient.delete(`${API_URL}/${id}`);
} catch (error) {
throw new Error(error.response?.data?.message || 'Failed to delete rule set');
}
};
export const parseRule = async (text) => {
try {
const response = await apiClient.post(`${API_URL}/parse`, { text });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.message || 'Failed to parse rules');
}
};

970
package-lock.json generated
View File

@@ -5,9 +5,142 @@
"packages": {
"": {
"dependencies": {
"gpxparser": "^3.0.8",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"react-toastify": "^11.0.5"
}
},
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
"integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==",
"deprecated": "Use your platform's native atob() and btoa() methods instead"
},
"node_modules/acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-globals": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz",
"integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==",
"dependencies": {
"acorn": "^6.0.1",
"acorn-walk": "^6.0.1"
}
},
"node_modules/acorn-globals/node_modules/acorn": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz",
"integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
"integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/array-equal": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.2.tgz",
"integrity": "sha512-gUHx76KtnhEgB3HOuFYiCm3FIdEs6ocM2asHvNTkfu/Y09qQVrrVVaOKENmS2KkSaGoxgXNqC+ZVtR/n0MOkSA==",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"engines": {
"node": ">=0.8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"engines": {
"node": "*"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/browser-process-hrtime": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
"integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow=="
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -16,25 +149,504 @@
"node": ">=6"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"peer": true,
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="
},
"node_modules/cssom": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz",
"integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw=="
},
"node_modules/cssstyle": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
"integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
"dependencies": {
"cssom": "~0.3.6"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cssstyle/node_modules/cssom": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/data-urls": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz",
"integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==",
"dependencies": {
"abab": "^2.0.0",
"whatwg-mimetype": "^2.2.0",
"whatwg-url": "^7.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/domexception": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz",
"integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==",
"deprecated": "Use your platform's native DOMException instead",
"dependencies": {
"webidl-conversions": "^4.0.2"
}
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/escodegen": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz",
"integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==",
"dependencies": {
"esprima": "^4.0.1",
"estraverse": "^4.2.0",
"esutils": "^2.0.2",
"optionator": "^0.8.1"
},
"bin": {
"escodegen": "bin/escodegen.js",
"esgenerate": "bin/esgenerate.js"
},
"engines": {
"node": ">=4.0"
},
"optionalDependencies": {
"source-map": "~0.6.1"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"engines": {
"node": ">=4.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"peer": true,
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
]
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"engines": {
"node": "*"
}
},
"node_modules/form-data": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
"integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
"dependencies": {
"scheduler": "^0.26.0"
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/gpxparser": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/gpxparser/-/gpxparser-3.0.8.tgz",
"integrity": "sha512-rXKrDQoXUHz7wZ+Q/C9EbzGNaRLGeEC3uT/KGMPOj3pCHXEJfKWYxVsd+WjVEyivuVsjJib7eR1H/BBO8USUgA==",
"dependencies": {
"jsdom": "^15.2.1",
"jsdom-global": "^3.0.2"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"engines": {
"node": ">=4"
}
},
"node_modules/har-validator": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/html-encoding-sniffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz",
"integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==",
"dependencies": {
"whatwg-encoding": "^1.0.1"
}
},
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.3.7"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ip-regex": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz",
"integrity": "sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==",
"engines": {
"node": ">=4"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g=="
},
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg=="
},
"node_modules/jsdom": {
"version": "15.2.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-15.2.1.tgz",
"integrity": "sha512-fAl1W0/7T2G5vURSyxBzrJ1LSdQn6Tr5UX/xD4PXDx/PDgwygedfW6El/KIj3xJ7FU61TTYnc/l/B7P49Eqt6g==",
"dependencies": {
"abab": "^2.0.0",
"acorn": "^7.1.0",
"acorn-globals": "^4.3.2",
"array-equal": "^1.0.0",
"cssom": "^0.4.1",
"cssstyle": "^2.0.0",
"data-urls": "^1.1.0",
"domexception": "^1.0.1",
"escodegen": "^1.11.1",
"html-encoding-sniffer": "^1.0.2",
"nwsapi": "^2.2.0",
"parse5": "5.1.0",
"pn": "^1.1.0",
"request": "^2.88.0",
"request-promise-native": "^1.0.7",
"saxes": "^3.1.9",
"symbol-tree": "^3.2.2",
"tough-cookie": "^3.0.1",
"w3c-hr-time": "^1.0.1",
"w3c-xmlserializer": "^1.1.2",
"webidl-conversions": "^4.0.2",
"whatwg-encoding": "^1.0.5",
"whatwg-mimetype": "^2.3.0",
"whatwg-url": "^7.0.0",
"ws": "^7.0.0",
"xml-name-validator": "^3.0.0"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": "^19.1.1"
"canvas": "^2.5.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom-global": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsdom-global/-/jsdom-global-3.0.2.tgz",
"integrity": "sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg==",
"peerDependencies": {
"jsdom": ">=10.0.0"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/levn": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
"integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==",
"dependencies": {
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.sortby": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
"integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nwsapi": {
"version": "2.2.22",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ=="
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"engines": {
"node": "*"
}
},
"node_modules/optionator": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
"integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
"dependencies": {
"deep-is": "~0.1.3",
"fast-levenshtein": "~2.0.6",
"levn": "~0.3.0",
"prelude-ls": "~1.1.2",
"type-check": "~0.3.2",
"word-wrap": "~1.2.3"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/parse5": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.0.tgz",
"integrity": "sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ=="
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/pn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz",
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA=="
},
"node_modules/prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-toastify": {
@@ -49,11 +661,335 @@
"react-dom": "^18 || ^19"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"peer": true
"node_modules/request": {
"version": "2.88.2",
"resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.3.2",
"har-validator": "~5.1.3",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~2.5.0",
"tunnel-agent": "^0.6.0",
"uuid": "^3.3.2"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/request-promise-core": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz",
"integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==",
"dependencies": {
"lodash": "^4.17.19"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"request": "^2.34"
}
},
"node_modules/request-promise-native": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz",
"integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==",
"deprecated": "request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142",
"dependencies": {
"request-promise-core": "1.1.4",
"stealthy-require": "^1.1.1",
"tough-cookie": "^2.3.3"
},
"engines": {
"node": ">=0.12.0"
},
"peerDependencies": {
"request": "^2.34"
}
},
"node_modules/request-promise-native/node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/request/node_modules/tough-cookie": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
"dependencies": {
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/saxes": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-3.1.11.tgz",
"integrity": "sha512-Ydydq3zC+WYDJK1+gRxRapLIED9PWeSuuS41wqyoRmzvhhh9nc+QQrVMKJYzJFULazeGhzSV0QleN2wD3boh2g==",
"dependencies": {
"xmlchars": "^2.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stealthy-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz",
"integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
},
"node_modules/tough-cookie": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz",
"integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==",
"dependencies": {
"ip-regex": "^2.1.0",
"psl": "^1.1.28",
"punycode": "^2.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
"integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
},
"node_modules/type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
"integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==",
"dependencies": {
"prelude-ls": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
"integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
"deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
"bin": {
"uuid": "bin/uuid"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/w3c-hr-time": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
"integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==",
"deprecated": "Use your platform's native performance.now() and performance.timeOrigin.",
"dependencies": {
"browser-process-hrtime": "^1.0.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-1.1.2.tgz",
"integrity": "sha512-p10l/ayESzrBMYWRID6xbuCKh2Fp77+sA0doRuGn4tTIMrrZVeqfpKjXHY+oDh3K4nLdPgNwMTVP6Vp4pvqbNg==",
"dependencies": {
"domexception": "^1.0.1",
"webidl-conversions": "^4.0.2",
"xml-name-validator": "^3.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="
},
"node_modules/whatwg-encoding": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
"integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==",
"dependencies": {
"iconv-lite": "0.4.24"
}
},
"node_modules/whatwg-mimetype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",
"integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="
},
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
"integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
"dependencies": {
"lodash.sortby": "^4.7.0",
"tr46": "^1.0.1",
"webidl-conversions": "^4.0.2"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
"integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw=="
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
}
}
}

View File

@@ -1,5 +1,8 @@
{
"dependencies": {
"gpxparser": "^3.0.8",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"react-toastify": "^11.0.5"
}
}