change to TUI

This commit is contained in:
2025-09-12 09:08:10 -07:00
parent 7c7dcb5b10
commit e0e70f6508
165 changed files with 3438 additions and 16154 deletions

27
MANIFEST.in Normal file
View 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
View File

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

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
node_modules
.next
Dockerfile
.dockerignore
.git
.gitignore
coverage
.env
.env.local
.vscode
*.log

View File

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

View File

@@ -1,10 +0,0 @@
module.exports = {
presets: [
['next/babel', {
'preset-react': {
runtime: 'automatic',
importSource: '@emotion/react'
}
}]
]
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
import { AuthProvider } from '../src/context/AuthContext';
function MyApp({ Component, pageProps }) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
export default MyApp;

View File

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

View File

@@ -1 +0,0 @@
import '@testing-library/jest-dom/extend-expect';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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