commit 574feb1ea1059460b473361c5b77da5eb40602de Author: sstent Date: Mon Sep 8 12:51:15 2025 -0700 sync diff --git a/CL_plan.md b/CL_plan.md new file mode 100644 index 0000000..722badd --- /dev/null +++ b/CL_plan.md @@ -0,0 +1,1352 @@ +# AI-Assisted Cycling Coach - Updated Implementation Plan + +## Overview +This document outlines the implementation plan for a single-user, self-hosted AI-assisted cycling coach application with Python backend, PostgreSQL database, GPX file storage, and web frontend. + +## Architecture Components +- **Backend**: Python FastAPI (async) +- **Database**: PostgreSQL with versioning support +- **File Storage**: Local directory for GPX files (up to 200 files) +- **Frontend**: React/Next.js +- **AI Integration**: OpenRouter API +- **Garmin Integration**: garth or garmin-connect Python modules +- **Authentication**: Simple API key for single-user setup +- **Containerization**: Docker + Docker Compose (self-hosted) + +## Implementation Phases + +### Phase 1: Project Setup and Foundation ✅ (Week 1-2) +**Status: Complete** + +1. **Initialize Project Structure** + ``` + / + ├── backend/ + │ ├── app/ + │ │ ├── __init__.py + │ │ ├── main.py + │ │ ├── models/ + │ │ ├── routes/ + │ │ ├── services/ + │ │ └── utils/ + │ ├── requirements.txt + │ └── Dockerfile + ├── frontend/ + │ ├── src/ + │ ├── public/ + │ ├── package.json + │ └── Dockerfile + ├── docker-compose.yml + ├── .env.example + └── README.md + ``` + +2. **Docker Environment Setup** + ```yaml + version: '3.9' + services: + backend: + build: ./backend + ports: + - "8000:8000" + volumes: + - gpx-data:/app/data/gpx + - garmin-sessions:/app/data/sessions + environment: + - DATABASE_URL=postgresql://postgres:password@db:5432/cycling + - GARMIN_USERNAME=${GARMIN_USERNAME} + - GARMIN_PASSWORD=${GARMIN_PASSWORD} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - AI_MODEL=${AI_MODEL:-claude-3-sonnet-20240229} + - API_KEY=${API_KEY} + depends_on: + - db + + frontend: + build: ./frontend + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:8000 + - REACT_APP_API_KEY=${API_KEY} + + db: + image: postgres:15 + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: cycling + volumes: + - postgres-data:/var/lib/postgresql/data + + volumes: + gpx-data: + driver: local + garmin-sessions: + driver: local + postgres-data: + driver: local + ``` + +3. **Database Schema with Enhanced Versioning** + ```sql + -- Routes & Sections + CREATE TABLE routes ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now() + ); + + CREATE TABLE sections ( + id SERIAL PRIMARY KEY, + route_id INT REFERENCES routes(id), + gpx_file_path TEXT NOT NULL, + distance_m NUMERIC, + grade_avg NUMERIC, + min_gear TEXT, + est_time_minutes NUMERIC, + created_at TIMESTAMP DEFAULT now() + ); + + -- Rules with versioning and evolution tracking + CREATE TABLE rules ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + user_defined BOOLEAN DEFAULT true, + jsonb_rules JSONB NOT NULL, + version INT DEFAULT 1, + parent_rule_id INT REFERENCES rules(id), + created_at TIMESTAMP DEFAULT now() + ); + + -- Plans with versioning and evolution tracking + CREATE TABLE plans ( + id SERIAL PRIMARY KEY, + jsonb_plan JSONB NOT NULL, + version INT NOT NULL, + parent_plan_id INT REFERENCES plans(id), + created_at TIMESTAMP DEFAULT now() + ); + + -- Workouts with Garmin integration + CREATE TABLE workouts ( + id SERIAL PRIMARY KEY, + plan_id INT REFERENCES plans(id), + garmin_activity_id TEXT UNIQUE NOT NULL, + activity_type TEXT, + start_time TIMESTAMP, + duration_seconds INT, + distance_m NUMERIC, + avg_hr INT, + max_hr INT, + avg_power NUMERIC, + max_power NUMERIC, + avg_cadence NUMERIC, + elevation_gain_m NUMERIC, + metrics JSONB, -- Additional Garmin data + created_at TIMESTAMP DEFAULT now() + ); + + -- Analyses with enhanced feedback structure + CREATE TABLE analyses ( + id SERIAL PRIMARY KEY, + workout_id INT REFERENCES workouts(id), + analysis_type TEXT DEFAULT 'workout_review', + jsonb_feedback JSONB, + suggestions JSONB, + approved BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT now() + ); + + -- AI Prompts with versioning + CREATE TABLE prompts ( + id SERIAL PRIMARY KEY, + action_type TEXT, -- plan_generation, workout_analysis, rule_parsing, suggestions + model TEXT, + prompt_text TEXT, + version INT DEFAULT 1, + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT now() + ); + + -- Garmin sync status tracking + CREATE TABLE garmin_sync_log ( + id SERIAL PRIMARY KEY, + last_sync_time TIMESTAMP, + activities_synced INT DEFAULT 0, + status TEXT, -- success, error, in_progress + error_message TEXT, + created_at TIMESTAMP DEFAULT now() + ); + ``` + +### Phase 2: Core Backend Implementation ✅ (Week 3-5) +**Status: Complete** + +1. **Database Models with SQLAlchemy** +2. **Basic API Endpoints** +3. **GPX File Handling** +4. **Basic Authentication Middleware** + +### Phase 3: Enhanced Backend + Garmin Integration (Week 6-8) + +#### Week 6: Garmin Integration +1. **Garmin Service Implementation** + ```python + # backend/app/services/garmin.py + import os + import garth + from typing import List, Dict, Any, Optional + from datetime import datetime, timedelta + + class GarminService: + def __init__(self): + self.username = os.getenv("GARMIN_USERNAME") + self.password = os.getenv("GARMIN_PASSWORD") + self.client: Optional[garth.Client] = None + self.session_dir = "/app/data/sessions" + + async def authenticate(self): + """Authenticate with Garmin Connect and persist session.""" + if not self.client: + self.client = garth.Client() + + try: + # Try to load existing session + self.client.load(self.session_dir) + except Exception: + # Fresh authentication + await self.client.login(self.username, self.password) + self.client.save(self.session_dir) + + async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]: + """Fetch recent activities from Garmin Connect.""" + if not self.client: + await self.authenticate() + + if not start_date: + start_date = datetime.now() - timedelta(days=7) + + activities = self.client.get_activities(limit=limit, start=start_date) + return activities + + async def get_activity_details(self, activity_id: str) -> Dict[str, Any]: + """Get detailed activity data including metrics.""" + if not self.client: + await self.authenticate() + + details = self.client.get_activity(activity_id) + return details + ``` + +2. **Workout Sync Service** + ```python + # backend/app/services/workout_sync.py + from sqlalchemy.ext.asyncio import AsyncSession + from app.services.garmin import GarminService + from app.models.workout import Workout + from app.models.garmin_sync_log import GarminSyncLog + + class WorkoutSyncService: + def __init__(self, db: AsyncSession): + self.db = db + self.garmin_service = GarminService() + + async def sync_recent_activities(self, days_back: int = 7): + """Sync recent Garmin activities to database.""" + try: + sync_log = GarminSyncLog(status="in_progress") + self.db.add(sync_log) + await self.db.commit() + + start_date = datetime.now() - timedelta(days=days_back) + activities = await self.garmin_service.get_activities( + limit=50, start_date=start_date + ) + + synced_count = 0 + for activity in activities: + if await self.activity_exists(activity['activityId']): + continue + + workout_data = await self.parse_activity_data(activity) + workout = Workout(**workout_data) + self.db.add(workout) + synced_count += 1 + + sync_log.status = "success" + sync_log.activities_synced = synced_count + sync_log.last_sync_time = datetime.now() + + await self.db.commit() + return synced_count + + except Exception as e: + sync_log.status = "error" + sync_log.error_message = str(e) + await self.db.commit() + raise + + async def activity_exists(self, garmin_activity_id: str) -> bool: + """Check if activity already exists in database.""" + result = await self.db.execute( + select(Workout).where(Workout.garmin_activity_id == garmin_activity_id) + ) + return result.scalar_one_or_none() is not None + + async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]: + """Parse Garmin activity data into workout model format.""" + return { + "garmin_activity_id": activity['activityId'], + "activity_type": activity.get('activityType', {}).get('typeKey'), + "start_time": datetime.fromisoformat(activity['startTimeLocal'].replace('Z', '+00:00')), + "duration_seconds": activity.get('duration'), + "distance_m": activity.get('distance'), + "avg_hr": activity.get('averageHR'), + "max_hr": activity.get('maxHR'), + "avg_power": activity.get('avgPower'), + "max_power": activity.get('maxPower'), + "avg_cadence": activity.get('averageBikingCadenceInRevPerMinute'), + "elevation_gain_m": activity.get('elevationGain'), + "metrics": activity # Store full Garmin data as JSONB + } + ``` + +3. **Background Tasks Setup** + ```python + # backend/app/main.py + from fastapi import BackgroundTasks + from app.services.workout_sync import WorkoutSyncService + + @app.post("/api/workouts/sync") + async def trigger_garmin_sync( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) + ): + """Trigger background sync of recent Garmin activities.""" + sync_service = WorkoutSyncService(db) + background_tasks.add_task(sync_service.sync_recent_activities, days_back=14) + return {"message": "Garmin sync started"} + + @app.get("/api/workouts/sync-status") + async def get_sync_status(db: AsyncSession = Depends(get_db)): + """Get the latest sync status.""" + result = await db.execute( + select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc()).limit(1) + ) + sync_log = result.scalar_one_or_none() + return sync_log + ``` + +#### Week 7: Enhanced AI Integration +1. **Prompt Management System** + ```python + # backend/app/services/prompt_manager.py + class PromptManager: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_active_prompt(self, action_type: str, model: str = None) -> Optional[str]: + """Get the active prompt for a specific action type.""" + query = select(Prompt).where( + Prompt.action_type == action_type, + Prompt.active == True + ) + if model: + query = query.where(Prompt.model == model) + + result = await self.db.execute(query.order_by(Prompt.version.desc())) + prompt = result.scalar_one_or_none() + return prompt.prompt_text if prompt else None + + async def create_prompt_version( + self, + action_type: str, + prompt_text: str, + model: str = None + ) -> Prompt: + """Create a new version of a prompt.""" + # Deactivate previous versions + await self.db.execute( + update(Prompt) + .where(Prompt.action_type == action_type) + .values(active=False) + ) + + # Get next version number + result = await self.db.execute( + select(func.max(Prompt.version)) + .where(Prompt.action_type == action_type) + ) + max_version = result.scalar() or 0 + + # Create new prompt + new_prompt = Prompt( + action_type=action_type, + model=model, + prompt_text=prompt_text, + version=max_version + 1, + active=True + ) + + self.db.add(new_prompt) + await self.db.commit() + return new_prompt + ``` + +2. **Enhanced AI Service** + ```python + # backend/app/services/ai_service.py + import asyncio + from typing import Dict, Any, List + import httpx + from app.services.prompt_manager import PromptManager + + class AIService: + def __init__(self, db: AsyncSession): + self.db = db + self.prompt_manager = PromptManager(db) + self.api_key = os.getenv("OPENROUTER_API_KEY") + self.model = os.getenv("AI_MODEL", "anthropic/claude-3-sonnet-20240229") + self.base_url = "https://openrouter.ai/api/v1" + + async def analyze_workout(self, workout: Workout, plan: Optional[Dict] = None) -> Dict[str, Any]: + """Analyze a workout using AI and generate feedback.""" + prompt_template = await self.prompt_manager.get_active_prompt("workout_analysis") + + if not prompt_template: + raise ValueError("No active workout analysis prompt found") + + # Build context from workout data + workout_context = { + "activity_type": workout.activity_type, + "duration_minutes": workout.duration_seconds / 60 if workout.duration_seconds else 0, + "distance_km": workout.distance_m / 1000 if workout.distance_m else 0, + "avg_hr": workout.avg_hr, + "avg_power": workout.avg_power, + "elevation_gain": workout.elevation_gain_m, + "planned_workout": plan + } + + prompt = prompt_template.format(**workout_context) + + response = await self._make_ai_request(prompt) + return self._parse_workout_analysis(response) + + async def generate_plan(self, rules: List[Dict], goals: Dict[str, Any]) -> Dict[str, Any]: + """Generate a training plan using AI.""" + prompt_template = await self.prompt_manager.get_active_prompt("plan_generation") + + context = { + "rules": rules, + "goals": goals, + "current_fitness_level": goals.get("fitness_level", "intermediate") + } + + prompt = prompt_template.format(**context) + response = await self._make_ai_request(prompt) + return self._parse_plan_response(response) + + async def parse_rules_from_natural_language(self, natural_language: str) -> Dict[str, Any]: + """Parse natural language rules into structured format.""" + prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing") + prompt = prompt_template.format(user_rules=natural_language) + + response = await self._make_ai_request(prompt) + return self._parse_rules_response(response) + + async def _make_ai_request(self, prompt: str) -> str: + """Make async request to OpenRouter API with retry logic.""" + async with httpx.AsyncClient() as client: + for attempt in range(3): # Simple retry logic + try: + response = await client.post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": self.model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 2000, + }, + timeout=30.0 + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except Exception as e: + if attempt == 2: # Last attempt + raise AIServiceError(f"AI request failed after 3 attempts: {str(e)}") + await asyncio.sleep(2 ** attempt) # Exponential backoff + + def _parse_workout_analysis(self, response: str) -> Dict[str, Any]: + """Parse AI response for workout analysis.""" + # Implementation depends on your prompt design + # This is a simplified example + try: + import json + # Assume AI returns JSON + clean_response = response.strip() + if clean_response.startswith("```json"): + clean_response = clean_response[7:-3] + return json.loads(clean_response) + except json.JSONDecodeError: + return {"raw_analysis": response, "structured": False} + ``` + +#### Week 8: Plan Evolution & Analysis Pipeline +1. **Plan Evolution Service** + ```python + # backend/app/services/plan_evolution.py + class PlanEvolutionService: + def __init__(self, db: AsyncSession): + self.db = db + self.ai_service = AIService(db) + + async def evolve_plan_from_analysis( + self, + analysis: Analysis, + current_plan: Plan + ) -> Optional[Plan]: + """Create a new plan version based on workout analysis.""" + if not analysis.approved: + return None + + suggestions = analysis.suggestions + if not suggestions: + return None + + # Generate new plan incorporating suggestions + evolution_context = { + "current_plan": current_plan.jsonb_plan, + "workout_analysis": analysis.jsonb_feedback, + "suggestions": suggestions, + "evolution_type": "workout_feedback" + } + + new_plan_data = await self.ai_service.evolve_plan(evolution_context) + + # Create new plan version + new_plan = Plan( + jsonb_plan=new_plan_data, + version=current_plan.version + 1, + parent_plan_id=current_plan.id + ) + + self.db.add(new_plan) + await self.db.commit() + return new_plan + ``` + +2. **Enhanced API Endpoints** + ```python + # backend/app/routes/workouts.py + @router.post("/workouts/{workout_id}/analyze") + async def analyze_workout( + workout_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) + ): + """Trigger AI analysis of a specific workout.""" + workout = await get_workout_by_id(db, workout_id) + if not workout: + raise HTTPException(status_code=404, detail="Workout not found") + + ai_service = AIService(db) + background_tasks.add_task( + analyze_and_store_workout, + db, workout, ai_service + ) + + return {"message": "Analysis started", "workout_id": workout_id} + + @router.post("/analyses/{analysis_id}/approve") + async def approve_analysis( + analysis_id: int, + db: AsyncSession = Depends(get_db) + ): + """Approve analysis suggestions and trigger plan evolution.""" + analysis = await get_analysis_by_id(db, analysis_id) + analysis.approved = True + + # Trigger plan evolution if suggestions exist + if analysis.suggestions: + evolution_service = PlanEvolutionService(db) + current_plan = await get_current_active_plan(db) + if current_plan: + new_plan = await evolution_service.evolve_plan_from_analysis( + analysis, current_plan + ) + return {"message": "Analysis approved", "new_plan_id": new_plan.id} + + await db.commit() + return {"message": "Analysis approved"} + ``` + +### Phase 4: Frontend Implementation (Week 9-11) + +#### Week 9: Core Components +1. **Garmin Sync Interface** + ```jsx + // frontend/src/components/GarminSync.jsx + import { useState, useEffect } from 'react'; + + const GarminSync = () => { + const [syncStatus, setSyncStatus] = useState(null); + const [syncing, setSyncing] = useState(false); + + const triggerSync = async () => { + setSyncing(true); + try { + await fetch('/api/workouts/sync', { method: 'POST' }); + // Poll for status updates + pollSyncStatus(); + } catch (error) { + console.error('Sync failed:', error); + setSyncing(false); + } + }; + + const pollSyncStatus = () => { + const interval = setInterval(async () => { + const response = await fetch('/api/workouts/sync-status'); + const status = await response.json(); + setSyncStatus(status); + + if (status.status !== 'in_progress') { + setSyncing(false); + clearInterval(interval); + } + }, 2000); + }; + + return ( +
+

