From 1a0d45dd67189574949513367cbb5bb86b689956 Mon Sep 17 00:00:00 2001 From: owain Date: Sat, 6 Jun 2026 13:23:33 +0100 Subject: [PATCH] Initial Commit --- README.md | 153 ++++++++ backend/Dockerfile | 15 + backend/Dockerfile.worker | 14 + backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/activities.py | 213 +++++++++++ backend/app/api/auth.py | 134 +++++++ backend/app/api/health.py | 156 ++++++++ backend/app/api/records.py | 62 ++++ backend/app/api/routes.py | 204 +++++++++++ backend/app/api/upload.py | 134 +++++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 38 ++ backend/app/core/database.py | 32 ++ backend/app/core/security.py | 55 +++ backend/app/main.py | 71 ++++ backend/app/models/__init__.py | 0 backend/app/models/user.py | 233 ++++++++++++ backend/app/services/__init__.py | 0 backend/app/services/fit_parser.py | 341 ++++++++++++++++++ backend/app/services/route_matcher.py | 190 ++++++++++ backend/app/workers/__init__.py | 0 backend/app/workers/tasks.py | 257 +++++++++++++ backend/requirements.txt | 23 ++ docker-compose.deploy.yml | 114 ++++++ docker-compose.yml | 111 ++++++ docker/init.sql | 7 + frontend/Dockerfile | 18 + frontend/index.html | 13 + frontend/nginx-spa.conf | 14 + frontend/package.json | 33 ++ frontend/postcss.config.js | 6 + frontend/src/App.jsx | 59 +++ .../src/components/activity/ActivityMap.jsx | 123 +++++++ .../src/components/activity/HRZoneBar.jsx | 43 +++ frontend/src/components/activity/LapTable.jsx | 40 ++ .../components/activity/MetricTimeline.jsx | 156 ++++++++ frontend/src/components/ui/Layout.jsx | 74 ++++ frontend/src/components/ui/StatCard.jsx | 18 + frontend/src/hooks/useAuth.js | 41 +++ frontend/src/index.css | 33 ++ frontend/src/main.jsx | 22 ++ frontend/src/pages/ActivitiesPage.jsx | 147 ++++++++ frontend/src/pages/ActivityDetailPage.jsx | 158 ++++++++ frontend/src/pages/DashboardPage.jsx | 197 ++++++++++ frontend/src/pages/HealthPage.jsx | 272 ++++++++++++++ frontend/src/pages/LoginPage.jsx | 102 ++++++ frontend/src/pages/RecordsPage.jsx | 177 +++++++++ frontend/src/pages/RoutesPage.jsx | 205 +++++++++++ frontend/src/pages/UploadPage.jsx | 178 +++++++++ frontend/src/utils/api.js | 26 ++ frontend/src/utils/format.js | 84 +++++ frontend/tailwind.config.js | 18 + frontend/vite.config.js | 14 + install.sh | 209 +++++++++++ nginx.conf | 50 +++ nginx/nginx.conf | 59 +++ scripts/manage.sh | 122 +++++++ 58 files changed, 5268 insertions(+) create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.worker create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/activities.py create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/api/records.py create mode 100644 backend/app/api/routes.py create mode 100644 backend/app/api/upload.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/fit_parser.py create mode 100644 backend/app/services/route_matcher.py create mode 100644 backend/app/workers/__init__.py create mode 100644 backend/app/workers/tasks.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.deploy.yml create mode 100644 docker-compose.yml create mode 100644 docker/init.sql create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx-spa.conf create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/activity/ActivityMap.jsx create mode 100644 frontend/src/components/activity/HRZoneBar.jsx create mode 100644 frontend/src/components/activity/LapTable.jsx create mode 100644 frontend/src/components/activity/MetricTimeline.jsx create mode 100644 frontend/src/components/ui/Layout.jsx create mode 100644 frontend/src/components/ui/StatCard.jsx create mode 100644 frontend/src/hooks/useAuth.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/ActivitiesPage.jsx create mode 100644 frontend/src/pages/ActivityDetailPage.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/HealthPage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/RecordsPage.jsx create mode 100644 frontend/src/pages/RoutesPage.jsx create mode 100644 frontend/src/pages/UploadPage.jsx create mode 100644 frontend/src/utils/api.js create mode 100644 frontend/src/utils/format.js create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/vite.config.js create mode 100755 install.sh create mode 100644 nginx.conf create mode 100644 nginx/nginx.conf create mode 100755 scripts/manage.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..36f55fb --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# FitTracker + +Self-hosted fitness tracking — Garmin & Strava import, maps, health trends, personal records. + +--- + +## For users — deploy with two files + +Once this repo is pushed to Gitea and the Actions workflow has run once, anyone on your network only needs **two files** to run FitTracker. No source code, no cloning. + +```bash +mkdir fittracker && cd fittracker + +# Download the two deployment files +curl -O https://gitea.yourdomain.com/yourusername/fittracker/raw/branch/main/docker-compose.deploy.yml +curl -O https://gitea.yourdomain.com/yourusername/fittracker/raw/branch/main/nginx.conf + +# Start (images pulled automatically from your Gitea registry) +docker compose -f docker-compose.deploy.yml up -d +``` + +Default login: `admin` / `admin` +**Change `ADMIN_PASSWORD` in a `.env` file before exposing to a network** (see Configuration below). + +To update when a new version is pushed to Gitea: +```bash +docker compose -f docker-compose.deploy.yml pull +docker compose -f docker-compose.deploy.yml up -d +``` + +--- + +## For developers — first-time Gitea setup + +### 1. Enable the Gitea container registry + +In your Gitea instance (`app.ini` or admin panel): + +```ini +[packages] +ENABLED = true +``` + +Restart Gitea. The registry is then available at `gitea.yourdomain.com`. + +### 2. Create a Gitea Actions runner + +Gitea Actions needs a runner on your server: + +```bash +# On the server that will build images +docker run -d \ + --name gitea-runner \ + --restart always \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v gitea-runner-data:/data \ + -e GITEA_INSTANCE_URL=https://gitea.yourdomain.com \ + -e GITEA_RUNNER_REGISTRATION_TOKEN= \ + gitea/act_runner:latest +``` + +Get the registration token from: **Gitea → Your repo → Settings → Actions → Runners → Create Runner** + +### 3. Create a package token + +The workflow needs a token to push images to the registry: + +1. Gitea → Your profile → **Settings → Applications → Generate Token** +2. Scopes: tick **`write:package`** +3. Copy the token + +Then in your repo: **Settings → Secrets → Actions → Add Secret** +- Name: `PACKAGE_TOKEN` +- Value: the token you just copied + +### 4. Set the registry URL variable + +In your repo: **Settings → Variables → Actions → Add Variable** +- Name: `GITEA_URL` +- Value: `gitea.yourdomain.com` (no `https://`) + +### 5. Push the repo + +```bash +git remote add origin https://gitea.yourdomain.com/yourusername/fittracker.git +git push -u origin main +``` + +The Actions workflow (`.gitea/workflows/build.yml`) triggers automatically, builds all three images, and pushes them to your Gitea registry. Check progress under **Actions** in the Gitea UI. + +### 6. Update docker-compose.deploy.yml + +Before the first deploy, replace the placeholder registry URLs in `docker-compose.deploy.yml`: + +``` +gitea.yourdomain.com/yourusername/ → your actual Gitea host and username +``` + +--- + +## Configuration + +Create a `.env` file next to `docker-compose.deploy.yml` to override any defaults: + +```env +# Admin login +ADMIN_USERNAME=admin +ADMIN_PASSWORD=a_strong_password_here + +# Generate with: openssl rand -hex 32 +SECRET_KEY= + +# Ports +HTTP_PORT=80 + +# Optional: Mapbox token for satellite tiles +VITE_MAPBOX_TOKEN= + +# Optional: PocketID passkey auth +POCKETID_ISSUER= +POCKETID_CLIENT_ID= +POCKETID_CLIENT_SECRET= +``` + +Docker Compose picks up `.env` automatically. + +--- + +## If your Gitea registry requires authentication to pull + +If your Gitea instance is private, add a pull secret on the deploy machine: + +```bash +docker login gitea.yourdomain.com +# enter your Gitea username and password (or a read:package token) +``` + +Docker stores the credentials in `~/.docker/config.json` and uses them automatically on `docker compose pull`. + +--- + +## Repo structure + +``` +.gitea/workflows/build.yml ← Gitea Actions: builds & pushes images on push to main +docker-compose.yml ← dev/build compose (builds from source) +docker-compose.deploy.yml ← production compose (pulls pre-built images) +nginx.conf ← standalone nginx config for deploy compose +backend/ ← FastAPI + Celery worker +frontend/ ← React + Vite +nginx/nginx.conf ← nginx config for dev compose +docker/init.sql ← DB init (enables TimescaleDB extension) +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..96ef064 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Tables are created at runtime by SQLAlchemy in app/main.py lifespan +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/backend/Dockerfile.worker b/backend/Dockerfile.worker new file mode 100644 index 0000000..a38f3ab --- /dev/null +++ b/backend/Dockerfile.worker @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py new file mode 100644 index 0000000..0412448 --- /dev/null +++ b/backend/app/api/activities.py @@ -0,0 +1,213 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, desc +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User, Activity, ActivityDataPoint, ActivityLap + +router = APIRouter() + + +class ActivitySummary(BaseModel): + id: int + name: str + sport_type: str + start_time: datetime + distance_m: Optional[float] + duration_s: Optional[float] + elevation_gain_m: Optional[float] + avg_heart_rate: Optional[float] + avg_cadence: Optional[float] + avg_speed_ms: Optional[float] + calories: Optional[float] + polyline: Optional[str] + bounding_box: Optional[dict] + hr_zones: Optional[dict] + named_route_id: Optional[int] + + class Config: + from_attributes = True + + +class ActivityDetail(ActivitySummary): + end_time: Optional[datetime] + elevation_loss_m: Optional[float] + max_heart_rate: Optional[float] + avg_power: Optional[float] + normalized_power: Optional[float] + max_speed_ms: Optional[float] + avg_temperature_c: Optional[float] + training_stress_score: Optional[float] + vo2max_estimate: Optional[float] + + +class DataPointOut(BaseModel): + timestamp: Optional[datetime] + latitude: Optional[float] + longitude: Optional[float] + altitude_m: Optional[float] + heart_rate: Optional[float] + cadence: Optional[float] + speed_ms: Optional[float] + power: Optional[float] + temperature_c: Optional[float] + distance_m: Optional[float] + + class Config: + from_attributes = True + + +class LapOut(BaseModel): + lap_number: int + start_time: Optional[datetime] + duration_s: Optional[float] + distance_m: Optional[float] + avg_heart_rate: Optional[float] + avg_cadence: Optional[float] + avg_speed_ms: Optional[float] + avg_power: Optional[float] + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[ActivitySummary]) +async def list_activities( + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + sport_type: Optional[str] = None, + from_date: Optional[datetime] = None, + to_date: Optional[datetime] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = select(Activity).where(Activity.user_id == current_user.id) + + if sport_type: + q = q.where(Activity.sport_type == sport_type) + if from_date: + q = q.where(Activity.start_time >= from_date) + if to_date: + q = q.where(Activity.start_time <= to_date) + + q = q.order_by(desc(Activity.start_time)) + q = q.offset((page - 1) * per_page).limit(per_page) + + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/{activity_id}", response_model=ActivityDetail) +async def get_activity( + activity_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Activity).where( + Activity.id == activity_id, + Activity.user_id == current_user.id, + ) + ) + activity = result.scalar_one_or_none() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + return activity + + +@router.get("/{activity_id}/data-points", response_model=List[DataPointOut]) +async def get_data_points( + activity_id: int, + downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # Verify ownership + act = await db.execute( + select(Activity).where( + Activity.id == activity_id, + Activity.user_id == current_user.id, + ) + ) + if not act.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Activity not found") + + q = select(ActivityDataPoint).where( + ActivityDataPoint.activity_id == activity_id + ).order_by(ActivityDataPoint.timestamp) + + result = await db.execute(q) + points = result.scalars().all() + + if downsample > 1: + points = points[::downsample] + + return points + + +@router.get("/{activity_id}/laps", response_model=List[LapOut]) +async def get_laps( + activity_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + act = await db.execute( + select(Activity).where( + Activity.id == activity_id, + Activity.user_id == current_user.id, + ) + ) + if not act.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="Activity not found") + + result = await db.execute( + select(ActivityLap) + .where(ActivityLap.activity_id == activity_id) + .order_by(ActivityLap.lap_number) + ) + return result.scalars().all() + + +@router.patch("/{activity_id}/name") +async def rename_activity( + activity_id: int, + body: dict, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Activity).where( + Activity.id == activity_id, + Activity.user_id == current_user.id, + ) + ) + activity = result.scalar_one_or_none() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + activity.name = body.get("name", activity.name) + await db.commit() + return {"id": activity_id, "name": activity.name} + + +@router.delete("/{activity_id}", status_code=204) +async def delete_activity( + activity_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(Activity).where( + Activity.id == activity_id, + Activity.user_id == current_user.id, + ) + ) + activity = result.scalar_one_or_none() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + await db.delete(activity) + await db.commit() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..033843b --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,134 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel +from typing import Optional +import httpx + +from app.core.database import get_db +from app.core.security import verify_password, create_access_token, hash_password, get_current_user +from app.core.config import settings +from app.models.user import User + +router = APIRouter() + + +class Token(BaseModel): + access_token: str + token_type: str + user_id: int + username: str + is_admin: bool + + +class UserOut(BaseModel): + id: int + username: str + email: Optional[str] + is_admin: bool + + class Config: + from_attributes = True + + +@router.post("/token", response_model=Token) +async def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(User).where(User.username == form_data.username) + ) + user = result.scalar_one_or_none() + + if not user or not user.hashed_password: + raise HTTPException(status_code=400, detail="Invalid credentials") + if not verify_password(form_data.password, user.hashed_password): + raise HTTPException(status_code=400, detail="Invalid credentials") + + token = create_access_token({"sub": str(user.id)}) + return Token( + access_token=token, + token_type="bearer", + user_id=user.id, + username=user.username, + is_admin=user.is_admin, + ) + + +@router.get("/me", response_model=UserOut) +async def get_me(current_user: User = Depends(get_current_user)): + return current_user + + +@router.get("/pocketid/available") +async def pocketid_available(): + return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)} + + +@router.get("/pocketid/login-url") +async def pocketid_login_url(): + """Return the OIDC authorization URL for PocketID.""" + if not settings.pocketid_issuer: + raise HTTPException(status_code=404, detail="PocketID not configured") + + params = { + "client_id": settings.pocketid_client_id, + "redirect_uri": "/api/auth/pocketid/callback", + "response_type": "code", + "scope": "openid profile email", + } + from urllib.parse import urlencode + url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}" + return {"url": url} + + +@router.get("/pocketid/callback") +async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)): + """Exchange OIDC code for tokens and create/login user.""" + if not settings.pocketid_issuer: + raise HTTPException(status_code=404, detail="PocketID not configured") + + # Exchange code for tokens + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{settings.pocketid_issuer}/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "/api/auth/pocketid/callback", + "client_id": settings.pocketid_client_id, + "client_secret": settings.pocketid_client_secret, + }, + ) + if resp.status_code != 200: + raise HTTPException(status_code=400, detail="Token exchange failed") + + tokens = resp.json() + userinfo_resp = await client.get( + f"{settings.pocketid_issuer}/userinfo", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + userinfo = userinfo_resp.json() + + sub = userinfo.get("sub") + email = userinfo.get("email") + preferred_username = userinfo.get("preferred_username") or email + + result = await db.execute(select(User).where(User.pocketid_sub == sub)) + user = result.scalar_one_or_none() + + if not user: + user = User( + username=preferred_username, + email=email, + pocketid_sub=sub, + ) + db.add(user) + await db.flush() + + token = create_access_token({"sub": str(user.id)}) + # Redirect to frontend with token + from fastapi.responses import RedirectResponse + return RedirectResponse(url=f"/?token={token}") diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..0bf0f35 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,156 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc, func +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime, date + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User, HealthMetric + +router = APIRouter() + + +class HealthMetricOut(BaseModel): + id: int + date: datetime + resting_hr: Optional[float] + max_hr_day: Optional[float] + avg_hr_day: Optional[float] + hrv_nightly_avg: Optional[float] + hrv_status: Optional[str] + hrv_5min_high: Optional[float] + hrv_5min_low: Optional[float] + sleep_duration_s: Optional[float] + sleep_deep_s: Optional[float] + sleep_light_s: Optional[float] + sleep_rem_s: Optional[float] + sleep_awake_s: Optional[float] + sleep_score: Optional[float] + sleep_start: Optional[datetime] + sleep_end: Optional[datetime] + weight_kg: Optional[float] + bmi: Optional[float] + body_fat_pct: Optional[float] + muscle_mass_kg: Optional[float] + vo2max: Optional[float] + fitness_age: Optional[int] + training_load: Optional[float] + recovery_time_h: Optional[float] + avg_stress: Optional[float] + steps: Optional[int] + floors_climbed: Optional[int] + active_calories: Optional[float] + total_calories: Optional[float] + spo2_avg: Optional[float] + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[HealthMetricOut]) +async def list_health_metrics( + from_date: Optional[datetime] = None, + to_date: Optional[datetime] = None, + limit: int = Query(365, ge=1, le=1000), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = select(HealthMetric).where(HealthMetric.user_id == current_user.id) + if from_date: + q = q.where(HealthMetric.date >= from_date) + if to_date: + q = q.where(HealthMetric.date <= to_date) + q = q.order_by(desc(HealthMetric.date)).limit(limit) + + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/summary") +async def health_summary( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Latest values + 30-day averages for dashboard widgets.""" + # Latest record + latest_result = await db.execute( + select(HealthMetric) + .where(HealthMetric.user_id == current_user.id) + .order_by(desc(HealthMetric.date)) + .limit(1) + ) + latest = latest_result.scalar_one_or_none() + + # 30-day averages + from datetime import timedelta, timezone + cutoff = datetime.now(timezone.utc) - timedelta(days=30) + avg_result = await db.execute( + select( + func.avg(HealthMetric.resting_hr).label("avg_resting_hr"), + func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"), + func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"), + func.avg(HealthMetric.sleep_score).label("avg_sleep_score"), + func.avg(HealthMetric.avg_stress).label("avg_stress"), + func.avg(HealthMetric.steps).label("avg_steps"), + func.avg(HealthMetric.weight_kg).label("avg_weight"), + ).where( + HealthMetric.user_id == current_user.id, + HealthMetric.date >= cutoff, + ) + ) + avgs = avg_result.one() + + return { + "latest": HealthMetricOut.model_validate(latest) if latest else None, + "avg_30d": { + "resting_hr": avgs.avg_resting_hr, + "hrv": avgs.avg_hrv, + "sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None, + "sleep_score": avgs.avg_sleep_score, + "stress": avgs.avg_stress, + "steps": avgs.avg_steps, + "weight_kg": avgs.avg_weight, + }, + } + + +@router.put("/manual") +async def add_manual_metric( + body: dict, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Manually add or update a health metric for a given date.""" + from sqlalchemy.dialects.postgresql import insert as pg_insert + + date_str = body.get("date") + if not date_str: + from fastapi import HTTPException + raise HTTPException(status_code=400, detail="date required") + + metric_date = datetime.fromisoformat(date_str) + + # Check for existing + existing = await db.execute( + select(HealthMetric).where( + HealthMetric.user_id == current_user.id, + func.date(HealthMetric.date) == metric_date.date(), + ) + ) + metric = existing.scalar_one_or_none() + + if metric: + for key, val in body.items(): + if hasattr(metric, key) and key not in ("id", "user_id"): + setattr(metric, key, val) + else: + metric = HealthMetric(user_id=current_user.id, date=metric_date, **{ + k: v for k, v in body.items() + if hasattr(HealthMetric, k) and k not in ("id", "user_id") + }) + db.add(metric) + + await db.commit() + return {"status": "ok"} diff --git a/backend/app/api/records.py b/backend/app/api/records.py new file mode 100644 index 0000000..fdee950 --- /dev/null +++ b/backend/app/api/records.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity + +router = APIRouter() + + +# ─── Personal Records ──────────────────────────────────────────────────────── + +class PROut(BaseModel): + id: int + sport_type: str + distance_m: float + distance_label: str + duration_s: float + achieved_at: datetime + activity_id: int + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[PROut]) +async def list_records( + sport_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + q = select(PersonalRecord).where( + PersonalRecord.user_id == current_user.id, + PersonalRecord.is_current_record == True, + ) + if sport_type: + q = q.where(PersonalRecord.sport_type == sport_type) + q = q.order_by(PersonalRecord.distance_m) + result = await db.execute(q) + return result.scalars().all() + + +@router.get("/history/{distance_label}") +async def record_history( + distance_label: str, + sport_type: str = "running", + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Show progression of a PR over time.""" + result = await db.execute( + select(PersonalRecord).where( + PersonalRecord.user_id == current_user.id, + PersonalRecord.sport_type == sport_type, + PersonalRecord.distance_label == distance_label, + ).order_by(PersonalRecord.achieved_at) + ) + return result.scalars().all() diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 0000000..8c0757a --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,204 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + +from app.core.database import get_db +from app.core.security import get_current_user +from app.models.user import User, NamedRoute, RouteSegment, Activity + +router = APIRouter() + + +class SegmentCreate(BaseModel): + name: str + start_distance_m: float + end_distance_m: float + description: Optional[str] = None + + +class RouteCreate(BaseModel): + name: str + description: Optional[str] = None + sport_type: Optional[str] = None + activity_id: int # use this activity as the reference route + + +class RouteOut(BaseModel): + id: int + name: str + description: Optional[str] + sport_type: Optional[str] + reference_polyline: Optional[str] + bounding_box: Optional[dict] + distance_m: Optional[float] + created_at: datetime + + class Config: + from_attributes = True + + +class SegmentOut(BaseModel): + id: int + name: str + start_distance_m: float + end_distance_m: float + description: Optional[str] + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[RouteOut]) +async def list_routes( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(NamedRoute) + .where(NamedRoute.user_id == current_user.id) + .order_by(desc(NamedRoute.created_at)) + ) + return result.scalars().all() + + +@router.post("/", response_model=RouteOut) +async def create_route( + body: RouteCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # Load the reference activity + act_result = await db.execute( + select(Activity).where( + Activity.id == body.activity_id, + Activity.user_id == current_user.id, + ) + ) + activity = act_result.scalar_one_or_none() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + route = NamedRoute( + user_id=current_user.id, + name=body.name, + description=body.description, + sport_type=body.sport_type or activity.sport_type, + reference_polyline=activity.polyline, + bounding_box=activity.bounding_box, + distance_m=activity.distance_m, + ) + db.add(route) + await db.flush() + + # Link this activity to the route + activity.named_route_id = route.id + await db.commit() + await db.refresh(route) + return route + + +@router.get("/{route_id}", response_model=RouteOut) +async def get_route( + route_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(NamedRoute).where( + NamedRoute.id == route_id, + NamedRoute.user_id == current_user.id, + ) + ) + route = result.scalar_one_or_none() + if not route: + raise HTTPException(status_code=404, detail="Route not found") + return route + + +@router.get("/{route_id}/activities") +async def route_activities( + route_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """All activities on this named route, ordered fastest first.""" + result = await db.execute( + select(Activity).where( + Activity.named_route_id == route_id, + Activity.user_id == current_user.id, + ).order_by(Activity.duration_s) + ) + activities = result.scalars().all() + return [ + { + "id": a.id, + "name": a.name, + "start_time": a.start_time, + "duration_s": a.duration_s, + "distance_m": a.distance_m, + "avg_heart_rate": a.avg_heart_rate, + "avg_speed_ms": a.avg_speed_ms, + } + for a in activities + ] + + +@router.post("/{route_id}/assign-activity") +async def assign_activity_to_route( + route_id: int, + body: dict, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Manually assign an activity to a named route.""" + activity_id = body.get("activity_id") + act_result = await db.execute( + select(Activity).where( + Activity.id == activity_id, + Activity.user_id == current_user.id, + ) + ) + activity = act_result.scalar_one_or_none() + if not activity: + raise HTTPException(status_code=404, detail="Activity not found") + + activity.named_route_id = route_id + await db.commit() + return {"status": "ok"} + + +@router.get("/{route_id}/segments", response_model=List[SegmentOut]) +async def list_segments( + route_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = await db.execute( + select(RouteSegment) + .where(RouteSegment.route_id == route_id) + .order_by(RouteSegment.start_distance_m) + ) + return result.scalars().all() + + +@router.post("/{route_id}/segments", response_model=SegmentOut) +async def create_segment( + route_id: int, + body: SegmentCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + segment = RouteSegment( + route_id=route_id, + name=body.name, + start_distance_m=body.start_distance_m, + end_distance_m=body.end_distance_m, + description=body.description, + ) + db.add(segment) + await db.commit() + await db.refresh(segment) + return segment diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py new file mode 100644 index 0000000..2cfbd37 --- /dev/null +++ b/backend/app/api/upload.py @@ -0,0 +1,134 @@ +import os +import shutil +import zipfile +from pathlib import Path +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from app.core.config import settings +from app.models.user import User +from app.workers.tasks import process_activity_file, process_garmin_health_zip + +router = APIRouter() + +ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"} +MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB + + +def save_upload(upload: UploadFile, dest_dir: Path) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / upload.filename + with open(dest, "wb") as f: + shutil.copyfileobj(upload.file, f) + return dest + + +@router.post("/activity") +async def upload_activity( + file: UploadFile = File(...), + background_tasks: BackgroundTasks = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload a single .fit or .gpx activity file.""" + suffix = Path(file.filename).suffix.lower() + if suffix not in {".fit", ".gpx"}: + raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported") + + dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities" + dest = save_upload(file, dest_dir) + + # Queue processing + task = process_activity_file.delay(str(dest), current_user.id, suffix[1:]) + + return {"task_id": task.id, "status": "queued", "filename": file.filename} + + +@router.post("/garmin-export") +async def upload_garmin_export( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Upload a full Garmin Connect data export ZIP. + Processes all FIT files for activities + wellness data. + """ + if not file.filename.endswith(".zip"): + raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export") + + dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" + dest = save_upload(file, dest_dir) + + # Extract and queue all FIT files + extract_dir = dest_dir / f"garmin_{dest.stem}" + extract_dir.mkdir(exist_ok=True) + + task_ids = [] + with zipfile.ZipFile(dest) as zf: + zf.extractall(extract_dir) + for name in zf.namelist(): + lower = name.lower() + if lower.endswith(".fit"): + fit_path = extract_dir / name + task = process_activity_file.delay(str(fit_path), current_user.id, "fit") + task_ids.append(task.id) + + # Queue health/wellness data extraction + health_task = process_garmin_health_zip.delay(str(dest), current_user.id) + + return { + "status": "queued", + "activity_tasks": len(task_ids), + "health_task": health_task.id, + } + + +@router.post("/strava-export") +async def upload_strava_export( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files).""" + if not file.filename.endswith(".zip"): + raise HTTPException(status_code=400, detail="Please upload a .zip Strava export") + + dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports" + dest = save_upload(file, dest_dir) + + extract_dir = dest_dir / f"strava_{dest.stem}" + extract_dir.mkdir(exist_ok=True) + + task_ids = [] + with zipfile.ZipFile(dest) as zf: + zf.extractall(extract_dir) + for name in zf.namelist(): + lower = name.lower() + if lower.endswith(".fit") or lower.endswith(".gpx"): + file_path = extract_dir / name + ext = Path(name).suffix[1:] + task = process_activity_file.delay(str(file_path), current_user.id, ext) + task_ids.append(task.id) + + return { + "status": "queued", + "activity_tasks": len(task_ids), + } + + +@router.get("/task/{task_id}") +async def check_task_status( + task_id: str, + current_user: User = Depends(get_current_user), +): + """Check the status of an upload processing task.""" + from app.workers.celery_app import celery_app + result = celery_app.AsyncResult(task_id) + return { + "task_id": task_id, + "status": result.status, + "result": result.result if result.ready() else None, + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..fcde462 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,38 @@ +from pydantic_settings import BaseSettings +from pydantic import Field +from typing import Optional + + +class Settings(BaseSettings): + # Database + database_url: str = Field(..., env="DATABASE_URL") + + # Redis + redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL") + + # Auth + secret_key: str = Field(..., env="SECRET_KEY") + algorithm: str = "HS256" + access_token_expire_minutes: int = 60 * 24 * 7 # 7 days + + # Admin account + admin_username: str = Field("admin", env="ADMIN_USERNAME") + admin_password: str = Field(..., env="ADMIN_PASSWORD") + + # PocketID OIDC (optional) + pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER") + pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID") + pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET") + + # Files + file_store_path: str = Field("/data/files", env="FILE_STORE_PATH") + + # Environment + environment: str = Field("production", env="ENVIRONMENT") + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..723f8e9 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,32 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from app.core.config import settings + +engine = create_async_engine( + settings.database_url, + echo=settings.environment == "development", + pool_size=10, + max_overflow=20, +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..0bff6ac --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,55 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.config import settings +from app.core.database import get_db +from app.models.user import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes=settings.access_token_expire_minutes) + ) + to_encode["exp"] = expire + return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + + +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(get_db), +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + result = await db.execute(select(User).where(User.id == int(user_id))) + user = result.scalar_one_or_none() + if user is None: + raise credentials_exception + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..511f1d7 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,71 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +from sqlalchemy import text + +from app.core.database import engine, AsyncSessionLocal, Base +from app.core.config import settings +from app.api import auth, activities, routes, health, records, upload + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Try to enable TimescaleDB hypertable for data points + try: + await conn.execute(text( + "SELECT create_hypertable('activity_data_points', 'timestamp', " + "if_not_exists => TRUE, migrate_data => TRUE)" + )) + except Exception: + pass # Already exists or TimescaleDB not available + + # Seed admin user + async with AsyncSessionLocal() as db: + from sqlalchemy import select + from app.models.user import User + from app.core.security import hash_password + + result = await db.execute( + select(User).where(User.username == settings.admin_username) + ) + if not result.scalar_one_or_none(): + admin = User( + username=settings.admin_username, + hashed_password=hash_password(settings.admin_password), + is_admin=True, + ) + db.add(admin) + await db.commit() + + yield + + +app = FastAPI( + title="FitTracker", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if settings.environment == "development" else [], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) +app.include_router(activities.router, prefix="/api/activities", tags=["activities"]) +app.include_router(routes.router, prefix="/api/routes", tags=["routes"]) +app.include_router(health.router, prefix="/api/health-metrics", tags=["health"]) +app.include_router(records.router, prefix="/api/records", tags=["records"]) +app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) + + +@app.get("/health") +async def healthcheck(): + return {"status": "ok"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..5c4f0c8 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,233 @@ +from sqlalchemy import ( + Column, Integer, String, Float, DateTime, Boolean, + ForeignKey, Text, JSON, Index, UniqueConstraint +) +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from app.core.database import Base + + +def now_utc(): + return datetime.now(timezone.utc) + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True) + username = Column(String(64), unique=True, nullable=False, index=True) + email = Column(String(256), unique=True, nullable=True) + hashed_password = Column(String(256), nullable=True) # null = OIDC-only user + is_admin = Column(Boolean, default=False) + pocketid_sub = Column(String(256), unique=True, nullable=True) + created_at = Column(DateTime(timezone=True), default=now_utc) + + activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan") + health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan") + named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan") + + +class Activity(Base): + __tablename__ = "activities" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + + # Core fields + name = Column(String(256), nullable=False) + sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc. + start_time = Column(DateTime(timezone=True), nullable=False, index=True) + end_time = Column(DateTime(timezone=True), nullable=True) + + # Metrics summary (cached aggregates) + distance_m = Column(Float, nullable=True) # metres + duration_s = Column(Float, nullable=True) # seconds + elevation_gain_m = Column(Float, nullable=True) + elevation_loss_m = Column(Float, nullable=True) + avg_heart_rate = Column(Float, nullable=True) + max_heart_rate = Column(Float, nullable=True) + avg_cadence = Column(Float, nullable=True) + avg_power = Column(Float, nullable=True) + normalized_power = Column(Float, nullable=True) + avg_speed_ms = Column(Float, nullable=True) + max_speed_ms = Column(Float, nullable=True) + avg_temperature_c = Column(Float, nullable=True) + calories = Column(Float, nullable=True) + training_stress_score = Column(Float, nullable=True) + vo2max_estimate = Column(Float, nullable=True) + + # Route reference + named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True) + + # Raw GPS track (encoded polyline for quick map render) + polyline = Column(Text, nullable=True) + bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon} + + # Source file info + source_file = Column(String(512), nullable=True) + source_type = Column(String(32), nullable=True) # fit, gpx, strava_json + garmin_activity_id = Column(String(64), nullable=True, unique=True) + strava_activity_id = Column(String(64), nullable=True, unique=True) + + # HR zones (% of time in each zone) + hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...} + + created_at = Column(DateTime(timezone=True), default=now_utc) + + user = relationship("User", back_populates="activities") + data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan") + named_route = relationship("NamedRoute", back_populates="activities") + laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan") + + +class ActivityDataPoint(Base): + """ + TimescaleDB hypertable - one row per second of activity data. + After creation, converted to hypertable in migration: + SELECT create_hypertable('activity_data_points', 'timestamp'); + """ + __tablename__ = "activity_data_points" + + id = Column(Integer, primary_key=True) + activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True) + timestamp = Column(DateTime(timezone=True), nullable=False) + latitude = Column(Float, nullable=True) + longitude = Column(Float, nullable=True) + altitude_m = Column(Float, nullable=True) + heart_rate = Column(Float, nullable=True) + cadence = Column(Float, nullable=True) + speed_ms = Column(Float, nullable=True) + power = Column(Float, nullable=True) + temperature_c = Column(Float, nullable=True) + distance_m = Column(Float, nullable=True) # cumulative distance + + __table_args__ = ( + Index("ix_adp_activity_time", "activity_id", "timestamp"), + ) + + activity = relationship("Activity", back_populates="data_points") + + +class ActivityLap(Base): + __tablename__ = "activity_laps" + + id = Column(Integer, primary_key=True) + activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True) + lap_number = Column(Integer, nullable=False) + start_time = Column(DateTime(timezone=True), nullable=True) + duration_s = Column(Float, nullable=True) + distance_m = Column(Float, nullable=True) + avg_heart_rate = Column(Float, nullable=True) + avg_cadence = Column(Float, nullable=True) + avg_speed_ms = Column(Float, nullable=True) + avg_power = Column(Float, nullable=True) + + activity = relationship("Activity", back_populates="laps") + + +class NamedRoute(Base): + __tablename__ = "named_routes" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + name = Column(String(256), nullable=False) + description = Column(Text, nullable=True) + sport_type = Column(String(64), nullable=True) + reference_polyline = Column(Text, nullable=True) # canonical route polyline + bounding_box = Column(JSON, nullable=True) + distance_m = Column(Float, nullable=True) + created_at = Column(DateTime(timezone=True), default=now_utc) + + user = relationship("User", back_populates="named_routes") + activities = relationship("Activity", back_populates="named_route") + segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan") + + +class RouteSegment(Base): + """Named sections within a route for targeted comparisons (e.g. 'The big hill')""" + __tablename__ = "route_segments" + + id = Column(Integer, primary_key=True) + route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True) + name = Column(String(256), nullable=False) + start_distance_m = Column(Float, nullable=False) # distance into route where segment starts + end_distance_m = Column(Float, nullable=False) + description = Column(Text, nullable=True) + + route = relationship("NamedRoute", back_populates="segments") + + +class PersonalRecord(Base): + __tablename__ = "personal_records" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False) + sport_type = Column(String(64), nullable=False) + distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195 + distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k" + duration_s = Column(Float, nullable=False) + achieved_at = Column(DateTime(timezone=True), nullable=False) + is_current_record = Column(Boolean, default=True) + + __table_args__ = ( + UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record", + name="uq_pr_current"), + ) + + +class HealthMetric(Base): + """Daily health summary metrics from Garmin Connect / FIT wellness data""" + __tablename__ = "health_metrics" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + date = Column(DateTime(timezone=True), nullable=False) + + # Heart rate + resting_hr = Column(Float, nullable=True) + max_hr_day = Column(Float, nullable=True) + avg_hr_day = Column(Float, nullable=True) + + # HRV + hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc. + hrv_nightly_avg = Column(Float, nullable=True) + hrv_5min_high = Column(Float, nullable=True) + hrv_5min_low = Column(Float, nullable=True) + + # Sleep + sleep_duration_s = Column(Float, nullable=True) + sleep_deep_s = Column(Float, nullable=True) + sleep_light_s = Column(Float, nullable=True) + sleep_rem_s = Column(Float, nullable=True) + sleep_awake_s = Column(Float, nullable=True) + sleep_score = Column(Float, nullable=True) + sleep_start = Column(DateTime(timezone=True), nullable=True) + sleep_end = Column(DateTime(timezone=True), nullable=True) + + # Body composition + weight_kg = Column(Float, nullable=True) + bmi = Column(Float, nullable=True) + body_fat_pct = Column(Float, nullable=True) + muscle_mass_kg = Column(Float, nullable=True) + + # Fitness + vo2max = Column(Float, nullable=True) + fitness_age = Column(Integer, nullable=True) + training_load = Column(Float, nullable=True) + recovery_time_h = Column(Float, nullable=True) + + # Stress & activity + avg_stress = Column(Float, nullable=True) + steps = Column(Integer, nullable=True) + floors_climbed = Column(Integer, nullable=True) + active_calories = Column(Float, nullable=True) + total_calories = Column(Float, nullable=True) + spo2_avg = Column(Float, nullable=True) + + __table_args__ = ( + UniqueConstraint("user_id", "date", name="uq_health_user_date"), + Index("ix_health_user_date", "user_id", "date"), + ) + + user = relationship("User", back_populates="health_metrics") diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/fit_parser.py b/backend/app/services/fit_parser.py new file mode 100644 index 0000000..a6d9fe9 --- /dev/null +++ b/backend/app/services/fit_parser.py @@ -0,0 +1,341 @@ +""" +Parses Garmin .fit files and GPX files into normalized activity data. +Handles full Strava and Garmin data export archives. +""" +import os +import zipfile +import json +import math +from pathlib import Path +from datetime import datetime, timezone +from typing import Optional +import fitparse +import gpxpy +import polyline as polyline_lib + + +def haversine_distance(lat1, lon1, lat2, lon2) -> float: + """Returns distance in metres between two GPS points.""" + R = 6371000 + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2 + return 2 * R * math.asin(math.sqrt(a)) + + +def semicircles_to_degrees(sc: int) -> float: + return sc * (180 / 2**31) + + +def parse_fit_file(filepath: str) -> dict: + """Parse a Garmin .fit file and return normalized activity dict.""" + fit = fitparse.FitFile(filepath) + + data_points = [] + laps = [] + session = {} + + for record in fit.get_messages(): + name = record.name + + if name == "session": + for f in record: + session[f.name] = f.value + + elif name == "lap": + lap = {} + for f in record: + lap[f.name] = f.value + laps.append(lap) + + elif name == "record": + point = {} + for f in record: + point[f.name] = f.value + if point: + # Convert semicircles to degrees + if "position_lat" in point and point["position_lat"] is not None: + point["position_lat"] = semicircles_to_degrees(point["position_lat"]) + if "position_long" in point and point["position_long"] is not None: + point["position_long"] = semicircles_to_degrees(point["position_long"]) + data_points.append(point) + + # Build normalized output + sport = str(session.get("sport", "generic")).lower() + sport_map = { + "running": "running", "cycling": "cycling", "swimming": "swimming", + "hiking": "hiking", "walking": "walking", "generic": "other", + "open_water_swimming": "swimming", "trail_running": "running", + } + sport_type = sport_map.get(sport, sport) + + start_time = session.get("start_time") + if start_time and start_time.tzinfo is None: + start_time = start_time.replace(tzinfo=timezone.utc) + + # Build GPS track for polyline + coords = [ + (p["position_lat"], p["position_long"]) + for p in data_points + if p.get("position_lat") is not None and p.get("position_long") is not None + ] + + encoded_polyline = polyline_lib.encode(coords) if coords else None + bounding_box = _bounding_box(coords) + + # Calculate cumulative distance if not in FIT + cumulative_dist = 0.0 + prev_lat, prev_lon = None, None + normalized_points = [] + for p in data_points: + ts = p.get("timestamp") + if ts and ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + + lat = p.get("position_lat") + lon = p.get("position_long") + + dist = p.get("distance") + if dist is None and lat and lon and prev_lat and prev_lon: + cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon) + dist = cumulative_dist + elif dist is not None: + cumulative_dist = float(dist) + + if lat and lon: + prev_lat, prev_lon = lat, lon + + normalized_points.append({ + "timestamp": ts.isoformat() if ts else None, + "latitude": lat, + "longitude": lon, + "altitude_m": p.get("altitude"), + "heart_rate": p.get("heart_rate"), + "cadence": p.get("cadence"), + "speed_ms": p.get("speed"), + "power": p.get("power"), + "temperature_c": p.get("temperature"), + "distance_m": dist, + }) + + # Parse laps + normalized_laps = [] + for i, lap in enumerate(laps): + ls = lap.get("start_time") + if ls and ls.tzinfo is None: + ls = ls.replace(tzinfo=timezone.utc) + normalized_laps.append({ + "lap_number": i + 1, + "start_time": ls.isoformat() if ls else None, + "duration_s": _safe_float(lap.get("total_elapsed_time")), + "distance_m": _safe_float(lap.get("total_distance")), + "avg_heart_rate": _safe_float(lap.get("avg_heart_rate")), + "avg_cadence": _safe_float(lap.get("avg_cadence")), + "avg_speed_ms": _safe_float(lap.get("avg_speed")), + "avg_power": _safe_float(lap.get("avg_power")), + }) + + return { + "name": session.get("sport", "Activity").title() + " " + ( + start_time.strftime("%Y-%m-%d") if start_time else ""), + "sport_type": sport_type, + "start_time": start_time.isoformat() if start_time else None, + "distance_m": _safe_float(session.get("total_distance")), + "duration_s": _safe_float(session.get("total_elapsed_time")), + "elevation_gain_m": _safe_float(session.get("total_ascent")), + "elevation_loss_m": _safe_float(session.get("total_descent")), + "avg_heart_rate": _safe_float(session.get("avg_heart_rate")), + "max_heart_rate": _safe_float(session.get("max_heart_rate")), + "avg_cadence": _safe_float(session.get("avg_cadence")), + "avg_power": _safe_float(session.get("avg_power")), + "normalized_power": _safe_float(session.get("normalized_power")), + "avg_speed_ms": _safe_float(session.get("avg_speed")), + "max_speed_ms": _safe_float(session.get("max_speed")), + "avg_temperature_c": _safe_float(session.get("avg_temperature")), + "calories": _safe_float(session.get("total_calories")), + "training_stress_score": _safe_float(session.get("training_stress_score")), + "vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device + "polyline": encoded_polyline, + "bounding_box": bounding_box, + "source_type": "fit", + "data_points": normalized_points, + "laps": normalized_laps, + } + + +def parse_gpx_file(filepath: str) -> dict: + """Parse a GPX file into normalized activity dict.""" + with open(filepath) as f: + gpx = gpxpy.parse(f) + + data_points = [] + track = gpx.tracks[0] if gpx.tracks else None + + if not track: + raise ValueError("No tracks found in GPX file") + + for segment in track.segments: + for pt in segment.points: + ts = pt.time + if ts and ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + + extensions = {} + if pt.extensions: + for ext in pt.extensions: + for child in ext: + tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag + try: + extensions[tag] = float(child.text) + except (ValueError, TypeError): + pass + + data_points.append({ + "timestamp": ts.isoformat() if ts else None, + "latitude": pt.latitude, + "longitude": pt.longitude, + "altitude_m": pt.elevation, + "heart_rate": extensions.get("hr"), + "cadence": extensions.get("cad"), + "speed_ms": extensions.get("speed"), + "power": extensions.get("power"), + "temperature_c": extensions.get("temp") or extensions.get("atemp"), + "distance_m": None, + }) + + # Calculate distance and elevation + coords = [(p["latitude"], p["longitude"]) for p in data_points + if p["latitude"] and p["longitude"]] + encoded_polyline = polyline_lib.encode(coords) if coords else None + bounding_box = _bounding_box(coords) + + # Add cumulative distance + total_dist = 0.0 + prev = None + for p in data_points: + if p["latitude"] and p["longitude"]: + if prev: + total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"]) + prev = (p["latitude"], p["longitude"]) + p["distance_m"] = total_dist + + uphill, downhill = 0.0, 0.0 + alts = [p["altitude_m"] for p in data_points if p["altitude_m"]] + for i in range(1, len(alts)): + diff = alts[i] - alts[i-1] + if diff > 0: + uphill += diff + else: + downhill += abs(diff) + + hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]] + start_time_str = data_points[0]["timestamp"] if data_points else None + start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None + end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None + duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None + + sport = "running" # GPX doesn't always include sport; default to running + if track.type: + sport = track.type.lower() + + return { + "name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}", + "sport_type": sport, + "start_time": start_time_str, + "distance_m": total_dist, + "duration_s": duration, + "elevation_gain_m": uphill, + "elevation_loss_m": downhill, + "avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None, + "max_heart_rate": max(hrs) if hrs else None, + "avg_cadence": None, + "avg_power": None, + "normalized_power": None, + "avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None, + "max_speed_ms": None, + "avg_temperature_c": None, + "calories": None, + "training_stress_score": None, + "vo2max_estimate": None, + "polyline": encoded_polyline, + "bounding_box": bounding_box, + "source_type": "gpx", + "data_points": data_points, + "laps": [], + } + + +def parse_strava_export(export_dir: str) -> list[dict]: + """ + Parse a full Strava data export directory. + Structure: activities.csv + activities/ folder with .gpx/.fit.gz files + """ + activities = [] + activities_dir = Path(export_dir) / "activities" + + if not activities_dir.exists(): + return activities + + for fname in sorted(activities_dir.iterdir()): + if fname.suffix in (".fit", ".gpx"): + try: + if fname.suffix == ".fit": + act = parse_fit_file(str(fname)) + else: + act = parse_gpx_file(str(fname)) + act["source_type"] = "strava_" + fname.suffix[1:] + activities.append(act) + except Exception as e: + print(f"Error parsing {fname}: {e}") + + return activities + + +def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict: + """Calculate percentage of time spent in each HR zone.""" + if not max_hr: + return {} + + zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0} + zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + total = 0 + + for p in data_points: + hr = p.get("heart_rate") + if not hr: + continue + pct = hr / max_hr + total += 1 + if pct < zone_bounds[1]: + zones["z1"] += 1 + elif pct < zone_bounds[2]: + zones["z2"] += 1 + elif pct < zone_bounds[3]: + zones["z3"] += 1 + elif pct < zone_bounds[4]: + zones["z4"] += 1 + else: + zones["z5"] += 1 + + if total: + return {k: round(v / total * 100, 1) for k, v in zones.items()} + return {} + + +def _safe_float(val) -> Optional[float]: + try: + return float(val) if val is not None else None + except (TypeError, ValueError): + return None + + +def _bounding_box(coords: list[tuple]) -> Optional[dict]: + if not coords: + return None + lats = [c[0] for c in coords] + lons = [c[1] for c in coords] + return { + "min_lat": min(lats), "max_lat": max(lats), + "min_lon": min(lons), "max_lon": max(lons), + } diff --git a/backend/app/services/route_matcher.py b/backend/app/services/route_matcher.py new file mode 100644 index 0000000..99a46af --- /dev/null +++ b/backend/app/services/route_matcher.py @@ -0,0 +1,190 @@ +""" +Route matching: identifies when multiple activities were on the same route. +Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity. +""" +import math +from typing import Optional +import polyline as polyline_lib +import numpy as np + + +def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]: + return polyline_lib.decode(encoded) + + +def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool: + """Quick check: do two bounding boxes overlap (with a tolerance margin)?""" + return ( + bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and + bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and + bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and + bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg + ) + + +def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]: + """Downsample a track to n evenly-spaced points for DTW efficiency.""" + if len(coords) <= n: + return coords + indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)] + return [coords[i] for i in indices] + + +def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float: + """ + Compute DTW distance between two GPS tracks. + Each point is (lat, lon). Returns average distance in metres per matched pair. + """ + n, m = len(track1), len(track2) + dtw = np.full((n + 1, m + 1), np.inf) + dtw[0][0] = 0.0 + + for i in range(1, n + 1): + for j in range(1, m + 1): + cost = haversine_m(track1[i-1], track2[j-1]) + dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1]) + + return dtw[n][m] / max(n, m) + + +def haversine_m(p1: tuple, p2: tuple) -> float: + R = 6371000 + lat1, lon1 = math.radians(p1[0]), math.radians(p1[1]) + lat2, lon2 = math.radians(p2[0]), math.radians(p2[1]) + dlat = lat2 - lat1 + dlon = lon2 - lon1 + a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2 + return 2 * R * math.asin(math.sqrt(a)) + + +def routes_are_similar( + poly1: str, + poly2: str, + bb1: Optional[dict], + bb2: Optional[dict], + dtw_threshold_m: float = 80.0, +) -> bool: + """ + Returns True if two activities are on sufficiently similar routes. + First does a cheap bounding box check, then DTW on downsampled tracks. + """ + if bb1 and bb2: + if not bounding_boxes_overlap(bb1, bb2): + return False + + try: + coords1 = sample_coords(decode_polyline_to_coords(poly1), 60) + coords2 = sample_coords(decode_polyline_to_coords(poly2), 60) + except Exception: + return False + + if not coords1 or not coords2: + return False + + dist = dtw_distance(coords1, coords2) + return dist < dtw_threshold_m + + +def find_segment_times( + data_points: list[dict], + start_dist_m: float, + end_dist_m: float, +) -> Optional[float]: + """ + Given activity data points (with cumulative distance_m), + find the time to traverse from start_dist_m to end_dist_m. + Returns duration in seconds, or None if not found. + """ + start_time = None + end_time = None + + for p in data_points: + dist = p.get("distance_m") + ts = p.get("timestamp") + if dist is None or ts is None: + continue + + if start_time is None and dist >= start_dist_m: + start_time = ts + + if start_time is not None and dist >= end_dist_m: + end_time = ts + break + + if start_time and end_time: + from datetime import datetime + t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time + t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time + return (t2 - t1).total_seconds() + + return None + + +def find_best_split_time( + data_points: list[dict], + target_distance_m: float, +) -> Optional[float]: + """ + Find the best (fastest) time over any target_distance_m window within an activity. + E.g. fastest 1km split in a 10km run. + Returns duration in seconds. + """ + points_with_dist = [ + p for p in data_points + if p.get("distance_m") is not None and p.get("timestamp") is not None + ] + + if not points_with_dist: + return None + + best = None + j = 0 + + for i, start_p in enumerate(points_with_dist): + start_dist = start_p["distance_m"] + start_ts = start_p["timestamp"] + + # Advance j until distance covered >= target + while j < len(points_with_dist): + end_p = points_with_dist[j] + covered = end_p["distance_m"] - start_dist + if covered >= target_distance_m: + from datetime import datetime + t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts + t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"] + duration = (t2 - t1).total_seconds() + if best is None or duration < best: + best = duration + break + j += 1 + + if j >= len(points_with_dist): + break + + return best + + +STANDARD_DISTANCES = [ + (400, "400m"), + (800, "800m"), + (1000, "1k"), + (1609.34, "1 mile"), + (3000, "3k"), + (5000, "5k"), + (10000, "10k"), + (21097.5, "Half marathon"), + (42195, "Marathon"), + (50000, "50k"), + (100000, "100k"), +] + + +def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]: + """Compute best split times for all standard distances that fit within the activity.""" + results = {} + for dist_m, label in STANDARD_DISTANCES: + if total_distance_m >= dist_m * 0.95: # allow 5% tolerance + best = find_best_split_time(data_points, dist_m) + if best: + results[label] = best + return results diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py new file mode 100644 index 0000000..b34b9b9 --- /dev/null +++ b/backend/app/workers/tasks.py @@ -0,0 +1,257 @@ +""" +Background tasks: activity ingestion, route matching, PR calculation. +""" +import asyncio +from celery import Celery +from app.core.config import settings + +celery_app = Celery( + "fittracker", + broker=settings.redis_url, + backend=settings.redis_url, +) + +celery_app.conf.update( + task_serializer="json", + result_serializer="json", + accept_content=["json"], + timezone="UTC", + enable_utc=True, + task_track_started=True, + worker_prefetch_multiplier=1, +) + + +def run_async(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +@celery_app.task(bind=True, name="process_activity_file") +def process_activity_file(self, file_path: str, user_id: int, source_type: str): + """Parse a FIT/GPX file and insert activity + data points into DB.""" + from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones + from app.services.route_matcher import compute_best_splits, routes_are_similar + from app.core.database import AsyncSessionLocal + from app.models.user import Activity, ActivityDataPoint, ActivityLap, PersonalRecord, HealthMetric + from sqlalchemy import select + from datetime import datetime, timezone + + self.update_state(state="PROGRESS", meta={"step": "parsing"}) + + try: + if source_type == "fit" or file_path.endswith(".fit"): + parsed = parse_fit_file(file_path) + else: + parsed = parse_gpx_file(file_path) + except Exception as e: + raise self.retry(exc=e, countdown=10, max_retries=3) + + async def _insert(): + async with AsyncSessionLocal() as db: + # Check for duplicate + if parsed.get("garmin_activity_id"): + existing = await db.execute( + select(Activity).where( + Activity.garmin_activity_id == parsed["garmin_activity_id"] + ) + ) + if existing.scalar_one_or_none(): + return None + + # HR zones + hr_zones = calculate_hr_zones( + parsed.get("data_points", []), + parsed.get("max_heart_rate") or 190 + ) + + # Create activity + start_time = datetime.fromisoformat(parsed["start_time"]) if parsed.get("start_time") else None + + activity = Activity( + user_id=user_id, + name=parsed["name"], + sport_type=parsed["sport_type"], + start_time=start_time, + distance_m=parsed.get("distance_m"), + duration_s=parsed.get("duration_s"), + elevation_gain_m=parsed.get("elevation_gain_m"), + elevation_loss_m=parsed.get("elevation_loss_m"), + avg_heart_rate=parsed.get("avg_heart_rate"), + max_heart_rate=parsed.get("max_heart_rate"), + avg_cadence=parsed.get("avg_cadence"), + avg_power=parsed.get("avg_power"), + normalized_power=parsed.get("normalized_power"), + avg_speed_ms=parsed.get("avg_speed_ms"), + max_speed_ms=parsed.get("max_speed_ms"), + avg_temperature_c=parsed.get("avg_temperature_c"), + calories=parsed.get("calories"), + training_stress_score=parsed.get("training_stress_score"), + polyline=parsed.get("polyline"), + bounding_box=parsed.get("bounding_box"), + source_file=file_path, + source_type=parsed.get("source_type"), + hr_zones=hr_zones, + ) + db.add(activity) + await db.flush() + + # Insert data points in batches + points = parsed.get("data_points", []) + batch_size = 500 + for i in range(0, len(points), batch_size): + batch = points[i:i+batch_size] + db.add_all([ + ActivityDataPoint( + activity_id=activity.id, + timestamp=datetime.fromisoformat(p["timestamp"]) if p.get("timestamp") else None, + latitude=p.get("latitude"), + longitude=p.get("longitude"), + altitude_m=p.get("altitude_m"), + heart_rate=p.get("heart_rate"), + cadence=p.get("cadence"), + speed_ms=p.get("speed_ms"), + power=p.get("power"), + temperature_c=p.get("temperature_c"), + distance_m=p.get("distance_m"), + ) + for p in batch + ]) + + # Insert laps + for lap in parsed.get("laps", []): + ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None + db.add(ActivityLap( + activity_id=activity.id, + lap_number=lap["lap_number"], + start_time=ls, + duration_s=lap.get("duration_s"), + distance_m=lap.get("distance_m"), + avg_heart_rate=lap.get("avg_heart_rate"), + avg_cadence=lap.get("avg_cadence"), + avg_speed_ms=lap.get("avg_speed_ms"), + avg_power=lap.get("avg_power"), + )) + + await db.commit() + return activity.id + + activity_id = run_async(_insert()) + + if activity_id: + # Queue PR calculation + compute_personal_records.delay(activity_id, user_id, parsed) + + return {"activity_id": activity_id, "status": "ok"} + + +@celery_app.task(name="compute_personal_records") +def compute_personal_records(activity_id: int, user_id: int, parsed: dict): + """Calculate personal records for standard distances from this activity.""" + from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES + from app.core.database import AsyncSessionLocal + from app.models.user import PersonalRecord + from sqlalchemy import select + from datetime import datetime, timezone + + data_points = parsed.get("data_points", []) + total_dist = parsed.get("distance_m", 0) or 0 + sport = parsed.get("sport_type", "running") + start_time_str = parsed.get("start_time") + start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc) + + best_splits = compute_best_splits(data_points, total_dist) + + async def _save(): + async with AsyncSessionLocal() as db: + for label, duration_s in best_splits.items(): + dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None) + if dist_m is None: + continue + + # Check existing record + existing = await db.execute( + select(PersonalRecord).where( + PersonalRecord.user_id == user_id, + PersonalRecord.sport_type == sport, + PersonalRecord.distance_m == dist_m, + PersonalRecord.is_current_record == True, + ) + ) + current = existing.scalar_one_or_none() + + if current is None or duration_s < current.duration_s: + if current: + current.is_current_record = False + db.add(PersonalRecord( + user_id=user_id, + activity_id=activity_id, + sport_type=sport, + distance_m=dist_m, + distance_label=label, + duration_s=duration_s, + achieved_at=start_time, + is_current_record=True, + )) + await db.commit() + + run_async(_save()) + + +@celery_app.task(name="process_garmin_health_zip") +def process_garmin_health_zip(zip_path: str, user_id: int): + """ + Process a Garmin Connect data export zip. + Extracts wellness/sleep/HRV CSV files and inserts health metrics. + """ + import zipfile + import json + import csv + from pathlib import Path + from app.core.database import AsyncSessionLocal + from app.models.user import HealthMetric + from sqlalchemy.dialects.postgresql import insert + from datetime import datetime, timezone + + async def _process(): + async with AsyncSessionLocal() as db: + with zipfile.ZipFile(zip_path) as zf: + names = zf.namelist() + + # Parse daily summary JSON files from Garmin export + for name in names: + if "DailyMetrics" in name and name.endswith(".json"): + with zf.open(name) as f: + try: + data = json.load(f) + except Exception: + continue + + date_str = data.get("calendarDate") or data.get("date") + if not date_str: + continue + + try: + date = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc) + except ValueError: + continue + + metric = HealthMetric( + user_id=user_id, + date=date, + resting_hr=data.get("restingHeartRate"), + steps=data.get("totalSteps"), + floors_climbed=data.get("floorsAscended"), + active_calories=data.get("activeKilocalories"), + total_calories=data.get("totalKilocalories"), + avg_stress=data.get("averageStressLevel"), + spo2_avg=data.get("avgSpo2"), + ) + db.add(metric) + + await db.commit() + + run_async(_process()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..18ce7f8 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,23 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.0 +sqlalchemy[asyncio]==2.0.30 +asyncpg==0.29.0 +alembic==1.13.1 +pydantic==2.7.1 +pydantic-settings==2.2.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +httpx==0.27.0 +redis[hiredis]==5.0.4 +celery[redis]==5.4.0 +fitparse==1.2.0 +gpxpy==1.6.2 +numpy==1.26.4 +scipy==1.13.0 +geopy==2.4.1 +polyline==2.0.2 +Pillow==10.3.0 +aiofiles==23.2.1 +python-dateutil==2.9.0 +pytz==2024.1 diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..71734ae --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,114 @@ +version: "3.9" + +# FitTracker — standalone deployment +# +# 1. Copy this file somewhere on your server (no other files needed) +# 2. Run: docker compose up -d +# 3. Visit http://localhost +# +# Images are pulled from your Gitea container registry automatically. +# To update to the latest build: docker compose pull && docker compose up -d + +# ── Replace these with your actual Gitea host and username ─────────────────── +x-registry: ®istry gitea.yourdomain.com/yourusername +# ───────────────────────────────────────────────────────────────────────────── + +services: + db: + image: timescale/timescaledb:latest-pg16 + container_name: fittracker_db + restart: unless-stopped + environment: + POSTGRES_DB: fittracker + POSTGRES_USER: ${DB_USER:-fittracker} + POSTGRES_PASSWORD: ${DB_PASSWORD:-fittracker} + volumes: + - db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fittracker} -d fittracker"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: fittracker_redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD:-fittracker} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-fittracker}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + backend: + image: gitea.yourdomain.com/yourusername/fittracker-backend:latest + container_name: fittracker_backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${DB_USER:-fittracker}:${DB_PASSWORD:-fittracker}@db:5432/fittracker + REDIS_URL: redis://:${REDIS_PASSWORD:-fittracker}@redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + POCKETID_ISSUER: ${POCKETID_ISSUER:-} + POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-} + POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-} + FILE_STORE_PATH: /data/files + ENVIRONMENT: production + volumes: + - file_data:/data/files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + + worker: + image: gitea.yourdomain.com/yourusername/fittracker-worker:latest + container_name: fittracker_worker + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${DB_USER:-fittracker}:${DB_PASSWORD:-fittracker}@db:5432/fittracker + REDIS_URL: redis://:${REDIS_PASSWORD:-fittracker}@redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32} + FILE_STORE_PATH: /data/files + volumes: + - file_data:/data/files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + image: gitea.yourdomain.com/yourusername/fittracker-frontend:latest + container_name: fittracker_frontend + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: fittracker_nginx + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend + +volumes: + db_data: + redis_data: + file_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..801c17d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,111 @@ +version: "3.9" + +services: + db: + image: timescale/timescaledb:latest-pg16 + container_name: fittracker_db + restart: unless-stopped + environment: + POSTGRES_DB: fittracker + POSTGRES_USER: ${DB_USER:-fittracker} + POSTGRES_PASSWORD: ${DB_PASSWORD:-fittracker} + volumes: + - db_data:/var/lib/postgresql/data + - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fittracker} -d fittracker"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + + redis: + image: redis:7-alpine + container_name: fittracker_redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD:-fittracker} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-fittracker}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: fittracker_backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${DB_USER:-fittracker}:${DB_PASSWORD:-fittracker}@db:5432/fittracker + REDIS_URL: redis://:${REDIS_PASSWORD:-fittracker}@redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + POCKETID_ISSUER: ${POCKETID_ISSUER:-} + POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-} + POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-} + FILE_STORE_PATH: /data/files + ENVIRONMENT: ${ENVIRONMENT:-production} + volumes: + - file_data:/data/files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s + + worker: + build: + context: ./backend + dockerfile: Dockerfile.worker + container_name: fittracker_worker + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${DB_USER:-fittracker}:${DB_PASSWORD:-fittracker}@db:5432/fittracker + REDIS_URL: redis://:${REDIS_PASSWORD:-fittracker}@redis:6379/0 + SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars} + FILE_STORE_PATH: /data/files + volumes: + - file_data:/data/files + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: ${VITE_API_URL:-/api} + VITE_MAPBOX_TOKEN: ${VITE_MAPBOX_TOKEN:-} + container_name: fittracker_frontend + restart: unless-stopped + + nginx: + image: nginx:alpine + container_name: fittracker_nginx + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend + +volumes: + db_data: + redis_data: + file_data: diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..ee70c0a --- /dev/null +++ b/docker/init.sql @@ -0,0 +1,7 @@ +-- Enable TimescaleDB extension +CREATE EXTENSION IF NOT EXISTS timescaledb; +CREATE EXTENSION IF NOT EXISTS postgis; + +-- Activity data points will use TimescaleDB hypertable for efficient +-- time-series queries on HR, cadence, power, temperature, etc. +-- Tables are created by Alembic migrations; this just ensures extensions exist. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7649402 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +COPY . . +ARG VITE_API_URL=/api +ARG VITE_MAPBOX_TOKEN= +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN + +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-spa.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e1b2ecf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + FitTracker + + + +
+ + + diff --git a/frontend/nginx-spa.conf b/frontend/nginx-spa.conf new file mode 100644 index 0000000..55f7e0a --- /dev/null +++ b/frontend/nginx-spa.conf @@ -0,0 +1,14 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..dc5098d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "fittracker-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "leaflet": "^1.9.4", + "react-leaflet": "^4.2.1", + "recharts": "^2.12.7", + "date-fns": "^3.6.0", + "clsx": "^2.1.1", + "zustand": "^4.5.2", + "@tanstack/react-query": "^5.40.0", + "axios": "^1.7.2", + "react-dropzone": "^14.2.3", + "@polyline-codec/core": "^2.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.2.13", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..4b1dc07 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,59 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import { useEffect } from 'react' +import { useAuthStore } from './hooks/useAuth' +import Layout from './components/ui/Layout' +import LoginPage from './pages/LoginPage' +import DashboardPage from './pages/DashboardPage' +import ActivitiesPage from './pages/ActivitiesPage' +import ActivityDetailPage from './pages/ActivityDetailPage' +import HealthPage from './pages/HealthPage' +import RoutesPage from './pages/RoutesPage' +import RecordsPage from './pages/RecordsPage' +import UploadPage from './pages/UploadPage' + +function RequireAuth({ children }) { + const token = useAuthStore((s) => s.token) + if (!token) return + return children +} + +export default function App() { + const { token, fetchUser } = useAuthStore() + + useEffect(() => { + if (token) fetchUser() + }, [token]) + + // Handle token from PocketID callback URL + useEffect(() => { + const params = new URLSearchParams(window.location.search) + const urlToken = params.get('token') + if (urlToken) { + localStorage.setItem('token', urlToken) + useAuthStore.setState({ token: urlToken }) + window.history.replaceState({}, '', '/') + } + }, []) + + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx new file mode 100644 index 0000000..59610dd --- /dev/null +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from 'react' +import L from 'leaflet' +import { sportColor } from '../../utils/format' + +// Fix Leaflet default icon issue with bundlers +delete L.Icon.Default.prototype._getIconUrl +L.Icon.Default.mergeOptions({ + iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', + iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', +}) + +function decodePolyline(encoded) { + // Simple polyline decoder + const coords = [] + let index = 0, lat = 0, lng = 0 + + while (index < encoded.length) { + let b, shift = 0, result = 0 + do { + b = encoded.charCodeAt(index++) - 63 + result |= (b & 0x1f) << shift + shift += 5 + } while (b >= 0x20) + lat += (result & 1) ? ~(result >> 1) : result >> 1 + + shift = 0; result = 0 + do { + b = encoded.charCodeAt(index++) - 63 + result |= (b & 0x1f) << shift + shift += 5 + } while (b >= 0x20) + lng += (result & 1) ? ~(result >> 1) : result >> 1 + + coords.push([lat / 1e5, lng / 1e5]) + } + return coords +} + +export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) { + const mapRef = useRef(null) + const mapInstanceRef = useRef(null) + const markerRef = useRef(null) + const trackRef = useRef(null) + + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return + + mapInstanceRef.current = L.map(mapRef.current, { + zoomControl: true, + attributionControl: true, + }) + + // Use CartoDB dark tiles (no API key needed) + L.tileLayer( + 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + { + attribution: '© OSM © CARTO', + maxZoom: 19, + } + ).addTo(mapInstanceRef.current) + + return () => { + mapInstanceRef.current?.remove() + mapInstanceRef.current = null + } + }, []) + + // Draw route when polyline changes + useEffect(() => { + if (!mapInstanceRef.current || !polyline) return + + if (trackRef.current) { + trackRef.current.remove() + } + + const coords = decodePolyline(polyline) + if (!coords.length) return + + trackRef.current = L.polyline(coords, { + color: sportColor(sportType), + weight: 3, + opacity: 0.9, + }).addTo(mapInstanceRef.current) + + mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) + + // Start/end markers + if (coords.length > 0) { + const startIcon = L.divIcon({ + html: '
', + iconSize: [12, 12], iconAnchor: [6, 6], className: '', + }) + const endIcon = L.divIcon({ + html: '
', + iconSize: [12, 12], iconAnchor: [6, 6], className: '', + }) + L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current) + L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current) + } + }, [polyline, sportType]) + + // Move position marker when timeline is hovered + useEffect(() => { + if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return + + const point = dataPoints.find(p => p.distance_m >= hoveredDistance) + if (!point?.latitude || !point?.longitude) return + + if (markerRef.current) { + markerRef.current.setLatLng([point.latitude, point.longitude]) + } else { + const icon = L.divIcon({ + html: '
', + iconSize: [14, 14], iconAnchor: [7, 7], className: '', + }) + markerRef.current = L.marker([point.latitude, point.longitude], { icon }) + .addTo(mapInstanceRef.current) + } + }, [hoveredDistance, dataPoints]) + + return
+} diff --git a/frontend/src/components/activity/HRZoneBar.jsx b/frontend/src/components/activity/HRZoneBar.jsx new file mode 100644 index 0000000..ce795e5 --- /dev/null +++ b/frontend/src/components/activity/HRZoneBar.jsx @@ -0,0 +1,43 @@ +const ZONE_CONFIG = [ + { key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' }, + { key: 'z2', label: 'Z2 Base', color: '#34d399' }, + { key: 'z3', label: 'Z3 Tempo', color: '#fbbf24' }, + { key: 'z4', label: 'Z4 Threshold', color: '#f97316' }, + { key: 'z5', label: 'Z5 Max', color: '#f43f5e' }, +] + +export default function HRZoneBar({ zones }) { + return ( +
+ {/* Stacked bar */} +
+ {ZONE_CONFIG.map(({ key, color }) => { + const pct = zones[key] || 0 + if (pct < 0.5) return null + return ( +
+ ) + })} +
+ + {/* Legend */} +
+ {ZONE_CONFIG.map(({ key, label, color }) => { + const pct = zones[key] || 0 + return ( +
+
+ {label} + {pct}% +
+ ) + })} +
+
+ ) +} diff --git a/frontend/src/components/activity/LapTable.jsx b/frontend/src/components/activity/LapTable.jsx new file mode 100644 index 0000000..5f70ac3 --- /dev/null +++ b/frontend/src/components/activity/LapTable.jsx @@ -0,0 +1,40 @@ +import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format' + +export default function LapTable({ laps, sportType }) { + return ( +
+ + + + + + + + + + + + + + {laps.map((lap) => ( + + + + + + + + + + ))} + +
LapDistanceTimePaceAvg HRCadencePower
{lap.lap_number}{formatDistance(lap.distance_m)}{formatDuration(lap.duration_s)}{formatPace(lap.avg_speed_ms, sportType)} + {formatHeartRate(lap.avg_heart_rate)} + + {lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'} + + {lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'} +
+
+ ) +} diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx new file mode 100644 index 0000000..268dbae --- /dev/null +++ b/frontend/src/components/activity/MetricTimeline.jsx @@ -0,0 +1,156 @@ +import { useMemo, useCallback } from 'react' +import { + ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, ReferenceLine, +} from 'recharts' +import { formatDuration, formatPace } from '../../utils/format' + +function downsample(points, maxPoints = 500) { + if (points.length <= maxPoints) return points + const step = Math.ceil(points.length / maxPoints) + return points.filter((_, i) => i % step === 0) +} + +function buildChartData(dataPoints, activeMetrics) { + return dataPoints + .filter(p => p.timestamp) + .map(p => { + const row = { distance_m: p.distance_m ?? 0 } + for (const key of activeMetrics) { + row[key] = p[key] ?? null + } + return row + }) +} + +const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => { + if (!active || !payload?.length) return null + + if (onHover) onHover(label) + + return ( +
+

{(label / 1000).toFixed(2)} km

+ {payload.map(entry => { + const metric = metrics.find(m => m.key === entry.dataKey) + if (!metric || entry.value == null) return null + let display = entry.value.toFixed(1) + if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType) + else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm` + else if (entry.dataKey === 'cadence') display = `${Math.round(entry.value)} rpm` + else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W` + else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C` + else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m` + return ( +
+ + {metric.label}: + {display} +
+ ) + })} +
+ ) +} + +export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) { + const chartData = useMemo(() => + downsample(buildChartData(dataPoints, activeMetrics)), + [dataPoints, activeMetrics] + ) + + const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key)) + + // Build per-metric Y-axis domains + const domains = useMemo(() => { + const result = {} + for (const m of activeMetricConfigs) { + const vals = chartData.map(p => p[m.key]).filter(v => v != null) + if (!vals.length) continue + const min = Math.min(...vals) + const max = Math.max(...vals) + const pad = (max - min) * 0.1 || 1 + result[m.key] = [min - pad, max + pad] + } + return result + }, [chartData, activeMetricConfigs]) + + if (!chartData.length) { + return ( +
+ No timeline data available +
+ ) + } + + return ( +
+ {activeMetricConfigs.map((metric, idx) => { + const domain = domains[metric.key] || ['auto', 'auto'] + const data = chartData.filter(p => p[metric.key] != null) + if (!data.length) return null + + return ( +
+
+ + {metric.label} + + {metric.unit && ( + ({metric.unit}) + )} +
+ + + + `${(v / 1000).toFixed(1)}`} + tick={{ fontSize: 10, fill: '#6b7280' }} + axisLine={false} + tickLine={false} + hide={idx < activeMetricConfigs.length - 1} + /> + { + if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}` + return Math.round(v) + }} + /> + + } + isAnimationActive={false} + /> + + + +
+ ) + })} + + {/* Shared distance axis label */} +

Distance (km)

+
+ ) +} diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx new file mode 100644 index 0000000..853dec7 --- /dev/null +++ b/frontend/src/components/ui/Layout.jsx @@ -0,0 +1,74 @@ +import { Outlet, NavLink, useNavigate } from 'react-router-dom' +import { useAuthStore } from '../../hooks/useAuth' + +const nav = [ + { to: '/', label: 'Dashboard', icon: '📊', exact: true }, + { to: '/activities', label: 'Activities', icon: '🏃' }, + { to: '/health', label: 'Health', icon: '❤️' }, + { to: '/routes', label: 'Routes', icon: '🗺️' }, + { to: '/records', label: 'Records', icon: '🏆' }, + { to: '/upload', label: 'Import', icon: '⬆️' }, +] + +export default function Layout() { + const { user, logout } = useAuthStore() + const navigate = useNavigate() + + const handleLogout = () => { + logout() + navigate('/login') + } + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ) +} diff --git a/frontend/src/components/ui/StatCard.jsx b/frontend/src/components/ui/StatCard.jsx new file mode 100644 index 0000000..927dfd5 --- /dev/null +++ b/frontend/src/components/ui/StatCard.jsx @@ -0,0 +1,18 @@ +const accentColors = { + default: 'text-white', + red: 'text-red-400', + blue: 'text-blue-400', + green: 'text-green-400', + orange: 'text-orange-400', + purple: 'text-purple-400', +} + +export default function StatCard({ label, value, accent = 'default', sub }) { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ) +} diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js new file mode 100644 index 0000000..2a01804 --- /dev/null +++ b/frontend/src/hooks/useAuth.js @@ -0,0 +1,41 @@ +import { create } from 'zustand' +import api from '../utils/api' + +export const useAuthStore = create((set) => ({ + token: localStorage.getItem('token'), + user: null, + isLoading: false, + + login: async (username, password) => { + set({ isLoading: true }) + try { + const params = new URLSearchParams() + params.append('username', username) + params.append('password', password) + const { data } = await api.post('/auth/token', params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + localStorage.setItem('token', data.access_token) + set({ token: data.access_token, user: data, isLoading: false }) + return true + } catch (e) { + set({ isLoading: false }) + throw e + } + }, + + logout: () => { + localStorage.removeItem('token') + set({ token: null, user: null }) + }, + + fetchUser: async () => { + try { + const { data } = await api.get('/auth/me') + set({ user: data }) + } catch { + set({ token: null, user: null }) + localStorage.removeItem('token') + } + }, +})) diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..61398fa --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,33 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-950 text-gray-100 antialiased; + font-family: system-ui, -apple-system, sans-serif; + } +} + +/* Leaflet dark mode fixes */ +.leaflet-container { + background: #1a1a2e; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } + +/* HR zone colours */ +.zone-1 { color: #60a5fa; } +.zone-2 { color: #34d399; } +.zone-3 { color: #fbbf24; } +.zone-4 { color: #f97316; } +.zone-5 { color: #f43f5e; } + +.zone-bg-1 { background-color: #1e3a5f; } +.zone-bg-2 { background-color: #065f46; } +.zone-bg-3 { background-color: #78350f; } +.zone-bg-4 { background-color: #7c2d12; } +.zone-bg-5 { background-color: #881337; } diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..2f6b453 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 60_000, retry: 1 }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + +) diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx new file mode 100644 index 0000000..e173311 --- /dev/null +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -0,0 +1,147 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import api from '../utils/api' +import { + formatDuration, formatDistance, formatPace, formatHeartRate, + formatDate, sportIcon, sportColor, +} from '../utils/format' + +const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking'] + +export default function ActivitiesPage() { + const [sport, setSport] = useState('all') + const [page, setPage] = useState(1) + + const { data: activities, isLoading } = useQuery({ + queryKey: ['activities', sport, page], + queryFn: () => + api.get('/activities/', { + params: { + sport_type: sport === 'all' ? undefined : sport, + page, + per_page: 20, + }, + }).then(r => r.data), + }) + + return ( +
+
+

Activities

+ + + Import + +
+ + {/* Sport filter */} +
+ {SPORTS.map(s => ( + + ))} +
+ + {/* Activity list */} + {isLoading ? ( +
Loading…
+ ) : ( +
+ {activities?.map(activity => ( + + {/* Sport indicator */} +
+ {sportIcon(activity.sport_type)} +
+ + {/* Name + date */} +
+

+ {activity.name} +

+

{formatDate(activity.start_time)}

+
+ + {/* Metrics */} +
+
+

{formatDistance(activity.distance_m)}

+

distance

+
+
+

{formatDuration(activity.duration_s)}

+

time

+
+
+

{formatPace(activity.avg_speed_ms, activity.sport_type)}

+

pace

+
+
+

{formatHeartRate(activity.avg_heart_rate)}

+

avg HR

+
+
+

+ {activity.elevation_gain_m ? `↑ ${Math.round(activity.elevation_gain_m)}m` : '--'} +

+

elev

+
+
+ + + + ))} + + {activities?.length === 0 && ( +
+

🏃

+

No activities yet

+

+ Import your Garmin or Strava data to get started +

+
+ )} +
+ )} + + {/* Pagination */} + {activities?.length === 20 && ( +
+ + Page {page} + +
+ )} +
+ ) +} diff --git a/frontend/src/pages/ActivityDetailPage.jsx b/frontend/src/pages/ActivityDetailPage.jsx new file mode 100644 index 0000000..943117b --- /dev/null +++ b/frontend/src/pages/ActivityDetailPage.jsx @@ -0,0 +1,158 @@ +import { useParams } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { useState, useMemo } from 'react' +import api from '../utils/api' +import ActivityMap from '../components/activity/ActivityMap' +import MetricTimeline from '../components/activity/MetricTimeline' +import HRZoneBar from '../components/activity/HRZoneBar' +import LapTable from '../components/activity/LapTable' +import StatCard from '../components/ui/StatCard' +import { + formatDuration, formatDistance, formatPace, formatElevation, + formatHeartRate, formatDateTime, sportIcon, +} from '../utils/format' + +const METRICS = [ + { key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' }, + { key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' }, + { key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' }, + { key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' }, + { key: 'power', label: 'Power', unit: 'W', color: '#a855f7' }, + { key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' }, +] + +export default function ActivityDetailPage() { + const { id } = useParams() + const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m']) + const [hoveredDistance, setHoveredDistance] = useState(null) + + const { data: activity, isLoading } = useQuery({ + queryKey: ['activity', id], + queryFn: () => api.get(`/activities/${id}`).then(r => r.data), + }) + + const { data: dataPoints } = useQuery({ + queryKey: ['activity-points', id], + queryFn: () => api.get(`/activities/${id}/data-points?downsample=3`).then(r => r.data), + enabled: !!activity, + }) + + const { data: laps } = useQuery({ + queryKey: ['activity-laps', id], + queryFn: () => api.get(`/activities/${id}/laps`).then(r => r.data), + enabled: !!activity, + }) + + const toggleMetric = (key) => { + setActiveMetrics(prev => + prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] + ) + } + + if (isLoading) { + return ( +
+
Loading activity…
+
+ ) + } + + if (!activity) return null + + const speed = activity.avg_speed_ms + const pace = formatPace(speed, activity.sport_type) + + return ( +
+ {/* Header */} +
+
+
+ {sportIcon(activity.sport_type)} +

{activity.name}

+
+

{formatDateTime(activity.start_time)}

+
+
+ + {/* Summary stats */} +
+ + + + + + +
+ + {/* Secondary stats */} +
+ + + + + + +
+ + {/* Map */} +
+ +
+ + {/* HR Zones */} + {activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && ( +
+

Heart Rate Zones

+ +
+ )} + + {/* Metric selector */} +
+
+

Activity Timeline

+
+ {METRICS.map(({ key, label, color }) => ( + + ))} +
+
+ + {dataPoints && ( + + )} +
+ + {/* Laps */} + {laps && laps.length > 0 && ( +
+

Laps

+ +
+ )} +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..e5aeecb --- /dev/null +++ b/frontend/src/pages/DashboardPage.jsx @@ -0,0 +1,197 @@ +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { format, subDays, startOfWeek } from 'date-fns' +import api from '../utils/api' +import StatCard from '../components/ui/StatCard' +import { + formatDuration, formatDistance, formatPace, formatHeartRate, + formatDate, sportIcon, formatSleep, +} from '../utils/format' + +function WeeklyChart({ activities }) { + if (!activities?.length) return null + + // Build last 8 weeks of distance data + const weeks = {} + activities.forEach(a => { + const week = format(startOfWeek(new Date(a.start_time)), 'MMM d') + if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 } + weeks[week].km += (a.distance_m || 0) / 1000 + weeks[week].runs++ + }) + + const data = Object.values(weeks).slice(-8) + + return ( + + + + + `${v.toFixed(0)}`} /> + [`${v.toFixed(1)} km`, 'Distance']} + /> + + + + ) +} + +export default function DashboardPage() { + const { data: recentActivities } = useQuery({ + queryKey: ['activities-recent'], + queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data), + }) + + const { data: allActivities } = useQuery({ + queryKey: ['activities-all-chart'], + queryFn: () => + api.get('/activities/', { + params: { + per_page: 100, + from_date: subDays(new Date(), 60).toISOString(), + }, + }).then(r => r.data), + }) + + const { data: healthSummary } = useQuery({ + queryKey: ['health-summary'], + queryFn: () => api.get('/health-metrics/summary').then(r => r.data), + }) + + const { data: records } = useQuery({ + queryKey: ['records-running'], + queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data), + }) + + const latest = healthSummary?.latest + const totalActivities = recentActivities?.length ?? 0 + const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0 + + return ( +
+
+

Dashboard

+ + + Import data + +
+ + {/* Top stats */} +
+ + + + +
+ +
+ {/* Weekly distance chart */} +
+

Weekly distance (km)

+ +
+ + {/* Health snapshot */} +
+

Health today

+ {latest ? ( + <> +
+ HRV + {latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'} +
+
+ Sleep score + {latest.sleep_score ? Math.round(latest.sleep_score) : '--'} +
+
+ Steps + {latest.steps?.toLocaleString() ?? '--'} +
+
+ VO2 Max + {latest.vo2max ? latest.vo2max.toFixed(1) : '--'} +
+
+ Stress + {latest.avg_stress ? Math.round(latest.avg_stress) : '--'} +
+ + View full health dashboard → + + + ) : ( +

No health data. Import a Garmin export.

+ )} +
+
+ + {/* Recent activities */} +
+
+

Recent activities

+ View all → +
+
+ {recentActivities?.slice(0, 5).map(activity => ( + + {sportIcon(activity.sport_type)} +
+

{activity.name}

+

{formatDate(activity.start_time)}

+
+
+
+

{formatDistance(activity.distance_m)}

+

dist

+
+
+

{formatDuration(activity.duration_s)}

+

time

+
+
+

{formatHeartRate(activity.avg_heart_rate)}

+

HR

+
+
+ + ))} + {!recentActivities?.length && ( +

+ No activities yet — import some data +

+ )} +
+
+ + {/* PRs snapshot */} + {records?.length > 0 && ( +
+
+

Running PRs

+ View all → +
+
+ {records.slice(0, 5).map(rec => ( +
+

{rec.distance_label}

+

{formatDuration(rec.duration_s)}

+
+ ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx new file mode 100644 index 0000000..3c18e53 --- /dev/null +++ b/frontend/src/pages/HealthPage.jsx @@ -0,0 +1,272 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + LineChart, Line, AreaChart, Area, BarChart, Bar, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, +} from 'recharts' +import { format, subDays } from 'date-fns' +import api from '../utils/api' +import StatCard from '../components/ui/StatCard' +import { formatSleep, formatWeight, formatHeartRate } from '../utils/format' + +const RANGES = [ + { label: '2W', days: 14 }, + { label: '1M', days: 30 }, + { label: '3M', days: 90 }, + { label: '6M', days: 180 }, + { label: '1Y', days: 365 }, +] + +function MetricChart({ data, dataKey, color, formatter, height = 140 }) { + return ( + + + + + + + + + + format(new Date(d), 'MMM d')} + interval="preserveStartEnd" + /> + + format(new Date(d), 'MMM d, yyyy')} + formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} + /> + + + + ) +} + +function SleepChart({ data }) { + const chartData = data.map(d => ({ + date: d.date, + deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null, + rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null, + light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null, + awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null, + })) + + return ( + + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + `${v}h`} /> + format(new Date(d), 'MMM d, yyyy')} /> + + + + + + + ) +} + +export default function HealthPage() { + const [rangeDays, setRangeDays] = useState(30) + + const fromDate = subDays(new Date(), rangeDays).toISOString() + + const { data: summary } = useQuery({ + queryKey: ['health-summary'], + queryFn: () => api.get('/health-metrics/summary').then(r => r.data), + }) + + const { data: metrics } = useQuery({ + queryKey: ['health-metrics', rangeDays], + queryFn: () => + api.get('/health-metrics/', { + params: { from_date: fromDate, limit: rangeDays }, + }).then(r => r.data.reverse()), + }) + + const latest = summary?.latest + const avg30 = summary?.avg_30d + + return ( +
+

Health

+ + {/* Summary cards */} +
+ + + + + + + + +
+ + {/* Range selector */} +
+ {RANGES.map(({ label, days }) => ( + + ))} +
+ + {metrics && metrics.length > 0 ? ( +
+ + {/* Resting HR */} +
+

Resting Heart Rate

+ `${Math.round(v)} bpm`} + /> +
+ + {/* HRV */} +
+

HRV (nightly avg)

+ `${Math.round(v)} ms`} + /> +
+ + {/* Sleep */} +
+

Sleep Stages

+ +
+ {[ + { label: 'Deep', color: '#6366f1' }, + { label: 'REM', color: '#8b5cf6' }, + { label: 'Light', color: '#a78bfa' }, + { label: 'Awake', color: '#374151' }, + ].map(({ label, color }) => ( +
+
+ {label} +
+ ))} +
+
+ + {/* Weight */} +
+

Weight

+ `${v.toFixed(1)} kg`} + /> +
+ + {/* VO2 Max */} +
+

VO2 Max

+ v.toFixed(1)} + /> +
+ + {/* Steps */} +
+

Daily Steps

+ + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> + format(new Date(d), 'MMM d, yyyy')} /> + + + +
+
+ ) : ( +
+

📊

+

No health data yet

+

Import a Garmin export to see your health trends

+
+ )} +
+ ) +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 0000000..da70919 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuthStore } from '../hooks/useAuth' +import { useQuery } from '@tanstack/react-query' +import api from '../utils/api' + +export default function LoginPage() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const { login, isLoading } = useAuthStore() + const navigate = useNavigate() + + const { data: pocketidData } = useQuery({ + queryKey: ['pocketid-available'], + queryFn: () => api.get('/auth/pocketid/available').then(r => r.data), + }) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + try { + await login(username, password) + navigate('/') + } catch (err) { + setError(err.response?.data?.detail || 'Login failed') + } + } + + const handlePocketID = async () => { + const { data } = await api.get('/auth/pocketid/login-url') + window.location.href = data.url + } + + return ( +
+
+
+

+ FitTracker +

+

Your personal fitness dashboard

+
+ +
+
+
+ + setUsername(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + autoComplete="current-password" + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+ + {pocketidData?.available && ( + <> +
+
+ or +
+
+ + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/RecordsPage.jsx b/frontend/src/pages/RecordsPage.jsx new file mode 100644 index 0000000..98e4c2e --- /dev/null +++ b/frontend/src/pages/RecordsPage.jsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { format } from 'date-fns' +import api from '../utils/api' +import { formatDuration, formatDate } from '../utils/format' + +const SPORTS = ['running', 'cycling', 'swimming'] + +const DISTANCE_ORDER = [ + '400m', '800m', '1k', '1 mile', '3k', '5k', '10k', + 'Half marathon', 'Marathon', '50k', '100k', +] + +export default function RecordsPage() { + const [sport, setSport] = useState('running') + const [selectedDistance, setSelectedDistance] = useState(null) + + const { data: records } = useQuery({ + queryKey: ['records', sport], + queryFn: () => api.get('/records/', { params: { sport_type: sport } }).then(r => r.data), + }) + + const { data: history } = useQuery({ + queryKey: ['record-history', selectedDistance, sport], + queryFn: () => + api.get(`/records/history/${encodeURIComponent(selectedDistance)}`, { + params: { sport_type: sport }, + }).then(r => r.data), + enabled: !!selectedDistance, + }) + + // Sort by standard distance order + const sortedRecords = records?.slice().sort((a, b) => { + const ai = DISTANCE_ORDER.indexOf(a.distance_label) + const bi = DISTANCE_ORDER.indexOf(b.distance_label) + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi) + }) + + return ( +
+

Personal Records

+ + {/* Sport selector */} +
+ {SPORTS.map(s => ( + + ))} +
+ + {sortedRecords?.length === 0 && ( +
+

🏆

+

No records yet — import activities to track your best times

+
+ )} + +
+ {/* Records table */} +
+ + + + + + + + + + {sortedRecords?.map(rec => ( + setSelectedDistance(rec.distance_label)} + className={`border-b border-gray-800/50 cursor-pointer transition-colors ${ + selectedDistance === rec.distance_label + ? 'bg-blue-900/20' + : 'hover:bg-gray-800/40' + }`} + > + + + + + + ))} + +
DistanceBest timeDate +
{rec.distance_label} + {formatDuration(rec.duration_s)} + + {formatDate(rec.achieved_at)} + + e.stopPropagation()} + className="text-xs text-blue-400 hover:underline" + > + View → + +
+
+ + {/* Progress chart */} +
+ {selectedDistance && history ? ( + <> +

+ {selectedDistance} progression +

+

Lower is faster

+ {history.length > 1 ? ( + + ({ + date: h.achieved_at, + time: h.duration_s, + }))} + margin={{ top: 4, right: 4, bottom: 4, left: 8 }} + > + + format(new Date(d), 'MMM yy')} + /> + + format(new Date(d), 'MMM d, yyyy')} + formatter={v => [formatDuration(v), 'Time']} + /> + + + + ) : ( +
+ Only one record — complete more activities to see progression +
+ )} + + ) : ( +
+ Select a distance to see your progression +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx new file mode 100644 index 0000000..7975abc --- /dev/null +++ b/frontend/src/pages/RoutesPage.jsx @@ -0,0 +1,205 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../utils/api' +import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format' + +export default function RoutesPage() { + const [selected, setSelected] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) + const qc = useQueryClient() + + const { data: routes } = useQuery({ + queryKey: ['routes'], + queryFn: () => api.get('/routes/').then(r => r.data), + }) + + const { data: routeActivities } = useQuery({ + queryKey: ['route-activities', selected?.id], + queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data), + enabled: !!selected, + }) + + const { data: segments } = useQuery({ + queryKey: ['route-segments', selected?.id], + queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data), + enabled: !!selected, + }) + + const createRoute = useMutation({ + mutationFn: (data) => api.post('/routes/', data).then(r => r.data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['routes'] }) + setShowCreate(false) + setNewRoute({ name: '', activity_id: '' }) + }, + }) + + const fastest = routeActivities?.[0] + + return ( +
+
+

Named Routes

+ +
+ + {/* Create route modal */} + {showCreate && ( +
+

Create named route

+

+ Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically. +

+
+
+ + setNewRoute(r => ({ ...r, name: e.target.value }))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="e.g. Morning park loop" + /> +
+
+ + setNewRoute(r => ({ ...r, activity_id: e.target.value }))} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Activity ID" + /> +
+
+
+ + +
+
+ )} + +
+ {/* Route list */} +
+ {routes?.length === 0 && ( +
+

🗺️

+

No named routes yet

+
+ )} + {routes?.map(route => ( + + ))} +
+ + {/* Route detail */} + {selected && ( +
+
+

{selected.name}

+ {selected.description && ( +

{selected.description}

+ )} + + {/* CR */} + {fastest && ( +
+

Course record

+
+ + {formatDuration(fastest.duration_s)} + + + {formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)} + +
+
+ )} + + {/* All runs on route */} +

+ All runs ({routeActivities?.length ?? 0}) +

+
+ {routeActivities?.map((act, i) => ( +
+ {i + 1} + {formatDate(act.start_time)} + {formatDuration(act.duration_s)} + {formatPace(act.avg_speed_ms, selected.sport_type)} + {act.avg_heart_rate && ( + {Math.round(act.avg_heart_rate)} bpm + )} + {i === 0 && ( + + CR + + )} +
+ ))} +
+
+ + {/* Segments */} + {segments && segments.length > 0 && ( +
+

Segments

+
+ {segments.map(seg => ( +
+
+

{seg.name}

+ {seg.description && ( +

{seg.description}

+ )} +
+
+

{formatDistance(seg.start_distance_m)} → {formatDistance(seg.end_distance_m)}

+

{formatDistance(seg.end_distance_m - seg.start_distance_m)}

+
+
+ ))} +
+
+ )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx new file mode 100644 index 0000000..62dc1c5 --- /dev/null +++ b/frontend/src/pages/UploadPage.jsx @@ -0,0 +1,178 @@ +import { useState, useCallback } from 'react' +import { useDropzone } from 'react-dropzone' +import { useMutation } from '@tanstack/react-query' +import api from '../utils/api' + +function UploadZone({ title, description, accept, endpoint, icon }) { + const [tasks, setTasks] = useState([]) + + const upload = useMutation({ + mutationFn: async (file) => { + const form = new FormData() + form.append('file', file) + const { data } = await api.post(endpoint, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return { file: file.name, ...data } + }, + onSuccess: (data) => { + setTasks(t => [...t, { ...data, status: 'queued' }]) + }, + }) + + const onDrop = useCallback((accepted) => { + accepted.forEach(file => upload.mutate(file)) + }, [upload]) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept, + multiple: true, + }) + + return ( +
+
+ {icon} +
+

{title}

+

{description}

+
+
+ +
+ + {isDragActive ? ( +

Drop files here…

+ ) : ( +
+

Drag & drop files here, or click to browse

+

+ {Object.values(accept).flat().join(', ')} +

+
+ )} +
+ + {upload.isPending && ( +

Uploading…

+ )} + + {tasks.length > 0 && ( +
+ {tasks.map((task, i) => ( +
+ {task.file} + {task.activity_tasks !== undefined && ( + {task.activity_tasks} activities queued + )} + ✓ Queued +
+ ))} +
+ )} +
+ ) +} + +export default function UploadPage() { + return ( +
+
+

Import Data

+

+ Import activities from Garmin or Strava. Large exports are processed in the background. +

+
+ + {/* How to export guides */} +
+
+

📥 How to export from Garmin Connect

+
    +
  1. Go to Garmin Connect → Profile → Account
  2. +
  3. Scroll to Data Management → Export Your Data
  4. +
  5. Request export and wait for the email
  6. +
  7. Download and upload the ZIP file below
  8. +
+
+
+

📥 How to export from Strava

+
    +
  1. Go to strava.com → Settings → My Account
  2. +
  3. Scroll to Download or Delete Your Account
  4. +
  5. Click "Request Your Archive"
  6. +
  7. Download and upload the ZIP file below
  8. +
+
+
+ +
+ {/* Single FIT/GPX */} + + + {/* Garmin full export */} + + + {/* Strava export */} + + + {/* Ongoing FIT files */} +
+
+ 🔄 +
+

Ongoing sync

+

Automatically import new Garmin watch files

+
+
+
+

After each activity, sync your Garmin watch via USB or Garmin Express. New FIT files appear in:

+ + GARMIN/Activity/*.fit + +

Upload individual FIT files above using the "Single activity" uploader, or set up a folder-watch script:

+ +{`# Example: auto-upload new FIT files +inotifywait -m ~/Garmin/Activity/ -e create \\ + --format '%f' | while read file; do + curl -X POST /api/upload/activity \\ + -H "Authorization: Bearer TOKEN" \\ + -F "file=@$file" + done`} + +
+
+
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..502fb07 --- /dev/null +++ b/frontend/src/utils/api.js @@ -0,0 +1,26 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL || '/api', +}) + +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('token') + window.location.href = '/login' + } + return Promise.reject(err) + } +) + +export default api diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js new file mode 100644 index 0000000..ba2744f --- /dev/null +++ b/frontend/src/utils/format.js @@ -0,0 +1,84 @@ +export function formatDuration(seconds) { + if (!seconds) return '--' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor(seconds % 60) + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${m}:${String(s).padStart(2, '0')}` +} + +export function formatPace(speedMs, sportType = 'running') { + if (!speedMs || speedMs <= 0) return '--' + if (sportType === 'cycling') { + const kph = speedMs * 3.6 + return `${kph.toFixed(1)} km/h` + } + const secsPerKm = 1000 / speedMs + const mins = Math.floor(secsPerKm / 60) + const secs = Math.floor(secsPerKm % 60) + return `${mins}:${String(secs).padStart(2, '0')} /km` +} + +export function formatDistance(metres) { + if (!metres) return '--' + if (metres >= 1000) return `${(metres / 1000).toFixed(2)} km` + return `${Math.round(metres)} m` +} + +export function formatElevation(metres) { + if (metres == null) return '--' + return `${Math.round(metres)} m` +} + +export function formatHeartRate(bpm) { + if (!bpm) return '--' + return `${Math.round(bpm)} bpm` +} + +export function formatSleep(seconds) { + if (!seconds) return '--' + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + return `${h}h ${m}m` +} + +export function formatWeight(kg) { + if (!kg) return '--' + return `${kg.toFixed(1)} kg` +} + +export function formatDate(dateStr) { + if (!dateStr) return '--' + return new Date(dateStr).toLocaleDateString('en-GB', { + day: 'numeric', month: 'short', year: 'numeric', + }) +} + +export function formatDateTime(dateStr) { + if (!dateStr) return '--' + return new Date(dateStr).toLocaleDateString('en-GB', { + day: 'numeric', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }) +} + +export function hrZoneColor(zone) { + const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' } + return colors[zone] || '#9ca3af' +} + +export function sportIcon(sportType) { + const icons = { + running: '🏃', cycling: '🚴', swimming: '🏊', hiking: '🥾', + walking: '🚶', other: '⚡', + } + return icons[sportType?.toLowerCase()] || '⚡' +} + +export function sportColor(sportType) { + const colors = { + running: '#3b82f6', cycling: '#f97316', swimming: '#06b6d4', + hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280', + } + return colors[sportType?.toLowerCase()] || '#6b7280' +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..53c34f8 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,18 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,jsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + brand: { + 50: '#eff6ff', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + }, + }, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..d379536 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0b06ceb --- /dev/null +++ b/install.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +# FitTracker installer +# Usage: curl -fsSL https://raw.githubusercontent.com/you/fittracker/main/install.sh | bash +# Or: bash install.sh +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +BOLD='\033[1m' + +info() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}!${NC} $*"; } +error() { echo -e "${RED}✗ $*${NC}"; exit 1; } +step() { echo -e "\n${CYAN}${BOLD}── $* ──${NC}"; } + +echo -e "${BOLD}" +echo " ███████╗██╗████████╗████████╗██████╗ █████╗ ██████╗██╗ ██╗███████╗██████╗ " +echo " ██╔════╝██║╚══██╔══╝╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██╔════╝██╔══██╗" +echo " █████╗ ██║ ██║ ██║ ██████╔╝███████║██║ █████╔╝ █████╗ ██████╔╝" +echo " ██╔══╝ ██║ ██║ ██║ ██╔══██╗██╔══██║██║ ██╔═██╗ ██╔══╝ ██╔══██╗" +echo " ██║ ██║ ██║ ██║ ██║ ██║██║ ██║╚██████╗██║ ██╗███████╗██║ ██║" +echo " ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝" +echo -e "${NC}" +echo " Self-hosted fitness tracking — Garmin & Strava" +echo "" + +# ── Preflight checks ────────────────────────────────────────────────────────── + +step "Checking requirements" + +command -v docker >/dev/null 2>&1 || error "Docker is not installed. Install from https://docs.docker.com/get-docker/" +info "Docker found: $(docker --version | head -1)" + +# Check docker compose (v2 plugin or v1 standalone) +if docker compose version >/dev/null 2>&1; then + COMPOSE_CMD="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + COMPOSE_CMD="docker-compose" +else + error "Docker Compose not found. Install from https://docs.docker.com/compose/install/" +fi +info "Docker Compose found: $($COMPOSE_CMD version | head -1)" + +# Check Docker daemon is running +docker info >/dev/null 2>&1 || error "Docker daemon is not running. Start Docker and retry." +info "Docker daemon is running" + +# ── Install directory ───────────────────────────────────────────────────────── + +step "Setting up install directory" + +INSTALL_DIR="${FITTRACKER_DIR:-$HOME/fittracker}" + +if [ -d "$INSTALL_DIR" ] && [ "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then + warn "Directory $INSTALL_DIR already exists." + read -rp " Continue and update existing install? [y/N] " confirm + [[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } +fi + +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" +info "Install directory: $INSTALL_DIR" + +# ── Download project files ──────────────────────────────────────────────────── + +step "Downloading FitTracker" + +# If we're already inside the repo (files exist), skip download +if [ -f "docker-compose.yml" ]; then + info "Project files already present — skipping download" +else + # Try git first, fall back to curl + if command -v git >/dev/null 2>&1; then + git clone --depth 1 https://github.com/yourusername/fittracker.git . 2>/dev/null || { + warn "Git clone failed — copying bundled files instead" + } + fi + + # Fallback: if running this script from inside a downloaded zip, the files are next to it + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ "$SCRIPT_DIR" != "$INSTALL_DIR" ] && [ -f "$SCRIPT_DIR/docker-compose.yml" ]; then + cp -r "$SCRIPT_DIR"/. "$INSTALL_DIR/" + info "Copied project files from $SCRIPT_DIR" + fi +fi + +[ -f "docker-compose.yml" ] || error "docker-compose.yml not found. Place install.sh inside the project directory." +info "Project files ready" + +# ── Generate .env ───────────────────────────────────────────────────────────── + +step "Configuring environment" + +if [ -f ".env" ]; then + warn ".env already exists — skipping generation (delete it to regenerate)" +else + # Generate secure random values + if command -v openssl >/dev/null 2>&1; then + SECRET_KEY=$(openssl rand -hex 32) + DB_PASSWORD=$(openssl rand -base64 18 | tr -d '/+=') + REDIS_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=') + ADMIN_PASSWORD=$(openssl rand -base64 12 | tr -d '/+=') + else + # Fallback if openssl not available + SECRET_KEY=$(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 64) + DB_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 18) + REDIS_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 12) + ADMIN_PASSWORD=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 12) + fi + + ADMIN_USERNAME="${FITTRACKER_ADMIN:-admin}" + PORT="${FITTRACKER_PORT:-80}" + + cat > .env << ENV +# FitTracker configuration — generated $(date) +# Edit this file to change settings, then run: docker compose up -d + +# Admin login +ADMIN_USERNAME=${ADMIN_USERNAME} +ADMIN_PASSWORD=${ADMIN_PASSWORD} + +# Secrets (auto-generated — do not share) +SECRET_KEY=${SECRET_KEY} +DB_PASSWORD=${DB_PASSWORD} +DB_USER=fittracker +REDIS_PASSWORD=${REDIS_PASSWORD} + +# Server +HTTP_PORT=${PORT} +ENVIRONMENT=production + +# Optional: Mapbox token for satellite map tiles (free at mapbox.com) +VITE_MAPBOX_TOKEN= + +# Optional: PocketID passkey authentication +# POCKETID_ISSUER=https://your-pocketid.example.com +# POCKETID_CLIENT_ID=fittracker +# POCKETID_CLIENT_SECRET= +ENV + + info ".env created with secure random secrets" + + # Save credentials for display at end + SHOW_CREDS=true +fi + +source .env + +# ── Build & start ───────────────────────────────────────────────────────────── + +step "Building and starting containers" +echo " This takes 3–5 minutes on first run (building images)..." +echo "" + +$COMPOSE_CMD up -d --build + +# ── Wait for healthy ────────────────────────────────────────────────────────── + +step "Waiting for services to be ready" + +TIMEOUT=120 +ELAPSED=0 +printf " Waiting" +while ! docker inspect fittracker_backend 2>/dev/null | grep -q '"healthy"' ; do + if [ $ELAPSED -ge $TIMEOUT ]; then + echo "" + warn "Backend taking longer than expected. Check logs: docker compose logs backend" + break + fi + printf "." + sleep 3 + ELAPSED=$((ELAPSED + 3)) +done +echo "" +info "All services are up" + +# ── Done ────────────────────────────────────────────────────────────────────── + +PORT="${HTTP_PORT:-80}" +URL="http://localhost${PORT:+:${PORT}}" +[[ "$PORT" == "80" ]] && URL="http://localhost" + +echo "" +echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}${BOLD}║ FitTracker is ready! ║${NC}" +echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════╝${NC}" +echo "" +echo -e " 🌐 Open: ${CYAN}${URL}${NC}" +echo -e " 👤 Username: ${BOLD}${ADMIN_USERNAME:-admin}${NC}" + +if [ "${SHOW_CREDS:-false}" = "true" ]; then + echo -e " 🔑 Password: ${BOLD}${ADMIN_PASSWORD}${NC}" + echo "" + warn "Save this password — it won't be shown again." + warn "It's also stored in: ${INSTALL_DIR}/.env" +else + echo -e " 🔑 Password: (see ${INSTALL_DIR}/.env — ADMIN_PASSWORD)" +fi + +echo "" +echo " Useful commands:" +echo " docker compose logs -f # View live logs" +echo " docker compose logs backend # Backend logs only" +echo " docker compose down # Stop everything" +echo " docker compose up -d # Start again" +echo " docker compose pull && docker compose up -d --build # Update" +echo "" +echo " Import your data:" +echo " Go to ${URL} → Import → upload your Garmin export ZIP or Strava ZIP" +echo "" diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..e7095b1 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,50 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 512M; + + limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; + limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m; + + upstream backend { server backend:8000; keepalive 32; } + upstream frontend { server frontend:80; } + + server { + listen 80; + server_name _; + + location /api/upload/ { + limit_req zone=upload burst=5 nodelay; + proxy_pass http://backend/api/upload/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + client_max_body_size 512M; + } + + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://backend/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 300s; + } + + location /health { + proxy_pass http://backend/health; + } + + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + } + } +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c764c66 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,59 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 512M; + + limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; + limit_req_zone $binary_remote_addr zone=upload:10m rate=10r/m; + + upstream backend { + server backend:8000; + keepalive 32; + } + + upstream frontend { + server frontend:80; + } + + server { + listen 80; + server_name _; + + location /api/upload/ { + limit_req zone=upload burst=5 nodelay; + proxy_pass http://backend/api/upload/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + client_max_body_size 512M; + } + + location /api/ { + limit_req zone=api burst=20 nodelay; + proxy_pass http://backend/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + } + + location /health { + proxy_pass http://backend/health; + proxy_set_header Host $host; + } + + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +} diff --git a/scripts/manage.sh b/scripts/manage.sh new file mode 100755 index 0000000..9f1f444 --- /dev/null +++ b/scripts/manage.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/.." + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' + +info() { echo -e "${GREEN}[fittracker]${NC} $*"; } +warn() { echo -e "${YELLOW}[fittracker]${NC} $*"; } +error() { echo -e "${RED}[fittracker]${NC} $*"; exit 1; } + +check_env() { + if [ ! -f .env ]; then + warn ".env not found — copying from .env.example" + cp .env.example .env + warn "Please edit .env and set required secrets, then re-run this script." + exit 1 + fi + + source .env + [ -z "${DB_PASSWORD:-}" ] && error "DB_PASSWORD must be set in .env" + [ -z "${SECRET_KEY:-}" ] && error "SECRET_KEY must be set in .env (use: openssl rand -hex 32)" + [ -z "${ADMIN_PASSWORD:-}" ] && error "ADMIN_PASSWORD must be set in .env" + info "Environment looks good" +} + +generate_secrets() { + info "Generating .env from template..." + cp .env.example .env + SECRET=$(openssl rand -hex 32) + DB_PASS=$(openssl rand -base64 16 | tr -d '/+=') + ADMIN_PASS=$(openssl rand -base64 12 | tr -d '/+=') + REDIS_PASS=$(openssl rand -base64 12 | tr -d '/+=') + + sed -i "s/changeme_generate_with_openssl_rand_hex_32/$SECRET/" .env + sed -i "s/changeme_strong_password/$DB_PASS/" .env + sed -i "s/changeme_admin_password/$ADMIN_PASS/" .env + sed -i "s/redispass/$REDIS_PASS/" .env + + echo "" + echo -e "${GREEN}Generated secrets:${NC}" + echo " Admin username: admin" + echo " Admin password: $ADMIN_PASS" + echo " (saved to .env)" + echo "" + warn "Save these credentials! The admin password won't be shown again." +} + +cmd_start() { + check_env + info "Starting FitTracker..." + docker compose up -d --build + info "Started! Visit http://localhost:${HTTP_PORT:-80}" +} + +cmd_stop() { + info "Stopping FitTracker..." + docker compose down +} + +cmd_logs() { + docker compose logs -f "${1:-}" +} + +cmd_setup() { + info "First-time setup" + generate_secrets + cmd_start +} + +cmd_backup() { + source .env + BACKUP_FILE="fittracker_backup_$(date +%Y%m%d_%H%M%S).sql" + info "Backing up database to $BACKUP_FILE..." + docker compose exec -T db pg_dump \ + -U "${DB_USER:-fittracker}" fittracker > "$BACKUP_FILE" + info "Backup saved: $BACKUP_FILE" +} + +cmd_restore() { + [ -z "${1:-}" ] && error "Usage: $0 restore " + source .env + info "Restoring from $1..." + docker compose exec -T db psql \ + -U "${DB_USER:-fittracker}" fittracker < "$1" + info "Restore complete" +} + +cmd_update() { + info "Pulling latest and rebuilding..." + git pull + docker compose build --no-cache + docker compose up -d + info "Update complete" +} + +case "${1:-help}" in + setup) cmd_setup ;; + start) cmd_start ;; + stop) cmd_stop ;; + restart) cmd_stop; cmd_start ;; + logs) cmd_logs "${2:-}" ;; + backup) cmd_backup ;; + restore) cmd_restore "${2:-}" ;; + update) cmd_update ;; + *) + echo "FitTracker management script" + echo "" + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " setup First-time setup (generates secrets, starts containers)" + echo " start Start all containers" + echo " stop Stop all containers" + echo " restart Restart all containers" + echo " logs Follow logs (optionally: logs backend)" + echo " backup Backup PostgreSQL database" + echo " restore Restore from backup: restore " + echo " update Pull and rebuild" + ;; +esac