mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-03-13 08:25:28 +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
|
||||
up:
|
||||
docker-compose up -d
|
||||
# Default target
|
||||
help:
|
||||
@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
|
||||
down:
|
||||
docker-compose down
|
||||
# Installation
|
||||
install:
|
||||
pip install .
|
||||
|
||||
# Rebuild all Docker images
|
||||
build:
|
||||
docker-compose build --no-cache
|
||||
dev-install:
|
||||
pip install -e .[dev]
|
||||
|
||||
# Start services if not running, otherwise restart
|
||||
start:
|
||||
docker-compose start || docker-compose up -d
|
||||
|
||||
# Stop running services
|
||||
stop:
|
||||
docker-compose stop
|
||||
|
||||
# Restart all services
|
||||
restart:
|
||||
docker-compose restart
|
||||
|
||||
# Show logs for all services
|
||||
logs:
|
||||
docker-compose logs -f
|
||||
|
||||
# Show backend logs
|
||||
backend-logs:
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Show frontend logs
|
||||
frontend-logs:
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# Show database logs
|
||||
db-logs:
|
||||
docker-compose logs -f db
|
||||
|
||||
# Initialize database and run migrations
|
||||
# Database initialization
|
||||
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
|
||||
migration:
|
||||
docker-compose run --rm backend alembic revision --autogenerate -m "$(m)"
|
||||
# Run application
|
||||
run:
|
||||
python main.py
|
||||
|
||||
# Run tests
|
||||
# Testing
|
||||
test:
|
||||
docker-compose run --rm backend pytest
|
||||
pytest
|
||||
|
||||
# Open database shell
|
||||
db-shell:
|
||||
docker-compose exec db psql -U appuser -d cyclingdb
|
||||
# Cleanup
|
||||
clean:
|
||||
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
|
||||
- Docker and Docker Compose
|
||||
- 2GB+ available RAM
|
||||
- 10GB+ available disk space
|
||||
- **🧠 AI-Powered Plan Generation**: Create personalized 4-week training plans based on your goals and constraints
|
||||
- **📊 Automatic Workout Analysis**: Get detailed AI feedback on your completed rides with terminal-based visualizations
|
||||
- **⌚ Garmin Connect Integration**: Sync activities automatically from your Garmin device
|
||||
- **🔄 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
|
||||
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
|
||||
## 🏁 Quick Start
|
||||
|
||||
### Option 1: Automated Installation (Recommended)
|
||||
```bash
|
||||
# Start development environment
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Run database migrations manually (if needed)
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# Access backend container
|
||||
docker-compose exec backend bash
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
git clone https://github.com/ai-cycling-coach/ai-cycling-coach.git
|
||||
cd ai-cycling-coach
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### Migration Management
|
||||
|
||||
#### Automatic Migrations
|
||||
Migrations run automatically when containers start. The entrypoint script:
|
||||
1. Runs `alembic upgrade head`
|
||||
2. Verifies migration success
|
||||
3. Starts the application
|
||||
|
||||
#### Manual Migration Operations
|
||||
### Option 2: Manual Installation
|
||||
```bash
|
||||
# Check migration status
|
||||
docker-compose exec backend python scripts/migration_checker.py check-db
|
||||
# Clone and setup
|
||||
git clone https://github.com/ai-cycling-coach/ai-cycling-coach.git
|
||||
cd ai-cycling-coach
|
||||
|
||||
# Generate new migration
|
||||
docker-compose exec backend alembic revision --autogenerate -m "description"
|
||||
# Create virtual environment
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Rollback migration
|
||||
docker-compose exec backend python scripts/migration_rollback.py rollback
|
||||
# Install
|
||||
pip install -e .
|
||||
|
||||
# Initialize database
|
||||
make init-db
|
||||
|
||||
# Run the application
|
||||
cycling-coach
|
||||
```
|
||||
|
||||
#### Migration Validation
|
||||
## ⚙️ Configuration
|
||||
|
||||
Edit the `.env` file with your settings:
|
||||
|
||||
```bash
|
||||
# Validate deployment readiness
|
||||
docker-compose exec backend python scripts/migration_checker.py validate-deploy
|
||||
# Database Configuration (SQLite)
|
||||
DATABASE_URL=sqlite+aiosqlite:///data/cycling_coach.db
|
||||
|
||||
# Generate migration report
|
||||
docker-compose exec backend python scripts/migration_checker.py report
|
||||
```
|
||||
# File Storage
|
||||
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
|
||||
```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 Connect Credentials
|
||||
GARMIN_USERNAME=your_garmin_email@example.com
|
||||
GARMIN_PASSWORD=your_secure_password
|
||||
|
||||
# AI Service
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
AI_MODEL=anthropic/claude-3-sonnet-20240229
|
||||
|
||||
# Application
|
||||
API_KEY=your_secure_random_api_key_here
|
||||
# Optional: Logging Configuration
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
## 🎮 Usage
|
||||
|
||||
The application includes comprehensive health monitoring:
|
||||
### Terminal Interface
|
||||
|
||||
Start the application with:
|
||||
```bash
|
||||
# Check overall health
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Response includes:
|
||||
# - Database connectivity
|
||||
# - Migration status
|
||||
# - Current vs head revision
|
||||
# - Service availability
|
||||
cycling-coach
|
||||
# or
|
||||
ai-cycling-coach
|
||||
# or
|
||||
python main.py
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
### 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
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │
|
||||
│ (React) │◄──►│ (FastAPI) │
|
||||
│ │ │ │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Garmin │ │ PostgreSQL │
|
||||
│ Connect │ │ Database │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
ai-cycling-coach/
|
||||
├── main.py # Application entrypoint
|
||||
├── backend/ # Core business logic
|
||||
│ ├── app/
|
||||
│ │ ├── models/ # Database models
|
||||
│ │ ├── services/ # Business services
|
||||
│ │ └── config.py # Configuration
|
||||
│ └── alembic/ # Database migrations
|
||||
├── tui/ # Terminal interface
|
||||
│ ├── 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
|
||||
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
|
||||
## 🛠️ Development
|
||||
|
||||
## 🧪 Testing & Validation
|
||||
|
||||
### CI/CD Pipeline
|
||||
GitHub Actions automatically validates:
|
||||
- ✅ No uncommitted migration files
|
||||
- ✅ No raw SQL in application code
|
||||
- ✅ Proper dependency management
|
||||
- ✅ Container build success
|
||||
- ✅ Migration compatibility
|
||||
|
||||
### Local Validation
|
||||
### Setup Development Environment
|
||||
```bash
|
||||
# Run all validation checks
|
||||
docker-compose exec backend python scripts/migration_checker.py validate-deploy
|
||||
# Clone repository
|
||||
git clone https://github.com/ai-cycling-coach/ai-cycling-coach.git
|
||||
cd ai-cycling-coach
|
||||
|
||||
# Check for raw SQL usage
|
||||
grep -r "SELECT.*FROM\|INSERT.*INTO\|UPDATE.*SET\|DELETE.*FROM" backend/app/
|
||||
# Install in development mode
|
||||
make dev-install
|
||||
|
||||
# Initialize database
|
||||
make init-db
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Run application
|
||||
make run
|
||||
```
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── backend/
|
||||
│ ├── Dockerfile # Multi-stage container build
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ ├── scripts/
|
||||
│ │ ├── migration_rollback.py # Rollback utilities
|
||||
│ │ ├── backup_restore.py # Backup/restore tools
|
||||
│ │ └── migration_checker.py # Validation tools
|
||||
│ └── app/
|
||||
│ ├── main.py # FastAPI application
|
||||
│ ├── database.py # Database configuration
|
||||
│ ├── models/ # SQLAlchemy models
|
||||
│ ├── routes/ # API endpoints
|
||||
│ ├── services/ # Business logic
|
||||
│ └── schemas/ # Pydantic schemas
|
||||
├── frontend/
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
├── docker-compose.yml # Development services
|
||||
├── .github/
|
||||
│ └── workflows/
|
||||
│ └── container-validation.yml # CI/CD checks
|
||||
└── .kilocode/
|
||||
└── rules/
|
||||
└── container-database-rules.md # Development guidelines
|
||||
```
|
||||
|
||||
## 🚨 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Migration Failures
|
||||
### Available Make Commands
|
||||
```bash
|
||||
# Check migration status
|
||||
docker-compose exec backend alembic current
|
||||
|
||||
# View migration history
|
||||
docker-compose exec backend alembic history
|
||||
|
||||
# Reset migrations (CAUTION: destroys data)
|
||||
docker-compose exec backend alembic downgrade base
|
||||
make help # Show all available commands
|
||||
make install # Install the application
|
||||
make dev-install # Install in development mode
|
||||
make run # Run the application
|
||||
make init-db # Initialize the database
|
||||
make test # Run tests
|
||||
make clean # Clean build artifacts
|
||||
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
|
||||
# Check database health
|
||||
docker-compose exec db pg_isready -U postgres
|
||||
|
||||
# View database logs
|
||||
docker-compose logs db
|
||||
|
||||
# Restart database
|
||||
docker-compose restart db
|
||||
make package
|
||||
# Creates: dist/cycling-coach
|
||||
```
|
||||
|
||||
#### Container Build Issues
|
||||
## 🚀 Deployment Options
|
||||
|
||||
### 1. Portable Installation
|
||||
```bash
|
||||
# Rebuild without cache
|
||||
docker-compose build --no-cache backend
|
||||
|
||||
# View build logs
|
||||
docker-compose build backend
|
||||
# Create portable package
|
||||
make build
|
||||
pip install dist/ai-cycling-coach-*.whl
|
||||
```
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
#### Service Health
|
||||
### 2. Standalone Executable
|
||||
```bash
|
||||
# Check all services
|
||||
docker-compose ps
|
||||
|
||||
# View service logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Check backend health
|
||||
curl http://localhost:8000/health
|
||||
# Create single-file executable
|
||||
make package
|
||||
# Copy dist/cycling-coach to target system
|
||||
```
|
||||
|
||||
#### Database Health
|
||||
### 3. Development Installation
|
||||
```bash
|
||||
# Check database connectivity
|
||||
docker-compose exec backend python -c "
|
||||
from app.database import get_db
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
async with AsyncSession(get_db()) as session:
|
||||
result = await session.execute('SELECT 1')
|
||||
print('Database OK')
|
||||
|
||||
asyncio.run(test())
|
||||
"
|
||||
# For development and testing
|
||||
make dev-install
|
||||
```
|
||||
|
||||
## 🔒 Security
|
||||
## 📋 Requirements
|
||||
|
||||
- API key authentication for all endpoints
|
||||
- Secure storage of Garmin credentials
|
||||
- No sensitive data in application logs
|
||||
- Container isolation prevents host system access
|
||||
- Regular security updates via container rebuilds
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
Once running, visit:
|
||||
- **API Docs**: http://localhost:8000/docs
|
||||
- **Alternative Docs**: http://localhost:8000/redoc
|
||||
- **Python**: 3.8 or higher
|
||||
- **Operating System**: Linux, macOS, Windows
|
||||
- **Terminal**: Any terminal with Unicode support
|
||||
- **Memory**: ~100MB RAM
|
||||
- **Storage**: ~50MB + data files
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow container-first development rules
|
||||
2. Ensure all changes pass CI/CD validation
|
||||
3. Update documentation for significant changes
|
||||
4. Test migration compatibility before merging
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes
|
||||
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
|
||||
- Keep dependencies in `requirements.txt`
|
||||
- Test schema changes in development environment
|
||||
- Document migration changes in commit messages
|
||||
- Run validation checks before pushing
|
||||
### Common Issues
|
||||
|
||||
**Database errors:**
|
||||
```bash
|
||||
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
|
||||
|
||||
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]
|
||||
script_location = alembic
|
||||
sqlalchemy.url = postgresql+asyncpg://postgres:password@db:5432/cycling
|
||||
sqlalchemy.url = sqlite+aiosqlite:///data/cycling_coach.db
|
||||
|
||||
[loggers]
|
||||
keys = root
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from logging.config import fileConfig
|
||||
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
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add app directory to path
|
||||
sys.path.append(os.getcwd())
|
||||
# Add backend directory to path
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
# Import base and models
|
||||
from app.models.base import Base
|
||||
from app.config import settings
|
||||
from backend.app.models.base import Base
|
||||
from backend.app.config import settings
|
||||
|
||||
# Import all models to ensure they're registered
|
||||
from backend.app.models import *
|
||||
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
@@ -19,12 +24,13 @@ target_metadata = Base.metadata
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
url = settings.DATABASE_URL
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True, # Important for SQLite
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
@@ -32,21 +38,28 @@ def run_migrations_offline():
|
||||
|
||||
async def run_migrations_online():
|
||||
"""Run migrations in 'online' mode."""
|
||||
connectable = AsyncEngine(
|
||||
engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
future=True,
|
||||
url=settings.DATABASE_URL,
|
||||
)
|
||||
# Ensure data directory exists
|
||||
data_dir = Path("data")
|
||||
data_dir.mkdir(exist_ok=True)
|
||||
|
||||
connectable = create_async_engine(
|
||||
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:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
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():
|
||||
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
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str
|
||||
GPX_STORAGE_PATH: str
|
||||
AI_MODEL: str = "openrouter/auto"
|
||||
API_KEY: str
|
||||
# Database settings
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///data/cycling_coach.db"
|
||||
|
||||
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()
|
||||
@@ -1,10 +1,19 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
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(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
@@ -15,4 +24,19 @@ Base = declarative_base()
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.services.ai_service import AIService
|
||||
from backend.app.database import get_db
|
||||
from backend.app.services.ai_service import AIService
|
||||
from typing import AsyncGenerator
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from .route import Route
|
||||
from .section import Section
|
||||
from .rule import Rule
|
||||
from .plan import Plan
|
||||
from .plan_rule import PlanRule
|
||||
from .plan_rule import plan_rules
|
||||
from .user import User
|
||||
from .workout import Workout
|
||||
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 .base import BaseModel
|
||||
|
||||
@@ -13,7 +14,7 @@ class Analysis(BaseModel):
|
||||
suggestions = Column(JSON)
|
||||
approved = Column(Boolean, default=False)
|
||||
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
|
||||
workout = relationship("Workout", back_populates="analyses")
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
from sqlalchemy import Column, DateTime
|
||||
from sqlalchemy import Column, Integer, DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class BaseModel(Base):
|
||||
__abstract__ = True
|
||||
|
||||
id = Column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import Column, Integer, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from .base import BaseModel
|
||||
|
||||
class Plan(BaseModel):
|
||||
__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)
|
||||
parent_plan_id = Column(Integer, ForeignKey('plans.id'), nullable=True)
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from .base import BaseModel
|
||||
from sqlalchemy import Column, Integer, ForeignKey, Table
|
||||
from .base import Base
|
||||
|
||||
class PlanRule(BaseModel):
|
||||
__tablename__ = "plan_rules"
|
||||
|
||||
plan_id = Column(Integer, ForeignKey('plans.id'), primary_key=True)
|
||||
rule_id = Column(Integer, ForeignKey('rules.id'), primary_key=True)
|
||||
|
||||
plan = relationship("Plan", back_populates="rules")
|
||||
rule = relationship("Rule", back_populates="plans")
|
||||
# Association table for many-to-many relationship between plans and rules
|
||||
plan_rules = Table(
|
||||
'plan_rules', Base.metadata,
|
||||
Column('plan_id', Integer, ForeignKey('plans.id'), primary_key=True),
|
||||
Column('rule_id', Integer, ForeignKey('rules.id'), primary_key=True)
|
||||
)
|
||||
@@ -1,7 +1,12 @@
|
||||
from .base import BaseModel
|
||||
from sqlalchemy import Column, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from .base import BaseModel
|
||||
|
||||
class User(BaseModel):
|
||||
__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 sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.models.workout import Workout
|
||||
from app.models.plan import Plan
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.database import get_db
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from sqlalchemy import select, desc
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from app.services.export_service import ExportService
|
||||
from backend.app.services.export_service import ExportService
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from fastapi import APIRouter, Depends, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.dependencies import verify_api_key
|
||||
from app.services.workout_sync import WorkoutSyncService
|
||||
from app.database import get_db
|
||||
from backend.app.dependencies import verify_api_key
|
||||
from backend.app.services.workout_sync import WorkoutSyncService
|
||||
from backend.app.database import get_db
|
||||
|
||||
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.services.gpx import parse_gpx, store_gpx_file
|
||||
from app.schemas.gpx import RouteCreate, Route as RouteSchema
|
||||
from app.models import Route
|
||||
from backend.app.database import get_db
|
||||
from backend.app.services.gpx import parse_gpx, store_gpx_file
|
||||
from backend.app.schemas.gpx import RouteCreate, Route as RouteSchema
|
||||
from backend.app.models import Route
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/gpx", tags=["GPX Routes"])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
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 pathlib import Path
|
||||
import json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.services.import_service import ImportService
|
||||
from backend.app.services.import_service import ImportService
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models.plan import Plan as PlanModel
|
||||
from app.models.rule import Rule
|
||||
from app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
|
||||
from app.dependencies import get_ai_service
|
||||
from app.services.ai_service import AIService
|
||||
from backend.app.database import get_db
|
||||
from backend.app.models.plan import Plan as PlanModel
|
||||
from backend.app.models.rule import Rule
|
||||
from backend.app.schemas.plan import PlanCreate, Plan as PlanSchema, PlanGenerationRequest, PlanGenerationResponse
|
||||
from backend.app.dependencies import get_ai_service
|
||||
from backend.app.services.ai_service import AIService
|
||||
from uuid import UUID, uuid4
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
@@ -3,10 +3,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.prompt import Prompt
|
||||
from app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
|
||||
from app.services.prompt_manager import PromptManager
|
||||
from backend.app.database import get_db
|
||||
from backend.app.models.prompt import Prompt
|
||||
from backend.app.schemas.prompt import Prompt as PromptSchema, PromptCreate, PromptUpdate
|
||||
from backend.app.services.prompt_manager import PromptManager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models.rule import Rule
|
||||
from app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
|
||||
from app.dependencies import get_ai_service
|
||||
from app.services.ai_service import AIService
|
||||
from backend.app.database import get_db
|
||||
from backend.app.models.rule import Rule
|
||||
from backend.app.schemas.rule import RuleCreate, Rule as RuleSchema, NaturalLanguageRuleRequest, ParsedRuleResponse
|
||||
from backend.app.dependencies import get_ai_service
|
||||
from backend.app.services.ai_service import AIService
|
||||
from uuid import UUID
|
||||
from typing import List
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.workout import Workout
|
||||
from app.models.analysis import Analysis
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from app.models.plan import Plan
|
||||
from app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus, WorkoutMetric
|
||||
from app.schemas.analysis import Analysis as AnalysisSchema
|
||||
from app.schemas.plan import Plan as PlanSchema
|
||||
from app.services.workout_sync import WorkoutSyncService
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.plan_evolution import PlanEvolutionService
|
||||
from backend.app.database import get_db
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.analysis import Analysis
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.schemas.workout import Workout as WorkoutSchema, WorkoutSyncStatus, WorkoutMetric
|
||||
from backend.app.schemas.analysis import Analysis as AnalysisSchema
|
||||
from backend.app.schemas.plan import Plan as PlanSchema
|
||||
from backend.app.services.workout_sync import WorkoutSyncService
|
||||
from backend.app.services.ai_service import AIService
|
||||
from backend.app.services.plan_evolution import PlanEvolutionService
|
||||
|
||||
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
|
||||
import httpx
|
||||
import json
|
||||
from app.services.prompt_manager import PromptManager
|
||||
from app.models.workout import Workout
|
||||
from backend.app.services.prompt_manager import PromptManager
|
||||
from backend.app.models.workout import Workout
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -2,8 +2,8 @@ import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import zipfile
|
||||
from app.database import SessionLocal
|
||||
from app.models import Route, Rule, Plan
|
||||
from backend.app.database import SessionLocal
|
||||
from backend.app.models import Route, Rule, Plan
|
||||
import tempfile
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
import logging
|
||||
from fastapi import UploadFile, HTTPException
|
||||
import gpxpy
|
||||
from app.config import settings
|
||||
from backend.app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ from datetime import datetime
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from sqlalchemy import text
|
||||
from app.database import get_db
|
||||
from app.models.garmin_sync_log import GarminSyncLog, SyncStatus
|
||||
from backend.app.database import get_db
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog, SyncStatus
|
||||
import requests
|
||||
from app.config import settings
|
||||
from backend.app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,12 +43,12 @@ class HealthMonitor:
|
||||
|
||||
def _get_sync_queue_size(self) -> int:
|
||||
"""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()
|
||||
|
||||
def _count_pending_analyses(self) -> int:
|
||||
"""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()
|
||||
|
||||
def _check_database(self) -> str:
|
||||
|
||||
@@ -3,8 +3,8 @@ import zipfile
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from app.database import SessionLocal
|
||||
from app.models import Route, Rule, Plan
|
||||
from backend.app.database import SessionLocal
|
||||
from backend.app.models import Route, Rule, Plan
|
||||
import shutil
|
||||
import logging
|
||||
from sqlalchemy import and_
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.services.ai_service import AIService
|
||||
from app.models.analysis import Analysis
|
||||
from app.models.plan import Plan
|
||||
from backend.app.services.ai_service import AIService
|
||||
from backend.app.models.analysis import Analysis
|
||||
from backend.app.models.plan import Plan
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
from app.models.prompt import Prompt
|
||||
from backend.app.models.prompt import Prompt
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
||||
from app.models.workout import Workout
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Optional
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from app.database import get_database_url
|
||||
from backend.app.database import get_database_url
|
||||
|
||||
class DatabaseManager:
|
||||
"""Handles database backup and restore operations."""
|
||||
|
||||
@@ -18,7 +18,7 @@ from alembic import command
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import create_engine, text
|
||||
from app.database import get_database_url
|
||||
from backend.app.database import get_database_url
|
||||
|
||||
class MigrationChecker:
|
||||
"""Validates migration compatibility and integrity."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from alembic import command
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
import sqlalchemy as sa
|
||||
from app.database import get_database_url
|
||||
from backend.app.database import get_database_url
|
||||
|
||||
def get_alembic_config():
|
||||
"""Get Alembic configuration."""
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.database import get_db, Base
|
||||
from backend.app.main import app
|
||||
from backend.app.database import get_db, Base
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from app.services.ai_service import AIService, AIServiceError
|
||||
from app.models.workout import Workout
|
||||
from backend.app.services.ai_service import AIService, AIServiceError
|
||||
from backend.app.models.workout import Workout
|
||||
import json
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.services.garmin import GarminService
|
||||
from app.models.garmin_sync_log import GarminSyncStatus
|
||||
from backend.app.services.garmin import GarminService
|
||||
from backend.app.models.garmin_sync_log import GarminSyncStatus
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from app.services.plan_evolution import PlanEvolutionService
|
||||
from app.models.plan import Plan
|
||||
from app.models.analysis import Analysis
|
||||
from backend.app.services.plan_evolution import PlanEvolutionService
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.models.analysis import Analysis
|
||||
from datetime import datetime
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from app.services.workout_sync import WorkoutSyncService
|
||||
from app.models.workout import Workout
|
||||
from app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.services.workout_sync import WorkoutSyncService
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from datetime import datetime, timedelta
|
||||
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