mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-03-27 15:26:05 +00:00
change to TUI
This commit is contained in:
27
MANIFEST.in
Normal file
27
MANIFEST.in
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Include important files
|
||||||
|
include README.md
|
||||||
|
include LICENSE
|
||||||
|
include requirements.txt
|
||||||
|
include .env
|
||||||
|
include Makefile
|
||||||
|
include pyproject.toml
|
||||||
|
|
||||||
|
# Include backend files
|
||||||
|
recursive-include backend *.py
|
||||||
|
recursive-include backend *.ini
|
||||||
|
recursive-include backend *.mako
|
||||||
|
|
||||||
|
# Include TUI files
|
||||||
|
recursive-include tui *.py
|
||||||
|
|
||||||
|
# Include data directories
|
||||||
|
recursive-include data *.gitkeep
|
||||||
|
|
||||||
|
# Exclude unnecessary files
|
||||||
|
exclude *.pyc
|
||||||
|
recursive-exclude * __pycache__
|
||||||
|
recursive-exclude * *.py[co]
|
||||||
|
exclude .git*
|
||||||
|
exclude .vscode
|
||||||
|
exclude .idea
|
||||||
|
exclude *.log
|
||||||
106
Makefile
106
Makefile
@@ -1,57 +1,67 @@
|
|||||||
.PHONY: up down build start stop restart logs backend-logs frontend-logs db-logs
|
.PHONY: install dev-install run test clean build package help init-db
|
||||||
|
|
||||||
# Start all services in detached mode
|
# Default target
|
||||||
up:
|
help:
|
||||||
docker-compose up -d
|
@echo "AI Cycling Coach - Available commands:"
|
||||||
|
@echo " install - Install the application"
|
||||||
|
@echo " dev-install - Install in development mode"
|
||||||
|
@echo " run - Run the application"
|
||||||
|
@echo " init-db - Initialize the database"
|
||||||
|
@echo " test - Run tests"
|
||||||
|
@echo " clean - Clean build artifacts"
|
||||||
|
@echo " build - Build distribution packages"
|
||||||
|
@echo " package - Create standalone executable"
|
||||||
|
|
||||||
# Stop and remove all containers
|
# Installation
|
||||||
down:
|
install:
|
||||||
docker-compose down
|
pip install .
|
||||||
|
|
||||||
# Rebuild all Docker images
|
dev-install:
|
||||||
build:
|
pip install -e .[dev]
|
||||||
docker-compose build --no-cache
|
|
||||||
|
|
||||||
# Start services if not running, otherwise restart
|
# Database initialization
|
||||||
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:
|
init-db:
|
||||||
docker-compose run --rm backend alembic upgrade head
|
@echo "Initializing database..."
|
||||||
|
@mkdir -p data
|
||||||
|
@cd backend && python -m alembic upgrade head
|
||||||
|
@echo "Database initialized successfully!"
|
||||||
|
|
||||||
# Create new database migration
|
# Run application
|
||||||
migration:
|
run:
|
||||||
docker-compose run --rm backend alembic revision --autogenerate -m "$(m)"
|
python main.py
|
||||||
|
|
||||||
# Run tests
|
# Testing
|
||||||
test:
|
test:
|
||||||
docker-compose run --rm backend pytest
|
pytest
|
||||||
|
|
||||||
# Open database shell
|
# Cleanup
|
||||||
db-shell:
|
clean:
|
||||||
docker-compose exec db psql -U appuser -d cyclingdb
|
rm -rf build/
|
||||||
|
rm -rf dist/
|
||||||
|
rm -rf *.egg-info/
|
||||||
|
find . -type d -name __pycache__ -exec rm -rf {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
|
||||||
|
# Build distribution
|
||||||
|
build: clean
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
# Package as executable (requires PyInstaller)
|
||||||
|
package:
|
||||||
|
@echo "Creating standalone executable..."
|
||||||
|
@pip install pyinstaller
|
||||||
|
@pyinstaller --onefile --name cycling-coach main.py
|
||||||
|
@echo "Executable created in dist/cycling-coach"
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
lint:
|
||||||
|
black --check .
|
||||||
|
isort --check-only .
|
||||||
|
|
||||||
|
format:
|
||||||
|
black .
|
||||||
|
isort .
|
||||||
|
|
||||||
|
# Quick setup for new users
|
||||||
|
setup: dev-install init-db
|
||||||
|
@echo "Setup complete! Run 'make run' to start the application."
|
||||||
471
README.md
471
README.md
@@ -1,325 +1,272 @@
|
|||||||
# AI Cycling Coach
|
# AI Cycling Coach - Terminal Edition
|
||||||
|
|
||||||
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.
|
🚴♂️ An intelligent cycling training coach with a sleek Terminal User Interface (TUI) that creates personalized training plans and analyzes your workouts using AI, with seamless Garmin Connect integration.
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## ✨ Features
|
||||||
|
|
||||||
### Prerequisites
|
- **🧠 AI-Powered Plan Generation**: Create personalized 4-week training plans based on your goals and constraints
|
||||||
- Docker and Docker Compose
|
- **📊 Automatic Workout Analysis**: Get detailed AI feedback on your completed rides with terminal-based visualizations
|
||||||
- 2GB+ available RAM
|
- **⌚ Garmin Connect Integration**: Sync activities automatically from your Garmin device
|
||||||
- 10GB+ available disk space
|
- **🔄 Plan Evolution**: Training plans adapt based on your actual performance
|
||||||
|
- **🗺️ GPX Route Management**: Upload and visualize your favorite cycling routes with ASCII maps
|
||||||
|
- **📈 Progress Tracking**: Monitor your training progress with terminal charts and metrics
|
||||||
|
- **💻 Pure Terminal Interface**: Beautiful, responsive TUI that works entirely in your terminal
|
||||||
|
- **🗃️ SQLite Database**: Lightweight, portable database that travels with your data
|
||||||
|
- **🚀 No Docker Required**: Simple installation and native performance
|
||||||
|
|
||||||
### Setup
|
## 🏁 Quick Start
|
||||||
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
|
|
||||||
|
|
||||||
|
### Option 1: Automated Installation (Recommended)
|
||||||
```bash
|
```bash
|
||||||
# Start development environment
|
git clone https://github.com/ai-cycling-coach/ai-cycling-coach.git
|
||||||
docker-compose up -d
|
cd ai-cycling-coach
|
||||||
|
./install.sh
|
||||||
# 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
|
### Option 2: Manual Installation
|
||||||
|
|
||||||
#### 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
|
```bash
|
||||||
# Check migration status
|
# Clone and setup
|
||||||
docker-compose exec backend python scripts/migration_checker.py check-db
|
git clone https://github.com/ai-cycling-coach/ai-cycling-coach.git
|
||||||
|
cd ai-cycling-coach
|
||||||
|
|
||||||
# Generate new migration
|
# Create virtual environment
|
||||||
docker-compose exec backend alembic revision --autogenerate -m "description"
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
# Rollback migration
|
# Install
|
||||||
docker-compose exec backend python scripts/migration_rollback.py rollback
|
pip install -e .
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
make init-db
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
cycling-coach
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Migration Validation
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
Edit the `.env` file with your settings:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Validate deployment readiness
|
# Database Configuration (SQLite)
|
||||||
docker-compose exec backend python scripts/migration_checker.py validate-deploy
|
DATABASE_URL=sqlite+aiosqlite:///data/cycling_coach.db
|
||||||
|
|
||||||
# Generate migration report
|
# File Storage
|
||||||
docker-compose exec backend python scripts/migration_checker.py report
|
GPX_STORAGE_PATH=data/gpx
|
||||||
```
|
|
||||||
|
|
||||||
### Database Backup & Restore
|
# AI Service Configuration
|
||||||
|
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||||
|
AI_MODEL=deepseek/deepseek-r1
|
||||||
|
|
||||||
#### Creating Backups
|
# Garmin Connect Credentials
|
||||||
```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_USERNAME=your_garmin_email@example.com
|
||||||
GARMIN_PASSWORD=your_secure_password
|
GARMIN_PASSWORD=your_secure_password
|
||||||
|
|
||||||
# AI Service
|
# Optional: Logging Configuration
|
||||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
LOG_LEVEL=INFO
|
||||||
AI_MODEL=anthropic/claude-3-sonnet-20240229
|
|
||||||
|
|
||||||
# Application
|
|
||||||
API_KEY=your_secure_random_api_key_here
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health Checks
|
## 🎮 Usage
|
||||||
|
|
||||||
The application includes comprehensive health monitoring:
|
### Terminal Interface
|
||||||
|
|
||||||
|
Start the application with:
|
||||||
```bash
|
```bash
|
||||||
# Check overall health
|
cycling-coach
|
||||||
curl http://localhost:8000/health
|
# or
|
||||||
|
ai-cycling-coach
|
||||||
# Response includes:
|
# or
|
||||||
# - Database connectivity
|
python main.py
|
||||||
# - Migration status
|
|
||||||
# - Current vs head revision
|
|
||||||
# - Service availability
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Navigate through the interface using:
|
||||||
|
|
||||||
|
1. **🏠 Dashboard**: View recent workouts, weekly stats, and sync status
|
||||||
|
2. **📋 Plans**: Generate new training plans or manage existing ones
|
||||||
|
3. **💪 Workouts**: Sync from Garmin, view detailed analysis, and approve AI suggestions
|
||||||
|
4. **📏 Rules**: Define custom training constraints and preferences
|
||||||
|
5. **🗺️ Routes**: Upload GPX files and view ASCII route visualizations
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
#### 🧠 AI-Powered Analysis
|
||||||
|
- Detailed workout feedback with actionable insights
|
||||||
|
- Performance trend analysis
|
||||||
|
- Training load recommendations
|
||||||
|
- Recovery suggestions
|
||||||
|
|
||||||
|
#### 🗺️ ASCII Route Visualization
|
||||||
|
```
|
||||||
|
Route Map:
|
||||||
|
S●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●E
|
||||||
|
● ●
|
||||||
|
● Morning Loop - 15.2km ●
|
||||||
|
● ●
|
||||||
|
●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●●
|
||||||
|
|
||||||
|
Elevation Profile (50m - 180m):
|
||||||
|
████████████████████████████████████████████████████████████
|
||||||
|
██████████████████████████████ ████████████████████████████
|
||||||
|
███████████████████████████ ████████████████████████
|
||||||
|
███████████████████████ ██████████████████████
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 📊 Terminal-Based Charts
|
||||||
|
- Heart rate zones
|
||||||
|
- Power distribution
|
||||||
|
- Training load trends
|
||||||
|
- Weekly volume tracking
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
### Service Architecture
|
### 🔧 Technology Stack
|
||||||
|
- **Backend**: Python with SQLAlchemy + SQLite
|
||||||
|
- **TUI Framework**: Textual (Rich terminal interface)
|
||||||
|
- **AI Integration**: OpenRouter API (Deepseek R1, Claude, GPT)
|
||||||
|
- **Garmin Integration**: garth library
|
||||||
|
- **Database**: SQLite with async support
|
||||||
|
|
||||||
|
### 📁 Project Structure
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
ai-cycling-coach/
|
||||||
│ Frontend │ │ Backend │
|
├── main.py # Application entrypoint
|
||||||
│ (React) │◄──►│ (FastAPI) │
|
├── backend/ # Core business logic
|
||||||
│ │ │ │
|
│ ├── app/
|
||||||
└─────────────────┘ └─────────────────┘
|
│ │ ├── models/ # Database models
|
||||||
│ │
|
│ │ ├── services/ # Business services
|
||||||
▼ ▼
|
│ │ └── config.py # Configuration
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
│ └── alembic/ # Database migrations
|
||||||
│ Garmin │ │ PostgreSQL │
|
├── tui/ # Terminal interface
|
||||||
│ Connect │ │ Database │
|
│ ├── views/ # TUI screens
|
||||||
└─────────────────┘ └─────────────────┘
|
│ ├── services/ # TUI service layer
|
||||||
|
│ └── widgets/ # Custom UI components
|
||||||
|
└── data/ # SQLite database and files
|
||||||
|
├── cycling_coach.db
|
||||||
|
└── gpx/ # GPX route files
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Flow
|
## 🛠️ Development
|
||||||
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
|
### Setup Development Environment
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Run all validation checks
|
# Clone repository
|
||||||
docker-compose exec backend python scripts/migration_checker.py validate-deploy
|
git clone https://github.com/ai-cycling-coach/ai-cycling-coach.git
|
||||||
|
cd ai-cycling-coach
|
||||||
|
|
||||||
# Check for raw SQL usage
|
# Install in development mode
|
||||||
grep -r "SELECT.*FROM\|INSERT.*INTO\|UPDATE.*SET\|DELETE.*FROM" backend/app/
|
make dev-install
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
make init-db
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
make format
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
make run
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📁 Project Structure
|
### Available Make Commands
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── 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
|
```bash
|
||||||
# Check migration status
|
make help # Show all available commands
|
||||||
docker-compose exec backend alembic current
|
make install # Install the application
|
||||||
|
make dev-install # Install in development mode
|
||||||
# View migration history
|
make run # Run the application
|
||||||
docker-compose exec backend alembic history
|
make init-db # Initialize the database
|
||||||
|
make test # Run tests
|
||||||
# Reset migrations (CAUTION: destroys data)
|
make clean # Clean build artifacts
|
||||||
docker-compose exec backend alembic downgrade base
|
make build # Build distribution packages
|
||||||
|
make package # Create standalone executable
|
||||||
|
make setup # Complete setup for new users
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Database Connection Issues
|
### Creating a Standalone Executable
|
||||||
```bash
|
```bash
|
||||||
# Check database health
|
make package
|
||||||
docker-compose exec db pg_isready -U postgres
|
# Creates: dist/cycling-coach
|
||||||
|
|
||||||
# View database logs
|
|
||||||
docker-compose logs db
|
|
||||||
|
|
||||||
# Restart database
|
|
||||||
docker-compose restart db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Container Build Issues
|
## 🚀 Deployment Options
|
||||||
|
|
||||||
|
### 1. Portable Installation
|
||||||
```bash
|
```bash
|
||||||
# Rebuild without cache
|
# Create portable package
|
||||||
docker-compose build --no-cache backend
|
make build
|
||||||
|
pip install dist/ai-cycling-coach-*.whl
|
||||||
# View build logs
|
|
||||||
docker-compose build backend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Health Monitoring
|
### 2. Standalone Executable
|
||||||
|
|
||||||
#### Service Health
|
|
||||||
```bash
|
```bash
|
||||||
# Check all services
|
# Create single-file executable
|
||||||
docker-compose ps
|
make package
|
||||||
|
# Copy dist/cycling-coach to target system
|
||||||
# View service logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Check backend health
|
|
||||||
curl http://localhost:8000/health
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Database Health
|
### 3. Development Installation
|
||||||
```bash
|
```bash
|
||||||
# Check database connectivity
|
# For development and testing
|
||||||
docker-compose exec backend python -c "
|
make dev-install
|
||||||
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
|
## 📋 Requirements
|
||||||
|
|
||||||
- API key authentication for all endpoints
|
- **Python**: 3.8 or higher
|
||||||
- Secure storage of Garmin credentials
|
- **Operating System**: Linux, macOS, Windows
|
||||||
- No sensitive data in application logs
|
- **Terminal**: Any terminal with Unicode support
|
||||||
- Container isolation prevents host system access
|
- **Memory**: ~100MB RAM
|
||||||
- Regular security updates via container rebuilds
|
- **Storage**: ~50MB + data files
|
||||||
|
|
||||||
## 📚 API Documentation
|
|
||||||
|
|
||||||
Once running, visit:
|
|
||||||
- **API Docs**: http://localhost:8000/docs
|
|
||||||
- **Alternative Docs**: http://localhost:8000/redoc
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. Follow container-first development rules
|
1. Fork the repository
|
||||||
2. Ensure all changes pass CI/CD validation
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
3. Update documentation for significant changes
|
3. Make your changes
|
||||||
4. Test migration compatibility before merging
|
4. Run tests (`make test`)
|
||||||
|
5. Format code (`make format`)
|
||||||
|
6. Commit changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
7. Push to branch (`git push origin feature/amazing-feature`)
|
||||||
|
8. Open a Pull Request
|
||||||
|
|
||||||
### Development Guidelines
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
- Use SQLAlchemy ORM for all database operations
|
### Common Issues
|
||||||
- Keep dependencies in `requirements.txt`
|
|
||||||
- Test schema changes in development environment
|
**Database errors:**
|
||||||
- Document migration changes in commit messages
|
```bash
|
||||||
- Run validation checks before pushing
|
make init-db # Reinitialize database
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import errors:**
|
||||||
|
```bash
|
||||||
|
pip install -e . # Reinstall in development mode
|
||||||
|
```
|
||||||
|
|
||||||
|
**Garmin sync fails:**
|
||||||
|
- Check credentials in `.env`
|
||||||
|
- Verify Garmin Connect account access
|
||||||
|
- Check internet connection
|
||||||
|
|
||||||
|
**TUI rendering issues:**
|
||||||
|
- Ensure terminal supports Unicode
|
||||||
|
- Try different terminal emulators
|
||||||
|
- Check terminal size (minimum 80x24)
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
- 📖 Check the documentation
|
||||||
|
- 🐛 Open an issue on GitHub
|
||||||
|
- 💬 Join our community discussions
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
MIT License - see [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
---
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
**Note**: This application is designed for single-user, self-hosted deployment. All data remains on your local infrastructure with no external data sharing.
|
- [Textual](https://github.com/Textualize/textual) - Amazing TUI framework
|
||||||
|
- [garth](https://github.com/matin/garth) - Garmin Connect integration
|
||||||
|
- [OpenRouter](https://openrouter.ai/) - AI model access
|
||||||
|
- [SQLAlchemy](https://www.sqlalchemy.org/) - Database toolkit
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 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 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 . .
|
|
||||||
|
|
||||||
# Create entrypoint script for migration handling
|
|
||||||
RUN echo '#!/bin/bash\n\
|
|
||||||
set -e\n\
|
|
||||||
\n\
|
|
||||||
# Run database migrations synchronously\n\
|
|
||||||
echo "Running database migrations..."\n\
|
|
||||||
python -m alembic upgrade head\n\
|
|
||||||
\n\
|
|
||||||
# Verify migration success\n\
|
|
||||||
echo "Verifying migration status..."\n\
|
|
||||||
python -m 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 and logs directory
|
|
||||||
RUN useradd -m appuser && \
|
|
||||||
mkdir -p /app/logs && \
|
|
||||||
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"]
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[alembic]
|
[alembic]
|
||||||
script_location = alembic
|
script_location = alembic
|
||||||
sqlalchemy.url = postgresql+asyncpg://postgres:password@db:5432/cycling
|
sqlalchemy.url = sqlite+aiosqlite:///data/cycling_coach.db
|
||||||
|
|
||||||
[loggers]
|
[loggers]
|
||||||
keys = root
|
keys = root
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||||
from alembic import context
|
from alembic import context
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Add app directory to path
|
# Add backend directory to path
|
||||||
sys.path.append(os.getcwd())
|
backend_dir = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(backend_dir))
|
||||||
|
|
||||||
# Import base and models
|
# Import base and models
|
||||||
from app.models.base import Base
|
from backend.app.models.base import Base
|
||||||
from app.config import settings
|
from backend.app.config import settings
|
||||||
|
|
||||||
|
# Import all models to ensure they're registered
|
||||||
|
from backend.app.models import *
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
@@ -19,12 +24,13 @@ target_metadata = Base.metadata
|
|||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
"""Run migrations in 'offline' mode."""
|
"""Run migrations in 'offline' mode."""
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
url = settings.DATABASE_URL
|
||||||
context.configure(
|
context.configure(
|
||||||
url=url,
|
url=url,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
render_as_batch=True, # Important for SQLite
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
@@ -32,21 +38,28 @@ def run_migrations_offline():
|
|||||||
|
|
||||||
async def run_migrations_online():
|
async def run_migrations_online():
|
||||||
"""Run migrations in 'online' mode."""
|
"""Run migrations in 'online' mode."""
|
||||||
connectable = AsyncEngine(
|
# Ensure data directory exists
|
||||||
engine_from_config(
|
data_dir = Path("data")
|
||||||
config.get_section(config.config_ini_section),
|
data_dir.mkdir(exist_ok=True)
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
connectable = create_async_engine(
|
||||||
future=True,
|
settings.DATABASE_URL,
|
||||||
url=settings.DATABASE_URL,
|
poolclass=pool.NullPool,
|
||||||
)
|
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
async with connectable.connect() as connection:
|
async with connectable.connect() as connection:
|
||||||
await connection.run_sync(do_run_migrations)
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
def do_run_migrations(connection):
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
render_as_batch=True, # Important for SQLite ALTER TABLE support
|
||||||
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|||||||
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,11 +1,20 @@
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
DATABASE_URL: str
|
# Database settings
|
||||||
GPX_STORAGE_PATH: str
|
DATABASE_URL: str = "sqlite+aiosqlite:///data/cycling_coach.db"
|
||||||
AI_MODEL: str = "openrouter/auto"
|
|
||||||
API_KEY: str
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
# File storage settings
|
||||||
|
GPX_STORAGE_PATH: str = "data/gpx"
|
||||||
|
|
||||||
|
# AI settings
|
||||||
|
AI_MODEL: str = "deepseek/deepseek-r1"
|
||||||
|
OPENROUTER_API_KEY: str = ""
|
||||||
|
|
||||||
|
# Garmin settings
|
||||||
|
GARMIN_USERNAME: str = ""
|
||||||
|
GARMIN_PASSWORD: str = ""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:password@db:5432/cycling")
|
# Use SQLite database in data directory
|
||||||
|
DATA_DIR = Path("data")
|
||||||
|
DATABASE_PATH = DATA_DIR / "cycling_coach.db"
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite+aiosqlite:///{DATABASE_PATH}")
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
echo=False, # Set to True for SQL debugging
|
||||||
|
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
||||||
|
)
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
|
||||||
AsyncSessionLocal = sessionmaker(
|
AsyncSessionLocal = sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
class_=AsyncSession,
|
class_=AsyncSession,
|
||||||
@@ -15,4 +24,19 @@ Base = declarative_base()
|
|||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
async def get_db() -> AsyncSession:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Initialize the database by creating all tables."""
|
||||||
|
# Ensure data directory exists
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Import all models to ensure they are registered
|
||||||
|
from .models import (
|
||||||
|
user, rule, plan, plan_rule, workout,
|
||||||
|
analysis, route, section, garmin_sync_log, prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create all tables
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.services.ai_service import AIService
|
from backend.app.services.ai_service import AIService
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from .route import Route
|
|||||||
from .section import Section
|
from .section import Section
|
||||||
from .rule import Rule
|
from .rule import Rule
|
||||||
from .plan import Plan
|
from .plan import Plan
|
||||||
from .plan_rule import PlanRule
|
from .plan_rule import plan_rules
|
||||||
from .user import User
|
from .user import User
|
||||||
from .workout import Workout
|
from .workout import Workout
|
||||||
from .analysis import Analysis
|
from .analysis import Analysis
|
||||||
|
|||||||
BIN
backend/app/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/analysis.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/analysis.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/base.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/base.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/garmin_sync_log.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/garmin_sync_log.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/plan.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/plan.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/plan_rule.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/plan_rule.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/prompt.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/prompt.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/route.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/route.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/rule.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/rule.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/section.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/section.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/user.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/user.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/workout.cpython-313.pyc
Normal file
BIN
backend/app/models/__pycache__/workout.cpython-313.pyc
Normal file
Binary file not shown.
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, Boolean, DateTime, func
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, Boolean, DateTime
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ class Analysis(BaseModel):
|
|||||||
suggestions = Column(JSON)
|
suggestions = Column(JSON)
|
||||||
approved = Column(Boolean, default=False)
|
approved = Column(Boolean, default=False)
|
||||||
created_plan_id = Column(Integer, ForeignKey('plans.id'))
|
created_plan_id = Column(Integer, ForeignKey('plans.id'))
|
||||||
approved_at = Column(DateTime(timezone=True), server_default=func.now())
|
approved_at = Column(DateTime, default=datetime.utcnow) # Changed from server_default=func.now()
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
workout = relationship("Workout", back_populates="analyses")
|
workout = relationship("Workout", back_populates="analyses")
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from sqlalchemy import Column, Integer, DateTime
|
||||||
from sqlalchemy import Column, DateTime
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
class BaseModel(Base):
|
class BaseModel(Base):
|
||||||
__abstract__ = True
|
__abstract__ = True
|
||||||
|
|
||||||
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from sqlalchemy import Column, Integer, ForeignKey
|
from sqlalchemy import Column, Integer, ForeignKey, JSON
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
class Plan(BaseModel):
|
class Plan(BaseModel):
|
||||||
__tablename__ = "plans"
|
__tablename__ = "plans"
|
||||||
|
|
||||||
jsonb_plan = Column(JSONB, nullable=False)
|
jsonb_plan = Column(JSON, nullable=False) # Changed from JSONB to JSON for SQLite compatibility
|
||||||
version = Column(Integer, nullable=False)
|
version = Column(Integer, nullable=False)
|
||||||
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
|
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
from sqlalchemy import Column, Integer, ForeignKey
|
from sqlalchemy import Column, Integer, ForeignKey, Table
|
||||||
from sqlalchemy.orm import relationship
|
from .base import Base
|
||||||
from .base import BaseModel
|
|
||||||
|
|
||||||
class PlanRule(BaseModel):
|
# Association table for many-to-many relationship between plans and rules
|
||||||
__tablename__ = "plan_rules"
|
plan_rules = Table(
|
||||||
|
'plan_rules', Base.metadata,
|
||||||
plan_id = Column(Integer, ForeignKey('plans.id'), primary_key=True)
|
Column('plan_id', Integer, ForeignKey('plans.id'), primary_key=True),
|
||||||
rule_id = Column(Integer, ForeignKey('rules.id'), primary_key=True)
|
Column('rule_id', Integer, ForeignKey('rules.id'), primary_key=True)
|
||||||
|
)
|
||||||
plan = relationship("Plan", back_populates="rules")
|
|
||||||
rule = relationship("Rule", back_populates="plans")
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
from .base import BaseModel
|
from sqlalchemy import Column, String
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
from .base import BaseModel
|
||||||
|
|
||||||
class User(BaseModel):
|
class User(BaseModel):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
plans = relationship("Plan", back_populates="user")
|
username = Column(String(100), nullable=False, unique=True)
|
||||||
|
email = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
# Note: Relationship removed as Plan model doesn't have user_id field
|
||||||
|
# plans = relationship("Plan", back_populates="user")
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
from app.models.plan import Plan
|
from backend.app.models.plan import Plan
|
||||||
from app.models.garmin_sync_log import GarminSyncLog
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Query, HTTPException
|
from fastapi import APIRouter, Query, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from app.services.export_service import ExportService
|
from backend.app.services.export_service import ExportService
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.dependencies import verify_api_key
|
from backend.app.dependencies import verify_api_key
|
||||||
from app.services.workout_sync import WorkoutSyncService
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.services.gpx import parse_gpx, store_gpx_file
|
from backend.app.services.gpx import parse_gpx, store_gpx_file
|
||||||
from app.schemas.gpx import RouteCreate, Route as RouteSchema
|
from backend.app.schemas.gpx import RouteCreate, Route as RouteSchema
|
||||||
from app.models import Route
|
from backend.app.models import Route
|
||||||
import os
|
import os
|
||||||
|
|
||||||
router = APIRouter(prefix="/gpx", tags=["GPX Routes"])
|
router = APIRouter(prefix="/gpx", tags=["GPX Routes"])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import PlainTextResponse, JSONResponse
|
from fastapi.responses import PlainTextResponse, JSONResponse
|
||||||
from app.services.health_monitor import HealthMonitor
|
from backend.app.services.health_monitor import HealthMonitor
|
||||||
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Gauge
|
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Gauge
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from app.services.import_service import ImportService
|
from backend.app.services.import_service import ImportService
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.models.plan import Plan as PlanModel
|
from backend.app.models.plan import Plan as PlanModel
|
||||||
from app.models.rule import Rule
|
from backend.app.models.rule import Rule
|
||||||
from app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
|
from backend.app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
|
||||||
from app.dependencies import get_ai_service
|
from backend.app.dependencies import get_ai_service
|
||||||
from app.services.ai_service import AIService
|
from backend.app.services.ai_service import AIService
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.models.prompt import Prompt
|
from backend.app.models.prompt import Prompt
|
||||||
from app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
|
from backend.app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
|
||||||
from app.services.prompt_manager import PromptManager
|
from backend.app.services.prompt_manager import PromptManager
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.models.rule import Rule
|
from backend.app.models.rule import Rule
|
||||||
from app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
|
from backend.app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
|
||||||
from app.dependencies import get_ai_service
|
from backend.app.dependencies import get_ai_service
|
||||||
from app.services.ai_service import AIService
|
from backend.app.services.ai_service import AIService
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
from app.models.analysis import Analysis
|
from backend.app.models.analysis import Analysis
|
||||||
from app.models.garmin_sync_log import GarminSyncLog
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
from app.models.plan import Plan
|
from backend.app.models.plan import Plan
|
||||||
from app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus, WorkoutMetric
|
from backend.app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus, WorkoutMetric
|
||||||
from app.schemas.analysis import Analysis as AnalysisSchema
|
from backend.app.schemas.analysis import Analysis as AnalysisSchema
|
||||||
from app.schemas.plan import Plan as PlanSchema
|
from backend.app.schemas.plan import Plan as PlanSchema
|
||||||
from app.services.workout_sync import WorkoutSyncService
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
from app.services.ai_service import AIService
|
from backend.app.services.ai_service import AIService
|
||||||
from app.services.plan_evolution import PlanEvolutionService
|
from backend.app.services.plan_evolution import PlanEvolutionService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
BIN
backend/app/services/__pycache__/ai_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/ai_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/garmin.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/garmin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/plan_evolution.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/plan_evolution.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/prompt_manager.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/prompt_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/workout_sync.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/workout_sync.cpython-313.pyc
Normal file
Binary file not shown.
@@ -3,8 +3,8 @@ import asyncio
|
|||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
import httpx
|
import httpx
|
||||||
import json
|
import json
|
||||||
from app.services.prompt_manager import PromptManager
|
from backend.app.services.prompt_manager import PromptManager
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import zipfile
|
import zipfile
|
||||||
from app.database import SessionLocal
|
from backend.app.database import SessionLocal
|
||||||
from app.models import Route, Rule, Plan
|
from backend.app.models import Route, Rule, Plan
|
||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import UploadFile, HTTPException
|
from fastapi import UploadFile, HTTPException
|
||||||
import gpxpy
|
import gpxpy
|
||||||
from app.config import settings
|
from backend.app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ from datetime import datetime
|
|||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from app.database import get_db
|
from backend.app.database import get_db
|
||||||
from app.models.garmin_sync_log import GarminSyncLog, SyncStatus
|
from backend.app.models.garmin_sync_log import GarminSyncLog, SyncStatus
|
||||||
import requests
|
import requests
|
||||||
from app.config import settings
|
from backend.app.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def _get_sync_queue_size(self) -> int:
|
def _get_sync_queue_size(self) -> int:
|
||||||
"""Get number of pending sync operations"""
|
"""Get number of pending sync operations"""
|
||||||
from app.models.garmin_sync_log import GarminSyncLog, SyncStatus
|
from backend.app.models.garmin_sync_log import GarminSyncLog, SyncStatus
|
||||||
return GarminSyncLog.query.filter_by(status=SyncStatus.PENDING).count()
|
return GarminSyncLog.query.filter_by(status=SyncStatus.PENDING).count()
|
||||||
|
|
||||||
def _count_pending_analyses(self) -> int:
|
def _count_pending_analyses(self) -> int:
|
||||||
"""Count workouts needing analysis"""
|
"""Count workouts needing analysis"""
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
return Workout.query.filter_by(analysis_status='pending').count()
|
return Workout.query.filter_by(analysis_status='pending').count()
|
||||||
|
|
||||||
def _check_database(self) -> str:
|
def _check_database(self) -> str:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.database import SessionLocal
|
from backend.app.database import SessionLocal
|
||||||
from app.models import Route, Rule, Plan
|
from backend.app.models import Route, Rule, Plan
|
||||||
import shutil
|
import shutil
|
||||||
import logging
|
import logging
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from app.services.ai_service import AIService
|
from backend.app.services.ai_service import AIService
|
||||||
from app.models.analysis import Analysis
|
from backend.app.models.analysis import Analysis
|
||||||
from app.models.plan import Plan
|
from backend.app.models.plan import Plan
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, update, func
|
from sqlalchemy import select, update, func
|
||||||
from app.models.prompt import Prompt
|
from backend.app.models.prompt import Prompt
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, desc
|
from sqlalchemy import select, desc
|
||||||
from app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
from app.models.garmin_sync_log import GarminSyncLog
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
from app.models.garmin_sync_log import GarminSyncLog
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Optional
|
|||||||
backend_dir = Path(__file__).parent.parent
|
backend_dir = Path(__file__).parent.parent
|
||||||
sys.path.insert(0, str(backend_dir))
|
sys.path.insert(0, str(backend_dir))
|
||||||
|
|
||||||
from app.database import get_database_url
|
from backend.app.database import get_database_url
|
||||||
|
|
||||||
class DatabaseManager:
|
class DatabaseManager:
|
||||||
"""Handles database backup and restore operations."""
|
"""Handles database backup and restore operations."""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from alembic import command
|
|||||||
from alembic.migration import MigrationContext
|
from alembic.migration import MigrationContext
|
||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from app.database import get_database_url
|
from backend.app.database import get_database_url
|
||||||
|
|
||||||
class MigrationChecker:
|
class MigrationChecker:
|
||||||
"""Validates migration compatibility and integrity."""
|
"""Validates migration compatibility and integrity."""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from alembic import command
|
|||||||
from alembic.migration import MigrationContext
|
from alembic.migration import MigrationContext
|
||||||
from alembic.script import ScriptDirectory
|
from alembic.script import ScriptDirectory
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from app.database import get_database_url
|
from backend.app.database import get_database_url
|
||||||
|
|
||||||
def get_alembic_config():
|
def get_alembic_config():
|
||||||
"""Get Alembic configuration."""
|
"""Get Alembic configuration."""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from app.main import app
|
from backend.app.main import app
|
||||||
from app.database import get_db, Base
|
from backend.app.database import get_db, Base
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch, MagicMock
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
from app.services.ai_service import AIService, AIServiceError
|
from backend.app.services.ai_service import AIService, AIServiceError
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from app.services.garmin import GarminService
|
from backend.app.services.garmin import GarminService
|
||||||
from app.models.garmin_sync_log import GarminSyncStatus
|
from backend.app.models.garmin_sync_log import GarminSyncStatus
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
from app.services.plan_evolution import PlanEvolutionService
|
from backend.app.services.plan_evolution import PlanEvolutionService
|
||||||
from app.models.plan import Plan
|
from backend.app.models.plan import Plan
|
||||||
from app.models.analysis import Analysis
|
from backend.app.models.analysis import Analysis
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from app.services.workout_sync import WorkoutSyncService
|
from backend.app.services.workout_sync import WorkoutSyncService
|
||||||
from app.models.workout import Workout
|
from backend.app.models.workout import Workout
|
||||||
from app.models.garmin_sync_log import GarminSyncLog
|
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
version: '3.9'
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile.prod
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data/gpx:/app/data/gpx
|
|
||||||
- ./data/sessions:/app/data/sessions
|
|
||||||
- ./data/logs:/app/logs
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "5"
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/cycling
|
|
||||||
- API_KEY=${API_KEY}
|
|
||||||
- GARMIN_USERNAME=${GARMIN_USERNAME}
|
|
||||||
- GARMIN_PASSWORD=${GARMIN_PASSWORD}
|
|
||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
|
||||||
- AI_MODEL=${AI_MODEL}
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "80:80"
|
|
||||||
environment:
|
|
||||||
- REACT_APP_API_URL=http://backend:8000
|
|
||||||
- NODE_ENV=production
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: '0.5'
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
volumes:
|
|
||||||
- gpx-data:/app/data/gpx
|
|
||||||
- garmin-sessions:/app/data/sessions
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=postgresql+asyncpg://postgres:password@db:5432/cycling
|
|
||||||
- GPX_STORAGE_PATH=/app/data/gpx
|
|
||||||
- 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:
|
|
||||||
context: ./frontend
|
|
||||||
args:
|
|
||||||
- REACT_APP_API_URL=http://backend:8000
|
|
||||||
ports:
|
|
||||||
- "8888:80"
|
|
||||||
environment:
|
|
||||||
- REACT_APP_CONTAINER_API_URL=http://backend: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:
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.next
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
coverage
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.vscode
|
|
||||||
*.log
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# Stage 1: Build application
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
# Allow environment variables to be passed at build time
|
|
||||||
ARG REACT_APP_API_URL
|
|
||||||
ENV REACT_APP_API_URL=$REACT_APP_API_URL
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package manifests first for optimal caching
|
|
||||||
COPY package.json package-lock.json* ./
|
|
||||||
|
|
||||||
# Clean cache and install dependencies
|
|
||||||
RUN npm cache clean --force && \
|
|
||||||
export NODE_OPTIONS="--max-old-space-size=1024" && \
|
|
||||||
npm install --omit=dev --legacy-peer-deps
|
|
||||||
|
|
||||||
# Copy source files
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build application with production settings
|
|
||||||
RUN export NODE_OPTIONS="--max-old-space-size=1024" && \
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Stage 2: Production runtime
|
|
||||||
FROM nginx:1.25-alpine
|
|
||||||
|
|
||||||
# Install curl for healthchecks
|
|
||||||
RUN apk add --no-cache curl
|
|
||||||
|
|
||||||
# Create necessary directories and set permissions
|
|
||||||
RUN mkdir -p /var/cache/nginx/client_temp && \
|
|
||||||
mkdir -p /var/run/nginx && \
|
|
||||||
chown -R nginx:nginx /usr/share/nginx/html && \
|
|
||||||
chown -R nginx:nginx /var/cache/nginx && \
|
|
||||||
chown -R nginx:nginx /var/run/nginx && \
|
|
||||||
chmod -R 755 /usr/share/nginx/html
|
|
||||||
|
|
||||||
# Copy build artifacts
|
|
||||||
COPY --from=builder /app/.next /usr/share/nginx/html/_next
|
|
||||||
|
|
||||||
# Copy nginx configuration
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Copy Next.js routes manifest for proper routing
|
|
||||||
COPY --from=builder /app/.next/routes-manifest.json /usr/share/nginx/html/_next/
|
|
||||||
|
|
||||||
# Copy main HTML files to root
|
|
||||||
COPY --from=builder /app/.next/server/pages/index.html /usr/share/nginx/html/index.html
|
|
||||||
COPY --from=builder /app/.next/server/pages/404.html /usr/share/nginx/html/404.html
|
|
||||||
|
|
||||||
# Modify nginx config to use custom PID path
|
|
||||||
RUN sed -i 's|pid /var/run/nginx.pid;|pid /var/run/nginx/nginx.pid;|' /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# Healthcheck
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl --fail http://localhost:80 || exit 1
|
|
||||||
|
|
||||||
# Run as root to avoid permission issues
|
|
||||||
# USER nginx
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
['next/babel', {
|
|
||||||
'preset-react': {
|
|
||||||
runtime: 'automatic',
|
|
||||||
importSource: '@emotion/react'
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
collectCoverage: true,
|
|
||||||
coverageDirectory: "coverage",
|
|
||||||
coverageReporters: ["text", "lcov"],
|
|
||||||
coveragePathIgnorePatterns: [
|
|
||||||
"/node_modules/",
|
|
||||||
"/.next/",
|
|
||||||
"/__tests__/",
|
|
||||||
"jest.config.js"
|
|
||||||
],
|
|
||||||
testEnvironment: "jest-environment-jsdom",
|
|
||||||
moduleNameMapper: {
|
|
||||||
"^@/(.*)$": "<rootDir>/src/$1"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
5
frontend/next-env.d.ts
vendored
5
frontend/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
worker_processes auto;
|
|
||||||
|
|
||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
sendfile on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
|
|
||||||
# Cache control for static assets
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Next.js specific routes
|
|
||||||
location /_next/ {
|
|
||||||
alias /usr/share/nginx/html/_next/;
|
|
||||||
expires 365d;
|
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
location /healthz {
|
|
||||||
access_log off;
|
|
||||||
return 200 'ok';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10048
frontend/package-lock.json
generated
10048
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"test:coverage": "jest --coverage"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@tmcw/togeojson": "^7.1.2",
|
|
||||||
"axios": "^1.7.2",
|
|
||||||
"date-fns": "^3.6.0",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"next": "14.2.3",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"react-hook-form": "^7.45.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-json-tree": "^0.20.0",
|
|
||||||
"react-leaflet": "^4.2.1",
|
|
||||||
"react-select": "^5.7.4",
|
|
||||||
"react-toastify": "^10.0.4",
|
|
||||||
"recharts": "2.8.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@testing-library/jest-dom": "6.4.2",
|
|
||||||
"@testing-library/react": "14.2.1",
|
|
||||||
"@testing-library/user-event": "14.5.2",
|
|
||||||
"@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",
|
|
||||||
"jest": "29.7.0",
|
|
||||||
"jest-environment-jsdom": "29.7.0",
|
|
||||||
"typescript": "5.3.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import GarminSync from '../src/components/garmin/GarminSync';
|
|
||||||
import WorkoutChart from '../src/components/analysis/WorkoutCharts';
|
|
||||||
import PlanTimeline from '../src/components/plans/PlanTimeline';
|
|
||||||
import { useAuth } from '../src/context/AuthContext';
|
|
||||||
import LoadingSpinner from '../src/components/LoadingSpinner';
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
|
||||||
const { apiKey, loading: apiLoading } = useAuth();
|
|
||||||
const isBuildTime = typeof window === 'undefined';
|
|
||||||
const [recentWorkouts, setRecentWorkouts] = useState([]);
|
|
||||||
const [currentPlan, setCurrentPlan] = useState(null);
|
|
||||||
const [stats, setStats] = useState({ totalWorkouts: 0, totalDistance: 0 });
|
|
||||||
const [healthStatus, setHealthStatus] = useState(null);
|
|
||||||
const [localLoading, setLocalLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchDashboardData = async () => {
|
|
||||||
try {
|
|
||||||
const [workoutsRes, planRes, statsRes, healthRes] = await Promise.all([
|
|
||||||
fetch(`${process.env.REACT_APP_API_URL}/api/workouts?limit=3`, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
}),
|
|
||||||
fetch(`${process.env.REACT_APP_API_URL}/api/plans/active`, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
}),
|
|
||||||
fetch(`${process.env.REACT_APP_API_URL}/api/stats`, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
}),
|
|
||||||
fetch(`${process.env.REACT_APP_API_URL}/api/health`, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const errors = [];
|
|
||||||
if (!workoutsRes.ok) errors.push('Failed to fetch workouts');
|
|
||||||
if (!planRes.ok) errors.push('Failed to fetch plan');
|
|
||||||
if (!statsRes.ok) errors.push('Failed to fetch stats');
|
|
||||||
if (!healthRes.ok) errors.push('Failed to fetch health status');
|
|
||||||
|
|
||||||
if (errors.length > 0) throw new Error(errors.join(', '));
|
|
||||||
|
|
||||||
const [workoutsData, planData, statsData, healthData] = await Promise.all([
|
|
||||||
workoutsRes.json(),
|
|
||||||
planRes.json(),
|
|
||||||
statsRes.json(),
|
|
||||||
healthRes.json()
|
|
||||||
]);
|
|
||||||
|
|
||||||
setRecentWorkouts(workoutsData.workouts || []);
|
|
||||||
setCurrentPlan(planData);
|
|
||||||
setStats(statsData.workouts || { totalWorkouts: 0, totalDistance: 0 });
|
|
||||||
setHealthStatus(healthData);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLocalLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchDashboardData();
|
|
||||||
}, [apiKey]);
|
|
||||||
|
|
||||||
if (isBuildTime) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-7xl mx-auto">
|
|
||||||
<h1 className="text-3xl font-bold">Training Dashboard</h1>
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<p className="text-gray-600">Loading dashboard data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localLoading || apiLoading) return <LoadingSpinner />;
|
|
||||||
if (error) return <div className="p-6 text-red-500">{error}</div>;
|
|
||||||
|
|
||||||
// Calculate total distance in km
|
|
||||||
const totalDistanceKm = (stats.totalDistance / 1000).toFixed(0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-7xl mx-auto space-y-8">
|
|
||||||
<h1 className="text-3xl font-bold">Training Dashboard</h1>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<GarminSync apiKey={apiKey} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Summary Cards */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">Total Workouts</h3>
|
|
||||||
<p className="text-2xl font-bold">{stats.totalWorkouts}</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">Total Distance</h3>
|
|
||||||
<p className="text-2xl font-bold">{totalDistanceKm} km</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">Current Plan</h3>
|
|
||||||
<p className="text-2xl font-bold">
|
|
||||||
{currentPlan ? `v${currentPlan.version}` : 'None'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
|
||||||
<h3 className="text-sm font-medium text-gray-600">System Status</h3>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`w-3 h-3 rounded-full mr-2 ${
|
|
||||||
healthStatus?.status === 'healthy' ? 'bg-green-500' : 'bg-red-500'
|
|
||||||
}`}></div>
|
|
||||||
<span className="text-xl font-bold capitalize">
|
|
||||||
{healthStatus?.status || 'unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
|
|
||||||
<div className="sm:col-span-2 space-y-3 sm:space-y-4 lg:space-y-6">
|
|
||||||
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Performance Metrics</h2>
|
|
||||||
<WorkoutChart workouts={recentWorkouts} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Recent Activities</h2>
|
|
||||||
{recentWorkouts.length > 0 ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{recentWorkouts.map(workout => (
|
|
||||||
<div key={workout.id} className="p-3 sm:p-4 border rounded-lg hover:bg-gray-50">
|
|
||||||
<div className="flex justify-between items-center gap-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm sm:text-base font-medium">{new Date(workout.start_time).toLocaleDateString()}</h3>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600">{workout.activity_type}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm sm:text-base font-medium">{(workout.distance_m / 1000).toFixed(1)} km</p>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600">{Math.round(workout.duration_seconds / 60)} mins</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500 text-center py-4">No recent activities found</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Current Plan</h2>
|
|
||||||
{currentPlan ? (
|
|
||||||
<PlanTimeline plan={currentPlan} />
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-500 text-center py-4">No active training plan</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-4 sm:p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-lg sm:text-xl font-semibold mb-3 sm:mb-4">Upcoming Workouts</h2>
|
|
||||||
{currentPlan?.jsonb_plan.weeks[0]?.workouts.map((workout, index) => (
|
|
||||||
<div key={index} className="p-2 sm:p-3 border-b last:border-b-0">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="capitalize">{workout.day}</span>
|
|
||||||
<span className="text-xs sm:text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
|
||||||
{workout.type.replace('_', ' ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs sm:text-sm text-gray-600 mt-1">{workout.description}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Dashboard;
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useRouter } from 'next/router'
|
|
||||||
import PlanTimeline from '../src/components/PlanTimeline'
|
|
||||||
|
|
||||||
const PlanDetails = () => {
|
|
||||||
const router = useRouter()
|
|
||||||
const { planId } = router.query
|
|
||||||
|
|
||||||
// If the planId is not available yet (still loading), show a loading state
|
|
||||||
if (!planId) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="h-12 bg-gray-100 rounded-lg"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 p-4 md:p-6">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<PlanTimeline planId={planId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PlanDetails
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useAuth } from '../src/context/AuthContext';
|
|
||||||
import GoalSelector from '../src/components/plans/GoalSelector';
|
|
||||||
import PlanParameters from '../src/components/plans/PlanParameters';
|
|
||||||
import { generatePlan } from '../src/services/planService';
|
|
||||||
import ProgressTracker from '../src/components/ui/ProgressTracker';
|
|
||||||
|
|
||||||
const PlanGeneration = () => {
|
|
||||||
const { apiKey } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [goals, setGoals] = useState([]);
|
|
||||||
const [rules, setRules] = useState([]);
|
|
||||||
const [params, setParams] = useState({
|
|
||||||
duration: 4,
|
|
||||||
weeklyHours: 8,
|
|
||||||
availableDays: []
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const plan = await generatePlan(apiKey, {
|
|
||||||
goals,
|
|
||||||
ruleIds: rules,
|
|
||||||
...params
|
|
||||||
});
|
|
||||||
router.push(`/plans/${plan.id}/preview`);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to generate plan. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
|
||||||
<ProgressTracker currentStep={step} totalSteps={3} />
|
|
||||||
|
|
||||||
{step === 1 && (
|
|
||||||
<GoalSelector
|
|
||||||
goals={goals}
|
|
||||||
onSelect={setGoals}
|
|
||||||
onNext={() => setStep(2)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<PlanParameters
|
|
||||||
values={params}
|
|
||||||
onChange={setParams}
|
|
||||||
onBack={() => setStep(1)}
|
|
||||||
onNext={() => setStep(3)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Review and Generate</h2>
|
|
||||||
<div className="mb-6">
|
|
||||||
<h3 className="font-semibold mb-2">Selected Goals:</h3>
|
|
||||||
<ul className="list-disc pl-5">
|
|
||||||
{goals.map((goal, index) => (
|
|
||||||
<li key={index}>{goal}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={loading}
|
|
||||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
{loading ? 'Generating...' : 'Generate Plan'}
|
|
||||||
</button>
|
|
||||||
{error && <p className="text-red-500 mt-2">{error}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlanGeneration;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const Plans = () => (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold mb-4">Training Plans</h1>
|
|
||||||
<p className="text-gray-600">Training plans page under development</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Plans;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useAuth } from '../src/context/AuthContext';
|
|
||||||
import FileUpload from '../src/components/routes/FileUpload';
|
|
||||||
import RouteList from '../src/components/routes/RouteList';
|
|
||||||
import RouteFilter from '../src/components/routes/RouteFilter';
|
|
||||||
import LoadingSpinner from '../src/components/LoadingSpinner';
|
|
||||||
|
|
||||||
const RoutesPage = () => {
|
|
||||||
const { apiKey } = useAuth();
|
|
||||||
const [routes, setRoutes] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
searchQuery: '',
|
|
||||||
minDistance: 0,
|
|
||||||
maxDistance: 500,
|
|
||||||
difficulty: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRoutes = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/routes', {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch routes');
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setRoutes(data.routes);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRoutes();
|
|
||||||
}, [apiKey]);
|
|
||||||
|
|
||||||
const filteredRoutes = routes.filter(route => {
|
|
||||||
const matchesSearch = route.name.toLowerCase().includes(filters.searchQuery.toLowerCase());
|
|
||||||
const matchesDistance = route.distance >= filters.minDistance &&
|
|
||||||
route.distance <= filters.maxDistance;
|
|
||||||
const matchesDifficulty = filters.difficulty === 'all' ||
|
|
||||||
route.difficulty === filters.difficulty;
|
|
||||||
return matchesSearch && matchesDistance && matchesDifficulty;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleUploadSuccess = (newRoute) => {
|
|
||||||
setRoutes(prev => [...prev, newRoute]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-7xl mx-auto">
|
|
||||||
<h1 className="text-3xl font-bold mb-8">Routes</h1>
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<RouteFilter filters={filters} onFilterChange={setFilters} />
|
|
||||||
<FileUpload onUploadSuccess={handleUploadSuccess} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<LoadingSpinner />
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-red-600 bg-red-50 p-4 rounded-md">{error}</div>
|
|
||||||
) : (
|
|
||||||
<RouteList routes={filteredRoutes} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoutesPage;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const Rules = () => (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold mb-4">Training Rules</h1>
|
|
||||||
<p className="text-gray-600">Training rules page under development</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Rules;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const Workouts = () => (
|
|
||||||
<div className="p-6">
|
|
||||||
<h1 className="text-2xl font-bold mb-4">Workouts</h1>
|
|
||||||
<p className="text-gray-600">Workouts page under development</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Workouts;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { AuthProvider } from '../src/context/AuthContext';
|
|
||||||
|
|
||||||
function MyApp({ Component, pageProps }) {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default MyApp;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Redirect to dashboard
|
|
||||||
router.push('/dashboard');
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return null; // or a loading spinner
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
import '@testing-library/jest-dom/extend-expect';
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { AuthProvider } from './context/AuthContext';
|
|
||||||
import Home from './pages/index';
|
|
||||||
import Navigation from './components/Navigation';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<Navigation />
|
|
||||||
<main className="p-4">
|
|
||||||
<Home />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
class ErrorBoundary extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
|
||||||
console.error('Error caught by boundary:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
|
|
||||||
<h2 className="font-bold">Something went wrong</h2>
|
|
||||||
<p>{this.state.error.message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
const FileUpload = ({ onUpload, acceptedTypes = ['.gpx'] }) => {
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [files, setFiles] = useState([]);
|
|
||||||
|
|
||||||
const handleDragOver = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setIsDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragging(false);
|
|
||||||
handleFiles(e.dataTransfer.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileInput = (e) => {
|
|
||||||
handleFiles(e.target.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFiles = (newFiles) => {
|
|
||||||
const validFiles = Array.from(newFiles).map(file => {
|
|
||||||
const fileExt = file.name.split('.').pop().toLowerCase();
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!acceptedTypes.includes(`.${fileExt}`)) {
|
|
||||||
return { file, error: 'Invalid file type', progress: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
return { file, error: 'File too large', progress: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { file, progress: 0, error: null };
|
|
||||||
});
|
|
||||||
|
|
||||||
setFiles(prev => [...prev, ...validFiles]);
|
|
||||||
uploadFiles(validFiles.filter(f => !f.error));
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFiles = async (filesToUpload) => {
|
|
||||||
for (const fileObj of filesToUpload) {
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('files', fileObj.file);
|
|
||||||
|
|
||||||
const response = await fetch('/api/routes/upload', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Upload failed');
|
|
||||||
|
|
||||||
setFiles(prev => prev.map(f =>
|
|
||||||
f.file === fileObj.file ? { ...f, progress: 100 } : f
|
|
||||||
));
|
|
||||||
|
|
||||||
toast.success(`${fileObj.file.name} uploaded successfully`);
|
|
||||||
|
|
||||||
if (onUpload) onUpload(fileObj.file);
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
setFiles(prev => prev.map(f =>
|
|
||||||
f.file === fileObj.file ? { ...f, error: err.message } : f
|
|
||||||
));
|
|
||||||
toast.error(`${fileObj.file.name} upload failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseGPXMetadata = (content) => {
|
|
||||||
try {
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const xmlDoc = parser.parseFromString(content, "text/xml");
|
|
||||||
return {
|
|
||||||
name: xmlDoc.getElementsByTagName('name')[0]?.textContent || 'Unnamed Route',
|
|
||||||
distance: xmlDoc.getElementsByTagName('distance')[0]?.textContent || 'N/A',
|
|
||||||
elevation: xmlDoc.getElementsByTagName('ele')[0]?.textContent || 'N/A'
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (fileName) => {
|
|
||||||
setFiles(prev => prev.filter(f => f.file.name !== fileName));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
|
||||||
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400'
|
|
||||||
}`}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => document.getElementById('file-input').click()}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="file-input"
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept={acceptedTypes.join(',')}
|
|
||||||
onChange={handleFileInput}
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<svg className="h-10 w-10 text-gray-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
||||||
</svg>
|
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
|
||||||
<span className="font-medium text-blue-600">Click to upload</span> or drag and drop
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{acceptedTypes.join(', ')} files, max 10MB each
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{files.map((fileObj, index) => (
|
|
||||||
<div key={index} className="p-4 border rounded-lg bg-white">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="truncate">
|
|
||||||
<span className="font-medium">{fileObj.file.name}</span>
|
|
||||||
{fileObj.error && (
|
|
||||||
<span className="text-red-500 text-sm ml-2">- {fileObj.error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => removeFile(fileObj.file.name)}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{!fileObj.error && (
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${fileObj.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileUpload;
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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 {
|
|
||||||
// Check API key configuration
|
|
||||||
if (!process.env.REACT_APP_API_KEY) {
|
|
||||||
throw new Error('API key missing - check environment configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch('/api/workouts/sync', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-API-Key': process.env.REACT_APP_API_KEY,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="garmin-sync bg-gray-50 p-4 rounded-lg shadow">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-3">Garmin Connect Sync</h3>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={triggerSync}
|
|
||||||
disabled={syncing}
|
|
||||||
className={`px-4 py-2 rounded-md font-medium ${
|
|
||||||
syncing ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
} text-white transition-colors`}
|
|
||||||
>
|
|
||||||
{syncing ? (
|
|
||||||
<span className="flex items-center">
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Syncing...
|
|
||||||
</span>
|
|
||||||
) : 'Sync Recent Activities'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-3 p-2 bg-red-50 text-red-700 rounded-md">
|
|
||||||
Error: {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{syncStatus && (
|
|
||||||
<div className="mt-4 p-3 bg-white rounded-md border border-gray-200">
|
|
||||||
<h4 className="font-medium text-gray-700 mb-2">Sync Status</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="text-gray-600">Last sync:</div>
|
|
||||||
<div className="text-gray-800">
|
|
||||||
{syncStatus.last_sync_time
|
|
||||||
? new Date(syncStatus.last_sync_time).toLocaleString()
|
|
||||||
: 'Never'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-gray-600">Status:</div>
|
|
||||||
<div className={`font-medium ${
|
|
||||||
syncStatus.status === 'success' ? 'text-green-600' :
|
|
||||||
syncStatus.status === 'error' ? 'text-red-600' : 'text-blue-600'
|
|
||||||
}`}>
|
|
||||||
{syncStatus.status}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncStatus.activities_synced > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="text-gray-600">Activities synced:</div>
|
|
||||||
<div className="text-gray-800">{syncStatus.activities_synced}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-gray-600">Last Updated:</div>
|
|
||||||
<div className="text-gray-800">
|
|
||||||
{syncStatus.last_sync_time
|
|
||||||
? new Date(syncStatus.last_sync_time).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: true
|
|
||||||
})
|
|
||||||
: 'Never'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{syncStatus.activities_synced > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="text-gray-600">New Activities:</div>
|
|
||||||
<div className="text-green-600 font-medium">{syncStatus.activities_synced}</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{syncStatus.warnings?.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="text-gray-600">Warnings:</div>
|
|
||||||
<div className="text-yellow-600 text-sm">
|
|
||||||
{syncStatus.warnings.join(', ')}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GarminSync;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const LoadingSpinner = () => (
|
|
||||||
<div className="flex justify-center items-center h-screen">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default LoadingSpinner;
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const Navigation = () => {
|
|
||||||
return (
|
|
||||||
<nav className="bg-white shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-3">
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/workouts"
|
|
||||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Workouts
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/plans"
|
|
||||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Plans
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/rules"
|
|
||||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Rules
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/routes"
|
|
||||||
className="text-gray-700 hover:text-blue-600 px-3 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Routes
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Navigation;
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
const PlanTimeline = ({ planId }) => {
|
|
||||||
const [planData, setPlanData] = useState(null)
|
|
||||||
const [versionHistory, setVersionHistory] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPlanData = async () => {
|
|
||||||
try {
|
|
||||||
const [planRes, historyRes] = await Promise.all([
|
|
||||||
fetch(`/api/plans/${planId}`),
|
|
||||||
fetch(`/api/plans/${planId}/evolution`)
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!planRes.ok || !historyRes.ok) {
|
|
||||||
throw new Error('Failed to load plan data')
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = await planRes.json()
|
|
||||||
const history = await historyRes.json()
|
|
||||||
|
|
||||||
setPlanData(plan)
|
|
||||||
setVersionHistory(history.evolution_history || [])
|
|
||||||
setError(null)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Plan load error:', err)
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchPlanData()
|
|
||||||
}, [planId])
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[...Array(3)].map((_, i) => (
|
|
||||||
<div key={i} className="h-12 bg-gray-100 rounded-lg"></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 text-red-600">
|
|
||||||
Error loading plan: {error}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="plan-timeline p-4 bg-white rounded-lg shadow">
|
|
||||||
{/* Current Plan Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-2xl font-semibold">{planData.jsonb_plan.overview.focus} Training Plan</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{planData.jsonb_plan.overview.duration_weeks} weeks •{' '}
|
|
||||||
{planData.jsonb_plan.overview.total_weekly_hours} hours/week
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Week Timeline */}
|
|
||||||
<div className="space-y-8">
|
|
||||||
{planData.jsonb_plan.weeks.map((week, index) => (
|
|
||||||
<div key={index} className="relative pl-6 border-l-2 border-gray-200">
|
|
||||||
<div className="absolute w-4 h-4 bg-blue-500 rounded-full -left-[9px] top-0"></div>
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="text-lg font-semibold">Week {week.week_number}</h3>
|
|
||||||
<p className="text-gray-600">{week.focus}</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{week.workouts.map((workout, wIndex) => (
|
|
||||||
<div key={wIndex} className="p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">{workout.type.replace(/_/g, ' ')}</h4>
|
|
||||||
<p className="text-sm text-gray-600">{workout.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-gray-900">{workout.duration_minutes} minutes</p>
|
|
||||||
<p className="text-sm text-gray-500 capitalize">{workout.intensity}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Version History */}
|
|
||||||
{versionHistory.length > 0 && (
|
|
||||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Version History</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{versionHistory.map((version, index) => (
|
|
||||||
<div key={index} className="p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium">Version {version.version}</h4>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{new Date(version.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
{version.changes_summary && (
|
|
||||||
<p className="text-sm mt-2 text-gray-600">
|
|
||||||
{version.changes_summary}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/plans/${version.parent_plan_id}`}
|
|
||||||
className="text-blue-500 hover:text-blue-700 text-sm"
|
|
||||||
>
|
|
||||||
View →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PlanTimeline
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
|
|
||||||
const WorkoutAnalysis = ({ workout, analysis }) => {
|
|
||||||
const [approving, setApproving] = useState(false);
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
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
|
|
||||||
router.push(`/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 (
|
|
||||||
<div className="workout-analysis bg-white rounded-lg shadow-md p-5">
|
|
||||||
<div className="workout-summary border-b border-gray-200 pb-4 mb-4">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-800">
|
|
||||||
{workout.activity_type || 'Cycling'} - {new Date(workout.start_time).toLocaleDateString()}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 text-sm">
|
|
||||||
<div className="metric-card">
|
|
||||||
<span className="text-gray-500">Duration</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.round(workout.duration_seconds / 60)} min
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="metric-card">
|
|
||||||
<span className="text-gray-500">Distance</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{(workout.distance_m / 1000).toFixed(1)} km
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{workout.avg_power && (
|
|
||||||
<div className="metric-card">
|
|
||||||
<span className="text-gray-500">Avg Power</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.round(workout.avg_power)}W
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{workout.avg_hr && (
|
|
||||||
<div className="metric-card">
|
|
||||||
<span className="text-gray-500">Avg HR</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.round(workout.avg_hr)} bpm
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{analysis && (
|
|
||||||
<div className="analysis-content">
|
|
||||||
<h4 className="text-lg font-medium text-gray-800 mb-3">AI Analysis</h4>
|
|
||||||
|
|
||||||
<div className="feedback-box bg-blue-50 p-4 rounded-md mb-5">
|
|
||||||
<p className="text-gray-700">{analysis.jsonb_feedback.summary}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="strengths-improvement grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
|
||||||
<div className="strengths">
|
|
||||||
<h5 className="font-medium text-green-700 mb-2">Strengths</h5>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
{analysis.jsonb_feedback.strengths.map((strength, index) => (
|
|
||||||
<li key={index} className="text-gray-700">{strength}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="improvements">
|
|
||||||
<h5 className="font-medium text-orange-600 mb-2">Areas for Improvement</h5>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
{analysis.jsonb_feedback.areas_for_improvement.map((area, index) => (
|
|
||||||
<li key={index} className="text-gray-700">{area}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{analysis.suggestions && analysis.suggestions.length > 0 && (
|
|
||||||
<div className="suggestions bg-yellow-50 p-4 rounded-md mb-5">
|
|
||||||
<h5 className="font-medium text-gray-800 mb-3">Training Suggestions</h5>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{analysis.suggestions.map((suggestion, index) => (
|
|
||||||
<li key={index} className="flex items-start">
|
|
||||||
<span className="inline-block w-6 h-6 bg-yellow-100 text-yellow-800 rounded-full text-center mr-2 flex-shrink-0">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-700">{suggestion}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{!analysis.approved && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
onClick={approveAnalysis}
|
|
||||||
disabled={approving}
|
|
||||||
className={`px-4 py-2 rounded-md font-medium ${
|
|
||||||
approving ? 'bg-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700'
|
|
||||||
} text-white transition-colors flex items-center`}
|
|
||||||
>
|
|
||||||
{approving ? (
|
|
||||||
<>
|
|
||||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Applying suggestions...
|
|
||||||
</>
|
|
||||||
) : 'Approve & Update Training Plan'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-2 text-red-600 text-sm">
|
|
||||||
Error: {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkoutAnalysis;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className="workout-charts bg-white p-4 rounded-lg shadow-md">
|
|
||||||
<h3 className="text-lg font-medium text-gray-800 mb-4">Workout Metrics</h3>
|
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<LineChart
|
|
||||||
data={formattedData}
|
|
||||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
|
||||||
>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="time"
|
|
||||||
label={{
|
|
||||||
value: 'Time (minutes)',
|
|
||||||
position: 'insideBottomRight',
|
|
||||||
offset: -5
|
|
||||||
}}
|
|
||||||
domain={['dataMin', 'dataMax']}
|
|
||||||
tickCount={6}
|
|
||||||
/>
|
|
||||||
<YAxis yAxisId="left" orientation="left" stroke="#8884d8">
|
|
||||||
<Label value="HR (bpm) / Cadence (rpm)" angle={-90} position="insideLeft" />
|
|
||||||
</YAxis>
|
|
||||||
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d">
|
|
||||||
<Label value="Power (W)" angle={90} position="insideRight" />
|
|
||||||
</YAxis>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value, name) => [`${value} ${name === 'power' ? 'W' : name === 'heart_rate' ? 'bpm' : 'rpm'}`, name]}
|
|
||||||
labelFormatter={(value) => `Time: ${value.toFixed(1)} min`}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="heart_rate"
|
|
||||||
name="Heart Rate"
|
|
||||||
stroke="#8884d8"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="right"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="power"
|
|
||||||
name="Power"
|
|
||||||
stroke="#82ca9d"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
yAxisId="left"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cadence"
|
|
||||||
name="Cadence"
|
|
||||||
stroke="#ffc658"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6 }}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
|
|
||||||
<div className="mt-4 text-sm text-gray-500">
|
|
||||||
<p>Note: Charts show metrics over time during the workout. Hover over points to see exact values.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkoutCharts;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react'
|
|
||||||
import FileUpload from '../FileUpload'
|
|
||||||
|
|
||||||
describe('FileUpload Component', () => {
|
|
||||||
test('renders upload button', () => {
|
|
||||||
render(<FileUpload onUpload={() => {}} />)
|
|
||||||
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(<FileUpload onUpload={mockOnUpload} />)
|
|
||||||
|
|
||||||
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(<FileUpload onUpload={() => {}} />)
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import LoadingSpinner from '../LoadingSpinner';
|
|
||||||
|
|
||||||
describe('LoadingSpinner Component', () => {
|
|
||||||
test('renders spinner with animation', () => {
|
|
||||||
render(<LoadingSpinner />);
|
|
||||||
|
|
||||||
// Check for the spinner container
|
|
||||||
const spinnerContainer = screen.getByRole('status');
|
|
||||||
expect(spinnerContainer).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify animation classes
|
|
||||||
const spinnerElement = screen.getByTestId('loading-spinner');
|
|
||||||
expect(spinnerElement).toHaveClass('animate-spin');
|
|
||||||
expect(spinnerElement).toHaveClass('rounded-full');
|
|
||||||
|
|
||||||
// Check accessibility attributes
|
|
||||||
expect(spinnerElement).toHaveAttribute('aria-live', 'polite');
|
|
||||||
expect(spinnerElement).toHaveAttribute('aria-busy', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matches snapshot', () => {
|
|
||||||
const { asFragment } = render(<LoadingSpinner />);
|
|
||||||
expect(asFragment()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import WorkoutMetrics from './WorkoutMetrics';
|
|
||||||
|
|
||||||
const WorkoutAnalysis = ({ workoutId }) => {
|
|
||||||
const { apiKey } = useAuth();
|
|
||||||
const [analysis, setAnalysis] = useState(null);
|
|
||||||
const [isApproving, setIsApproving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAnalysis = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`/api/analyses/${workoutId}`, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
setAnalysis(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching analysis:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (workoutId) {
|
|
||||||
fetchAnalysis();
|
|
||||||
}
|
|
||||||
}, [workoutId, apiKey]);
|
|
||||||
|
|
||||||
const handleApprove = async () => {
|
|
||||||
setIsApproving(true);
|
|
||||||
try {
|
|
||||||
await axios.post(`/api/analyses/${analysis.id}/approve`, {}, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
// Refresh analysis data
|
|
||||||
const response = await axios.get(`/api/analyses/${analysis.id}`, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
setAnalysis(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Approval failed:', error);
|
|
||||||
}
|
|
||||||
setIsApproving(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!analysis) return <div>Loading analysis...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<h3 className="text-xl font-semibold mb-4">
|
|
||||||
{analysis.workout.activity_type} Analysis
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<WorkoutMetrics workout={analysis.workout} />
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h4 className="font-medium mb-2">AI Feedback</h4>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md">
|
|
||||||
<p className="mb-3">{analysis.jsonb_feedback.summary}</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h5 className="font-medium">Strengths</h5>
|
|
||||||
<ul className="list-disc pl-5">
|
|
||||||
{analysis.jsonb_feedback.strengths.map((s, i) => (
|
|
||||||
<li key={i} className="text-green-700">{s}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-medium">Improvements</h5>
|
|
||||||
<ul className="list-disc pl-5">
|
|
||||||
{analysis.jsonb_feedback.areas_for_improvement.map((s, i) => (
|
|
||||||
<li key={i} className="text-orange-700">{s}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{analysis.suggestions?.length > 0 && !analysis.approved && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<button
|
|
||||||
onClick={handleApprove}
|
|
||||||
disabled={isApproving}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
{isApproving ? 'Approving...' : 'Approve Suggestions'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<WorkoutCharts metrics={analysis.workout.metrics} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkoutAnalysis;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
const WorkoutCharts = ({ metrics }) => {
|
|
||||||
if (!metrics?.time_series?.length) return null;
|
|
||||||
|
|
||||||
// Process metrics data for charting
|
|
||||||
const chartData = metrics.time_series.map(entry => ({
|
|
||||||
time: new Date(entry.start_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
||||||
power: entry.avg_power,
|
|
||||||
heartRate: entry.avg_heart_rate,
|
|
||||||
cadence: entry.avg_cadence
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6 space-y-6">
|
|
||||||
<div className="h-64">
|
|
||||||
<h4 className="font-medium mb-2">Power Output</h4>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" />
|
|
||||||
<YAxis unit="W" />
|
|
||||||
<Tooltip />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="power"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-64">
|
|
||||||
<h4 className="font-medium mb-2">Heart Rate</h4>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" />
|
|
||||||
<YAxis unit="bpm" />
|
|
||||||
<Tooltip />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="heartRate"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-64">
|
|
||||||
<h4 className="font-medium mb-2">Cadence</h4>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" />
|
|
||||||
<YAxis unit="rpm" />
|
|
||||||
<Tooltip />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cadence"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkoutCharts;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
const WorkoutMetrics = ({ workout }) => {
|
|
||||||
const formatDuration = (seconds) => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
return `${mins} minutes`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md">
|
|
||||||
<p className="text-sm text-gray-600">Duration</p>
|
|
||||||
<p className="text-lg font-medium">
|
|
||||||
{formatDuration(workout.duration_seconds)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md">
|
|
||||||
<p className="text-sm text-gray-600">Distance</p>
|
|
||||||
<p className="text-lg font-medium">
|
|
||||||
{(workout.distance_m / 1000).toFixed(1)} km
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md">
|
|
||||||
<p className="text-sm text-gray-600">Avg Power</p>
|
|
||||||
<p className="text-lg font-medium">
|
|
||||||
{workout.avg_power?.toFixed(0) || '-'} W
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md">
|
|
||||||
<p className="text-sm text-gray-600">Avg HR</p>
|
|
||||||
<p className="text-lg font-medium">
|
|
||||||
{workout.avg_hr?.toFixed(0) || '-'} bpm
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-md">
|
|
||||||
<p className="text-sm text-gray-600">Elevation</p>
|
|
||||||
<p className="text-lg font-medium">
|
|
||||||
{workout.elevation_gain_m?.toFixed(0) || '-'} m
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkoutMetrics;
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import axios from 'axios';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
|
|
||||||
const GarminSync = ({ apiKey }) => {
|
|
||||||
const [syncStatus, setSyncStatus] = useState(null);
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const fetchSyncStatus = async () => {
|
|
||||||
try {
|
|
||||||
const response = await axios.get('/api/workouts/sync-status', {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
setSyncStatus(response.data);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to fetch sync status');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerSync = async () => {
|
|
||||||
setIsSyncing(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await axios.post('/api/workouts/sync', {}, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
// Poll status every 2 seconds
|
|
||||||
const interval = setInterval(fetchSyncStatus, 2000);
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(interval);
|
|
||||||
setIsSyncing(false);
|
|
||||||
}, 30000);
|
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to start sync');
|
|
||||||
setIsSyncing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSyncStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold">Garmin Connect Sync</h2>
|
|
||||||
<button
|
|
||||||
onClick={triggerSync}
|
|
||||||
disabled={isSyncing}
|
|
||||||
className={`px-4 py-2 rounded-md ${
|
|
||||||
isSyncing ? 'bg-gray-300' : 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
} text-white transition-colors`}
|
|
||||||
>
|
|
||||||
{isSyncing ? 'Syncing...' : 'Sync Now'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{syncStatus && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm">
|
|
||||||
Last sync: {syncStatus.last_sync_time ?
|
|
||||||
formatDistanceToNow(new Date(syncStatus.last_sync_time)) + ' ago' : 'Never'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
Status: <span className="font-medium">{syncStatus.status}</span>
|
|
||||||
</p>
|
|
||||||
{syncStatus.activities_synced > 0 && (
|
|
||||||
<p className="text-sm">
|
|
||||||
Activities synced: {syncStatus.activities_synced}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{syncStatus.error_message && (
|
|
||||||
<p className="text-sm text-red-600">{syncStatus.error_message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GarminSync;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const EditWorkoutModal = ({ workout, onClose, onSave }) => {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
type: '',
|
|
||||||
duration_minutes: 0,
|
|
||||||
intensity: '',
|
|
||||||
description: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (workout) {
|
|
||||||
setFormData({
|
|
||||||
type: workout.type || '',
|
|
||||||
duration_minutes: workout.duration_minutes || 0,
|
|
||||||
intensity: workout.intensity || '',
|
|
||||||
description: workout.description || ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [workout]);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onSave(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!workout) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Edit Workout</h3>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Workout Type
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="type"
|
|
||||||
value={formData.type}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Duration (minutes)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="duration_minutes"
|
|
||||||
value={formData.duration_minutes}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Intensity
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="intensity"
|
|
||||||
value={formData.intensity}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">Select intensity</option>
|
|
||||||
<option value="zone_1">Zone 1 (Easy)</option>
|
|
||||||
<option value="zone_2">Zone 2 (Moderate)</option>
|
|
||||||
<option value="zone_3">Zone 3 (Tempo)</option>
|
|
||||||
<option value="zone_4">Zone 4 (Threshold)</option>
|
|
||||||
<option value="zone_5">Zone 5 (VO2 Max)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={handleChange}
|
|
||||||
rows="3"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
EditWorkoutModal.propTypes = {
|
|
||||||
workout: PropTypes.object,
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onSave: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
EditWorkoutModal.defaultProps = {
|
|
||||||
workout: null
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EditWorkoutModal;
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const predefinedGoals = [
|
|
||||||
{ id: 'endurance', label: 'Build Endurance', description: 'Focus on longer rides at moderate intensity' },
|
|
||||||
{ id: 'power', label: 'Increase Power', description: 'High-intensity intervals and strength training' },
|
|
||||||
{ id: 'weight-loss', label: 'Weight Management', description: 'Calorie-burning rides with nutrition planning' },
|
|
||||||
{ id: 'event-prep', label: 'Event Preparation', description: 'Targeted training for specific competitions' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const GoalSelector = ({ goals, onSelect, onNext }) => {
|
|
||||||
const [customGoal, setCustomGoal] = useState('');
|
|
||||||
const [showCustom, setShowCustom] = useState(false);
|
|
||||||
|
|
||||||
const toggleGoal = (goalId) => {
|
|
||||||
const newGoals = goals.includes(goalId)
|
|
||||||
? goals.filter(g => g !== goalId)
|
|
||||||
: [...goals, goalId];
|
|
||||||
onSelect(newGoals);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addCustomGoal = () => {
|
|
||||||
if (customGoal.trim()) {
|
|
||||||
onSelect([...goals, customGoal.trim()]);
|
|
||||||
setCustomGoal('');
|
|
||||||
setShowCustom(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-2xl font-bold mb-6">Select Training Goals</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
|
||||||
{predefinedGoals.map((goal) => (
|
|
||||||
<button
|
|
||||||
key={goal.id}
|
|
||||||
onClick={() => toggleGoal(goal.id)}
|
|
||||||
className={`p-4 text-left rounded-lg border-2 transition-colors ${
|
|
||||||
goals.includes(goal.id)
|
|
||||||
? 'border-blue-500 bg-blue-50'
|
|
||||||
: 'border-gray-200 hover:border-blue-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<h3 className="font-semibold mb-2">{goal.label}</h3>
|
|
||||||
<p className="text-sm text-gray-600">{goal.description}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
{!showCustom ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCustom(true)}
|
|
||||||
className="text-blue-600 hover:text-blue-700 flex items-center"
|
|
||||||
>
|
|
||||||
<span className="mr-2">+</span> Add Custom Goal
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customGoal}
|
|
||||||
onChange={(e) => setCustomGoal(e.target.value)}
|
|
||||||
placeholder="Enter custom goal"
|
|
||||||
className="flex-1 p-2 border rounded-md"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={addCustomGoal}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={goals.length === 0}
|
|
||||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
GoalSelector.propTypes = {
|
|
||||||
goals: PropTypes.arrayOf(PropTypes.string).isRequired,
|
|
||||||
onSelect: PropTypes.func.isRequired,
|
|
||||||
onNext: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GoalSelector;
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const daysOfWeek = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
||||||
|
|
||||||
const PlanParameters = ({ values, onChange, onBack, onNext }) => {
|
|
||||||
const [localValues, setLocalValues] = useState(values);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalValues(values);
|
|
||||||
}, [values]);
|
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
|
||||||
const newValues = { ...localValues, [field]: value };
|
|
||||||
setLocalValues(newValues);
|
|
||||||
onChange(newValues);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDay = (day) => {
|
|
||||||
const days = localValues.availableDays.includes(day)
|
|
||||||
? localValues.availableDays.filter(d => d !== day)
|
|
||||||
: [...localValues.availableDays, day];
|
|
||||||
handleChange('availableDays', days);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<h2 className="text-2xl font-bold mb-6">Set Plan Parameters</h2>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Duration: {localValues.duration} weeks
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="4"
|
|
||||||
max="20"
|
|
||||||
value={localValues.duration}
|
|
||||||
onChange={(e) => handleChange('duration', parseInt(e.target.value))}
|
|
||||||
className="w-full range-slider"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
|
||||||
<span>4</span>
|
|
||||||
<span>20</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">
|
|
||||||
Weekly Hours: {localValues.weeklyHours}h
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="5"
|
|
||||||
max="15"
|
|
||||||
value={localValues.weeklyHours}
|
|
||||||
onChange={(e) => handleChange('weeklyHours', parseInt(e.target.value))}
|
|
||||||
className="w-full range-slider"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-sm text-gray-600">
|
|
||||||
<span>5</span>
|
|
||||||
<span>15</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Difficulty Level</label>
|
|
||||||
<select
|
|
||||||
value={localValues.difficulty || 'intermediate'}
|
|
||||||
onChange={(e) => handleChange('difficulty', e.target.value)}
|
|
||||||
className="w-full p-2 border rounded-md"
|
|
||||||
>
|
|
||||||
<option value="beginner">Beginner</option>
|
|
||||||
<option value="intermediate">Intermediate</option>
|
|
||||||
<option value="advanced">Advanced</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Available Days</label>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
||||||
{daysOfWeek.map(day => (
|
|
||||||
<label key={day} className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={localValues.availableDays.includes(day)}
|
|
||||||
onChange={() => toggleDay(day)}
|
|
||||||
className="form-checkbox h-4 w-4 text-blue-600"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{day}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between mt-8">
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
className="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onNext}
|
|
||||||
disabled={localValues.availableDays.length === 0}
|
|
||||||
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 disabled:bg-gray-400"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
PlanParameters.propTypes = {
|
|
||||||
values: PropTypes.shape({
|
|
||||||
duration: PropTypes.number,
|
|
||||||
weeklyHours: PropTypes.number,
|
|
||||||
difficulty: PropTypes.string,
|
|
||||||
availableDays: PropTypes.arrayOf(PropTypes.string)
|
|
||||||
}).isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
onBack: PropTypes.func.isRequired,
|
|
||||||
onNext: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlanParameters;
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
|
||||||
import axios from 'axios';
|
|
||||||
import WorkoutCard from './WorkoutCard';
|
|
||||||
import EditWorkoutModal from './EditWorkoutModal';
|
|
||||||
|
|
||||||
const PlanTimeline = ({ plan, mode = 'view' }) => {
|
|
||||||
const { apiKey } = useAuth();
|
|
||||||
const [currentPlan, setCurrentPlan] = useState(plan?.jsonb_plan);
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
|
||||||
const [selectedWorkout, setSelectedWorkout] = useState(null);
|
|
||||||
const [selectedWeek, setSelectedWeek] = useState(0);
|
|
||||||
|
|
||||||
const handleWorkoutUpdate = async (updatedWorkout) => {
|
|
||||||
try {
|
|
||||||
const newPlan = { ...currentPlan };
|
|
||||||
newPlan.weeks[selectedWeek].workouts = newPlan.weeks[selectedWeek].workouts.map(w =>
|
|
||||||
w.day === updatedWorkout.day ? updatedWorkout : w
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mode === 'edit') {
|
|
||||||
await axios.put(`/api/plans/${plan.id}`, newPlan, {
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentPlan(newPlan);
|
|
||||||
setShowEditModal(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating workout:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-xl font-semibold">
|
|
||||||
{currentPlan?.plan_overview?.focus} Training Plan
|
|
||||||
</h3>
|
|
||||||
<div className="text-gray-600">
|
|
||||||
{currentPlan?.plan_overview?.duration_weeks} weeks •
|
|
||||||
{currentPlan?.plan_overview?.weekly_hours} hrs/week
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{currentPlan?.weeks?.map((week, weekIndex) => (
|
|
||||||
<div key={weekIndex} className="border-l-2 border-blue-100 pl-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h4 className="font-medium">
|
|
||||||
Week {weekIndex + 1}: {week.focus}
|
|
||||||
</h4>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="h-2 w-24 bg-gray-200 rounded-full">
|
|
||||||
<div
|
|
||||||
className="h-full bg-blue-600 rounded-full"
|
|
||||||
style={{ width: `${(weekIndex + 1) / currentPlan.plan_overview.duration_weeks * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-600">
|
|
||||||
{week.workouts.length} workouts
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{week.workouts.map((workout, workoutIndex) => (
|
|
||||||
<WorkoutCard
|
|
||||||
key={`${weekIndex}-${workoutIndex}`}
|
|
||||||
workout={workout}
|
|
||||||
onEdit={() => {
|
|
||||||
setSelectedWeek(weekIndex);
|
|
||||||
setSelectedWorkout(workout);
|
|
||||||
setShowEditModal(true);
|
|
||||||
}}
|
|
||||||
editable={mode === 'edit'}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showEditModal && (
|
|
||||||
<EditWorkoutModal
|
|
||||||
workout={selectedWorkout}
|
|
||||||
onClose={() => setShowEditModal(false)}
|
|
||||||
onSave={handleWorkoutUpdate}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
PlanTimeline.propTypes = {
|
|
||||||
plan: PropTypes.shape({
|
|
||||||
id: PropTypes.number,
|
|
||||||
jsonb_plan: PropTypes.object
|
|
||||||
}),
|
|
||||||
mode: PropTypes.oneOf(['view', 'edit'])
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PlanTimeline;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const WorkoutCard = ({ workout, onEdit, editable }) => {
|
|
||||||
return (
|
|
||||||
<div className="border rounded-lg p-4 bg-white shadow-sm">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<h5 className="font-medium capitalize">{workout?.type?.replace('_', ' ') || 'Workout'}</h5>
|
|
||||||
{editable && (
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600 space-y-1">
|
|
||||||
<div>Duration: {workout?.duration_minutes || 0} minutes</div>
|
|
||||||
<div>Intensity: {workout?.intensity || 'N/A'}</div>
|
|
||||||
{workout?.description && (
|
|
||||||
<div className="mt-2 text-gray-700">{workout.description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
WorkoutCard.propTypes = {
|
|
||||||
workout: PropTypes.shape({
|
|
||||||
type: PropTypes.string,
|
|
||||||
duration_minutes: PropTypes.number,
|
|
||||||
intensity: PropTypes.string,
|
|
||||||
description: PropTypes.string
|
|
||||||
}),
|
|
||||||
onEdit: PropTypes.func,
|
|
||||||
editable: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
WorkoutCard.defaultProps = {
|
|
||||||
workout: {},
|
|
||||||
onEdit: () => {},
|
|
||||||
editable: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default WorkoutCard;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user