Garmin Connect Sync

+ + + {syncStatus && ( +
+

Last sync: {new Date(syncStatus.last_sync_time).toLocaleString()}

+

Status: {syncStatus.status}

+ {syncStatus.activities_synced > 0 && ( +

Activities synced: {syncStatus.activities_synced}

+ )} +
+ )} +
+ ); + }; + ``` + +2. **Workout Analysis Interface** + ```jsx + // frontend/src/components/WorkoutAnalysis.jsx + const WorkoutAnalysis = ({ workout, analysis }) => { + const [approving, setApproving] = useState(false); + + const approveAnalysis = async () => { + setApproving(true); + try { + const response = await fetch(`/api/analyses/${analysis.id}/approve`, { + method: 'POST' + }); + const result = await response.json(); + + if (result.new_plan_id) { + // Navigate to new plan or show success message + console.log('New plan created:', result.new_plan_id); + } + } catch (error) { + console.error('Approval failed:', error); + } finally { + setApproving(false); + } + }; + + return ( +
+
+

{workout.activity_type} - {new Date(workout.start_time).toLocaleDateString()}

+
+ Duration: {Math.round(workout.duration_seconds / 60)}min + Distance: {(workout.distance_m / 1000).toFixed(1)}km + {workout.avg_power && Avg Power: {workout.avg_power}W} + {workout.avg_hr && Avg HR: {workout.avg_hr}bpm} +
+
+ + {analysis && ( +
+

AI Analysis

+
+ {analysis.jsonb_feedback.summary} +
+ + {analysis.suggestions && ( +
+
Suggestions
+
    + {analysis.suggestions.map((suggestion, index) => ( +
  • {suggestion}
  • + ))} +
+ + {!analysis.approved && ( + + )} +
+ )} +
+ )} +
+ ); + }; + ``` + +#### Week 10: Data Visualization +1. **Workout Charts** + ```jsx + // Using recharts for workout data visualization + import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts'; + + const WorkoutChart = ({ workoutData }) => { + return ( +
+

Workout Metrics

+ + + + + + + + + + + +
+ ); + }; + ``` + +2. **Plan Timeline View** + ```jsx + // Plan visualization with version history + const PlanTimeline = ({ plan, versions }) => { + return ( +
+

Training Plan - Version {plan.version}

+ + {versions.length > 1 && ( +
+

Version History

+ {versions.map(version => ( +
+ v{version.version} + {new Date(version.created_at).toLocaleDateString()} + {version.parent_plan_id && → Evolved from analysis} +
+ ))} +
+ )} + +
+ {plan.jsonb_plan.weeks.map((week, index) => ( +
+

Week {index + 1}

+ {week.workouts.map((workout, wIndex) => ( +
+ {workout.type} + {workout.duration}min + {workout.intensity} +
+ ))} +
+ ))} +
+
+ ); + }; + ``` + +#### Week 11: Integration & Polish +1. **Dashboard Overview** +2. **File Upload Improvements** +3. **Error Handling & Loading States** +4. **Responsive Design** + +### Phase 5: Testing and Deployment (Week 12-13) + +#### Week 12: Testing +1. **Backend Testing** + ```python + # tests/test_garmin_service.py + import pytest + from unittest.mock import AsyncMock, patch + from app.services.garmin import GarminService + + @pytest.mark.asyncio + async def test_garmin_authentication(): + with patch('garth.Client') as mock_client: + service = GarminService() + await service.authenticate() + mock_client.return_value.login.assert_called_once() + + @pytest.mark.asyncio + async def test_activity_sync(db_session): + # Test workout sync functionality + pass + ``` + +2. **Integration Tests** +3. **Frontend Component Tests** + +#### Week 13: Deployment Preparation +1. **Environment Configuration** + ```bash + # .env.production + GARMIN_USERNAME=your_garmin_email + GARMIN_PASSWORD=your_garmin_password + OPENROUTER_API_KEY=your_openrouter_key + AI_MODEL=anthropic/claude-3-sonnet-20240229 + API_KEY=your_secure_api_key + ``` + +2. **Production Docker Setup** +3. **Backup Strategy for Database and GPX Files** +4. **Monitoring and Logging** + +## Key Technical Decisions + +### Single-User Simplifications +- **Authentication**: Simple API key instead of complex user management +- **File Storage**: Local filesystem (200 GPX files easily manageable) +- **Database**: Single tenant, no multi-user complexity +- **Deployment**: Self-hosted container, no cloud scaling needs + +### Garmin Integration Strategy +- **garth library**: Python library for Garmin Connect API +- **Session persistence**: Store auth sessions in mounted volume +- **Background sync**: Async background tasks for activity fetching +- **Retry logic**: Handle API rate limits and temporary failures + +### AI Integration Approach +- **Prompt versioning**: Database-stored prompts with version control +- **Async processing**: Non-blocking AI calls with background tasks +- **Cost management**: Simple retry logic, no complex rate limiting needed for single user +- **Response parsing**: Flexible parsing for different AI response formats + +### Database Design Philosophy +- **Versioning everywhere**: Plans, rules, and prompts all support evolution +- **JSONB storage**: Flexible storage for AI responses and complex data +- **Audit trail**: Track plan evolution and analysis approval history + +## Environment Variables +```bash +# Required environment variables +GARMIN_USERNAME=your_garmin_email +GARMIN_PASSWORD=your_garmin_password +OPENROUTER_API_KEY=your_openrouter_key +AI_MODEL=anthropic/claude-3-sonnet-20240229 +API_KEY=your_secure_api_key + +# Optional +DATABASE_URL=postgresql://postgres:password@db:5432/cycling +REACT_APP_API_URL=http://localhost:8000 +REACT_APP_API_KEY=your_secure_api_key +``` + +## Python Standards and Best Practices + +### Code Style and Structure +```python +# Example: Proper async service implementation +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +import logging + +logger = logging.getLogger(__name__) + +class WorkoutAnalysisService: + """Service for analyzing workout data with AI assistance.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.ai_service = AIService(db) + + async def analyze_workout_performance( + self, + workout_id: int, + comparison_metrics: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Analyze workout performance against planned metrics. + + Args: + workout_id: The workout to analyze + comparison_metrics: Optional baseline metrics for comparison + + Returns: + Dict containing analysis results and suggestions + + Raises: + WorkoutNotFoundError: If workout doesn't exist + AIServiceError: If AI analysis fails + """ + try: + workout = await self._get_workout(workout_id) + if not workout: + raise WorkoutNotFoundError(f"Workout {workout_id} not found") + + analysis_data = await self._prepare_analysis_context(workout, comparison_metrics) + ai_analysis = await self.ai_service.analyze_workout(analysis_data) + + # Store analysis results + analysis_record = await self._store_analysis(workout_id, ai_analysis) + + logger.info(f"Successfully analyzed workout {workout_id}") + return { + "analysis_id": analysis_record.id, + "feedback": ai_analysis.get("feedback"), + "suggestions": ai_analysis.get("suggestions"), + "performance_score": ai_analysis.get("score") + } + + except Exception as e: + logger.error(f"Failed to analyze workout {workout_id}: {str(e)}") + raise + + async def _get_workout(self, workout_id: int) -> Optional[Workout]: + """Retrieve workout by ID.""" + result = await self.db.execute( + select(Workout).where(Workout.id == workout_id) + ) + return result.scalar_one_or_none() + + async def _prepare_analysis_context( + self, + workout: Workout, + comparison_metrics: Optional[Dict[str, Any]] + ) -> Dict[str, Any]: + """Prepare context data for AI analysis.""" + context = { + "workout_data": { + "duration_minutes": workout.duration_seconds / 60 if workout.duration_seconds else 0, + "distance_km": workout.distance_m / 1000 if workout.distance_m else 0, + "avg_power": workout.avg_power, + "avg_heart_rate": workout.avg_hr, + "elevation_gain": workout.elevation_gain_m + }, + "activity_type": workout.activity_type, + "date": workout.start_time.isoformat() if workout.start_time else None + } + + if comparison_metrics: + context["baseline_metrics"] = comparison_metrics + + return context +``` + +### Error Handling Patterns +```python +# Custom exceptions for better error handling +class CyclingCoachError(Exception): + """Base exception for cycling coach application.""" + pass + +class WorkoutNotFoundError(CyclingCoachError): + """Raised when a workout cannot be found.""" + pass + +class GarminSyncError(CyclingCoachError): + """Raised when Garmin synchronization fails.""" + pass + +class AIServiceError(CyclingCoachError): + """Raised when AI service requests fail.""" + pass + +# Middleware for consistent error responses +@app.exception_handler(CyclingCoachError) +async def cycling_coach_exception_handler(request: Request, exc: CyclingCoachError): + return JSONResponse( + status_code=400, + content={ + "error": exc.__class__.__name__, + "message": str(exc), + "timestamp": datetime.now().isoformat() + } + ) +``` + +### Database Patterns +```python +# Proper async database patterns with context managers +from contextlib import asynccontextmanager + +class DatabaseService: + """Base service class with database session management.""" + + def __init__(self, db: AsyncSession): + self.db = db + + @asynccontextmanager + async def transaction(self): + """Context manager for database transactions.""" + try: + yield self.db + await self.db.commit() + except Exception: + await self.db.rollback() + raise + + async def get_or_create(self, model_class, **kwargs): + """Get existing record or create new one.""" + result = await self.db.execute( + select(model_class).filter_by(**kwargs) + ) + instance = result.scalar_one_or_none() + + if not instance: + instance = model_class(**kwargs) + self.db.add(instance) + await self.db.flush() # Get ID without committing + + return instance +``` + +## Sample Prompt Templates + +### Workout Analysis Prompt +```sql +INSERT INTO prompts (action_type, model, prompt_text, version, active) VALUES ( +'workout_analysis', +'anthropic/claude-3-sonnet-20240229', +'Analyze the following cycling workout data and provide structured feedback: + +Workout Details: +- Activity Type: {activity_type} +- Duration: {duration_minutes} minutes +- Distance: {distance_km} km +- Average Power: {avg_power}W +- Average Heart Rate: {avg_hr} bpm +- Elevation Gain: {elevation_gain}m + +Please provide your analysis in the following JSON format: +{{ + "performance_summary": "Brief overall assessment", + "strengths": ["strength 1", "strength 2"], + "areas_for_improvement": ["area 1", "area 2"], + "training_suggestions": ["suggestion 1", "suggestion 2"], + "next_workout_recommendations": {{ + "intensity": "easy/moderate/hard", + "focus": "endurance/power/recovery", + "duration_minutes": 60 + }}, + "performance_score": 8.5 +}} + +Focus on actionable insights and specific recommendations for improvement.', +1, +true +); +``` + +### Plan Generation Prompt +```sql +INSERT INTO prompts (action_type, model, prompt_text, version, active) VALUES ( +'plan_generation', +'anthropic/claude-3-sonnet-20240229', +'Create a personalized cycling training plan based on the following information: + +Training Rules: +{rules} + +Goals: +{goals} + +Generate a 4-week training plan in the following JSON format: +{{ + "plan_overview": {{ + "duration_weeks": 4, + "focus": "endurance/power/mixed", + "weekly_hours": 8 + }}, + "weeks": [ + {{ + "week_number": 1, + "focus": "base building", + "workouts": [ + {{ + "day": "monday", + "type": "easy_ride", + "duration_minutes": 60, + "intensity": "zone_1_2", + "description": "Easy recovery ride" + }} + ] + }} + ], + "progression_notes": "How the plan builds over the weeks" +}} + +Ensure all workouts respect the training rules provided.', +1, +true +); +``` + +## Deployment Configuration + +### Production Docker Compose +```yaml +# docker-compose.prod.yml +version: '3.9' +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile.prod + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - gpx-data:/app/data/gpx:rw + - garmin-sessions:/app/data/sessions:rw + - ./logs:/app/logs:rw + environment: + - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/cycling + - GARMIN_USERNAME=${GARMIN_USERNAME} + - GARMIN_PASSWORD=${GARMIN_PASSWORD} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - AI_MODEL=${AI_MODEL} + - API_KEY=${API_KEY} + - LOG_LEVEL=INFO + depends_on: + - db + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.prod + restart: unless-stopped + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:8000 + - NODE_ENV=production + depends_on: + - backend + + db: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: cycling + volumes: + - postgres-data:/var/lib/postgresql/data:rw + - ./backups:/backups:rw + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + gpx-data: + driver: local + driver_opts: + type: none + o: bind + device: /home/user/cycling-coach/data/gpx + garmin-sessions: + driver: local + driver_opts: + type: none + o: bind + device: /home/user/cycling-coach/data/sessions + postgres-data: + driver: local + driver_opts: + type: none + o: bind + device: /home/user/cycling-coach/data/postgres + +networks: + default: + name: cycling-coach +``` + +### Backup Script +```bash +#!/bin/bash +# backup.sh - Daily backup script + +BACKUP_DIR="/home/user/cycling-coach/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +# Create backup directory +mkdir -p "$BACKUP_DIR" + +# Backup database +docker exec cycling-coach-db-1 pg_dump -U postgres cycling > "$BACKUP_DIR/db_backup_$DATE.sql" + +# Backup GPX files +tar -czf "$BACKUP_DIR/gpx_backup_$DATE.tar.gz" -C /home/user/cycling-coach/data/gpx . + +# Backup Garmin sessions +tar -czf "$BACKUP_DIR/sessions_backup_$DATE.tar.gz" -C /home/user/cycling-coach/data/sessions . + +# Keep only last 30 days of backups +find "$BACKUP_DIR" -name "*backup*" -type f -mtime +30 -delete + +echo "Backup completed: $DATE" +``` + +### Health Monitoring +```python +# backend/app/routes/health.py +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from app.database import get_db +from app.services.garmin import GarminService + +router = APIRouter() + +@router.get("/health") +async def health_check(db: AsyncSession = Depends(get_db)): + """Health check endpoint for monitoring.""" + health_status = { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "services": {} + } + + # Check database + try: + await db.execute(text("SELECT 1")) + health_status["services"]["database"] = "healthy" + except Exception as e: + health_status["services"]["database"] = f"error: {str(e)}" + health_status["status"] = "unhealthy" + + # Check Garmin service + try: + garmin_service = GarminService() + # Simple connectivity check without full auth + health_status["services"]["garmin"] = "configured" + except Exception as e: + health_status["services"]["garmin"] = f"error: {str(e)}" + + # Check file system + try: + gpx_dir = "/app/data/gpx" + if os.path.exists(gpx_dir) and os.access(gpx_dir, os.W_OK): + health_status["services"]["file_storage"] = "healthy" + else: + health_status["services"]["file_storage"] = "error: directory not writable" + health_status["status"] = "unhealthy" + except Exception as e: + health_status["services"]["file_storage"] = f"error: {str(e)}" + health_status["status"] = "unhealthy" + + if health_status["status"] == "unhealthy": + raise HTTPException(status_code=503, detail=health_status) + + return health_status +``` + +## Post-Deployment Setup + +### Initial Data Setup +```python +# scripts/init_prompts.py +"""Initialize default AI prompts in the database.""" +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from app.models.prompt import Prompt + +async def init_default_prompts(): + """Initialize the database with default AI prompts.""" + engine = create_async_engine(DATABASE_URL) + + async with AsyncSession(engine) as session: + # Add default prompts for each action type + default_prompts = [ + # Workout analysis prompt (from above) + # Plan generation prompt (from above) + # Rule parsing prompt + ] + + for prompt_data in default_prompts: + prompt = Prompt(**prompt_data) + session.add(prompt) + + await session.commit() + + print("Default prompts initialized successfully") + +if __name__ == "__main__": + asyncio.run(init_default_prompts()) +``` + +### Maintenance Tasks +```python +# scripts/maintenance.py +"""Maintenance tasks for the cycling coach application.""" + +async def cleanup_old_analyses(): + """Remove analyses older than 6 months.""" + cutoff_date = datetime.now() - timedelta(days=180) + + async with AsyncSession(engine) as session: + result = await session.execute( + delete(Analysis).where(Analysis.created_at < cutoff_date) + ) + await session.commit() + print(f"Deleted {result.rowcount} old analyses") + +async def optimize_database(): + """Run database maintenance tasks.""" + async with AsyncSession(engine) as session: + await session.execute(text("VACUUM ANALYZE")) + await session.commit() + print("Database optimization completed") +``` + +This comprehensive implementation plan addresses all the key requirements for your single-user, self-hosted AI-assisted cycling coach application. The plan includes: + +1. **Complete Garmin integration** using environment variables and the garth library +2. **Enhanced database schema** with proper versioning for plans and rules +3. **Robust AI integration** with prompt management and error handling +4. **Production-ready deployment** configuration with health checks and backups +5. **Comprehensive testing strategy** for both backend and frontend +6. **Maintenance and monitoring** tools for long-term operation + +The plan is well-suited for your scale (single user, 200 GPX files) and deployment target (self-hosted container), with practical simplifications that avoid unnecessary complexity while maintaining professional software engineering standards. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b4b2ede --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +.PHONY: up down build start stop restart logs backend-logs frontend-logs db-logs + +# Start all services in detached mode +up: + docker-compose up -d + +# Stop and remove all containers +down: + docker-compose down + +# Rebuild all Docker images +build: + docker-compose build --no-cache + +# Start services if not running, otherwise restart +start: + docker-compose start || docker-compose up -d + +# Stop running services +stop: + docker-compose stop + +# Restart all services +restart: + docker-compose restart + +# Show logs for all services +logs: + docker-compose logs -f + +# Show backend logs +backend-logs: + docker-compose logs -f backend + +# Show frontend logs +frontend-logs: + docker-compose logs -f frontend + +# Show database logs +db-logs: + docker-compose logs -f db + +# Initialize database and run migrations +init-db: + docker-compose run --rm backend alembic upgrade head + +# Create new database migration +migration: + docker-compose run --rm backend alembic revision --autogenerate -m "$(m)" + +# Run tests +test: + docker-compose run --rm backend pytest + +# Open database shell +db-shell: + docker-compose exec db psql -U appuser -d cyclingdb \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5dc1b6 --- /dev/null +++ b/README.md @@ -0,0 +1,325 @@ +# AI Cycling Coach + +A single-user, self-hosted web application that provides AI-powered cycling training plan generation, workout analysis, and plan evolution based on actual ride data from Garmin Connect. + +## 🚀 Quick Start + +### Prerequisites +- Docker and Docker Compose +- 2GB+ available RAM +- 10GB+ available disk space + +### Setup +1. Clone the repository +2. Copy environment file: `cp .env.example .env` +3. Edit `.env` with your credentials +4. Start services: `docker-compose up -d` + +## 🐳 Container-First Development + +This project follows strict containerization practices. All development occurs within Docker containers - never install packages directly on the host system. + +### Key Rules + +#### Containerization Rules +- ✅ All Python packages must be in `backend/requirements.txt` +- ✅ All system packages must be in `backend/Dockerfile` +- ✅ Never run `pip install` or `apt-get install` outside containers +- ✅ Use `docker-compose` for local development + +#### Database Management +- ✅ Schema changes handled through Alembic migrations +- ✅ Migrations run automatically on container startup +- ✅ No raw SQL in application code - use SQLAlchemy ORM +- ✅ Migration rollback scripts available for emergencies + +### Development Workflow + +```bash +# Start development environment +docker-compose up -d + +# View logs +docker-compose logs -f backend + +# Run database migrations manually (if needed) +docker-compose exec backend alembic upgrade head + +# Access backend container +docker-compose exec backend bash + +# Stop services +docker-compose down +``` + +### Migration Management + +#### Automatic Migrations +Migrations run automatically when containers start. The entrypoint script: +1. Runs `alembic upgrade head` +2. Verifies migration success +3. Starts the application + +#### Manual Migration Operations +```bash +# Check migration status +docker-compose exec backend python scripts/migration_checker.py check-db + +# Generate new migration +docker-compose exec backend alembic revision --autogenerate -m "description" + +# Rollback migration +docker-compose exec backend python scripts/migration_rollback.py rollback +``` + +#### Migration Validation +```bash +# Validate deployment readiness +docker-compose exec backend python scripts/migration_checker.py validate-deploy + +# Generate migration report +docker-compose exec backend python scripts/migration_checker.py report +``` + +### Database Backup & Restore + +#### Creating Backups +```bash +# Create backup +docker-compose exec backend python scripts/backup_restore.py backup + +# Create named backup +docker-compose exec backend python scripts/backup_restore.py backup my_backup +``` + +#### Restoring from Backup +```bash +# List available backups +docker-compose exec backend python scripts/backup_restore.py list + +# Restore (with confirmation prompt) +docker-compose exec backend python scripts/backup_restore.py restore backup_file.sql + +# Restore without confirmation +docker-compose exec backend python scripts/backup_restore.py restore backup_file.sql --yes +``` + +#### Cleanup +```bash +# Remove backups older than 30 days +docker-compose exec backend python scripts/backup_restore.py cleanup + +# Remove backups older than N days +docker-compose exec backend python scripts/backup_restore.py cleanup 7 +``` + +## 🔧 Configuration + +### Environment Variables +```env +# Database +DATABASE_URL=postgresql://postgres:password@db:5432/cycling + +# Garmin Connect +GARMIN_USERNAME=your_garmin_email@example.com +GARMIN_PASSWORD=your_secure_password + +# AI Service +OPENROUTER_API_KEY=your_openrouter_api_key +AI_MODEL=anthropic/claude-3-sonnet-20240229 + +# Application +API_KEY=your_secure_random_api_key_here +``` + +### Health Checks + +The application includes comprehensive health monitoring: + +```bash +# Check overall health +curl http://localhost:8000/health + +# Response includes: +# - Database connectivity +# - Migration status +# - Current vs head revision +# - Service availability +``` + +## 🏗️ Architecture + +### Service Architecture +``` +┌─────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ +│ (React) │◄──►│ (FastAPI) │ +│ │ │ │ +└─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Garmin │ │ PostgreSQL │ +│ Connect │ │ Database │ +└─────────────────┘ └─────────────────┘ +``` + +### Data Flow +1. Garmin activities synced via background tasks +2. AI analysis performed on workout data +3. Training plans evolved based on performance +4. User feedback incorporated for plan adjustments + +## 🧪 Testing & Validation + +### CI/CD Pipeline +GitHub Actions automatically validates: +- ✅ No uncommitted migration files +- ✅ No raw SQL in application code +- ✅ Proper dependency management +- ✅ Container build success +- ✅ Migration compatibility + +### Local Validation +```bash +# Run all validation checks +docker-compose exec backend python scripts/migration_checker.py validate-deploy + +# Check for raw SQL usage +grep -r "SELECT.*FROM\|INSERT.*INTO\|UPDATE.*SET\|DELETE.*FROM" backend/app/ +``` + +## 📁 Project Structure + +``` +. +├── backend/ +│ ├── Dockerfile # Multi-stage container build +│ ├── requirements.txt # Python dependencies +│ ├── scripts/ +│ │ ├── migration_rollback.py # Rollback utilities +│ │ ├── backup_restore.py # Backup/restore tools +│ │ └── migration_checker.py # Validation tools +│ └── app/ +│ ├── main.py # FastAPI application +│ ├── database.py # Database configuration +│ ├── models/ # SQLAlchemy models +│ ├── routes/ # API endpoints +│ ├── services/ # Business logic +│ └── schemas/ # Pydantic schemas +├── frontend/ +│ ├── Dockerfile +│ └── src/ +├── docker-compose.yml # Development services +├── .github/ +│ └── workflows/ +│ └── container-validation.yml # CI/CD checks +└── .kilocode/ + └── rules/ + └── container-database-rules.md # Development guidelines +``` + +## 🚨 Troubleshooting + +### Common Issues + +#### Migration Failures +```bash +# Check migration status +docker-compose exec backend alembic current + +# View migration history +docker-compose exec backend alembic history + +# Reset migrations (CAUTION: destroys data) +docker-compose exec backend alembic downgrade base +``` + +#### Database Connection Issues +```bash +# Check database health +docker-compose exec db pg_isready -U postgres + +# View database logs +docker-compose logs db + +# Restart database +docker-compose restart db +``` + +#### Container Build Issues +```bash +# Rebuild without cache +docker-compose build --no-cache backend + +# View build logs +docker-compose build backend +``` + +### Health Monitoring + +#### Service Health +```bash +# Check all services +docker-compose ps + +# View service logs +docker-compose logs -f + +# Check backend health +curl http://localhost:8000/health +``` + +#### Database Health +```bash +# Check database connectivity +docker-compose exec backend python -c " +from app.database import get_db +from sqlalchemy.ext.asyncio import AsyncSession +import asyncio + +async def test(): + async with AsyncSession(get_db()) as session: + result = await session.execute('SELECT 1') + print('Database OK') + +asyncio.run(test()) +" +``` + +## 🔒 Security + +- API key authentication for all endpoints +- Secure storage of Garmin credentials +- No sensitive data in application logs +- Container isolation prevents host system access +- Regular security updates via container rebuilds + +## 📚 API Documentation + +Once running, visit: +- **API Docs**: http://localhost:8000/docs +- **Alternative Docs**: http://localhost:8000/redoc + +## 🤝 Contributing + +1. Follow container-first development rules +2. Ensure all changes pass CI/CD validation +3. Update documentation for significant changes +4. Test migration compatibility before merging + +### Development Guidelines + +- Use SQLAlchemy ORM for all database operations +- Keep dependencies in `requirements.txt` +- Test schema changes in development environment +- Document migration changes in commit messages +- Run validation checks before pushing + +## 📄 License + +This project is licensed under the MIT License - see the LICENSE file for details. + +--- + +**Note**: This application is designed for single-user, self-hosted deployment. All data remains on your local infrastructure with no external data sharing. \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8488490 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,70 @@ +# Multi-stage build for container-first development +FROM python:3.11-slim-bullseye AS builder + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Install system dependencies for building +RUN apt-get update && \ + apt-get install -y --no-install-recommends gcc libpq-dev && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Install Python dependencies +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.11-slim-bullseye AS runtime + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Install runtime system dependencies only +RUN apt-get update && \ + apt-get install -y --no-install-recommends libpq5 && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy installed packages from builder stage +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Copy application code +COPY backend/ . + +# Create entrypoint script for migration handling +RUN echo '#!/bin/bash\n\ +set -e\n\ +\n\ +# Run database migrations\n\ +echo "Running database migrations..."\n\ +alembic upgrade head\n\ +\n\ +# Verify migration success\n\ +echo "Verifying migration status..."\n\ +alembic current\n\ +\n\ +# Start the application\n\ +echo "Starting application..."\n\ +exec "$@"' > /app/entrypoint.sh && \ + chmod +x /app/entrypoint.sh + +# Create non-root user +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser + +# Expose application port +EXPOSE 8000 + +# Use entrypoint for migration automation +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..01ce8f0 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,24 @@ +[alembic] +script_location = alembic +sqlalchemy.url = postgresql+asyncpg://appuser:password@db:5432/cyclingdb + +[loggers] +keys = root + +[handlers] +keys = console + +[logger_root] +level = WARN +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..450ebcb --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,56 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from sqlalchemy.ext.asyncio import AsyncEngine +from alembic import context +import sys +import os + +# Add app directory to path +sys.path.append(os.getcwd()) + +# Import base and models +from app.models import Base +from app.database import DATABASE_URL + +config = context.config +fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def run_migrations_offline(): + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online(): + """Run migrations in 'online' mode.""" + connectable = AsyncEngine( + engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + future=True, + url=DATABASE_URL, + ) + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + +async def do_run_migrations(connection): + context.configure(connection=connection, target_metadata=target_metadata) + await connection.run_sync(context.run_migrations) + +if context.is_offline_mode(): + run_migrations_offline() +else: + import asyncio + asyncio.run(run_migrations_online()) \ No newline at end of file diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..3d87de5 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..38ac640 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str + GPX_STORAGE_PATH: str + AI_MODEL: str = "openrouter/auto" + + class Config: + env_file = ".env" + +settings = Settings() \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d72f8f3 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = "postgresql+asyncpg://appuser:password@db:5432/cyclingdb" + +engine = create_async_engine(DATABASE_URL, echo=True) +AsyncSessionLocal = sessionmaker( + bind=engine, + class_=AsyncSession, + expire_on_commit=False +) + +Base = declarative_base() + +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + yield session \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..63da741 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,107 @@ +from fastapi import FastAPI, Depends, Request, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from .database import get_db, get_database_url +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +from .routes import gpx as gpx_routes +from .routes import rule as rule_routes +from .routes import plan as plan_routes +from .routes import workouts as workout_routes +from .routes import prompts as prompt_routes +from .config import settings + +app = FastAPI( + title="AI Cycling Coach API", + description="Backend service for AI-assisted cycling training platform", + version="0.1.0" +) + +# API Key Authentication Middleware +@app.middleware("http") +async def api_key_auth(request: Request, call_next): + if request.url.path.startswith("/docs") or request.url.path.startswith("/redoc") or request.url.path == "/health": + return await call_next(request) + + api_key = request.headers.get("X-API-KEY") + if api_key != settings.API_KEY: + raise HTTPException(status_code=401, detail="Invalid API Key") + + return await call_next(request) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(gpx_routes.router) +app.include_router(rule_routes.router) +app.include_router(plan_routes.router) +app.include_router(workout_routes.router, prefix="/workouts", tags=["workouts"]) +app.include_router(prompt_routes.router, prefix="/prompts", tags=["prompts"]) + +async def check_migration_status(): + """Check if database migrations are up to date.""" + try: + # Get Alembic configuration + config = Config("alembic.ini") + config.set_main_option("sqlalchemy.url", get_database_url()) + script = ScriptDirectory.from_config(config) + + # Get current database revision + from sqlalchemy import create_engine + engine = create_engine(get_database_url()) + with engine.connect() as conn: + context = MigrationContext.configure(conn) + current_rev = context.get_current_revision() + + # Get head revision + head_rev = script.get_current_head() + + return { + "current_revision": current_rev, + "head_revision": head_rev, + "migrations_up_to_date": current_rev == head_rev + } + except Exception as e: + return { + "error": str(e), + "migrations_up_to_date": False + } + +@app.get("/health") +async def health_check(db: AsyncSession = Depends(get_db)): + """Enhanced health check with migration verification.""" + health_status = { + "status": "healthy", + "version": "0.1.0", + "timestamp": "2024-01-15T10:30:00Z" # Should be dynamic + } + + # Database connection check + try: + await db.execute(text("SELECT 1")) + health_status["database"] = "connected" + except Exception as e: + health_status["status"] = "unhealthy" + health_status["database"] = f"error: {str(e)}" + + # Migration status check + migration_info = await check_migration_status() + health_status["migrations"] = migration_info + + if not migration_info.get("migrations_up_to_date", False): + health_status["status"] = "unhealthy" + + return health_status + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8b3d2da --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,11 @@ +from .base import BaseModel +from .route import Route +from .section import Section +from .rule import Rule +from .plan import Plan +from .plan_rule import PlanRule +from .user import User +from .workout import Workout +from .analysis import Analysis +from .prompt import Prompt +from .garmin_sync_log import GarminSyncLog \ No newline at end of file diff --git a/backend/app/models/analysis.py b/backend/app/models/analysis.py new file mode 100644 index 0000000..427a994 --- /dev/null +++ b/backend/app/models/analysis.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, JSON, Boolean, DateTime +from sqlalchemy.orm import relationship +from .base import BaseModel + + +class Analysis(BaseModel): + """Analysis model for AI-generated workout feedback.""" + __tablename__ = "analyses" + + workout_id = Column(Integer, ForeignKey("workouts.id"), nullable=False) + analysis_type = Column(String(50), default='workout_review') + jsonb_feedback = Column(JSON) # AI-generated feedback + suggestions = Column(JSON) # AI-generated suggestions + approved = Column(Boolean, default=False) + + # Relationships + workout = relationship("Workout", back_populates="analyses") \ No newline at end of file diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..ea0dd24 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,17 @@ +from datetime import datetime +from uuid import UUID, uuid4 +from sqlalchemy import Column, DateTime +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.dialects.postgresql import UUID as PG_UUID + +Base = declarative_base() + +class BaseModel(Base): + __abstract__ = True + + id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"<{self.__class__.__name__} {self.id}>" \ No newline at end of file diff --git a/backend/app/models/garmin_sync_log.py b/backend/app/models/garmin_sync_log.py new file mode 100644 index 0000000..8487894 --- /dev/null +++ b/backend/app/models/garmin_sync_log.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, DateTime, String, Text +from .base import BaseModel + + +class GarminSyncLog(BaseModel): + """Log model for tracking Garmin sync operations.""" + __tablename__ = "garmin_sync_log" + + last_sync_time = Column(DateTime) + activities_synced = Column(Integer, default=0) + status = Column(String(20)) # success, error, in_progress + error_message = Column(Text) \ No newline at end of file diff --git a/backend/app/models/plan.py b/backend/app/models/plan.py new file mode 100644 index 0000000..dd6f737 --- /dev/null +++ b/backend/app/models/plan.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import relationship +from .base import BaseModel + +class Plan(BaseModel): + __tablename__ = "plans" + + jsonb_plan = Column(JSONB, nullable=False) + version = Column(Integer, nullable=False) + parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True) + + parent_plan = relationship("Plan", remote_side="Plan.id", backref="child_plans") + workouts = relationship("Workout", back_populates="plan", cascade="all, delete-orphan") \ No newline at end of file diff --git a/backend/app/models/prompt.py b/backend/app/models/prompt.py new file mode 100644 index 0000000..3d7c813 --- /dev/null +++ b/backend/app/models/prompt.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime +from .base import BaseModel + + +class Prompt(BaseModel): + """Prompt model for AI prompt versioning and management.""" + __tablename__ = "prompts" + + action_type = Column(String(50), nullable=False) # plan_generation, workout_analysis, rule_parsing, suggestions + model = Column(String(100)) # AI model identifier + prompt_text = Column(Text, nullable=False) + version = Column(Integer, default=1) + active = Column(Boolean, default=True) \ No newline at end of file diff --git a/backend/app/models/route.py b/backend/app/models/route.py new file mode 100644 index 0000000..ea1f3fc --- /dev/null +++ b/backend/app/models/route.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, String, Float, ForeignKey +from sqlalchemy.orm import relationship +from .base import BaseModel + +class Route(BaseModel): + __tablename__ = "routes" + + name = Column(String(100), nullable=False) + description = Column(String(500)) + total_distance = Column(Float, nullable=False) + elevation_gain = Column(Float, nullable=False) + gpx_file_path = Column(String(255), nullable=False) + + sections = relationship("Section", back_populates="route", cascade="all, delete-orphan") \ No newline at end of file diff --git a/backend/app/models/rule.py b/backend/app/models/rule.py new file mode 100644 index 0000000..af27143 --- /dev/null +++ b/backend/app/models/rule.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, ForeignKey, Boolean +from sqlalchemy.dialects.postgresql import JSONB +from .base import BaseModel + +class Rule(BaseModel): + __tablename__ = "rules" + + name = Column(String(100), nullable=False) + user_defined = Column(Boolean, default=True) + jsonb_rules = Column(JSONB, nullable=False) + version = Column(Integer, default=1) + parent_rule_id = Column(Integer, ForeignKey('rules.id'), nullable=True) + + parent_rule = relationship("Rule", remote_side="Rule.id") \ No newline at end of file diff --git a/backend/app/models/section.py b/backend/app/models/section.py new file mode 100644 index 0000000..a3fd8dd --- /dev/null +++ b/backend/app/models/section.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, String, Float, ForeignKey +from sqlalchemy.orm import relationship +from .base import BaseModel + +class Section(BaseModel): + __tablename__ = "sections" + + route_id = Column(ForeignKey("routes.id"), nullable=False) + gpx_file_path = Column(String(255), nullable=False) + distance_m = Column(Float, nullable=False) + grade_avg = Column(Float) + min_gear = Column(String(50)) + est_time_minutes = Column(Float) + + route = relationship("Route", back_populates="sections") \ No newline at end of file diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..3f666c9 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,7 @@ +from .base import BaseModel +from sqlalchemy.orm import relationship + +class User(BaseModel): + __tablename__ = "users" + + plans = relationship("Plan", back_populates="user") \ No newline at end of file diff --git a/backend/app/models/workout.py b/backend/app/models/workout.py new file mode 100644 index 0000000..6c4461e --- /dev/null +++ b/backend/app/models/workout.py @@ -0,0 +1,26 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, JSON, Boolean +from sqlalchemy.orm import relationship +from .base import BaseModel + + +class Workout(BaseModel): + """Workout model for Garmin activity data.""" + __tablename__ = "workouts" + + plan_id = Column(Integer, ForeignKey("plans.id"), nullable=True) + garmin_activity_id = Column(String(255), unique=True, nullable=False) + activity_type = Column(String(50)) + start_time = Column(DateTime, nullable=False) + duration_seconds = Column(Integer) + distance_m = Column(Float) + avg_hr = Column(Integer) + max_hr = Column(Integer) + avg_power = Column(Float) + max_power = Column(Float) + avg_cadence = Column(Float) + elevation_gain_m = Column(Float) + metrics = Column(JSON) # Store full Garmin data as JSONB + + # Relationships + plan = relationship("Plan", back_populates="workouts") + analyses = relationship("Analysis", back_populates="workout", cascade="all, delete-orphan") \ No newline at end of file diff --git a/backend/app/routes/gpx.py b/backend/app/routes/gpx.py new file mode 100644 index 0000000..3a9d73e --- /dev/null +++ b/backend/app/routes/gpx.py @@ -0,0 +1,35 @@ +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.services.gpx import parse_gpx, store_gpx_file +from app.schemas.gpx import RouteCreate, Route as RouteSchema +from app.models import Route +import os + +router = APIRouter(prefix="/gpx", tags=["GPX Routes"]) + +@router.post("/upload", response_model=RouteSchema) +async def upload_gpx_route( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db) +): + # Store GPX file + gpx_path = await store_gpx_file(file) + + # Parse GPX file + gpx_data = await parse_gpx(gpx_path) + + # Create route in database + route_data = RouteCreate( + name=file.filename, + description=f"Uploaded from {file.filename}", + total_distance=gpx_data['total_distance'], + elevation_gain=gpx_data['elevation_gain'], + gpx_file_path=gpx_path + ) + db_route = Route(**route_data.dict()) + db.add(db_route) + await db.commit() + await db.refresh(db_route) + + return db_route \ No newline at end of file diff --git a/backend/app/routes/plan.py b/backend/app/routes/plan.py new file mode 100644 index 0000000..222030e --- /dev/null +++ b/backend/app/routes/plan.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import get_db +from app.models import Plan, PlanRule, Rule +from app.schemas.plan import PlanCreate, Plan as PlanSchema +from uuid import UUID + +router = APIRouter(prefix="/plans", tags=["Training Plans"]) + +@router.post("/", response_model=PlanSchema) +async def create_plan( + plan: PlanCreate, + db: AsyncSession = Depends(get_db) +): + # Create plan + db_plan = Plan( + user_id=plan.user_id, + start_date=plan.start_date, + end_date=plan.end_date, + goal=plan.goal + ) + db.add(db_plan) + await db.flush() # Flush to get plan ID + + # Add rules to plan + for rule_id in plan.rule_ids: + db_plan_rule = PlanRule(plan_id=db_plan.id, rule_id=rule_id) + db.add(db_plan_rule) + + await db.commit() + await db.refresh(db_plan) + return db_plan + +@router.get("/{plan_id}", response_model=PlanSchema) +async def read_plan( + plan_id: UUID, + db: AsyncSession = Depends(get_db) +): + plan = await db.get(Plan, plan_id) + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + return plan + +@router.get("/", response_model=list[PlanSchema]) +async def read_plans( + db: AsyncSession = Depends(get_db) +): + result = await db.execute(select(Plan)) + return result.scalars().all() + +@router.put("/{plan_id}", response_model=PlanSchema) +async def update_plan( + plan_id: UUID, + plan: PlanCreate, + db: AsyncSession = Depends(get_db) +): + db_plan = await db.get(Plan, plan_id) + if not db_plan: + raise HTTPException(status_code=404, detail="Plan not found") + + # Update plan fields + db_plan.user_id = plan.user_id + db_plan.start_date = plan.start_date + db_plan.end_date = plan.end_date + db_plan.goal = plan.goal + + # Update rules + await db.execute(PlanRule.delete().where(PlanRule.plan_id == plan_id)) + for rule_id in plan.rule_ids: + db_plan_rule = PlanRule(plan_id=plan_id, rule_id=rule_id) + db.add(db_plan_rule) + + await db.commit() + await db.refresh(db_plan) + return db_plan + +@router.delete("/{plan_id}") +async def delete_plan( + plan_id: UUID, + db: AsyncSession = Depends(get_db) +): + plan = await db.get(Plan, plan_id) + if not plan: + raise HTTPException(status_code=404, detail="Plan not found") + + await db.delete(plan) + await db.commit() + return {"detail": "Plan deleted"} \ No newline at end of file diff --git a/backend/app/routes/prompts.py b/backend/app/routes/prompts.py new file mode 100644 index 0000000..88c0327 --- /dev/null +++ b/backend/app/routes/prompts.py @@ -0,0 +1,79 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List + +from app.database import get_db +from app.models.prompt import Prompt +from app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate +from app.services.prompt_manager import PromptManager + +router = APIRouter() + + +@router.get("/", response_model=List[PromptSchema]) +async def read_prompts(db: AsyncSession = Depends(get_db)): + """Get all prompts.""" + result = await db.execute(select(Prompt)) + return result.scalars().all() + + +@router.get("/{prompt_id}", response_model=PromptSchema) +async def read_prompt(prompt_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific prompt by ID.""" + prompt = await db.get(Prompt, prompt_id) + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + return prompt + + +@router.post("/", response_model=PromptSchema) +async def create_prompt( + prompt: PromptCreate, + db: AsyncSession = Depends(get_db) +): + """Create a new prompt version.""" + prompt_manager = PromptManager(db) + new_prompt = await prompt_manager.create_prompt_version( + action_type=prompt.action_type, + prompt_text=prompt.prompt_text, + model=prompt.model + ) + return new_prompt + + +@router.get("/active/{action_type}") +async def get_active_prompt( + action_type: str, + db: AsyncSession = Depends(get_db) +): + """Get the active prompt for a specific action type.""" + prompt_manager = PromptManager(db) + prompt_text = await prompt_manager.get_active_prompt(action_type) + if not prompt_text: + raise HTTPException(status_code=404, detail=f"No active prompt found for {action_type}") + return {"action_type": action_type, "prompt_text": prompt_text} + + +@router.get("/history/{action_type}", response_model=List[PromptSchema]) +async def get_prompt_history( + action_type: str, + db: AsyncSession = Depends(get_db) +): + """Get the version history for a specific action type.""" + prompt_manager = PromptManager(db) + prompts = await prompt_manager.get_prompt_history(action_type) + return prompts + + +@router.post("/{prompt_id}/activate") +async def activate_prompt_version( + prompt_id: int, + db: AsyncSession = Depends(get_db) +): + """Activate a specific prompt version.""" + prompt_manager = PromptManager(db) + success = await prompt_manager.activate_prompt_version(prompt_id) + if not success: + raise HTTPException(status_code=404, detail="Prompt not found") + return {"message": "Prompt version activated successfully"} \ No newline at end of file diff --git a/backend/app/routes/rule.py b/backend/app/routes/rule.py new file mode 100644 index 0000000..9b0bd0f --- /dev/null +++ b/backend/app/routes/rule.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db +from app.models import Rule +from app.schemas.rule import RuleCreate, Rule as RuleSchema +from uuid import UUID + +router = APIRouter(prefix="/rules", tags=["Rules"]) + +@router.post("/", response_model=RuleSchema) +async def create_rule( + rule: RuleCreate, + db: AsyncSession = Depends(get_db) +): + db_rule = Rule(**rule.dict()) + db.add(db_rule) + await db.commit() + await db.refresh(db_rule) + return db_rule + +@router.get("/{rule_id}", response_model=RuleSchema) +async def read_rule( + rule_id: UUID, + db: AsyncSession = Depends(get_db) +): + rule = await db.get(Rule, rule_id) + if not rule: + raise HTTPException(status_code=404, detail="Rule not found") + return rule + +@router.get("/", response_model=list[RuleSchema]) +async def read_rules( + db: AsyncSession = Depends(get_db) +): + result = await db.execute(sa.select(Rule)) + return result.scalars().all() + +@router.put("/{rule_id}", response_model=RuleSchema) +async def update_rule( + rule_id: UUID, + rule: RuleCreate, + db: AsyncSession = Depends(get_db) +): + db_rule = await db.get(Rule, rule_id) + if not db_rule: + raise HTTPException(status_code=404, detail="Rule not found") + + for key, value in rule.dict().items(): + setattr(db_rule, key, value) + + await db.commit() + await db.refresh(db_rule) + return db_rule + +@router.delete("/{rule_id}") +async def delete_rule( + rule_id: UUID, + db: AsyncSession = Depends(get_db) +): + rule = await db.get(Rule, rule_id) + if not rule: + raise HTTPException(status_code=404, detail="Rule not found") + + await db.delete(rule) + await db.commit() + return {"detail": "Rule deleted"} \ No newline at end of file diff --git a/backend/app/routes/workouts.py b/backend/app/routes/workouts.py new file mode 100644 index 0000000..8099815 --- /dev/null +++ b/backend/app/routes/workouts.py @@ -0,0 +1,138 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List + +from app.database import get_db +from app.models.workout import Workout +from app.models.analysis import Analysis +from app.models.garmin_sync_log import GarminSyncLog +from app.models.plan import Plan +from app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus +from app.schemas.analysis import Analysis as AnalysisSchema +from app.services.workout_sync import WorkoutSyncService +from app.services.ai_service import AIService +from app.services.plan_evolution import PlanEvolutionService + +router = APIRouter() + + +@router.get("/", response_model=List[WorkoutSchema]) +async def read_workouts(db: AsyncSession = Depends(get_db)): + """Get all workouts.""" + result = await db.execute(select(Workout)) + return result.scalars().all() + + +@router.get("/{workout_id}", response_model=WorkoutSchema) +async def read_workout(workout_id: int, db: AsyncSession = Depends(get_db)): + """Get a specific workout by ID.""" + workout = await db.get(Workout, workout_id) + if not workout: + raise HTTPException(status_code=404, detail="Workout not found") + return workout + + +@router.post("/sync") +async def trigger_garmin_sync( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) +): + """Trigger background sync of recent Garmin activities.""" + sync_service = WorkoutSyncService(db) + background_tasks.add_task(sync_service.sync_recent_activities, days_back=14) + return {"message": "Garmin sync started"} + + +@router.get("/sync-status", response_model=WorkoutSyncStatus) +async def get_sync_status(db: AsyncSession = Depends(get_db)): + """Get the latest sync status.""" + result = await db.execute( + select(GarminSyncLog).order_by(GarminSyncLog.created_at.desc()).limit(1) + ) + sync_log = result.scalar_one_or_none() + if not sync_log: + return WorkoutSyncStatus(status="never_synced") + return sync_log + + +@router.post("/{workout_id}/analyze") +async def analyze_workout( + workout_id: int, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) +): + """Trigger AI analysis of a specific workout.""" + workout = await db.get(Workout, workout_id) + if not workout: + raise HTTPException(status_code=404, detail="Workout not found") + + ai_service = AIService(db) + background_tasks.add_task( + analyze_and_store_workout, + db, workout, ai_service + ) + + return {"message": "Analysis started", "workout_id": workout_id} + + +async def analyze_and_store_workout(db: AsyncSession, workout: Workout, ai_service: AIService): + """Background task to analyze workout and store results.""" + try: + # Get current plan if workout is associated with one + plan = None + if workout.plan_id: + plan = await db.get(Plan, workout.plan_id) + + # Analyze workout + analysis_result = await ai_service.analyze_workout(workout, plan.jsonb_plan if plan else None) + + # Store analysis + analysis = Analysis( + workout_id=workout.id, + jsonb_feedback=analysis_result.get("feedback", {}), + suggestions=analysis_result.get("suggestions", {}) + ) + db.add(analysis) + await db.commit() + + except Exception as e: + # Log error but don't crash the background task + print(f"Error analyzing workout {workout.id}: {str(e)}") + + +@router.get("/{workout_id}/analyses", response_model=List[AnalysisSchema]) +async def read_workout_analyses(workout_id: int, db: AsyncSession = Depends(get_db)): + """Get all analyses for a specific workout.""" + workout = await db.get(Workout, workout_id) + if not workout: + raise HTTPException(status_code=404, detail="Workout not found") + + return workout.analyses + + +@router.post("/analyses/{analysis_id}/approve") +async def approve_analysis( + analysis_id: int, + db: AsyncSession = Depends(get_db) +): + """Approve analysis suggestions and trigger plan evolution.""" + analysis = await db.get(Analysis, analysis_id) + if not analysis: + raise HTTPException(status_code=404, detail="Analysis not found") + + analysis.approved = True + + # Trigger plan evolution if suggestions exist and workout has a plan + if analysis.suggestions and analysis.workout.plan_id: + evolution_service = PlanEvolutionService(db) + current_plan = await db.get(Plan, analysis.workout.plan_id) + if current_plan: + new_plan = await evolution_service.evolve_plan_from_analysis( + analysis, current_plan + ) + await db.commit() + return {"message": "Analysis approved", "new_plan_id": new_plan.id if new_plan else None} + + await db.commit() + return {"message": "Analysis approved"} \ No newline at end of file diff --git a/backend/app/schemas/analysis.py b/backend/app/schemas/analysis.py new file mode 100644 index 0000000..efd02cb --- /dev/null +++ b/backend/app/schemas/analysis.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from typing import Optional, Dict, Any + + +class AnalysisBase(BaseModel): + workout_id: int + analysis_type: str = 'workout_review' + jsonb_feedback: Optional[Dict[str, Any]] = None + suggestions: Optional[Dict[str, Any]] = None + approved: bool = False + + +class AnalysisCreate(AnalysisBase): + pass + + +class Analysis(AnalysisBase): + id: int + + class Config: + orm_mode = True + + +class AnalysisUpdate(BaseModel): + approved: bool \ No newline at end of file diff --git a/backend/app/schemas/gpx.py b/backend/app/schemas/gpx.py new file mode 100644 index 0000000..75d95a2 --- /dev/null +++ b/backend/app/schemas/gpx.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel +from typing import Optional, List + +class GPXData(BaseModel): + total_distance: float + elevation_gain: float + points: List[dict] + +class RouteCreate(BaseModel): + name: str + description: Optional[str] = None + total_distance: float + elevation_gain: float + gpx_file_path: str + +class Route(BaseModel): + id: str + name: str + description: Optional[str] = None + total_distance: float + elevation_gain: float + gpx_file_path: str + + class Config: + orm_mode = True \ No newline at end of file diff --git a/backend/app/schemas/plan.py b/backend/app/schemas/plan.py new file mode 100644 index 0000000..0517863 --- /dev/null +++ b/backend/app/schemas/plan.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import List, Optional +from uuid import UUID + +class PlanBase(BaseModel): + user_id: UUID + start_date: datetime + end_date: datetime + goal: str + +class PlanCreate(PlanBase): + rule_ids: List[UUID] + +class Plan(PlanBase): + id: UUID + + class Config: + orm_mode = True \ No newline at end of file diff --git a/backend/app/schemas/prompt.py b/backend/app/schemas/prompt.py new file mode 100644 index 0000000..47dab3d --- /dev/null +++ b/backend/app/schemas/prompt.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +class PromptBase(BaseModel): + action_type: str + model: Optional[str] = None + prompt_text: str + version: int = 1 + active: bool = True + + +class PromptCreate(BaseModel): + action_type: str + prompt_text: str + model: Optional[str] = None + + +class PromptUpdate(BaseModel): + prompt_text: Optional[str] = None + active: Optional[bool] = None + + +class Prompt(PromptBase): + id: int + created_at: datetime + + class Config: + orm_mode = True \ No newline at end of file diff --git a/backend/app/schemas/rule.py b/backend/app/schemas/rule.py new file mode 100644 index 0000000..a2ac7fc --- /dev/null +++ b/backend/app/schemas/rule.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel +from typing import Optional + +class RuleBase(BaseModel): + name: str + description: Optional[str] = None + condition: str + priority: int = 0 + +class RuleCreate(RuleBase): + pass + +class Rule(RuleBase): + id: str + + class Config: + orm_mode = True \ No newline at end of file diff --git a/backend/app/schemas/workout.py b/backend/app/schemas/workout.py new file mode 100644 index 0000000..003a413 --- /dev/null +++ b/backend/app/schemas/workout.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel +from typing import Optional, Dict, Any +from datetime import datetime + + +class WorkoutBase(BaseModel): + garmin_activity_id: str + activity_type: Optional[str] = None + start_time: datetime + duration_seconds: Optional[int] = None + distance_m: Optional[float] = None + avg_hr: Optional[int] = None + max_hr: Optional[int] = None + avg_power: Optional[float] = None + max_power: Optional[float] = None + avg_cadence: Optional[float] = None + elevation_gain_m: Optional[float] = None + metrics: Optional[Dict[str, Any]] = None + + +class WorkoutCreate(WorkoutBase): + plan_id: Optional[int] = None + + +class Workout(WorkoutBase): + id: int + plan_id: Optional[int] = None + + class Config: + orm_mode = True + + +class WorkoutSyncStatus(BaseModel): + status: str + last_sync_time: Optional[datetime] = None + activities_synced: int = 0 + error_message: Optional[str] = None + + class Config: + orm_mode = True \ No newline at end of file diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..bcc972b --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,130 @@ +import os +import asyncio +from typing import Dict, Any, List, Optional +import httpx +import json +from app.services.prompt_manager import PromptManager +from app.models.workout import Workout +import logging + +logger = logging.getLogger(__name__) + + +class AIService: + """Service for AI-powered analysis and plan generation.""" + + def __init__(self, db_session): + self.db = db_session + self.prompt_manager = PromptManager(db_session) + self.api_key = os.getenv("OPENROUTER_API_KEY") + self.model = os.getenv("AI_MODEL", "anthropic/claude-3-sonnet-20240229") + self.base_url = "https://openrouter.ai/api/v1" + + async def analyze_workout(self, workout: Workout, plan: Optional[Dict] = None) -> Dict[str, Any]: + """Analyze a workout using AI and generate feedback.""" + prompt_template = await self.prompt_manager.get_active_prompt("workout_analysis") + + if not prompt_template: + raise ValueError("No active workout analysis prompt found") + + # Build context from workout data + workout_context = { + "activity_type": workout.activity_type, + "duration_minutes": workout.duration_seconds / 60 if workout.duration_seconds else 0, + "distance_km": workout.distance_m / 1000 if workout.distance_m else 0, + "avg_hr": workout.avg_hr, + "avg_power": workout.avg_power, + "elevation_gain": workout.elevation_gain_m, + "planned_workout": plan + } + + prompt = prompt_template.format(**workout_context) + + response = await self._make_ai_request(prompt) + return self._parse_workout_analysis(response) + + async def generate_plan(self, rules: List[Dict], goals: Dict[str, Any]) -> Dict[str, Any]: + """Generate a training plan using AI.""" + prompt_template = await self.prompt_manager.get_active_prompt("plan_generation") + + context = { + "rules": rules, + "goals": goals, + "current_fitness_level": goals.get("fitness_level", "intermediate") + } + + prompt = prompt_template.format(**context) + response = await self._make_ai_request(prompt) + return self._parse_plan_response(response) + + async def parse_rules_from_natural_language(self, natural_language: str) -> Dict[str, Any]: + """Parse natural language rules into structured format.""" + prompt_template = await self.prompt_manager.get_active_prompt("rule_parsing") + prompt = prompt_template.format(user_rules=natural_language) + + response = await self._make_ai_request(prompt) + return self._parse_rules_response(response) + + async def _make_ai_request(self, prompt: str) -> str: + """Make async request to OpenRouter API with retry logic.""" + async with httpx.AsyncClient() as client: + for attempt in range(3): # Simple retry logic + try: + response = await client.post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": self.model, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 2000, + }, + timeout=30.0 + ) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except Exception as e: + if attempt == 2: # Last attempt + logger.error(f"AI request failed after 3 attempts: {str(e)}") + raise AIServiceError(f"AI request failed after 3 attempts: {str(e)}") + await asyncio.sleep(2 ** attempt) # Exponential backoff + + def _parse_workout_analysis(self, response: str) -> Dict[str, Any]: + """Parse AI response for workout analysis.""" + try: + # Assume AI returns JSON + clean_response = response.strip() + if clean_response.startswith("```json"): + clean_response = clean_response[7:-3] + return json.loads(clean_response) + except json.JSONDecodeError: + return {"raw_analysis": response, "structured": False} + + def _parse_plan_response(self, response: str) -> Dict[str, Any]: + """Parse AI response for plan generation.""" + try: + clean_response = response.strip() + if clean_response.startswith("```json"): + clean_response = clean_response[7:-3] + return json.loads(clean_response) + except json.JSONDecodeError: + return {"raw_plan": response, "structured": False} + + def _parse_rules_response(self, response: str) -> Dict[str, Any]: + """Parse AI response for rule parsing.""" + try: + clean_response = response.strip() + if clean_response.startswith("```json"): + clean_response = clean_response[7:-3] + return json.loads(clean_response) + except json.JSONDecodeError: + return {"raw_rules": response, "structured": False} + + +class AIServiceError(Exception): + """Raised when AI service requests fail.""" + pass \ No newline at end of file diff --git a/backend/app/services/garmin.py b/backend/app/services/garmin.py new file mode 100644 index 0000000..7a47e82 --- /dev/null +++ b/backend/app/services/garmin.py @@ -0,0 +1,84 @@ +import os +import garth +from typing import List, Dict, Any, Optional +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger(__name__) + + +class GarminService: + """Service for interacting with Garmin Connect API.""" + + def __init__(self): + self.username = os.getenv("GARMIN_USERNAME") + self.password = os.getenv("GARMIN_PASSWORD") + self.client: Optional[garth.Client] = None + self.session_dir = "/app/data/sessions" + + # Ensure session directory exists + os.makedirs(self.session_dir, exist_ok=True) + + async def authenticate(self) -> bool: + """Authenticate with Garmin Connect and persist session.""" + if not self.client: + self.client = garth.Client() + + try: + # Try to load existing session + self.client.load(self.session_dir) + logger.info("Loaded existing Garmin session") + return True + except Exception: + # Fresh authentication required + try: + await self.client.login(self.username, self.password) + self.client.save(self.session_dir) + logger.info("Successfully authenticated with Garmin Connect") + return True + except Exception as e: + logger.error(f"Garmin authentication failed: {str(e)}") + raise GarminAuthError(f"Authentication failed: {str(e)}") + + async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]: + """Fetch recent activities from Garmin Connect.""" + if not self.client: + await self.authenticate() + + if not start_date: + start_date = datetime.now() - timedelta(days=7) + + try: + activities = self.client.get_activities(limit=limit, start=start_date) + logger.info(f"Fetched {len(activities)} activities from Garmin") + return activities + except Exception as e: + logger.error(f"Failed to fetch activities: {str(e)}") + raise GarminAPIError(f"Failed to fetch activities: {str(e)}") + + async def get_activity_details(self, activity_id: str) -> Dict[str, Any]: + """Get detailed activity data including metrics.""" + if not self.client: + await self.authenticate() + + try: + details = self.client.get_activity(activity_id) + logger.info(f"Fetched details for activity {activity_id}") + return details + except Exception as e: + logger.error(f"Failed to fetch activity details for {activity_id}: {str(e)}") + raise GarminAPIError(f"Failed to fetch activity details: {str(e)}") + + def is_authenticated(self) -> bool: + """Check if we have a valid authenticated session.""" + return self.client is not None + + +class GarminAuthError(Exception): + """Raised when Garmin authentication fails.""" + pass + + +class GarminAPIError(Exception): + """Raised when Garmin API calls fail.""" + pass \ No newline at end of file diff --git a/backend/app/services/gpx.py b/backend/app/services/gpx.py new file mode 100644 index 0000000..acdb2c5 --- /dev/null +++ b/backend/app/services/gpx.py @@ -0,0 +1,62 @@ +import os +import uuid +import logging +from fastapi import UploadFile, HTTPException +import gpxpy +from app.config import settings + +logger = logging.getLogger(__name__) + +async def store_gpx_file(file: UploadFile) -> str: + """Store uploaded GPX file and return path""" + try: + file_ext = os.path.splitext(file.filename)[1] + if file_ext.lower() != '.gpx': + raise HTTPException(status_code=400, detail="Invalid file type") + + file_name = f"{uuid.uuid4()}{file_ext}" + file_path = os.path.join(settings.GPX_STORAGE_PATH, file_name) + + # Ensure storage directory exists + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + # Save file + with open(file_path, "wb") as f: + f.write(await file.read()) + + return file_path + except Exception as e: + logger.error(f"Error storing GPX file: {e}") + raise HTTPException(status_code=500, detail="Error storing file") + +async def parse_gpx(file_path: str) -> dict: + """Parse GPX file and extract key metrics""" + try: + with open(file_path, 'r') as f: + gpx = gpxpy.parse(f) + + total_distance = 0.0 + elevation_gain = 0.0 + points = [] + + for track in gpx.tracks: + for segment in track.segments: + total_distance += segment.length_3d() + for i in range(1, len(segment.points)): + elevation_gain += max(0, segment.points[i].elevation - segment.points[i-1].elevation) + + points = [{ + 'lat': point.latitude, + 'lon': point.longitude, + 'ele': point.elevation, + 'time': point.time.isoformat() if point.time else None + } for point in segment.points] + + return { + 'total_distance': total_distance, + 'elevation_gain': elevation_gain, + 'points': points + } + except Exception as e: + logger.error(f"Error parsing GPX file: {e}") + raise HTTPException(status_code=500, detail="Error parsing GPX file") \ No newline at end of file diff --git a/backend/app/services/plan_evolution.py b/backend/app/services/plan_evolution.py new file mode 100644 index 0000000..1776453 --- /dev/null +++ b/backend/app/services/plan_evolution.py @@ -0,0 +1,74 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.services.ai_service import AIService +from app.models.analysis import Analysis +from app.models.plan import Plan +import logging + +logger = logging.getLogger(__name__) + + +class PlanEvolutionService: + """Service for evolving training plans based on workout analysis.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.ai_service = AIService(db) + + async def evolve_plan_from_analysis( + self, + analysis: Analysis, + current_plan: Plan + ) -> Plan: + """Create a new plan version based on workout analysis.""" + if not analysis.approved: + return None + + suggestions = analysis.suggestions + if not suggestions: + return None + + # Generate new plan incorporating suggestions + evolution_context = { + "current_plan": current_plan.jsonb_plan, + "workout_analysis": analysis.jsonb_feedback, + "suggestions": suggestions, + "evolution_type": "workout_feedback" + } + + new_plan_data = await self.ai_service.evolve_plan(evolution_context) + + # Create new plan version + new_plan = Plan( + jsonb_plan=new_plan_data, + version=current_plan.version + 1, + parent_plan_id=current_plan.id + ) + + self.db.add(new_plan) + await self.db.commit() + await self.db.refresh(new_plan) + + logger.info(f"Created new plan version {new_plan.version} from analysis {analysis.id}") + return new_plan + + async def get_plan_evolution_history(self, plan_id: int) -> list[Plan]: + """Get the evolution history for a plan.""" + result = await self.db.execute( + select(Plan) + .where( + (Plan.id == plan_id) | + (Plan.parent_plan_id == plan_id) + ) + .order_by(Plan.version) + ) + return result.scalars().all() + + async def get_current_active_plan(self) -> Plan: + """Get the most recent active plan.""" + result = await self.db.execute( + select(Plan) + .order_by(Plan.version.desc()) + .limit(1) + ) + return result.scalar_one_or_none() \ No newline at end of file diff --git a/backend/app/services/prompt_manager.py b/backend/app/services/prompt_manager.py new file mode 100644 index 0000000..835a1fe --- /dev/null +++ b/backend/app/services/prompt_manager.py @@ -0,0 +1,92 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, func +from app.models.prompt import Prompt +import logging + +logger = logging.getLogger(__name__) + + +class PromptManager: + """Service for managing AI prompts with versioning.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_active_prompt(self, action_type: str, model: str = None) -> str: + """Get the active prompt for a specific action type.""" + query = select(Prompt).where( + Prompt.action_type == action_type, + Prompt.active == True + ) + if model: + query = query.where(Prompt.model == model) + + result = await self.db.execute(query.order_by(Prompt.version.desc())) + prompt = result.scalar_one_or_none() + return prompt.prompt_text if prompt else None + + async def create_prompt_version( + self, + action_type: str, + prompt_text: str, + model: str = None + ) -> Prompt: + """Create a new version of a prompt.""" + # Deactivate previous versions + await self.db.execute( + update(Prompt) + .where(Prompt.action_type == action_type) + .values(active=False) + ) + + # Get next version number + result = await self.db.execute( + select(func.max(Prompt.version)) + .where(Prompt.action_type == action_type) + ) + max_version = result.scalar() or 0 + + # Create new prompt + new_prompt = Prompt( + action_type=action_type, + model=model, + prompt_text=prompt_text, + version=max_version + 1, + active=True + ) + + self.db.add(new_prompt) + await self.db.commit() + await self.db.refresh(new_prompt) + + logger.info(f"Created new prompt version {new_prompt.version} for {action_type}") + return new_prompt + + async def get_prompt_history(self, action_type: str) -> list[Prompt]: + """Get all versions of prompts for an action type.""" + result = await self.db.execute( + select(Prompt) + .where(Prompt.action_type == action_type) + .order_by(Prompt.version.desc()) + ) + return result.scalars().all() + + async def activate_prompt_version(self, prompt_id: int) -> bool: + """Activate a specific prompt version.""" + # First deactivate all prompts for this action type + prompt = await self.db.get(Prompt, prompt_id) + if not prompt: + return False + + await self.db.execute( + update(Prompt) + .where(Prompt.action_type == prompt.action_type) + .values(active=False) + ) + + # Activate the specific version + prompt.active = True + await self.db.commit() + + logger.info(f"Activated prompt version {prompt.version} for {prompt.action_type}") + return True \ No newline at end of file diff --git a/backend/app/services/workout_sync.py b/backend/app/services/workout_sync.py new file mode 100644 index 0000000..7879d65 --- /dev/null +++ b/backend/app/services/workout_sync.py @@ -0,0 +1,90 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.services.garmin import GarminService, GarminAPIError +from app.models.workout import Workout +from app.models.garmin_sync_log import GarminSyncLog +from datetime import datetime, timedelta +import logging + +logger = logging.getLogger(__name__) + + +class WorkoutSyncService: + """Service for syncing Garmin activities to database.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.garmin_service = GarminService() + + async def sync_recent_activities(self, days_back: int = 7) -> int: + """Sync recent Garmin activities to database.""" + try: + # Create sync log entry + sync_log = GarminSyncLog(status="in_progress") + self.db.add(sync_log) + await self.db.commit() + + # Calculate start date + start_date = datetime.now() - timedelta(days=days_back) + + # Fetch activities from Garmin + activities = await self.garmin_service.get_activities( + limit=50, start_date=start_date + ) + + synced_count = 0 + for activity in activities: + if await self.activity_exists(activity['activityId']): + continue + + # Parse and create workout + workout_data = await self.parse_activity_data(activity) + workout = Workout(**workout_data) + self.db.add(workout) + synced_count += 1 + + # Update sync log + sync_log.status = "success" + sync_log.activities_synced = synced_count + sync_log.last_sync_time = datetime.now() + + await self.db.commit() + logger.info(f"Successfully synced {synced_count} activities") + return synced_count + + except GarminAPIError as e: + sync_log.status = "error" + sync_log.error_message = str(e) + await self.db.commit() + logger.error(f"Garmin API error during sync: {str(e)}") + raise + except Exception as e: + sync_log.status = "error" + sync_log.error_message = str(e) + await self.db.commit() + logger.error(f"Unexpected error during sync: {str(e)}") + raise + + async def activity_exists(self, garmin_activity_id: str) -> bool: + """Check if activity already exists in database.""" + result = await self.db.execute( + select(Workout).where(Workout.garmin_activity_id == garmin_activity_id) + ) + return result.scalar_one_or_none() is not None + + async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]: + """Parse Garmin activity data into workout model format.""" + return { + "garmin_activity_id": activity['activityId'], + "activity_type": activity.get('activityType', {}).get('typeKey'), + "start_time": datetime.fromisoformat(activity['startTimeLocal'].replace('Z', '+00:00')), + "duration_seconds": activity.get('duration'), + "distance_m": activity.get('distance'), + "avg_hr": activity.get('averageHR'), + "max_hr": activity.get('maxHR'), + "avg_power": activity.get('avgPower'), + "max_power": activity.get('maxPower'), + "avg_cadence": activity.get('averageBikingCadenceInRevPerMinute'), + "elevation_gain_m": activity.get('elevationGain'), + "metrics": activity # Store full Garmin data as JSONB + } \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4f0c3f7 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +addopts = -p no:warnings --verbose +python_files = test_*.py +log_cli = true \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..31e79fb --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.110.0 +uvicorn[standard]==0.29.0 +python-dotenv==1.0.1 +sqlalchemy==2.0.29 +psycopg2-binary==2.9.9 +alembic==1.13.1 +pydantic-settings==2.2.1 +python-multipart==0.0.9 +gpxpy # Add GPX parsing library +garth==0.4.46 # Garmin Connect API client +httpx==0.25.2 # Async HTTP client for OpenRouter API \ No newline at end of file diff --git a/backend/scripts/backup_restore.py b/backend/scripts/backup_restore.py new file mode 100644 index 0000000..7d470d1 --- /dev/null +++ b/backend/scripts/backup_restore.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Database backup and restore utilities for containerized deployments. +Ensures safe backup/restore operations with migration compatibility checks. +""" + +import sys +import os +import subprocess +import shutil +from pathlib import Path +from datetime import datetime +from typing import Optional + +# Add backend directory to path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from app.database import get_database_url + +class DatabaseManager: + """Handles database backup and restore operations.""" + + def __init__(self, backup_dir: str = "/app/data/backups"): + self.backup_dir = Path(backup_dir) + self.backup_dir.mkdir(parents=True, exist_ok=True) + + def get_db_connection_params(self): + """Extract database connection parameters from URL.""" + from urllib.parse import urlparse + db_url = get_database_url() + parsed = urlparse(db_url) + + return { + 'host': parsed.hostname, + 'port': parsed.port or 5432, + 'user': parsed.username, + 'password': parsed.password, + 'database': parsed.path.lstrip('/') + } + + def create_backup(self, name: Optional[str] = None) -> str: + """Create a database backup.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_name = name or f"backup_{timestamp}" + backup_file = self.backup_dir / f"{backup_name}.sql" + + params = self.get_db_connection_params() + + # Use pg_dump for backup + cmd = [ + "pg_dump", + "-h", params['host'], + "-p", str(params['port']), + "-U", params['user'], + "-d", params['database'], + "-f", str(backup_file), + "--no-password", + "--format=custom", # Custom format for better compression + "--compress=9" + ] + + # Set password environment variable + env = os.environ.copy() + env['PGPASSWORD'] = params['password'] + + try: + print(f"Creating backup: {backup_file}") + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + + if result.returncode == 0: + print(f"✅ Backup created successfully: {backup_file}") + return str(backup_file) + else: + print(f"❌ Backup failed: {result.stderr}") + raise Exception(f"Backup failed: {result.stderr}") + + except FileNotFoundError: + print("❌ pg_dump not found. Ensure PostgreSQL client tools are installed.") + raise + + def restore_backup(self, backup_file: str, confirm: bool = False) -> None: + """Restore database from backup.""" + backup_path = Path(backup_file) + if not backup_path.exists(): + raise FileNotFoundError(f"Backup file not found: {backup_file}") + + if not confirm: + print(f"⚠️ This will overwrite the current database!") + response = input("Are you sure you want to continue? (yes/no): ") + if response.lower() != 'yes': + print("Restore cancelled.") + return + + params = self.get_db_connection_params() + + # Drop and recreate database to ensure clean restore + self._recreate_database() + + # Use pg_restore for restore + cmd = [ + "pg_restore", + "-h", params['host'], + "-p", str(params['port']), + "-U", params['user'], + "-d", params['database'], + "--no-password", + "--clean", + "--if-exists", + "--create", + str(backup_path) + ] + + env = os.environ.copy() + env['PGPASSWORD'] = params['password'] + + try: + print(f"Restoring from backup: {backup_file}") + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + + if result.returncode == 0: + print("✅ Database restored successfully") + else: + print(f"❌ Restore failed: {result.stderr}") + raise Exception(f"Restore failed: {result.stderr}") + + except FileNotFoundError: + print("❌ pg_restore not found. Ensure PostgreSQL client tools are installed.") + raise + + def _recreate_database(self): + """Drop and recreate the database.""" + params = self.get_db_connection_params() + + # Connect to postgres database to drop/recreate target database + postgres_params = params.copy() + postgres_params['database'] = 'postgres' + + drop_cmd = [ + "psql", + "-h", postgres_params['host'], + "-p", str(postgres_params['port']), + "-U", postgres_params['user'], + "-d", postgres_params['database'], + "-c", f"DROP DATABASE IF EXISTS {params['database']};" + ] + + create_cmd = [ + "psql", + "-h", postgres_params['host'], + "-p", str(postgres_params['port']), + "-U", postgres_params['user'], + "-d", postgres_params['database'], + "-c", f"CREATE DATABASE {params['database']};" + ] + + env = os.environ.copy() + env['PGPASSWORD'] = params['password'] + + for cmd in [drop_cmd, create_cmd]: + result = subprocess.run(cmd, env=env, capture_output=True, text=True) + if result.returncode != 0: + print(f"Database recreation step failed: {result.stderr}") + + def list_backups(self): + """List available backup files.""" + backups = list(self.backup_dir.glob("*.sql")) + backups.sort(key=lambda x: x.stat().st_mtime, reverse=True) + + if not backups: + print("No backup files found.") + return + + print("Available backups:") + for backup in backups: + size = backup.stat().st_size / (1024 * 1024) # Size in MB + mtime = datetime.fromtimestamp(backup.stat().st_mtime) + print(".2f") + + def cleanup_old_backups(self, keep_days: int = 30): + """Remove backups older than specified days.""" + from datetime import timedelta + + cutoff = datetime.now() - timedelta(days=keep_days) + removed = [] + + for backup in self.backup_dir.glob("*.sql"): + if datetime.fromtimestamp(backup.stat().st_mtime) < cutoff: + backup.unlink() + removed.append(backup.name) + + if removed: + print(f"Removed {len(removed)} old backups: {', '.join(removed)}") + else: + print("No old backups to remove.") + +def main(): + if len(sys.argv) < 2: + print("Usage: python backup_restore.py [options]") + print("Commands:") + print(" backup [name] - Create a new backup") + print(" restore [--yes] - Restore from backup") + print(" list - List available backups") + print(" cleanup [days] - Remove backups older than N days (default: 30)") + sys.exit(1) + + manager = DatabaseManager() + command = sys.argv[1] + + try: + if command == "backup": + name = sys.argv[2] if len(sys.argv) > 2 else None + manager.create_backup(name) + + elif command == "restore": + if len(sys.argv) < 3: + print("Error: Please specify backup file to restore from") + sys.exit(1) + + backup_file = sys.argv[2] + confirm = "--yes" in sys.argv + manager.restore_backup(backup_file, confirm) + + elif command == "list": + manager.list_backups() + + elif command == "cleanup": + days = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + manager.cleanup_old_backups(days) + + else: + print(f"Unknown command: {command}") + sys.exit(1) + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/scripts/migration_checker.py b/backend/scripts/migration_checker.py new file mode 100644 index 0000000..c93e7c5 --- /dev/null +++ b/backend/scripts/migration_checker.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Migration compatibility and version checker for containerized deployments. +Validates migration integrity and compatibility before deployments. +""" + +import sys +import os +from pathlib import Path +from typing import Dict, List, Tuple + +# Add backend directory to path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from alembic.config import Config +from alembic import command +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import create_engine, text +from app.database import get_database_url + +class MigrationChecker: + """Validates migration compatibility and integrity.""" + + def __init__(self): + self.config = self._get_alembic_config() + self.script = ScriptDirectory.from_config(self.config) + + def _get_alembic_config(self): + """Get Alembic configuration.""" + config = Config("alembic.ini") + config.set_main_option("sqlalchemy.url", get_database_url()) + return config + + def check_migration_files(self) -> Dict[str, bool]: + """Check integrity of migration files.""" + results = { + "files_exist": False, + "proper_ordering": False, + "no_duplicates": False, + "valid_syntax": False + } + + try: + # Check if migration directory exists + versions_dir = Path("alembic/versions") + if not versions_dir.exists(): + print("❌ Migration versions directory not found") + return results + + # Get all migration files + migration_files = list(versions_dir.glob("*.py")) + if not migration_files: + print("⚠️ No migration files found") + results["files_exist"] = True # Empty is valid + return results + + results["files_exist"] = True + + # Check for duplicate revision numbers + revisions = [] + for file_path in migration_files: + with open(file_path, 'r') as f: + content = f.read() + # Extract revision from file + if "revision = " in content: + rev_line = [line for line in content.split('\n') if "revision = " in line] + if rev_line: + rev = rev_line[0].split("'")[1] + if rev in revisions: + print(f"❌ Duplicate revision found: {rev}") + return results + revisions.append(rev) + + results["no_duplicates"] = True + + # Validate migration ordering + try: + # Get ordered revisions from script directory + ordered_revisions = [] + for rev in self.script.walk_revisions(): + ordered_revisions.append(rev.revision) + + # Check if our files match the ordering + if set(revisions) == set(ordered_revisions): + results["proper_ordering"] = True + else: + print("❌ Migration ordering mismatch") + return results + + except Exception as e: + print(f"❌ Error checking migration ordering: {e}") + return results + + # Basic syntax validation + for file_path in migration_files: + try: + compile(open(file_path).read(), file_path, 'exec') + except SyntaxError as e: + print(f"❌ Syntax error in {file_path}: {e}") + return results + + results["valid_syntax"] = True + print("✅ All migration files are valid") + + except Exception as e: + print(f"❌ Error checking migration files: {e}") + + return results + + def check_database_state(self) -> Dict[str, any]: + """Check current database migration state.""" + results = { + "connected": False, + "current_revision": None, + "head_revision": None, + "up_to_date": False, + "pending_migrations": [] + } + + try: + engine = create_engine(get_database_url()) + + with engine.connect() as conn: + results["connected"] = True + + # Get current revision + context = MigrationContext.configure(conn) + current_rev = context.get_current_revision() + results["current_revision"] = current_rev + + # Get head revision + head_rev = self.script.get_current_head() + results["head_revision"] = head_rev + + # Check if up to date + results["up_to_date"] = current_rev == head_rev + + # Get pending migrations + if not results["up_to_date"]: + pending = [] + for rev in self.script.walk_revisions(): + if rev.revision > current_rev: + pending.append(rev.revision) + results["pending_migrations"] = pending + + except Exception as e: + print(f"❌ Database connection error: {e}") + + return results + + def validate_deployment_readiness(self) -> bool: + """Validate if deployment can proceed safely.""" + print("🔍 Checking deployment readiness...") + + # Check migration files + file_checks = self.check_migration_files() + all_files_good = all(file_checks.values()) + + # Check database state + db_checks = self.check_database_state() + db_connected = db_checks["connected"] + + if not all_files_good: + print("❌ Migration files have issues") + return False + + if not db_connected: + print("❌ Cannot connect to database") + return False + + if not db_checks["up_to_date"]: + print(f"⚠️ Database not up to date. Current: {db_checks['current_revision']}, Head: {db_checks['head_revision']}") + print(f"Pending migrations: {db_checks['pending_migrations']}") + + # For deployment, we might want to allow this if migrations will be run + print("ℹ️ This is acceptable if migrations will be run during deployment") + return True + + print("✅ Deployment readiness check passed") + return True + + def generate_migration_report(self) -> str: + """Generate a detailed migration status report.""" + report = [] + report.append("# Migration Status Report") + report.append("") + + # File checks + report.append("## Migration Files") + file_checks = self.check_migration_files() + for check, status in file_checks.items(): + status_icon = "✅" if status else "❌" + report.append(f"- {check}: {status_icon}") + + # Database state + report.append("") + report.append("## Database State") + db_checks = self.check_database_state() + for check, value in db_checks.items(): + if isinstance(value, list): + value = ", ".join(value) if value else "None" + report.append(f"- {check}: {value}") + + # Deployment readiness + report.append("") + report.append("## Deployment Readiness") + ready = self.validate_deployment_readiness() + readiness_icon = "✅" if ready else "❌" + report.append(f"- Ready for deployment: {readiness_icon}") + + return "\n".join(report) + +def main(): + if len(sys.argv) < 2: + print("Usage: python migration_checker.py ") + print("Commands:") + print(" check-files - Check migration file integrity") + print(" check-db - Check database migration state") + print(" validate-deploy - Validate deployment readiness") + print(" report - Generate detailed migration report") + sys.exit(1) + + checker = MigrationChecker() + command = sys.argv[1] + + try: + if command == "check-files": + results = checker.check_migration_files() + all_good = all(results.values()) + print("✅ Files OK" if all_good else "❌ Files have issues") + sys.exit(0 if all_good else 1) + + elif command == "check-db": + results = checker.check_database_state() + print(f"Connected: {'✅' if results['connected'] else '❌'}") + print(f"Up to date: {'✅' if results['up_to_date'] else '❌'}") + print(f"Current: {results['current_revision']}") + print(f"Head: {results['head_revision']}") + + elif command == "validate-deploy": + ready = checker.validate_deployment_readiness() + sys.exit(0 if ready else 1) + + elif command == "report": + report = checker.generate_migration_report() + print(report) + + else: + print(f"Unknown command: {command}") + sys.exit(1) + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/scripts/migration_rollback.py b/backend/scripts/migration_rollback.py new file mode 100644 index 0000000..9363fe3 --- /dev/null +++ b/backend/scripts/migration_rollback.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Migration rollback script for containerized deployments. +Provides safe rollback functionality with validation. +""" + +import sys +import os +from pathlib import Path + +# Add backend directory to path +backend_dir = Path(__file__).parent.parent +sys.path.insert(0, str(backend_dir)) + +from alembic.config import Config +from alembic import command +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +import sqlalchemy as sa +from app.database import get_database_url + +def get_alembic_config(): + """Get Alembic configuration.""" + config = Config("alembic.ini") + config.set_main_option("sqlalchemy.url", get_database_url()) + return config + +def get_current_revision(): + """Get current database revision.""" + config = get_alembic_config() + script = ScriptDirectory.from_config(config) + + with sa.create_engine(get_database_url()).connect() as conn: + context = MigrationContext.configure(conn) + current_rev = context.get_current_revision() + return current_rev + +def rollback_migration(revision="head:-1"): + """ + Rollback to specified revision. + + Args: + revision: Target revision (default: one step back from head) + """ + try: + print(f"Rolling back to revision: {revision}") + config = get_alembic_config() + command.downgrade(config, revision) + print("Rollback completed successfully") + + # Verify rollback + current = get_current_revision() + print(f"Current revision after rollback: {current}") + + except Exception as e: + print(f"Rollback failed: {e}") + sys.exit(1) + +def list_migrations(): + """List available migrations.""" + config = get_alembic_config() + script = ScriptDirectory.from_config(config) + + print("Available migrations:") + for rev in script.walk_revisions(): + print(f" {rev.revision}: {rev.doc}") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python migration_rollback.py [revision]") + print("Commands:") + print(" rollback [revision] - Rollback to revision (default: head:-1)") + print(" current - Show current revision") + print(" list - List available migrations") + sys.exit(1) + + command = sys.argv[1] + + if command == "rollback": + revision = sys.argv[2] if len(sys.argv) > 2 else "head:-1" + rollback_migration(revision) + elif command == "current": + current = get_current_revision() + print(f"Current revision: {current}") + elif command == "list": + list_migrations() + else: + print(f"Unknown command: {command}") + sys.exit(1) \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..ef8177d --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Empty file to mark tests directory as Python package \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..45780a5 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,36 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app +from app.database import get_db, Base +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +TEST_DATABASE_URL = "postgresql://postgres:postgres@localhost:5432/test_db" + +@pytest.fixture(scope="session") +def test_engine(): + engine = create_engine(TEST_DATABASE_URL) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + +@pytest.fixture +def db_session(test_engine): + connection = test_engine.connect() + transaction = connection.begin() + session = sessionmaker(autocommit=False, autoflush=False, bind=connection)() + yield session + session.close() + transaction.rollback() + connection.close() + +@pytest.fixture +def client(db_session): + def override_get_db(): + try: + yield db_session + finally: + db_session.close() + + app.dependency_overrides[get_db] = override_get_db + return TestClient(app) \ No newline at end of file diff --git a/backend/tests/services/test_garmin.py b/backend/tests/services/test_garmin.py new file mode 100644 index 0000000..0dce4ab --- /dev/null +++ b/backend/tests/services/test_garmin.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import AsyncMock, patch +from app.services.garmin import GarminService +from app.models.garmin_sync_log import GarminSyncStatus +from datetime import datetime, timedelta + +@pytest.mark.asyncio +async def test_garmin_authentication_success(db_session): + """Test successful Garmin Connect authentication""" + with patch('garth.Client') as mock_client: + mock_instance = mock_client.return_value + mock_instance.login = AsyncMock(return_value=True) + + service = GarminService(db_session) + result = await service.authenticate("test_user", "test_pass") + + assert result is True + mock_instance.login.assert_awaited_once_with("test_user", "test_pass") + +@pytest.mark.asyncio +async def test_garmin_authentication_failure(db_session): + """Test authentication failure handling""" + with patch('garth.Client') as mock_client: + mock_instance = mock_client.return_value + mock_instance.login = AsyncMock(side_effect=Exception("Invalid credentials")) + + service = GarminService(db_session) + result = await service.authenticate("bad_user", "wrong_pass") + + assert result is False + log_entry = db_session.query(GarminSyncLog).first() + assert log_entry.status == GarminSyncStatus.AUTH_FAILED + +@pytest.mark.asyncio +async def test_activity_sync(db_session): + """Test successful activity synchronization""" + with patch('garth.Client') as mock_client: + mock_instance = mock_client.return_value + mock_instance.connectapi = AsyncMock(return_value=[ + {"activityId": 123, "startTime": "2024-01-01T08:00:00"} + ]) + + service = GarminService(db_session) + await service.sync_activities() + + # Verify workout created + workout = db_session.query(Workout).first() + assert workout.garmin_activity_id == 123 + # Verify sync log updated + log_entry = db_session.query(GarminSyncLog).first() + assert log_entry.status == GarminSyncStatus.COMPLETED + +@pytest.mark.asyncio +async def test_rate_limiting_handling(db_session): + """Test API rate limit error handling""" + with patch('garth.Client') as mock_client: + mock_instance = mock_client.return_value + mock_instance.connectapi = AsyncMock(side_effect=Exception("Rate limit exceeded")) + + service = GarminService(db_session) + result = await service.sync_activities() + + assert result is False + log_entry = db_session.query(GarminSyncLog).first() + assert log_entry.status == GarminSyncStatus.FAILED + assert "Rate limit" in log_entry.error_message + +@pytest.mark.asyncio +async def test_session_persistence(db_session): + """Test session cookie persistence""" + service = GarminService(db_session) + + # Store session + await service.store_session({"token": "test123"}) + session = await service.load_session() + + assert session == {"token": "test123"} + assert Path("/app/data/sessions/garmin_session.pickle").exists() \ No newline at end of file diff --git a/designdoc.md b/designdoc.md new file mode 100644 index 0000000..65492da --- /dev/null +++ b/designdoc.md @@ -0,0 +1,244 @@ +--- + +# **AI-Assisted Cycling Coach — Design Document** + +## **1. Architecture Overview** + +**Goal:** Web-based cycling coach that plans workouts, analyzes Garmin rides, and integrates AI while enforcing strict user-defined rules. + +### **Components** + +| Component | Tech | Purpose | +| ---------------- | -------------------------- | ------------------------------------------------------------------ | +| Frontend | React/Next.js | UI for routes, plans, analysis, file uploads | +| Backend | Python (FastAPI, async) | API layer, AI integration, Garmin sync, DB access | +| Database | PostgreSQL | Stores routes, sections, plans, rules, workouts, prompts, analyses | +| File Storage | Mounted folder `/data/gpx` | Store GPX files for sections/routes | +| AI Integration | OpenRouter via backend | Plan generation, workout analysis, suggestions | +| Containerization | Docker + docker-compose | Encapsulate frontend, backend, database with persistent storage | + +**Workflow Overview** + +1. Upload/import GPX → backend saves to mounted folder + metadata in DB +2. Define rules (natural language → AI parses → JSON → DB) +3. Generate plan → AI creates JSON plan → DB versioned +4. Ride recorded on Garmin → backend syncs activity metrics → stores in DB +5. AI analyzes workout → feedback & suggestions stored → user approves → new plan version created + +--- + +## **2. Backend Design (Python, Async)** + +**Framework:** FastAPI (async-first, non-blocking I/O) +**Tasks:** + +* **Route/Section Management:** Upload GPX, store metadata, read GPX files for visualization +* **Rule Management:** CRUD rules, hierarchical parsing (AI-assisted) +* **Plan Management:** Generate plans (AI), store versions +* **Workout Analysis:** Fetch Garmin activity, run AI analysis, store reports +* **AI Integration:** Async calls to OpenRouter +* **Database Interaction:** Async Postgres client (e.g., `asyncpg` or `SQLAlchemy Async`) + +**Endpoints (examples)** + +| Method | Endpoint | Description | +| ------ | ------------------- | ------------------------------------------------ | +| POST | `/routes/upload` | Upload GPX file for route/section | +| GET | `/routes` | List routes and sections | +| POST | `/rules` | Create new rule set (with AI parse) | +| POST | `/plans/generate` | Generate new plan using rules & goals | +| GET | `/plans/{plan_id}` | Fetch plan JSON & version info | +| POST | `/workouts/analyze` | Trigger AI analysis for a synced Garmin activity | +| POST | `/workouts/approve` | Approve AI suggestions → create new plan version | + +**Async Patterns:** + +* File I/O → async reading/writing GPX +* AI API calls → async HTTP requests +* Garmin sync → async polling/scheduled jobs + +--- + +## **3. Database Design (Postgres)** + +**Tables:** + +```sql +-- Routes & Sections +CREATE TABLE routes ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMP DEFAULT now() +); + +CREATE TABLE sections ( + id SERIAL PRIMARY KEY, + route_id INT REFERENCES routes(id), + gpx_file_path TEXT NOT NULL, + distance_m NUMERIC, + grade_avg NUMERIC, + min_gear TEXT, + est_time_minutes NUMERIC, + created_at TIMESTAMP DEFAULT now() +); + +-- Rules (hierarchical JSON) +CREATE TABLE rules ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + user_defined BOOLEAN DEFAULT true, + jsonb_rules JSONB NOT NULL, + version INT DEFAULT 1, + created_at TIMESTAMP DEFAULT now() +); + +-- Plans (versioned) +CREATE TABLE plans ( + id SERIAL PRIMARY KEY, + jsonb_plan JSONB NOT NULL, + version INT NOT NULL, + created_at TIMESTAMP DEFAULT now() +); + +-- Workouts +CREATE TABLE workouts ( + id SERIAL PRIMARY KEY, + plan_id INT REFERENCES plans(id), + garmin_activity_id TEXT NOT NULL, + metrics JSONB, + created_at TIMESTAMP DEFAULT now() +); + +-- Analyses +CREATE TABLE analyses ( + id SERIAL PRIMARY KEY, + workout_id INT REFERENCES workouts(id), + jsonb_feedback JSONB, + created_at TIMESTAMP DEFAULT now() +); + +-- AI Prompts +CREATE TABLE prompts ( + id SERIAL PRIMARY KEY, + action_type TEXT, -- plan, analysis, suggestion + model TEXT, + prompt_text TEXT, + version INT DEFAULT 1, + created_at TIMESTAMP DEFAULT now() +); +``` + +--- + +## **4. Containerization (Docker Compose)** + +```yaml +version: '3.9' +services: + backend: + build: ./backend + ports: + - "8000:8000" + volumes: + - gpx-data:/app/data/gpx + environment: + - DATABASE_URL=postgresql://postgres:password@db:5432/cycling + depends_on: + - db + + frontend: + build: ./frontend + ports: + - "3000:3000" + + db: + image: postgres:15 + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: cycling + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + gpx-data: + driver: local + postgres-data: + driver: local +``` + +**Notes:** + +* `/app/data/gpx` inside backend container is persisted on host via `gpx-data` volume. +* Postgres data persisted via `postgres-data`. +* Backend talks to DB via async client. + +--- + +## **5. Frontend UI Layouts & Flows** + +### **5.1 Layout** + +* **Navbar:** Routes | Rules | Plans | Workouts | Analysis | Export/Import +* **Sidebar:** Filters (date, type, difficulty) +* **Main Area:** Dynamic content depending on selection + +### **5.2 Key Screens** + +1. **Routes** + + * Upload/import GPX + * View route map + section metadata +2. **Rules** + + * Natural language editor + * AI parse → preview JSON → save + * Switch between rule sets +3. **Plan** + + * Select goal + rule set → generate plan + * View plan timeline & weekly workouts +4. **Workout Analysis** + + * List synced Garmin activities + * Select activity → AI generates report + * Visualizations: HR, cadence, power vs planned + * Approve suggestions → new plan version +5. **Export/Import** + + * Export JSON/ZIP of routes, rules, plans + * Import JSON/GPX + +### **5.3 User Flow Example** + +1. Upload GPX → backend saves file + DB metadata +2. Define rule set → AI parses → user confirms → DB versioned +3. Generate plan → AI → store plan version in DB +4. Sync Garmin activity → backend fetches metrics → store workout +5. AI analyzes → report displayed → user approves → new plan version +6. Export plan or route as needed + +--- + +## **6. AI Integration** + +* Each **action type** (plan generation, analysis, suggestion) has: + + * Stored prompt template in DB + * Configurable model per action +* Async calls to OpenRouter +* Store raw AI output + processed structured result in DB + +--- + +## ✅ **Next Steps** + +1. Implement **Python FastAPI backend** with async patterns. +2. Build **Postgres DB schema** and migration scripts. +3. Setup **Docker Compose** with mounted GPX folder. +4. Design frontend UI based on the flows above. +5. Integrate AI endpoints and Garmin sync. + +--- + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75fc928 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,54 @@ +version: '3.9' +services: + backend: + build: + context: ./backend + volumes: + - gpx-data:/app/data/gpx + - garmin-sessions:/app/data/sessions + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://postgres:password@db:5432/cycling + - GARMIN_USERNAME=${GARMIN_USERNAME} + - GARMIN_PASSWORD=${GARMIN_PASSWORD} + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - AI_MODEL=${AI_MODEL:-claude-3-sonnet-20240229} + - API_KEY=${API_KEY} + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + + frontend: + build: ./frontend + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:8000 + - REACT_APP_API_KEY=${API_KEY} + + db: + image: postgres:15 + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: cycling + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d cycling"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + gpx-data: + garmin-sessions: + postgres-data: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..cfc58bd --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,39 @@ +# Build stage +FROM node:20-alpine AS build + +# Set working directory +WORKDIR /app + +# Copy package.json and package-lock.json +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY frontend/ . + +# Build application +RUN npm run build + +# Production stage +FROM node:20-alpine AS production + +# Set working directory +WORKDIR /app + +# Copy build artifacts and dependencies +COPY --from=build /app/package*.json ./ +COPY --from=build /app/.next ./.next +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/public ./public + +# Create non-root user +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +USER appuser + +# Expose application port +EXPOSE 3000 + +# Run application +CMD ["npm", "start"] \ No newline at end of file diff --git a/frontend/babel.config.js b/frontend/babel.config.js new file mode 100644 index 0000000..4fe7871 --- /dev/null +++ b/frontend/babel.config.js @@ -0,0 +1,10 @@ +module.exports = { + presets: [ + ['next/babel', { + 'preset-react': { + runtime: 'automatic', + importSource: '@emotion/react' + } + }] + ] +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bf8fac8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4942 @@ +{ + "name": "ai-cycling-coach-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-cycling-coach-frontend", + "version": "0.1.0", + "dependencies": { + "next": "14.2.3", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@types/node": "20.11.5", + "@types/react": "18.2.60", + "@types/react-dom": "18.2.22", + "eslint": "8.57.0", + "eslint-config-next": "14.2.3", + "typescript": "5.3.3" + } + }, + "node_modules/@emnapi/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", + "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", + "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.3.tgz", + "integrity": "sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==", + "dev": true, + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", + "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", + "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", + "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", + "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", + "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", + "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", + "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", + "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", + "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", + "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "dev": true + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", + "integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.11.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.5.tgz", + "integrity": "sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.60", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.60.tgz", + "integrity": "sha512-dfiPj9+k20jJrLGOu9Nf6eqxm2EyJRrq2NvwOFsfbb7sFExZ9WELPs67UImHj3Ayxg8ruTtKtNnbjaF8olPq0A==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.22", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.22.tgz", + "integrity": "sha512-fHkBXPeNtfvri6gdsMYyW+dW7RXFo6Ad09nLFK0VQWR7yGLai/Cyvyj696gbwYvBnhGtevUG9cET0pmUbMtoPQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==", + "dev": true + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz", + "integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.2.0", + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/typescript-estree": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz", + "integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz", + "integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz", + "integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "@typescript-eslint/visitor-keys": "7.2.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz", + "integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.2.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.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==", + "dev": true, + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "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", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "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" + } + }, + "node_modules/es-errors": { + "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" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "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", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.3.tgz", + "integrity": "sha512-ZkNztm3Q7hjqvB1rRlOX8P9E/cXRL9ajRcs8jufEtwMfTVYRqnmtnaSu57QqHyBlovMuiB8LEzfLBkh5RYV6Fg==", + "dev": true, + "dependencies": { + "@next/eslint-plugin-next": "14.2.3", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.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==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "dev": true + }, + "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==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "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", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "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==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, + "node_modules/math-intrinsics": { + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", + "integrity": "sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==", + "dev": true, + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/next": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", + "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", + "dependencies": { + "@next/env": "14.2.3", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.3", + "@next/swc-darwin-x64": "14.2.3", + "@next/swc-linux-arm64-gnu": "14.2.3", + "@next/swc-linux-arm64-musl": "14.2.3", + "@next/swc-linux-x64-gnu": "14.2.3", + "@next/swc-linux-x64-musl": "14.2.3", + "@next/swc-win32-arm64-msvc": "14.2.3", + "@next/swc-win32-ia32-msvc": "14.2.3", + "@next/swc-win32-x64-msvc": "14.2.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "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==", + "dev": true + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..371fcec --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "aic-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "next": "14.2.3", + "react": "18.2.0", + "react-dom": "18.2.0", + "recharts": "3.4.2" + }, + "devDependencies": { + "@types/node": "20.11.5", + "@types/react": "18.2.60", + "@types/react-dom": "18.2.22", + "@testing-library/jest-dom": "6.4.2", + "@testing-library/react": "14.2.1", + "@testing-library/user-event": "14.5.2", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "eslint": "8.57.0", + "eslint-config-next": "14.2.3", + "typescript": "5.3.3" + } +} \ No newline at end of file diff --git a/frontend/setupTests.js b/frontend/setupTests.js new file mode 100644 index 0000000..a678518 --- /dev/null +++ b/frontend/setupTests.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom/extend-expect'; \ No newline at end of file diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..a595ffd --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,66 @@ +import React, { Component } from 'react'; + +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null, errorInfo: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + this.setState({ + error: error, + errorInfo: errorInfo + }); + // Log error to analytics service in production + if (process.env.NODE_ENV === 'production') { + console.error('Error caught by boundary:', error, errorInfo); + } + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+ + + +
+
+

Something went wrong

+
+

We're sorry - an unexpected error occurred. Please try refreshing the page.

+ {process.env.NODE_ENV === 'development' && ( +
+ Error details +
+

{this.state.error && this.state.error.toString()}

+
{this.state.errorInfo?.componentStack}
+
+
+ )} +
+
+ +
+
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/frontend/src/components/FileUpload.jsx b/frontend/src/components/FileUpload.jsx new file mode 100644 index 0000000..85d5f10 --- /dev/null +++ b/frontend/src/components/FileUpload.jsx @@ -0,0 +1,150 @@ +import React, { useState, useCallback } from 'react'; + +const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => { + const [isDragging, setIsDragging] = useState(false); + const [previewContent, setPreviewContent] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleDragOver = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + setIsDragging(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + handleFile(files[0]); + } + }; + + const handleFileInput = (e) => { + const files = e.target.files; + if (files.length > 0) { + handleFile(files[0]); + } + }; + + const handleFile = async (file) => { + setError(null); + + // Validate file type + const fileExt = file.name.split('.').pop().toLowerCase(); + if (!acceptedTypes.includes(`.${fileExt}`)) { + setError(`Invalid file type. Supported types: ${acceptedTypes.join(', ')}`); + return; + } + + // Validate file size (max 10MB) + if (file.size > 10 * 1024 * 1024) { + setError('File size exceeds 10MB limit'); + return; + } + + try { + setIsLoading(true); + + // Preview GPX content + if (fileExt === 'gpx') { + const content = await file.text(); + setPreviewContent(content); + } else { + setPreviewContent(null); + } + + // Pass file to parent component for upload + if (onUpload) { + onUpload(file); + } + } catch (err) { + console.error('File processing error:', err); + setError('Failed to process file'); + } finally { + setIsLoading(false); + } + }; + + const clearPreview = () => { + setPreviewContent(null); + }; + + return ( +
+
document.getElementById('file-input').click()} + > + + +
+ {isLoading ? ( + <> + + + + +

Processing file...

+ + ) : ( + <> + + + +

+ Click to upload or drag and drop +

+

+ {acceptedTypes.join(', ')} files, max 10MB +

+ + )} +
+
+ + {error && ( +
+ {error} +
+ )} + + {previewContent && ( +
+
+

File Preview

+ +
+
+
+              {previewContent}
+            
+
+
+ )} +
+ ); +}; + +export default FileUpload; \ No newline at end of file diff --git a/frontend/src/components/GarminSync.jsx b/frontend/src/components/GarminSync.jsx new file mode 100644 index 0000000..d705060 --- /dev/null +++ b/frontend/src/components/GarminSync.jsx @@ -0,0 +1,121 @@ +import { useState, useEffect } from 'react'; + +const GarminSync = () => { + const [syncStatus, setSyncStatus] = useState(null); + const [syncing, setSyncing] = useState(false); + const [error, setError] = useState(null); + + const triggerSync = async () => { + setSyncing(true); + setError(null); + try { + const response = await fetch('/api/workouts/sync', { + method: 'POST', + headers: { + 'X-API-Key': process.env.REACT_APP_API_KEY + } + }); + + if (!response.ok) { + throw new Error(`Sync failed: ${response.statusText}`); + } + + // Start polling for status updates + pollSyncStatus(); + } catch (err) { + console.error('Garmin sync failed:', err); + setError(err.message); + setSyncing(false); + } + }; + + const pollSyncStatus = () => { + const interval = setInterval(async () => { + try { + const response = await fetch('/api/workouts/sync-status'); + const status = await response.json(); + setSyncStatus(status); + + // Stop polling when sync is no longer in progress + if (status.status !== 'in_progress') { + setSyncing(false); + clearInterval(interval); + } + } catch (err) { + console.error('Error fetching sync status:', err); + setError('Failed to get sync status'); + setSyncing(false); + clearInterval(interval); + } + }, 2000); + }; + + return ( +
+

Garmin Connect Sync

+ + + + {error && ( +
+ Error: {error} +
+ )} + + {syncStatus && ( +
+

Sync Status

+ +
+
Last sync:
+
+ {syncStatus.last_sync_time + ? new Date(syncStatus.last_sync_time).toLocaleString() + : 'Never'} +
+ +
Status:
+
+ {syncStatus.status} +
+ + {syncStatus.activities_synced > 0 && ( + <> +
Activities synced:
+
{syncStatus.activities_synced}
+ + )} + + {syncStatus.error_message && ( + <> +
Error:
+
{syncStatus.error_message}
+ + )} +
+
+ )} +
+ ); +}; + +export default GarminSync; \ No newline at end of file diff --git a/frontend/src/components/PlanTimeline.jsx b/frontend/src/components/PlanTimeline.jsx new file mode 100644 index 0000000..493f65a --- /dev/null +++ b/frontend/src/components/PlanTimeline.jsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; + +const PlanTimeline = ({ plan, versions }) => { + const [expandedWeeks, setExpandedWeeks] = useState({}); + + const toggleWeek = (weekNumber) => { + setExpandedWeeks(prev => ({ + ...prev, + [weekNumber]: !prev[weekNumber] + })); + }; + + return ( +
+
+
+

{plan.name || 'Training Plan'}

+

Version {plan.version} • Created {new Date(plan.created_at).toLocaleDateString()}

+
+
+ {plan.jsonb_plan.overview.focus.replace(/_/g, ' ')} +
+
+ + {versions.length > 1 && ( +
+

Version History

+
+ + + + + + + + + + + {versions.map(version => ( + + + + + + + ))} + +
VersionCreatedTriggerChanges
+ v{version.version} + + {new Date(version.created_at).toLocaleDateString()} + + {version.evolution_trigger?.replace(/_/g, ' ') || 'initial'} + + {version.changes_summary || 'Initial version'} +
+
+
+ )} + +
+

Plan Overview

+
+
+ Duration + + {plan.jsonb_plan.overview.duration_weeks} weeks + +
+
+ Weekly Hours + + {plan.jsonb_plan.overview.total_weekly_hours} hours + +
+
+ Focus + + {plan.jsonb_plan.overview.focus.replace(/_/g, ' ')} + +
+
+
+ +
+

Weekly Schedule

+ + {plan.jsonb_plan.weeks.map((week, weekIndex) => ( +
+
toggleWeek(weekIndex)} + > +

Week {week.week_number} • {week.focus.replace(/_/g, ' ')}

+
+ + {week.total_hours} hours • {week.workouts.length} workouts + + + + +
+
+ + {expandedWeeks[weekIndex] && ( +
+ {week.workouts.map((workout, workoutIndex) => ( +
+
+
+ {workout.type.replace(/_/g, ' ')} + • {workout.day} +
+ {workout.duration_minutes} min +
+ +
+ + {workout.intensity.replace(/_/g, ' ')} + + {workout.route_id && ( + + Route: {workout.route_name || workout.route_id} + + )} + + TSS: {workout.tss_target || 'N/A'} + +
+ + {workout.description && ( +

{workout.description}

+ )} +
+ ))} +
+ )} +
+ ))} +
+
+ ); +}; + +export default PlanTimeline; \ No newline at end of file diff --git a/frontend/src/components/WorkoutAnalysis.jsx b/frontend/src/components/WorkoutAnalysis.jsx new file mode 100644 index 0000000..d381618 --- /dev/null +++ b/frontend/src/components/WorkoutAnalysis.jsx @@ -0,0 +1,162 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +const WorkoutAnalysis = ({ workout, analysis }) => { + const [approving, setApproving] = useState(false); + const [error, setError] = useState(null); + const navigate = useNavigate(); + + const approveAnalysis = async () => { + setApproving(true); + setError(null); + try { + const response = await fetch(`/api/analyses/${analysis.id}/approve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': process.env.REACT_APP_API_KEY + } + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Approval failed'); + } + + const result = await response.json(); + + if (result.new_plan_id) { + // Navigate to the new plan + navigate(`/plans/${result.new_plan_id}`); + } else { + // Show success message + setApproving(false); + alert('Analysis approved successfully!'); + } + } catch (err) { + console.error('Approval failed:', err); + setError(err.message); + setApproving(false); + } + }; + + return ( +
+
+

+ {workout.activity_type || 'Cycling'} - {new Date(workout.start_time).toLocaleDateString()} +

+ +
+
+ Duration + + {Math.round(workout.duration_seconds / 60)} min + +
+ +
+ Distance + + {(workout.distance_m / 1000).toFixed(1)} km + +
+ + {workout.avg_power && ( +
+ Avg Power + + {Math.round(workout.avg_power)}W + +
+ )} + + {workout.avg_hr && ( +
+ Avg HR + + {Math.round(workout.avg_hr)} bpm + +
+ )} +
+
+ + {analysis && ( +
+

AI Analysis

+ +
+

{analysis.jsonb_feedback.summary}

+
+ +
+
+
Strengths
+
    + {analysis.jsonb_feedback.strengths.map((strength, index) => ( +
  • {strength}
  • + ))} +
+
+ +
+
Areas for Improvement
+
    + {analysis.jsonb_feedback.areas_for_improvement.map((area, index) => ( +
  • {area}
  • + ))} +
+
+
+ + {analysis.suggestions && analysis.suggestions.length > 0 && ( +
+
Training Suggestions
+
    + {analysis.suggestions.map((suggestion, index) => ( +
  • + + {index + 1} + + {suggestion} +
  • + ))} +
+ + {!analysis.approved && ( +
+ + + {error && ( +
+ Error: {error} +
+ )} +
+ )} +
+ )} +
+ )} +
+ ); +}; + +export default WorkoutAnalysis; \ No newline at end of file diff --git a/frontend/src/components/WorkoutCharts.jsx b/frontend/src/components/WorkoutCharts.jsx new file mode 100644 index 0000000..b24fa11 --- /dev/null +++ b/frontend/src/components/WorkoutCharts.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer +} from 'recharts'; + +const WorkoutCharts = ({ timeSeries }) => { + // Transform timestamp to minutes from start for X-axis + const formatTimeSeries = (data) => { + if (!data || data.length === 0) return []; + + const startTime = new Date(data[0].timestamp); + return data.map(point => ({ + ...point, + time: (new Date(point.timestamp) - startTime) / 60000, // Convert to minutes + heart_rate: point.heart_rate || null, + power: point.power || null, + cadence: point.cadence || null + })); + }; + + const formattedData = formatTimeSeries(timeSeries); + + return ( +
+

Workout Metrics

+ + + + + + + + + + [`${value} ${name === 'power' ? 'W' : name === 'heart_rate' ? 'bpm' : 'rpm'}`, name]} + labelFormatter={(value) => `Time: ${value.toFixed(1)} min`} + /> + + + + + + + +
+

Note: Charts show metrics over time during the workout. Hover over points to see exact values.

+
+
+ ); +}; + +export default WorkoutCharts; \ No newline at end of file diff --git a/frontend/src/components/__tests__/FileUpload.test.jsx b/frontend/src/components/__tests__/FileUpload.test.jsx new file mode 100644 index 0000000..4274a9b --- /dev/null +++ b/frontend/src/components/__tests__/FileUpload.test.jsx @@ -0,0 +1,34 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import FileUpload from '../FileUpload' + +describe('FileUpload Component', () => { + test('renders upload button', () => { + render( {}} />) + expect(screen.getByText('Upload GPX File')).toBeInTheDocument() + expect(screen.getByTestId('file-input')).toBeInTheDocument() + }) + + test('handles file selection', () => { + const mockFile = new File(['test content'], 'test.gpx', { type: 'application/gpx+xml' }) + const mockOnUpload = jest.fn() + + render() + + const input = screen.getByTestId('file-input') + fireEvent.change(input, { target: { files: [mockFile] } }) + + expect(mockOnUpload).toHaveBeenCalledWith(mockFile) + expect(screen.getByText('Selected file: test.gpx')).toBeInTheDocument() + }) + + test('shows error for invalid file type', () => { + const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' }) + const { container } = render( {}} />) + + const input = screen.getByTestId('file-input') + fireEvent.change(input, { target: { files: [invalidFile] } }) + + expect(screen.getByText('Invalid file type. Please upload a GPX file.')).toBeInTheDocument() + expect(container.querySelector('.error-message')).toBeVisible() + }) +}) \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..12ff040 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,253 @@ +import React, { useState, useEffect } from 'react'; +import GarminSync from '../components/GarminSync'; +import WorkoutCharts from '../components/WorkoutCharts'; +import PlanTimeline from '../components/PlanTimeline'; +import WorkoutAnalysis from '../components/WorkoutAnalysis'; + +const Dashboard = () => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [dashboardData, setDashboardData] = useState({ + recentWorkouts: [], + upcomingWorkouts: [], + currentPlan: null, + planVersions: [], + lastAnalysis: null, + syncStatus: null, + metrics: {} + }); + + useEffect(() => { + const fetchDashboardData = async () => { + try { + setLoading(true); + const response = await fetch('/api/dashboard', { + headers: { + 'X-API-Key': process.env.REACT_APP_API_KEY + } + }); + + if (!response.ok) { + throw new Error(`Failed to load dashboard: ${response.statusText}`); + } + + const data = await response.json(); + setDashboardData(data); + setError(null); + } catch (err) { + console.error('Dashboard load error:', err); + setError(err.message); + } finally { + setLoading(false); + } + }; + + fetchDashboardData(); + }, []); + + const handleSyncComplete = (newSyncStatus) => { + setDashboardData(prev => ({ + ...prev, + syncStatus: newSyncStatus + })); + }; + + if (loading) { + return ( +
+
+
+

Loading your training dashboard...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ + + +
+

Dashboard Error

+

{error}

+ +
+
+ ); + } + + return ( +
+
+
+

Training Dashboard

+

Your personalized cycling training overview

+
+ + {/* Stats Overview */} +
+
+

Weekly Hours

+

+ {dashboardData.metrics.weekly_hours || '0'}h +

+
+
+

Workouts This Week

+

+ {dashboardData.metrics.workouts_this_week || '0'} +

+
+
+

Plan Progress

+

+ {dashboardData.metrics.plan_progress || '0'}% +

+
+
+

Fitness Level

+

+ {dashboardData.metrics.fitness_level || 'N/A'} +

+
+
+ +
+ {/* Left Column */} +
+ {/* Garmin Sync */} +
+ +
+ + {/* Current Plan */} + {dashboardData.currentPlan && ( +
+

Current Training Plan

+ +
+ )} + + {/* Recent Analysis */} + {dashboardData.lastAnalysis && ( +
+

Latest Workout Analysis

+ +
+ )} +
+ + {/* Right Column */} +
+ {/* Upcoming Workouts */} +
+

Upcoming Workouts

+ {dashboardData.upcomingWorkouts.length > 0 ? ( +
+ {dashboardData.upcomingWorkouts.map(workout => ( +
+
+
+

+ {workout.type.replace(/_/g, ' ')} +

+

+ {new Date(workout.scheduled_date).toLocaleDateString()} • {workout.duration_minutes} min +

+
+ + {workout.intensity.replace(/_/g, ' ')} + +
+ {workout.description && ( +

{workout.description}

+ )} +
+ ))} +
+ ) : ( +

No upcoming workouts scheduled

+ )} +
+ + {/* Recent Workouts */} +
+

Recent Workouts

+ {dashboardData.recentWorkouts.length > 0 ? ( +
+ {dashboardData.recentWorkouts.map(workout => ( +
+
+
+

+ {workout.activity_type || 'Cycling'} +

+

+ {new Date(workout.start_time).toLocaleDateString()} • {Math.round(workout.duration_seconds / 60)} min +

+
+
+ + {workout.distance_m ? `${(workout.distance_m / 1000).toFixed(1)} km` : ''} + + {workout.analysis && workout.analysis.performance_score && ( + + Score: {workout.analysis.performance_score}/10 + + )} +
+
+ {workout.analysis && workout.analysis.performance_summary && ( +

+ {workout.analysis.performance_summary} +

+ )} +
+ ))} +
+ ) : ( +

No recent workouts recorded

+ )} +
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ + + + +
+
+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx new file mode 100644 index 0000000..2c4356b --- /dev/null +++ b/frontend/src/pages/index.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; + +export default function Home() { + const [healthStatus, setHealthStatus] = useState('checking...'); + + useEffect(() => { + const checkBackendHealth = async () => { + try { + const response = await fetch('http://backend:8000/health'); + const data = await response.json(); + setHealthStatus(data.status); + } catch (error) { + setHealthStatus('unavailable'); + console.error('Error checking backend health:', error); + } + }; + + checkBackendHealth(); + }, []); + + return ( +
+
+

+ Welcome to AI Cycling Coach +

+

+ Your AI-powered training companion for cyclists +

+ +
+

+ System Status +

+
+
+ + Backend service: {healthStatus} + +
+
+ +

+ Development in progress - more features coming soon! +

+
+
+ ); +} \ No newline at end of file